diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000000..08e1985ae7 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,18 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: "en-US" +early_access: false +reviews: + profile: "chill" + request_changes_workflow: true + high_level_summary: true + poem: true + review_status: true + collapse_walkthrough: false + auto_review: + enabled: true + drafts: false + base_branches: + - develop + - main +chat: + auto_reply: true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000..de7cff92ec --- /dev/null +++ b/.env.example @@ -0,0 +1,31 @@ +# 👋 Welcome, we're glad you're setting up an installation of Talawa-admin. Copy this +# file to .env or set the variables in your local environment manually. + + +# Custom port number for the talawa-admin development server to run on. Default is 4321. + +PORT=4321 + +# Run Talawa-api locally in your system, and put its url into the same. + +REACT_APP_TALAWA_URL= + +# Do you want to setup and use "I'm not a robot" Checkbox (Google Recaptcha)? +# If no, leave blank, else write yes +# Example: REACT_APP_USE_RECAPTCHA=yes + +REACT_APP_USE_RECAPTCHA= + +# If you are using Google Recaptcha, i.e., REACT_APP_USE_RECAPTCHA=yes, read the following steps +# Get the google recaptcha site key from google recaptcha admin or https://www.google.com/recaptcha/admin/create +# from here for reCAPTCHA v2 and "I'm not a robot" Checkbox, and paste the key here. +# Note: In domains, fill localhost + +REACT_APP_RECAPTCHA_SITE_KEY= + +# has to be inserted in the env file to use plugins and other websocket based features. +REACT_APP_BACKEND_WEBSOCKET_URL=ws://localhost:4000/graphql/ + +# If you want to logs Compiletime and Runtime error , warning and info write YES or if u want to +# keep the console clean leave it blank +ALLOW_LOGS= diff --git a/.eslintignore b/.eslintignore index e69de29bb2..7e45de312a 100644 --- a/.eslintignore +++ b/.eslintignore @@ -0,0 +1,2 @@ +# Contains the PDF file of the Tag as JSON string, thus does not need to be linted +src/components/CheckIn/tagTemplate.ts \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index a1f8002a3c..26470f7aab 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,6 +1,7 @@ { "env": { "browser": true, + "node": true, "es6": true }, @@ -10,7 +11,9 @@ "eslint:recommended", "plugin:jest/recommended", "plugin:prettier/recommended", - "plugin:@typescript-eslint/recommended" + "plugin:@typescript-eslint/recommended", + "eslint-config-prettier", + "prettier" ], "globals": { "Atomics": "readonly", @@ -26,12 +29,97 @@ }, // Specify the ESLint plugins tobe used - "plugins": ["react", "@typescript-eslint", "react-hooks", "jest"], + "plugins": [ + "react", + "@typescript-eslint", + "jest", + "import", + "eslint-plugin-tsdoc", + "prettier" + ], "rules": { - "react/destructuring-assignment": ["warn", "always"], - "react/no-multi-comp": ["error", { "ignoreStateless": false }], - "react/jsx-filename-extension": ["error", { "extensions": [".tsx"] }], + "react/destructuring-assignment": "error", + "@typescript-eslint/explicit-module-boundary-types": "error", + "react/no-multi-comp": [ + "error", + { + "ignoreStateless": false + } + ], + "react/jsx-filename-extension": [ + "error", + { + "extensions": [".tsx"] + } + ], + "import/no-duplicates": "error", + "tsdoc/syntax": "error", + "@typescript-eslint/ban-ts-comment": "error", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-inferrable-types": "error", + "@typescript-eslint/no-non-null-asserted-optional-chain": "error", + "@typescript-eslint/no-non-null-assertion": "error", + "@typescript-eslint/no-var-requires": "error", + "@typescript-eslint/no-unsafe-function-type": "error", + "@typescript-eslint/no-wrapper-object-types": "error", + "@typescript-eslint/no-empty-object-type": "error", + "@typescript-eslint/no-duplicate-enum-values": "error", + "@typescript-eslint/array-type": "error", + "@typescript-eslint/consistent-type-assertions": "error", + "@typescript-eslint/consistent-type-imports": "error", + "@typescript-eslint/explicit-function-return-type": [ + 2, + { + "allowExpressions": true, + "allowTypedFunctionExpressions": true + } + ], + "camelcase": "off", + "@typescript-eslint/naming-convention": [ + "error", + // Interfaces must begin with Interface or TestInterface followed by a PascalCase name + { + "selector": "interface", + "format": ["PascalCase"], + "prefix": ["Interface", "TestInterface"] + }, + // Type Aliases must be in PascalCase + { + "selector": ["typeAlias", "typeLike", "enum"], + "format": ["PascalCase"] + }, + { + "selector": "typeParameter", + "format": ["PascalCase"], + "prefix": ["T"] + }, + { + "selector": "variable", + "format": ["camelCase", "UPPER_CASE", "PascalCase"], + "leadingUnderscore": "allow" + }, + { + "selector": "parameter", + "format": ["camelCase"], + "leadingUnderscore": "allow" + }, + { + "selector": "function", + "format": ["camelCase", "PascalCase"] + }, + { + "selector": "memberLike", + "modifiers": ["private"], + "format": ["camelCase"], + "leadingUnderscore": "require" + }, + { + "selector": "variable", + "modifiers": ["exported"], + "format": null + } + ], // Ensures that components are always written in PascalCase "react/jsx-pascal-case": [ "error", @@ -42,23 +130,16 @@ "react/jsx-equals-spacing": ["warn", "never"], "react/no-this-in-sfc": "error", - // Ensures that components are always indented by 2 spaces - "react/jsx-indent": ["warn", 2], - "react/jsx-tag-spacing": [ - "warn", - { - "afterOpening": "never", - "beforeClosing": "never", - "beforeSelfClosing": "always" - } - ], + // All tests must need not have an assertion + "jest/expect-expect": 0, // Enforce Strictly functional components "react/no-unstable-nested-components": ["error", { "allowAsProps": true }], "react/function-component-definition": [ - "error", + 0, { "namedComponents": "function-declaration" } - ] + ], + "prettier/prettier": "error" }, // Let ESLint use the react version in the package.json diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug-report.md similarity index 60% rename from .github/ISSUE_TEMPLATE/bug_report.md rename to .github/ISSUE_TEMPLATE/bug-report.md index ccbb9c4d8e..d9f95c0d65 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug-report.md @@ -1,9 +1,10 @@ --- -name: Bug report +name: Bug Report about: Create a report to help us improve. -title: Bug report -labels: Bug -assignees: "" +title: Bug Report +labels: bug +assignees: '' + --- **Describe the bug** @@ -27,3 +28,9 @@ A clear and concise description of how the code performed w.r.t expectations. If applicable, add screenshots to help explain your problem. **Additional details** +Add any other context or screenshots about the feature request here. + +**Potential internship candidates** + +Please read this if you are planning to apply for a Palisadoes Foundation internship +- https://github.com/PalisadoesFoundation/talawa/issues/359 diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature-request.md similarity index 69% rename from .github/ISSUE_TEMPLATE/feature_request.md rename to .github/ISSUE_TEMPLATE/feature-request.md index 1c93611c44..51aea0e9d9 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature-request.md @@ -1,9 +1,10 @@ --- -name: Feature request +name: Feature Request about: Suggest an idea for this project -title: Feature request -labels: Feature -assignees: "" +title: Feature Request +labels: feature request +assignees: '' + --- **Is your feature request related to a problem? Please describe.** @@ -20,3 +21,8 @@ A clear and concise description of approach to be followed. **Additional context** Add any other context or screenshots about the feature request here. + +**Potential internship candidates** + +Please read this if you are planning to apply for a Palisadoes Foundation internship +- https://github.com/PalisadoesFoundation/talawa/issues/359 diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000000..81d68df4bd --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,18 @@ +# Configuration for automated dependency updates using Dependabot +version: 2 +updates: + # Define the target package ecosystem + - package-ecosystem: "npm" + # Specify the root directory + directory: "/" + # Schedule automated updates to run weekly + schedule: + interval: "monthly" + # Labels to apply to Dependabot PRs + labels: + - "dependencies" + # Specify the target branch for PRs + target-branch: "develop-postgres" + # Customize commit message prefix + commit-message: + prefix: "chore(deps):" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b3f679554c..9e3081d0ee 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,13 +1,40 @@ - + + + **What kind of change does this PR introduce?** +**Issue Number:** + +Fixes # + **Did you add tests for your changes?** + + +**Snapshots/Videos:** + + + **If relevant, did you update the documentation?** + + **Summary** @@ -18,3 +45,9 @@ **Other information** + + + +**Have you read the [contributing guide](https://github.com/PalisadoesFoundation/talawa-admin/blob/master/CONTRIBUTING.md)?** + + diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 0000000000..1e9a81eaf8 --- /dev/null +++ b/.github/workflows/README.md @@ -0,0 +1,47 @@ +# Talawa GitHub Workflows Guidelines + +Follow these guidelines when contributing to this directory. + +## General + +Any changes to files in this directory are flagged when pull requests are run. Make changes only on the advice of a contributor. + +## YAML Workflow Files + +The YAML files in this directory have very specific roles depending on the type of workflow. + +Whenever possible you must ensure that: +1. The file roles below are maintained +1. The sequence of the jobs in the workflows are maintained using [GitHub Action dependencies](https://docs.github.com/en/actions/learn-github-actions/managing-complex-workflows). + +### File Roles +Follow these guidelines when creating new YAML defined GitHub actions. This is done to make troubleshooting easier. + +1. `Issue` Workflows: + 1. Place all actions related to issues in the `issues.yml` file. +1. `Pull Request` workflows to be run by: + 1. Workflows to run **First Time** repo contributors: + 1. Place all actions related to to this in the `pull-request-target.yml` file. + 1. Workflows to be run by **ALL** repo contributors: + 1. Place all actions related to pull requests in the `pull-request.yml` file. +1. `Push` workflows: + 1. Place all actions related to pushes in the `push.yml` file. + +#### File Role Exceptions + +There are some exceptions to these rules in which jobs can be placed in dedicated separate files: +1. Jobs that require unique `cron:` schedules +1. Jobs that require unique `paths:` statements that operate only when files in a specific path are updated. +1. Jobs only work correctly if they have a dedicated file (eg. `CodeQL`) + +## Scripts + +Follow these guidelines when creating or modifying scripts in this directory. + +1. All scripts in this directory must be written in python3 for consistency. +1. The python3 scripts must follow the following coding standards. Run these commands against your scripts before submitting PRs that modify or create python3 scripts in this directory. + 1. Pycodestyle + 1. Pydocstyle + 1. Pylint + 1. Flake8 +1. All scripts must run a main() function. diff --git a/.github/workflows/auto-label.json5 b/.github/workflows/auto-label.json5 new file mode 100644 index 0000000000..37929ea97b --- /dev/null +++ b/.github/workflows/auto-label.json5 @@ -0,0 +1,8 @@ +{ + "labelsSynonyms": { + "dependencies": ["dependabot", "dependency", "dependencies"], + "security": ["security"], + "ui/ux": ["layout", "screen", "design", "figma"] + }, + "defaultLabels": ["unapproved"], +} \ No newline at end of file diff --git a/.github/workflows/check-tsdoc.js b/.github/workflows/check-tsdoc.js new file mode 100644 index 0000000000..d5c3b33b90 --- /dev/null +++ b/.github/workflows/check-tsdoc.js @@ -0,0 +1,68 @@ +import fs from 'fs/promises'; // Import fs.promises for async operations +import path from 'path'; + +// List of files to skip +const filesToSkip = [ + 'index.tsx', + 'EventActionItems.tsx', + 'OrgPostCard.tsx', + 'UsersTableItem.tsx', + 'FundCampaignPledge.tsx' +]; + +// Recursively find all .tsx files, excluding files listed in filesToSkip +async function findTsxFiles(dir) { + let results = []; + try { + const list = await fs.readdir(dir); + for (const file of list) { + const filePath = path.join(dir, file); + const stat = await fs.stat(filePath); + if (stat.isDirectory()) { + results = results.concat(await findTsxFiles(filePath)); + } else if ( + filePath.endsWith('.tsx') && + !filePath.endsWith('.test.tsx') && + !filesToSkip.includes(path.relative(dir, filePath)) + ) { + results.push(filePath); + } + } + } catch (err) { + console.error(`Error reading directory ${dir}: ${err.message}`); + } + return results; +} + +// Check if a file contains at least one TSDoc comment +async function containsTsDocComment(filePath) { + try { + const content = await fs.readFile(filePath, 'utf8'); + return /\/\*\*[\s\S]*?\*\//.test(content); + } catch (err) { + console.error(`Error reading file ${filePath}: ${err.message}`); + return false; + } +} + +// Main function to run the validation +async function run() { + const dir = process.argv[2] || './src'; // Allow directory path as a command-line argument + const files = await findTsxFiles(dir); + const filesWithoutTsDoc = []; + + for (const file of files) { + if (!await containsTsDocComment(file)) { + filesWithoutTsDoc.push(file); + } + } + + if (filesWithoutTsDoc.length > 0) { + filesWithoutTsDoc.forEach(file => { + console.error(`No TSDoc comment found in file: ${file}`); + }); + process.exit(1); + } +} + +run(); \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 3efc4fd51f..0000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: Continuous Integration -on: [pull_request] -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Install Dependencies - run: yarn - - name: Run tests - run: yarn test - - \ No newline at end of file diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml deleted file mode 100644 index ccd6fc3705..0000000000 --- a/.github/workflows/codeql-analysis.yml +++ /dev/null @@ -1,67 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ master ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ master ] - schedule: - - cron: '22 0 * * 5' - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - - strategy: - fail-fast: false - matrix: - language: [ 'javascript' ] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v1 - with: - languages: ${{ matrix.language }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v1 - - # ℹ️ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/codeql-codescan.yml b/.github/workflows/codeql-codescan.yml new file mode 100644 index 0000000000..6fa463001f --- /dev/null +++ b/.github/workflows/codeql-codescan.yml @@ -0,0 +1,44 @@ +############################################################################## +############################################################################## +# +# NOTE! +# +# Please read the README.md file in this directory that defines what should +# be placed in this file +# +############################################################################## +############################################################################## + +name: codeql codescan workflow + +on: + pull_request: + branches: + - '**' + push: + branches: + - '**' +jobs: + CodeQL: + if: ${{ github.actor != 'dependabot[bot]' }} + name: Analyse Code With CodeQL + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: [ 'javascript' ] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + debug: true + + - name: Autobuild + uses: github/codeql-action/autobuild@v2 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/compare_translations.py b/.github/workflows/compare_translations.py new file mode 100644 index 0000000000..ef65b6c52b --- /dev/null +++ b/.github/workflows/compare_translations.py @@ -0,0 +1,212 @@ +"""Script to encourage more efficient coding practices. +Methodology: + + Utility for comparing translations between default and other languages. + + This module defines a function to compare two translations + and print any missing keys in the other language's translation. +Attributes: + + FileTranslation : Named tuple to represent a combination + of file and missing translations. + + Fields: + - file (str): The file name. + - missing_translations (list): List of missing translations. + +Functions: + compare_translations(default_translation, other_translation): + Compare two translations and print missing keys. + + load_translation(filepath): + Load translation from a file. + + check_translations(): + Load the default translation and compare it with other translations. + + main(): + The main function to run the script. + Parses command-line arguments, checks for the + existence of the specified directory, and then + calls check_translations with the provided or default directory. + + +Usage: + This script can be executed to check and print missing + translations in other languages based on the default English translation. + +Example: + python compare_translations.py +NOTE: + This script complies with our python3 coding and documentation standards + and should be used as a reference guide. It complies with: + + 1) Pylint + 2) Pydocstyle + 3) Pycodestyle + 4) Flake8 + +""" +# standard imports +import argparse +import json +import os +import sys +from collections import namedtuple + +# Named tuple for file and missing +# translations combination +FileTranslation = namedtuple("FileTranslation", + ["file", "missing_translations"]) + + +def compare_translations(default_translation, + other_translation, default_file, other_file): + """Compare two translations and return detailed info about missing/mismatched keys. + + Args: + default_translation (dict): The default translation (en.json). + other_translation (dict): The other language translation. + default_file (str): The name of the default translation file. + other_file (str): The name of the other + translation file. + + Returns: + list: A list of detailed error messages for each missing/mismatched key. + """ + errors = [] + + # Check for missing keys in other_translation + for key in default_translation: + if key not in other_translation: + error_msg = f"Missing Key: '{key}' - This key from '{default_file}' is missing in '{other_file}'." + errors.append(error_msg) + # Check for keys in other_translation that don't match any in default_translation + for key in other_translation: + if key not in default_translation: + error_msg = f"Error Key: '{key}' - This key in '{other_file}' does not match any key in '{default_file}'." + errors.append(error_msg) + return errors + +def flatten_json(nested_json, parent_key=""): + """ + Flattens a nested JSON, concatenating keys to represent the hierarchy. + + Args: + nested_json (dict): The JSON object to flatten. + parent_key (str): The base key for recursion (used to track key hierarchy). + + Returns: + dict: A flattened dictionary with concatenated keys. + """ + flat_dict = {} + + for key, value in nested_json.items(): + # Create the new key by concatenating parent and current key + new_key = f"{parent_key}.{key}" if parent_key else key + + if isinstance(value, dict): + # Recursively flatten the nested dictionary + flat_dict.update(flatten_json(value, new_key)) + else: + # Assign the value to the flattened key + flat_dict[new_key] = value + + return flat_dict + +def load_translation(filepath): + """Load translation from a file. + + Args: + filepath: Path to the translation file + + Returns: + translation: Loaded translation + """ + try: + with open(filepath, "r", encoding="utf-8") as file: + content = file.read() + if not content.strip(): + raise ValueError(f"File {filepath} is empty.") + translation = json.loads(content) + flattened_translation = flatten_json(translation) + return flattened_translation + except json.JSONDecodeError as e: + raise ValueError(f"Error decoding JSON from file {filepath}: {e}") + + +def check_translations(directory): + """Load default translation and compare with other translations. + + Args: + directory (str): The directory containing translation files. + + Returns: + None + """ + default_language_dir = os.path.join(directory, "en") + default_files = ["common.json", "errors.json", "translation.json"] + default_translations = {} + for file in default_files: + file_path = os.path.join(default_language_dir, file) + default_translations[file] = load_translation(file_path) + + languages = os.listdir(directory) + languages.remove("en") # Exclude default language directory + + + error_found = False + + for language in languages: + language_dir = os.path.join(directory, language) + for file in default_files: + default_translation = default_translations[file] + other_file_path = os.path.join(language_dir, file) + other_translation = load_translation(other_file_path) + + # Compare translations and get detailed error messages + errors = compare_translations( + default_translation, other_translation, f"en/{file}", f"{language}/{file}" + ) + if errors: + error_found = True + print(f"File {language}/{file} has missing translations for:") + for error in errors: + print(f" - {error}") + + + if error_found: + sys.exit(1) # Exit with an error status code + else: + print("All translations are present") + sys.exit(0) + + +def main(): + """ + + Parse command-line arguments, check for the existence of the specified directory + and call check_translations with the provided or default directory. + + """ + parser = argparse.ArgumentParser( + description="Check and print missing translations for all non-default languages." + ) + parser.add_argument( + "--directory", + type=str, + nargs="?", + default=os.path.join(os.getcwd(), "public/locales"), + help="Directory containing translation files(relative to the root directory).", + ) + args = parser.parse_args() + + if not os.path.exists(args.directory): + print(f"Error: The specified directory '{args.directory}' does not exist.") + sys.exit(1) + + check_translations(args.directory) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/countline.py b/.github/workflows/countline.py new file mode 100755 index 0000000000..d0b03c503f --- /dev/null +++ b/.github/workflows/countline.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- +"""Script to encourage more efficient coding practices. + +Methodology: + + Analyses the `lib` and `test` directories to find files that exceed a + pre-defined number of lines of code. + + This script was created to help improve code quality by encouraging + contributors to create reusable code. + +NOTE: + + This script complies with our python3 coding and documentation standards + and should be used as a reference guide. It complies with: + + 1) Pylint + 2) Pydocstyle + 3) Pycodestyle + 4) Flake8 + + Run these commands from the CLI to ensure the code is compliant for all + your pull requests. + +""" + +# Standard imports +import os +import sys +import argparse +from collections import namedtuple + + +def _valid_filename(filepath): + """Determine whether filepath has the correct filename. + + Args: + filepath: Filepath to check + + Returns: + result: True if valid + + """ + # Initialize key variables + invalid_filenames = [".test.", ".spec."] + result = True + + # Test + for invalid_filename in invalid_filenames: + if invalid_filename.lower() not in filepath.lower(): + continue + result = False + + return result + + +def _valid_extension(filepath): + """Determine whether filepath has the correct extension. + + Args: + filepath: Filepath to check + + Returns: + result: True if valid + + """ + # Initialize key variables + invalid_extensions = [".css", ".jpg", ".png", ".jpeg"] + result = True + + # Test + for invalid_extension in invalid_extensions: + if filepath.lower().endswith(invalid_extension.lower()) is False: + continue + result = False + + return result + + +def _valid_exclusions(excludes): + """Create a list of full file paths to exclude from the analysis. + + Args: + excludes: Excludes object + + Returns: + result: A list of full file paths + + """ + # Initialize key variables + result = [] + filenames = [] + more_filenames = [] + + # Create a list of files to ignore + if bool(excludes.files): + filenames = excludes.files + if bool(excludes.directories): + more_filenames = _filepaths_in_directories(excludes.directories) + filenames.extend(more_filenames) + + # Remove duplicates + filenames = list(set(filenames)) + + # Process files + for filename in filenames: + # Ignore files that appear to be full paths because they start + # with a '/' or whatever the OS uses to distinguish directories + if filename.startswith(os.sep): + continue + + # Create a file path + filepath = "{}{}{}".format(os.getcwd(), os.sep, filename) + if os.path.isfile(filepath) is True: + result.append(filepath) + + # Return + return result + + +def _filepaths_in_directories(directories): + """Create a list of full file paths based on input directories. + + Args: + directories: A list of directories + + Returns: + result: A list of full file paths + + """ + # Initialize key variables + result = [] + + # Iterate and analyze each directory + for directory in directories: + for root, _, files in os.walk(directory, topdown=False): + for name in files: + # Read each file and count the lines found + result.append(os.path.join(root, name)) + # Return + return result + + +def _arg_parser_resolver(): + """Resolve the CLI arguments provided by the user. + + Args: + None + + Returns: + result: Parsed argument object + + """ + # Initialize parser and add the CLI options we should expect + parser = argparse.ArgumentParser() + parser.add_argument( + "--lines", + type=int, + required=False, + default=300, + help="The maximum number of lines of code to accept.", + ) + parser.add_argument( + "--directory", + type=str, + required=False, + default=os.getcwd(), + help="The parent directory of files to analyze.", + ) + parser.add_argument( + "--exclude_files", + type=str, + required=False, + nargs="*", + default=None, + const=None, + help="""An optional space separated list of \ +files to exclude from the analysis.""", + ) + parser.add_argument( + "--exclude_directories", + type=str, + required=False, + nargs="*", + default=None, + const=None, + help="""An optional space separated list of \ +directories to exclude from the analysis.""", + ) + + # Return parser + result = parser.parse_args() + return result + + +def main(): + """Analyze dart files. + + This function finds, and prints the files that exceed the CLI + defined defaults. + + Args: + None + + Returns: + None + + """ + # Initialize key variables + lookup = {} + errors_found = False + file_count = 0 + Excludes = namedtuple("Excludes", "files directories") + + # Get the CLI arguments + args = _arg_parser_resolver() + + # Define the directories of interest + directories = [ + os.path.expanduser(os.path.join(args.directory, "lib")), + os.path.expanduser(os.path.join(args.directory, "src")), + os.path.expanduser(os.path.join(args.directory, "test")), + ] + + # Get a corrected list of filenames to exclude + exclude_list = _valid_exclusions( + Excludes( + files=args.exclude_files, directories=args.exclude_directories + ) + ) + + # Get interesting filepaths + repo_filepath_list = _filepaths_in_directories(directories) + + # Iterate and analyze each directory + for filepath in repo_filepath_list: + # Skip excluded files + if filepath in exclude_list: + continue + + # Skip /node_modules/ sub directories + if "{0}node_modules{0}".format(os.sep) in filepath: + continue + + # Ignore invalid file extensions + if _valid_extension(filepath) is False: + continue + + # Ignore invalid file filenames + if _valid_filename(filepath) is False: + continue + + # Process the rest + with open(filepath, encoding="latin-1") as code: + line_count = sum( + 1 + for line in code + if line.strip() + and not ( + line.strip().startswith("#") + or line.strip().startswith("/") + ) + ) + lookup[filepath] = line_count + + # If the line rule is voilated then the value is changed to 1 + for filepath, line_count in lookup.items(): + if line_count > args.lines: + errors_found = True + file_count += 1 + if file_count == 1: + print( + """ +LINE COUNT ERROR: Files with excessive lines of code have been found\n""" + ) + + print(" Line count: {:>5} File: {}".format(line_count, filepath)) + + # Evaluate and exit + if bool(errors_found) is True: + print( + """ +The {} files listed above have more than {} lines of code. + +Please fix this. It is a pre-requisite for pull request approval. +""".format( + file_count, args.lines + ) + ) + sys.exit(1) + else: + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/eslint_disable_check.py b/.github/workflows/eslint_disable_check.py new file mode 100644 index 0000000000..201b4462b8 --- /dev/null +++ b/.github/workflows/eslint_disable_check.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- +"""ESLint Checker Script. + +Methodology: + + Recursively analyzes TypeScript files in the 'src' directory and its subdirectories + as well as 'setup.ts' files to ensure they do not contain eslint-disable statements. + + This script enforces code quality practices in the project. + +NOTE: + + This script complies with our python3 coding and documentation standards. + It complies with: + + 1) Pylint + 2) Pydocstyle + 3) Pycodestyle + 4) Flake8 + +""" + +import os +import re +import argparse +import sys + +def has_eslint_disable(file_path): + """ + Check if a TypeScript file contains eslint-disable statements. + + Args: + file_path (str): Path to the TypeScript file. + + Returns: + bool: True if eslint-disable statement is found, False otherwise. + """ + eslint_disable_pattern = re.compile(r'//\s*eslint-disable(?:-next-line|-line)?', re.IGNORECASE) + + try: + with open(file_path, 'r', encoding='utf-8') as file: + content = file.read() + return bool(eslint_disable_pattern.search(content)) + except Exception as e: + print(f"Error reading file {file_path}: {e}") + return False + +def check_eslint(directory): + """ + Recursively check TypeScript files for eslint-disable statements in the 'src' directory. + + Args: + directory (str): Path to the directory. + + Returns: + bool: True if eslint-disable statement is found, False otherwise. + """ + eslint_found = False + + for root, dirs, files in os.walk(os.path.join(directory, 'src')): + for file_name in files: + if file_name.endswith('.tsx') and not file_name.endswith('.test.tsx'): + file_path = os.path.join(root, file_name) + if has_eslint_disable(file_path): + print(f'File {file_path} contains eslint-disable statement.') + eslint_found = True + + setup_path = os.path.join(directory, 'setup.ts') + if os.path.exists(setup_path) and has_eslint_disable(setup_path): + print(f'Setup file {setup_path} contains eslint-disable statement.') + eslint_found = True + + return eslint_found + +def arg_parser_resolver(): + """Resolve the CLI arguments provided by the user. + + Returns: + result: Parsed argument object + """ + parser = argparse.ArgumentParser() + parser.add_argument( + "--directory", + type=str, + default=os.getcwd(), + help="Path to the directory to check (default: current directory)" + ) + return parser.parse_args() + +def main(): + """ + Execute the script's main functionality. + + This function serves as the entry point for the script. It performs + the following tasks: + 1. Validates and retrieves the directory to check from + command line arguments. + 2. Recursively checks TypeScript files for eslint-disable statements. + 3. Provides informative messages based on the analysis. + 4. Exits with an error if eslint-disable statements are found. + + Raises: + SystemExit: If an error occurs during execution. + """ + args = arg_parser_resolver() + + if not os.path.exists(args.directory): + print(f"Error: The specified directory '{args.directory}' does not exist.") + sys.exit(1) + + # Check eslint in the specified directory + eslint_found = check_eslint(args.directory) + + if eslint_found: + print("ESLint-disable check failed. Exiting with error.") + sys.exit(1) + + print("ESLint-disable check completed successfully.") + +if __name__ == "__main__": + main() diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml index 798c766ca6..420d50adbe 100644 --- a/.github/workflows/issue.yml +++ b/.github/workflows/issue.yml @@ -1,13 +1,66 @@ -name: Issue Auto label +############################################################################## +############################################################################## +# +# NOTE! +# +# Please read the README.md file in this directory that defines what should +# be placed in this file +# +############################################################################## +############################################################################## + +name: Issue Workflow on: issues: types: ['opened'] jobs: - build: + Opened-issue-label: + name: Adding Issue Label runs-on: ubuntu-latest steps: - - uses: Renato66/auto-label@v2.2.0 + - uses: actions/checkout@v4 + with: + sparse-checkout: | + .github/workflows/auto-label.json5 + sparse-checkout-cone-mode: false + - uses: Renato66/auto-label@v3 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - uses: actions/github-script@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + script: | + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + const apiParams = { + owner, + repo, + issue_number + }; + const labels = await github.rest.issues.listLabelsOnIssue(apiParams); + if(labels.data.reduce((a, c)=>a||["dependencies"].includes(c.name), false)) + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ["good first issue", "security"] + }); + else if(labels.data.reduce((a, c)=>a||["security", "ui/ux"].includes(c.name), false)) + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: ["good first issue"] + }); + + + Issue-Greeting: + name: Greeting Message to User + runs-on: ubuntu-latest + steps: + - uses: actions/first-interaction@v1 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - ignore-comments: true - default-labels: '["unapproved"]' + issue-message: "Congratulations on making your first Issue! :confetti_ball: If you haven't already, check out our [Contributing Guidelines](https://github.com/PalisadoesFoundation/talawa-admin/blob/develop/CONTRIBUTING.md) and [Issue Reporting Guidelines](https://github.com/PalisadoesFoundation/talawa-admin/blob/develop/ISSUE_GUIDELINES.md) to ensure that you are following our guidelines for contributing and making issues." + diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml deleted file mode 100644 index cae9570993..0000000000 --- a/.github/workflows/linter.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: Linter -on: [pull_request] -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Install Dependencies - run: yarn - - name: Run ESLint and Prettier - run: yarn lint \ No newline at end of file diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml deleted file mode 100644 index 861e37239f..0000000000 --- a/.github/workflows/npm-publish.yml +++ /dev/null @@ -1,47 +0,0 @@ -# This workflow will run tests using node and then publish a package to GitHub Packages when a release is created -# For more information see: https://help.github.com/actions/language-and-framework-guides/publishing-nodejs-packages - -name: Node.js Package - -on: - release: - types: [created] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v1 - with: - node-version: 12 - - run: npm ci - - run: npm test - - publish-npm: - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v1 - with: - node-version: 12 - registry-url: https://registry.npmjs.org/ - - run: npm ci - - run: npm publish - env: - NODE_AUTH_TOKEN: ${{secrets.npm_token}} - - publish-gpr: - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v1 - with: - node-version: 12 - registry-url: https://npm.pkg.github.com/ - - run: npm ci - - run: npm publish - env: - NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/pull-request-target.yml b/.github/workflows/pull-request-target.yml new file mode 100644 index 0000000000..af75effc13 --- /dev/null +++ b/.github/workflows/pull-request-target.yml @@ -0,0 +1,77 @@ +############################################################################## +############################################################################## +# +# NOTE! +# +# Please read the README.md file in this directory that defines what should +# be placed in this file +# +############################################################################## +############################################################################## + +name: PR Target Workflow +on: + pull_request_target: + +jobs: + PR-Greeting: + name: Pull Request Target + runs-on: ubuntu-latest + steps: + - name: Add the PR Review Policy + uses: thollander/actions-comment-pull-request@v2 + with: + comment_tag: pr_review_policy + message: | + ## Our Pull Request Approval Process + + Thanks for contributing! + + ### Testing Your Code + + Remember, your PRs won't be reviewed until these criteria are met: + + 1. We don't merge PRs with poor code quality. + 1. Follow coding best practices such that CodeRabbit.ai approves your PR. + 1. We don't merge PRs with failed tests. + 1. When tests fail, click on the `Details` link to learn more. + 1. Write sufficient tests for your changes (CodeCov Patch Test). Your testing level must be better than the target threshold of the repository + 1. Tests may fail if you edit sensitive files. Ask to add the `ignore-sensitive-files-pr` label if the edits are necessary. + 1. We cannot merge PRs with conflicting files. These must be fixed. + + Our policies make our code better. + + ### Reviewers + + Do not assign reviewers. Our Queue Monitors will review your PR and assign them. + When your PR has been assigned reviewers contact them to get your code reviewed and approved via: + + 1. comments in this PR or + 1. our slack channel + + #### Reviewing Your Code + + Your reviewer(s) will have the following roles: + + 1. arbitrators of future discussions with other contributors about the validity of your changes + 2. point of contact for evaluating the validity of your work + 3. person who verifies matching issues by others that should be closed. + 4. person who gives general guidance in fixing your tests + + ### CONTRIBUTING.md + + Read our CONTRIBUTING.md file. Most importantly: + + 1. PRs with issues not assigned to you will be closed by the reviewer + 1. Fix the first comment in the PR so that each issue listed automatically closes + + ### Other + + 1. :dart: Please be considerate of our volunteers' time. Contacting the person who assigned the reviewers is not advised unless they ask for your input. Do not @ the person who did the assignment otherwise. + 2. Read the CONTRIBUTING.md file make + + - name: Greeting Message to User + uses: actions/first-interaction@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + pr-message: "Congratulations on making your first PR! :confetti_ball: If you haven't already, check out our [Contributing Guidelines](https://github.com/PalisadoesFoundation/talawa-admin/blob/-/CONTRIBUTING.md) and [PR Reporting Guidelines](https://github.com/PalisadoesFoundation/talawa-admin/blob/-/PR_GUIDELINES.md) to ensure that you are following our guidelines for contributing and creating PR." diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 0000000000..aaeebc8345 --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,265 @@ +############################################################################## +############################################################################## +# +# NOTE! +# +# Please read the README.md file in this directory that defines what should +# be placed in this file +# +############################################################################## +############################################################################## + +name: PR Workflow + +on: + pull_request: + branches: + - '**' + +env: + CODECOV_UNIQUE_NAME: CODECOV_UNIQUE_NAME-${{ github.run_id }}-${{ github.run_number }} + +jobs: + Code-Quality-Checks: + name: Performs linting, formatting, type-checking, checking for different source and target branch + runs-on: ubuntu-latest + steps: + - name: Checkout the Repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + + - name: Install Dependencies + run: npm install + + - name: Count number of lines + run: | + chmod +x ./.github/workflows/countline.py + ./.github/workflows/countline.py --lines 600 --exclude_files src/screens/LoginPage/LoginPage.tsx src/GraphQl/Queries/Queries.ts src/screens/OrgList/OrgList.tsx src/GraphQl/Mutations/mutations.ts src/components/EventListCard/EventListCardModals.tsx src/components/TagActions/TagActionsMocks.ts src/utils/interfaces.ts src/screens/MemberDetail/MemberDetail.tsx + + - name: Get changed TypeScript files + id: changed-files + uses: tj-actions/changed-files@v40 + - name: Check formatting + if: steps.changed-files.outputs.only_changed != 'true' + run: npm run format:check + + - name: Run formatting if check fails + if: failure() + run: npm run format + + - name: Check for type errors + if: steps.changed-files.outputs.only_changed != 'true' + run: npm run typecheck + + - name: Check for linting errors in modified files + if: steps.changed-files.outputs.only_changed != 'true' + env: + CHANGED_FILES: ${{ steps.changed_files.outputs.all_changed_files }} + run: npx eslint ${CHANGED_FILES} && python .github/workflows/eslint_disable_check.py + + - name: Check for TSDoc comments + run: npm run check-tsdoc # Run the TSDoc check script + + - name: Check for localStorage Usage + run: | + chmod +x scripts/githooks/check-localstorage-usage.js + node scripts/githooks/check-localstorage-usage.js --scan-entire-repo + + - name: Compare translation files + run: | + chmod +x .github/workflows/compare_translations.py + python .github/workflows/compare_translations.py --directory public/locales + + - name: Check if the source and target branches are different + if: ${{ github.event.pull_request.base.ref == github.event.pull_request.head.ref }} + run: | + echo "Source Branch ${{ github.event.pull_request.head.ref }}" + echo "Target Branch ${{ github.event.pull_request.base.ref }}" + echo "Error: Source and Target Branches are the same. Please ensure they are different." + exit 1 + + Check-Sensitive-Files: + if: ${{ github.actor != 'dependabot[bot]' && !contains(github.event.pull_request.labels.*.name, 'ignore-sensitive-files-pr') }} + name: Checks if sensitive files have been changed without authorization + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get Changed Unauthorized files + id: changed-unauth-files + uses: tj-actions/changed-files@v40 + with: + files: | + .github/** + env.example + .node-version + .husky/** + scripts/** + schema.graphql + package.json + tsconfig.json + .gitignore + .eslintrc.json + .eslintignore + .prettierrc + .prettierignore + vite.config.ts + docker-compose.yaml + Dockerfile + CODEOWNERS + LICENSE + setup.ts + .coderabbit.yaml + CODE_OF_CONDUCT.md + CODE_STYLE.md + CONTRIBUTING.md + DOCUMENTATION.md + INSTALLATION.md + ISSUE_GUIDELINES.md + PR_GUIDELINES.md + README.md + + - name: List all changed unauthorized files + if: steps.changed-unauth-files.outputs.any_changed == 'true' || steps.changed-unauth-files.outputs.any_deleted == 'true' + env: + CHANGED_UNAUTH_FILES: ${{ steps.changed-unauth-files.outputs.all_changed_files }} + run: | + for file in ${CHANGED_UNAUTH_FILES}; do + echo "$file is unauthorized to change/delete" + done + exit 1 + + Count-Changed-Files: + if: ${{ github.actor != 'dependabot[bot]' }} + name: Checks if number of files changed is acceptable + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@v40 + + - name: Echo number of changed files + env: + CHANGED_FILES_COUNT: ${{ steps.changed-files.outputs.all_changed_files_count }} + run: | + echo "Number of files changed: $CHANGED_FILES_COUNT" + + - name: Check if the number of changed files is less than 100 + if: steps.changed-files.outputs.all_changed_files_count > 100 + env: + CHANGED_FILES_COUNT: ${{ steps.changed-files.outputs.all_changed_files_count }} + run: | + echo "Error: Too many files (greater than 100) changed in the pull request." + echo "Possible issues:" + echo "- Contributor may be merging into an incorrect branch." + echo "- Source branch may be incorrect please use develop as source branch." + exit 1 + + Check-ESlint-Disable: + name: Check for eslint-disable + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: 3.9 + + - name: Run Python script + run: | + python .github/workflows/eslint_disable_check.py + + Test-Application: + name: Test Application + runs-on: ubuntu-latest + needs: [Code-Quality-Checks, Check-ESlint-Disable] + steps: + - name: Checkout the Repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + + - name: Install Dependencies + run: npm install + + - name: Get changed TypeScript files + id: changed-files + uses: tj-actions/changed-files@v40 + + - name: Run tests + if: steps.changed-files.outputs.only_changed != 'true' + run: npm run test -- --watchAll=false --coverage + + - name: TypeScript compilation for changed files + run: | + for file in ${{ steps.changed-files.outputs.all_files }}; do + if [[ "$file" == *.ts || "$file" == *.tsx ]]; then + npx tsc --noEmit "$file" + fi + done + + - name: Present and Upload coverage to Codecov as ${{env.CODECOV_UNIQUE_NAME}} + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true + fail_ci_if_error: false + name: '${{env.CODECOV_UNIQUE_NAME}}' + + - name: Test acceptable level of code coverage + uses: VeryGoodOpenSource/very_good_coverage@v2 + with: + path: "./coverage/lcov.info" + min_coverage: 95.0 + + Graphql-Inspector: + if: ${{ github.actor != 'dependabot[bot]' }} + name: Runs Introspection on the GitHub talawa-api repo on the schema.graphql file + runs-on: ubuntu-latest + steps: + - name: Checkout the Repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.x' + + - name: resolve dependency + run: npm install -g @graphql-inspector/cli + + - name: Clone API Repository + run: | + # Retrieve the complete branch name directly from the GitHub context + FULL_BRANCH_NAME=${{ github.base_ref }} + echo "FULL_Branch_NAME: $FULL_BRANCH_NAME" + + # Clone the specified repository using the extracted branch name + git clone --branch $FULL_BRANCH_NAME https://github.com/PalisadoesFoundation/talawa-api && ls -a + + - name: Validate Documents + run: graphql-inspector validate './src/GraphQl/**/*.ts' './talawa-api/schema.graphql' + + Check-Target-Branch: + if: ${{ github.actor != 'dependabot[bot]' }} + name: Check Target Branch + runs-on: ubuntu-latest + steps: + - name: Check if the target branch is develop + if: github.event.pull_request.base.ref != 'develop' + run: | + echo "Error: Pull request target branch must be 'develop'. Please refer PR_GUIDELINES.md" + exit 1 diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000000..950d063fac --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,62 @@ +############################################################################## +############################################################################## +# +# NOTE! +# +# Please read the README.md file in this directory that defines what should +# be placed in this file +# +############################################################################## +############################################################################## + +name: push workflow + +on: + push: + branches: + - '**' + +env: + CODECOV_UNIQUE_NAME: CODECOV_UNIQUE_NAME-${{ github.run_id }}-${{ github.run_number }} + +jobs: + Code-Coverage: + if: ${{ github.actor != 'dependabot[bot]' }} + name: Test and Calculate Code Coverage + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [22.x] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Cache node modules + id: cache-npm + uses: actions/cache@v4 + env: + cache-name: cache-node-modules + with: + path: | + ~/.npm + node_modules + key: ${{ runner.os }}-code-coverage-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} + restore-keys: | + ${{ runner.os }}-code-coverage-${{ env.cache-name }}- + ${{ runner.os }}-code-coverage- + ${{ runner.os }}- + + - if: ${{ steps.cache-npm.outputs.cache-hit != 'true' }} + name: List the state of node modules + run: npm install + - run: npm run test -- --watchAll=false --coverage + - name: Present and upload coverage to Codecov as ${{env.CODECOV_UNIQUE_NAME}} + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true + fail_ci_if_error: false + name: '${{env.CODECOV_UNIQUE_NAME}}' + diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000000..24667f8e06 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,43 @@ +############################################################################## +############################################################################## +# +# NOTE! +# +# Please read the README.md file in this directory that defines what should +# be placed in this file +# +############################################################################## +############################################################################## + +name: Mark stale issues and pull requests + +on: + schedule: + - cron: "0 0 * * *" + +permissions: + issues: write + pull-requests: write + +jobs: + stale: + + runs-on: ubuntu-latest + + steps: + - uses: actions/stale@v8 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + stale-issue-message: 'This issue did not get any activity in the past 10 days and will be closed in 180 days if no update occurs. Please check if the develop branch has fixed it and report again or close the issue.' + stale-pr-message: 'This pull request did not get any activity in the past 10 days and will be closed in 180 days if no update occurs. Please verify it has no conflicts with the develop branch and rebase if needed. Mention it now if you need help or give permission to other people to finish your work.' + close-issue-message: 'This issue did not get any activity in the past 180 days and thus has been closed. Please check if the newest release or develop branch has it fixed. Please, create a new issue if the issue is not fixed.' + close-pr-message: 'This pull request did not get any activity in the past 180 days and thus has been closed.' + stale-issue-label: 'no-issue-activity' + stale-pr-label: 'no-pr-activity' + days-before-stale: 10 + days-before-close: 180 + remove-stale-when-updated: true + exempt-all-milestones: true + exempt-pr-labels: 'wip' + exempt-issue-labels: 'wip' + operations-per-run: 30 diff --git a/.github/workflows/talawa_admin_md_mdx_format_adjuster.py b/.github/workflows/talawa_admin_md_mdx_format_adjuster.py new file mode 100644 index 0000000000..cd76a30cf6 --- /dev/null +++ b/.github/workflows/talawa_admin_md_mdx_format_adjuster.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +# -*- coding: UTF-8 -*- +""" +Script to make Markdown files MDX compatible. + +This script scans Markdown files and escapes special characters (<, >, {, }) +to make them compatible with the MDX standard used in Docusaurus v3. + +This script complies with: + 1) Pylint + 2) Pydocstyle + 3) Pycodestyle + 4) Flake8 +""" +import os +import argparse +import re + +def escape_mdx_characters(text): + """ + Escape special characters in a text string for MDX compatibility. + Avoids escaping already escaped characters. + + Args: + text: A string containing the text to be processed. + + Returns: + A string with special characters (<, >, {, }) escaped, avoiding + double escaping. + """ + # Regular expressions to find unescaped special characters + patterns = { + "<": r"(?": r"(?", + "{": r"(? + +- [Our Pledge](#our-pledge) +- [Our Standards](#our-standards) +- [Enforcement Responsibilities](#enforcement-responsibilities) +- [Scope](#scope) +- [Enforcement](#enforcement) +- [Enforcement Guidelines](#enforcement-guidelines) + - [1. Correction](#1-correction) + - [2. Warning](#2-warning) + - [3. Temporary Ban](#3-temporary-ban) + - [4. Permanent Ban](#4-permanent-ban) +- [Attribution](#attribution) + + + ## Our Pledge We as members, contributors, and leaders pledge to make participation in our diff --git a/CODE_STYLE.md b/CODE_STYLE.md new file mode 100644 index 0000000000..df184b12a0 --- /dev/null +++ b/CODE_STYLE.md @@ -0,0 +1,253 @@ +# Talawa Admin Code Style + +For Talawa Admin, most of the rules for the code style have been enforced with ESLint, but this document serves to provide an overview of the Code style used in Talawa Admin and the Rationale behind it. + +The code style must be strictly adhered to, to ensure that there is consistency throughout the contributions made to Talawa-Admin + +code style should not be changed and must be followed. + +# Table of Contents + + + +- [Tech Stack](#tech-stack) +- [Component Structure](#component-structure) +- [Code Style and Naming Conventions](#code-style-and-naming-conventions) +- [Test and Code Linting](#test-and-code-linting) +- [Folder/Directory Structure](#folderdirectory-structure) + - [Sub Directories of `src`](#sub-directories-of-src) +- [Imports](#imports) +- [Customising Bootstrap](#customising-bootstrap) + + + +## Tech Stack + +- Typescript + +- React.js + +- CSS module + +- React bootstrap + +- Material UI + +- GraphQL + +- Jest & React Testing Library for testing + +## Component Structure + +- Components should be strictly functional components + +- Should make use of React hooks where appropriate + + +## Code Style and Naming Conventions + +- All React components *must* be written in PascalCase, with their file names, and associated CSS modules being written in PascalCase + +- All other files may follow the camelCase naming convention + +- All the Return fragment should be closed in empty tag + +- Use of custom classes directly are refrained, use of modular css is encouraged along with bootstrap classes + +**Wrong way ❌** +``` +
...
+
...
// No using personal custom classes directly, here you should not use myCustomClass2 +.container{...} // No changing the property of already existing classes reserved by boostrap directly in css files +``` + +**Correct ways ✅** +``` +
...
// Use custom class defined in modular css file +
...
// Use classes already defined in Bootstrap +
...
// Use classes already defined in Bootstrap +``` + +- All components should be either imported from React-Bootstrap library or Material UI library, components should not be written using plain Bootstrap classes and attributes without leveraging the React-Bootstrap library. + +**Example: Bootstrap Dropdown** + +**Wrong way ❌** + +Using plain Bootstrap classes and attributes without leveraging the React-Bootstrap library should be refrained. While it may work for basic functionality, it doesn't fully integrate with React and may cause issues when dealing with more complex state management or component interactions. +``` + +``` + + +**Correct way ✅** + +It's recommended to use the React-Bootstrap library for seamless integration of Bootstrap components in a React application. +``` +import Dropdown from 'react-bootstrap/Dropdown'; + +function BasicExample() { + return ( + + + Dropdown Button + + + + Action + Another action + Something else + + + ); +} + +export default BasicExample; +``` + + +## Test and Code Linting + +Unit tests must be written for *all* code submissions to the repository, +the code submitted must also be linted ESLint and formatted with Prettier. + +## Folder/Directory Structure + +### Sub Directories of `src` + +`assets` - This houses all of the static assets used in the project + - `css` - This houses all of the css files used in the project + - `images` - This houses all of the images used in the project + - `scss` - This houses all of the scss files used in the project + - `components -` All Sass files for components + - `content -` All Sass files for content + - `forms -` All Sass files for forms + - `_talawa.scss` - Partial Sass file for Talawa + - `_utilities.scss` - Partial Sass file for utilities + - `_variables.scss` - Partial Sass file for variables + - `app.scss` - Main Sass file for the app, imports all other partial Sass files + +`components` - The directory for base components that will be used in the various views/screens + +`Constant` - This houses all of the constants used in the project + +`GraphQl` - This houses all of the GraphQL queries and mutations used in the project + +`screens` - This houses all of the views/screens to be navigated through in Talawa-Admin + +`state` - This houses all of the state management code for the project + +`utils` - This holds the utility functions that do not fall into any of the other categories + + +## Imports + +Absolute imports have been set up for the project, so imports may be done directly from `src`. + +An example being + +``` +import Navbar from 'components/Navbar/Navbar'; +``` + +Imports should be grouped in the following order: + + - React imports + - Third party imports + - Local imports + + +If there is more than one import from a single library, they should be grouped together + +Example - If there is single import from a library, both ways will work + +``` +import Row from 'react-bootstrap/Row'; +// OR +import { Row } from 'react-bootstrap'; +``` + +If there are multiple imports from a library, they should be grouped together + +``` +import { Row, Col, Container } from 'react-bootstrap'; +``` + +## Customising Bootstrap + +Bootstrap v5.3.0 is used in the project. +Follow this [link](https://getbootstrap.com/docs/5.3/customize/sass/) to learn how to customise bootstrap. + +**File Structure** + +- `src/assets/scss/components/{'{partialFile}'}.scss` - where the {'{partialFile}'} are the following files + - **_accordion.scss** + - **_alert.scss** + - **_badge.scss** + - **_breadcrumb.scss** + - **_buttons.scss** + - **_card.scss** + - **_carousel.scss** + - **_close.scss** + - **_dropdown.scss** + - **_list-group.scss** + - **_modal.scss** + - **_nav.scss** + - **_navbar.scss** + - **_offcanvas.scss** + - **_pagination.scss** + - **_placeholder.scss** + - **_progress.scss** + - **_spinners.scss** + +- `src/assets/scss/content/{'{partialFile}'}.scss` - where the {'{partialFile}'} are the following files + - **_table.scss** + - **_typography.scss** + + +- `src/assets/scss/forms/{'{partialFile}'}.scss` - where the {'{partialFile}'} are the following files + - **_check-radios.scss** + - **_floating-label.scss** + - **_form-control.scss** + - **_input-group.scss** + - **_range.scss** + - **_select.scss** + - **_validation.scss** + +- `src/assets/scss/_utilities.scss` - The utility API is a Sass-based tool to generate utility classes. +- `src/assets/scss/_variables.scss` - This file contains all the Sass variables used in the project +- `src/assets/scss/_talawa.scss` - This files contains all the partial Sass files imported into it + +**How to compile Sass file** + +`src/assets/scss/app.scss` is the main Sass file for the app, it imports all other partial Sass files. +According to naming convention the file name of the partial Sass files should start with an underscore `_` and end with `.scss`, these partial Sass files are not meant to be compiled directly, they are meant to be imported into another Sass file. Only the main Sass file `src/assets/scss/app.scss` should be compiled. + +The compiled CSS file is `src/assets/css/app.css` and it is imported into `src/index.tsx` file. + +To compile the Sass file once, run the following command in the terminal + +``` +npx sass src/assets/scss/app.scss src/assets/css/app.css +``` + +To watch the Sass file for changes and compile it automatically, run the following command in the terminal + +``` +npx sass src/assets/scss/app.scss src/assets/css/app.css --watch +``` +The `src/assets/css/app.css.map` file associates the generated CSS code with the original SCSS code. It allows you to see your SCSS code in the browser's developer tools for debugging. + +To skip generating the map file, run +``` +npx sass --no-source-map src/assets/scss/app.scss src/assets/css/app.css +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d4a02270b6..dbe448c807 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,25 +4,50 @@ Thank you for your interest in contributing to Talawa Admin. Regardless of the s If you are new to contributing to open source, please read the Open Source Guides on [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/). +## Table of Contents + + + +- [Code of Conduct](#code-of-conduct) +- [Videos](#videos) +- [Ways to Contribute](#ways-to-contribute) + - [Our Development Process](#our-development-process) + - [Issues](#issues) + - [Pull Requests](#pull-requests) + - [Branching Strategy](#branching-strategy) + - [Conflict Resolution](#conflict-resolution) + - [Contributing Code](#contributing-code) +- [Internships](#internships) +- [Community](#community) + + + ## Code of Conduct -A safe environment is required for everyone to contribute. Read our [Code of Conduct Guide](https://github.com/PalisadoesFoundation/talawa-admin/blob/master/CODE_OF_CONDUCT.md) to understand what this means. Let us know immediately if you have unacceptable experiences in this area. +A safe environment is required for everyone to contribute. Read our [Code of Conduct Guide](CODE_OF_CONDUCT.md) to understand what this means. Let us know immediately if you have unacceptable experiences in this area. No one should fear voicing their opinion. Respones must be respectful. +## Videos + +1. Visit our [YouTube Channel playlists](https://www.youtube.com/@PalisadoesOrganization/playlists) for more insights + 1. The "[Getting Started - Developers](https://www.youtube.com/watch?v=YpBUoHxEeyg&list=PLv50qHwThlJUIzscg9a80a9-HmAlmUdCF&index=1)" videos are extremely helpful for new open source contributors. + ## Ways to Contribute -If you are ready to start contributing code right away, we have a list of [good first issues](https://github.com/PalisadoesFoundation/talawa-admin/labels/good%20first%20issue) that contain issues with a limited scope. +If you are ready to start contributing code right away, get ready! -## Quicklinks +1. Join our Slack and introduce yourself. See details on how to join below in the Community section. + 1. This repository has its own dedicated channel. + 1. There are many persons on the various channels who are willing to assist you in getting started. +1. Take a look at our issues (**_after reading our guidelines below_**): + 1. We have a list of [good first issues](https://github.com/PalisadoesFoundation/talawa-admin/labels/good%20first%20issue) that contain challenges with a limited scope for beginners. + 1. There are issues for creating tests for our code base. We need to increase reliablility. Try those issues, or create your own for files that don't already have tests. This is another good strategy for beginners. + 1. There are [dormant issues on which nobody has worked for some time](https://github.com/PalisadoesFoundation/talawa-admin/issues?q=is%3Aopen+is%3Aissue+label%3Ano-issue-activity). These are another place to start + 1. There may also be [dormant PRs on which nobody has worked for some time](https://github.com/PalisadoesFoundation/talawa-admin/issues?q=is%3Aopen+is%3Aissue+label%3Ano-issue-activity+label%3Ano-pr-activity)! +1. Create an issue based on a bug you have found or a feature you would like to add. We value meaningful sugestions and will prioritize them. -- [Our Development Process](#Our-development-process) - - [Issues](#issues) - - [Pull Requests](#pull-requests) - - [Git Flow](#git-flow) -- [Contributing Code](#contributing-code) -- [GSoC](#gsoc) -- [Community](#community) +Welcome aboard! ### Our Development Process @@ -30,24 +55,27 @@ We utilize GitHub issues and pull requests to keep track of issues and contribut #### Issues -Make sure you are following [issue report guidelines](https://github.com/PalisadoesFoundation/talawa-admin/blob/master/issue-guidelines.md) available here before creating any new issues on Talawa Admin project. +Make sure you are following [issue report guidelines](ISSUE_GUIDELINES.md) available here before creating any new issues on Talawa Admin project. #### Pull Requests -[Pull Request guidelines](https://github.com/PalisadoesFoundation/talawa-admin/blob/master/PR-guidelines.md) is best resource to follow to start working on open issues. +[Pull Request guidelines](PR_GUIDELINES.md) is best resource to follow to start working on open issues. -#### Git Flow +#### Branching Strategy -For Talawa Admin, we utilize the GitFlow branching model. GitFlow is geared towards efficiently tracking development and managing releases. The model makes parallel development efforts easy and safe by isolating new development efforts from completed work. +For Talawa Admin, we had employed the following branching strategy to simplify the development process and to ensure that only stable code is pushed to the `main` branch: -The different types of branches we may use are: +- `develop`: For unstable code and bug fixing +- `main`: Where the stable production ready code lies. This is our default branch. -- Feature branches (feature/branch-name) -- Release branches (release/1.XX) -- Bug branches (bugfix/branch-name) -- Hotfix branches (hotfix/branch-name) +#### Conflict Resolution -Detailed document containing how GitFlow works: https://nvie.com/posts/a-successful-git-branching-model/ +When multiple developers are working on issues there is bound to be a conflict of interest (not to be confused with git conflicts) among issues, PRs or even ideas. Usually these conflicts are resolved in a **First Come First Serve** basis however there are certain exceptions to it. + +- In the cases where you feel your potential issues could be an extension or in conflict with other PRs it is important to ask the author of the PR in the slack channel or in their PRs or issues themselves why he/she did not write code for something that would require minimal effort on their part. +- Based on basic courtesy, it is good practice to let the person who created a function apply and test that function when needed. +- Last but not the least, communication is important make sure to talk to other contributors, in these cases, in slack channel or in a issue/PR thread. +- As a last resort the Admins would be responsible for deciding how to resolve this conflict. ### Contributing Code @@ -57,11 +85,81 @@ Make sure you have read the [Documentation for Setting up the Project](https://g The process of proposing a change to Talawa Admin can be summarized as: -1. Fork the Talawa Admin repository and branch off `master`. -1. The repository can be cloned locally using `git clone `. +1. Fork the Talawa Admin repository and branch off `develop`. +1. Your newly forked repository can be cloned locally using `git clone `. +1. Make the Palisadoes Foundation's repo your `git upstream` for your local repo. 1. Make the desired changes to the Talawa Admin project. 1. Run the app and test your changes. -1. If you've added code that should be tested, write tests. +1. If you've added code, then test suites must be added. + + 1. **_General_:** + + 1. We need to get to 100% test coverage for the app. We periodically increase the desired test coverage for our pull requests to meet this goal. + 1. Pull requests that don't meet the minimum test coverage levels will not be accepted. This may mean that you will have to create tests for code you did not write. You can decide which part of the code base needs additional tests if this happens to you. + + 2. **_Testing_:** + + 1. Test using this set of commands: + + ``` + npm install + npm run test --watchAll=false --coverage + ``` + + 2. Debug tests in browser + + You can see the output of failing tests in broswer by running `jest-preview` package before running your tests + + ``` + npm install + npm run jest-preview + npm run test --watchAll=false --coverage + ``` + + You don't need to re-run the `npm run jest-preview` command each time, simply run the `npm run test` command if the Jest Preview server is already running in the background, it'll automatically detect any failing tests and show the preview at `http://localhost:3336` as shown in this screenshot - + + ![Debugging Test Demo](./public/images/jest-preview.webp) + + 3. **_Test Code Coverage_:** + + 1. _General Information_ + 1. The current code coverage of the repo is: [![codecov](https://codecov.io/gh/PalisadoesFoundation/talawa-admin/branch/develop/graph/badge.svg?token=II0R0RREES)](https://codecov.io/gh/PalisadoesFoundation/talawa-admin) + 2. You can determine the percentage test coverage of your code by running these two commands in sequence: + ``` + npm install + npm run test --watchAll=false --coverage + genhtml coverage/lcov.info -o coverage + ``` + 3. The output of the `npm run test` command will give you a tablular coverage report per file + 4. The overall coverage rate will be visible on the penultimate line of the `genhtml` command's output. + 5. The `genhtml` command is part of the Linux `lcov` package. Similar packages can be found for Windows and MacOS. + 6. The currently acceptable coverage rate can be found in the [GitHub Pull Request file](.github/workflows/pull-requests.yml). Search for the value below the line containing `min_coverage`. + 2. _Testing Individual Files_ + 1. You can test an individual file by running this command: + ``` + npm run test --watchAll=false /path/to/test/file + ``` + 2. You can get the test coverage report for that file by running this command. The report will list all tests in the suite. Those tests that are not run will have zero values. You will need to look for the output line relevant to your test file. + ``` + npm run test --watchAll=false --coverage /path/to/test/file + ``` + 3. _Creating your code coverage account_ + + 1. You can also see your code coverage online for your fork of the repo. This is provided by `codecov.io` + + 1. Go to this link: `https://app.codecov.io/gh/XXXX/YYYY` where XXXX is your GitHub account username and YYYY is the name of the repository + 2. Login to `codecov.io` using your GitHub account, and add your **repo** and **branches** to the `codecov.io` dashboard. + ![Debugging Test Demo](/public/images/codecov/authorise-codecov-github.jpg) + 3. Remember to add the `Repository Upload Token` for your forked repo. This can be found under `Settings` of your `codecov.io` account. + + 4. Click on Setup Repo option + ![Debugging Test Demo]() + 5. Use the value of this token to create a secret named CODE_COV for your forked repo. + [![Code-cov-token.jpg](/public/images/codecov/Code-cov-token.jpg)]() + [![addd-your-key.jpg](/public/images/codecov/addd-your-key.jpg)]() + 6. You will see your code coverage reports with every push to your repo after following these steps + [![results.jpg](/public/images/codecov/results.jpg)]() + 1. After making changes you can add them to git locally using `git add `(to add changes only in a particular file) or `git add .` (to add all changes). 1. After adding the changes you need to commit them using `git commit -m ''`(look at the commit guidelines below for commit messages). 1. Once you have successfully commited your changes, you need to push the changes to the forked repo on github using: `git push origin `.(Here branch name must be name of the branch you want to push the changes to.) @@ -69,18 +167,13 @@ The process of proposing a change to Talawa Admin can be summarized as: 1. Ensure the test suite passes, either locally or on CI once a PR has been created. 1. Review and address comments on your pull request if requested. -### Internships - -We have internship partnerships with a number of organizations. See below for more details. - -#### GSoC - -If you are participating in the 2021 Summer of Code, please read more about us and our processes [here](https://palisadoesfoundation.github.io/talawa-docs/docs/internships/gsoc/gsoc-introduction) +## Internships -#### GitHub Externship +If you are participating in any of the various internship programs we are members of, then please read the [introduction guides on our documentation website](https://docs.talawa.io/docs/). -If you are participating in the 2021 GitHub Externship, please read more about us and our processes [here](https://palisadoesfoundation.github.io/talawa-docs/docs/internships/github/github-introduction) +## Community -### Community +There are many ways to communicate with the community. -The Palisadoes Foundation has a Slack channel where members can assist with support and clarification. Click [here](https://join.slack.com/t/thepalisadoes-dyb6419/shared_invite/zt-nk79xxlg-OxTdlrD7RLaswu8EO_Q5rg) to join our slack channel. +1. The Palisadoes Foundation has a Slack channel where members can assist with support and clarification. Visit the [Talawa GitHub repository home page](https://github.com/PalisadoesFoundation/talawa) for the link to join our slack channel. +1. We also have a technical email list run by [freelists.org](https://www.freelists.org/). Search for "palisadoes" and join. Members on this list are also periodically added to our marketing email list that focuses on less technical aspects of our work. diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md new file mode 100644 index 0000000000..7691b5d452 --- /dev/null +++ b/DOCUMENTATION.md @@ -0,0 +1,32 @@ +# Documentation +Welcome to our documentation guide. Here are some useful tips you need to know! + +# Table of Contents + + + +- [Where to find our documentation](#where-to-find-our-documentation) +- [How to use Docusaurus](#how-to-use-docusaurus) +- [Other information](#other-information) + + + +## Where to find our documentation + +Our documentation can be found in ONLY TWO PLACES: + +1. ***Inline within the repository's code files***: We have automated processes to extract this information and place it in our Talawa documentation site [docs.talawa.io](https://docs.talawa.io/). +1. ***In our `talawa-docs` repository***: Our [Talawa-Docs](https://github.com/PalisadoesFoundation/talawa-docs) repository contains user edited markdown files that are automatically integrated into our Talawa documentation site [docs.talawa.io](https://docs.talawa.io/) using the [Docusaurus](https://docusaurus.io/) package. + +## How to use Docusaurus +The process in easy: +1. Install `talawa-docs` on your system +1. Launch docusaurus on your system according to the `talawa-docs`documentation. + - A local version of `docs.talawa.io` should automatically launched in your browser at http://localhost:3000/ +1. Add/modify the markdown documents to the `docs/` directory of the `talawa-docs` repository +1. If adding a file, then you will also need to edit the `sidebars.js` which is used to generate the [docs.talawa.io](https://docs.talawa.io/) menus. +1. Always monitor the local website in your brower to make sure the changes are acceptable. + - You'll be able to see errors that you can use for troubleshooting in the CLI window you used to launch the local website. + +## Other information +***PLEASE*** do not add markdown files in this repository. Add them to `talawa-docs`! diff --git a/Docker_Container/.dockerignore b/Docker_Container/.dockerignore deleted file mode 100644 index 56e8146402..0000000000 --- a/Docker_Container/.dockerignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules -build -.dockerignore -Dockerfile -Dockerfile.prod \ No newline at end of file diff --git a/Docker_Container/README.md b/Docker_Container/README.md deleted file mode 100644 index b4bc5bf290..0000000000 --- a/Docker_Container/README.md +++ /dev/null @@ -1,50 +0,0 @@ -## Steps to start the build with Docker - -### Project Setup - -- Install [Docker](https://www.docker.com/) - -- Build and Tag The Docker Image - -`$ docker build -t sample:dev` - -- Then Spin up the container once build is done - -``` -$ docker run \ - -it \ - --rm \ - -v ${PWD}:/app \ - -v /app/node_modules \ - -p 3001:3000 \ - -e CHOKIDAR_USEPOLLING=true \ - sample:dev -``` - -- Whats Happening here - -1. The `docker run` command creates and runs a new conatiner instance from the image we just created - -2. `-it` starts the container in interactive mode - -3. `--rm` removes the container and volumes after the container exists. - -4. `-v ${PWD}:/app` mounts the code into the container at "/app". - -5. Since we want to use the container version of the “node_modules” folder, we configured another volume: `-v /app/node_modules` . You should now be able to remove the local “node_modules” flavor. - -6. `-p 3001:3000` exposes port 3000 to other Docker containers on the same network (for inter-container communication) and port 3001 to the host. - -7. Finally , `-e CHOKIDAR_USEPOLLING=true` enables a polling mechanism via chokidar (which wraps `fs.watch`, `fs.watchFile`, and `fsevents`) so that hot-reloading will work. - -### For using compose file - -- Build the image and fire up the container - -`$ docker-compose up -d --build` - -- Ensure the app is running in the browser and test hot - reloading again. Bring down the container before moving on - -`$ docker-compose stop` - -- Now your container is ready to run diff --git a/Docker_Container/docker-compose.yml b/Docker_Container/docker-compose.yml deleted file mode 100644 index ad7cad7f48..0000000000 --- a/Docker_Container/docker-compose.yml +++ /dev/null @@ -1,16 +0,0 @@ -version: '3.7' - -services: - - sample: - container_name: sample - build: - context: . - dockerfile: Dockerfile - volumes: - - '.:/app' - - '/app/node_modules' - ports: - - 3001:3000 - environment: - - CHOKIDAR_USEPOLLING=true \ No newline at end of file diff --git a/Docker_Container/dockerfile b/Docker_Container/dockerfile deleted file mode 100644 index 34eca8c102..0000000000 --- a/Docker_Container/dockerfile +++ /dev/null @@ -1,21 +0,0 @@ -# pull official base image -FROM node:13.12.0-alpine - -# set working directory -WORKDIR /app - -# add `/app/node_modules/.bin` to $PATH -ENV PATH /app/node_modules/.bin:$PATH - -# install app dependencies -COPY package.json ./ -COPY yarn.lock ./ -COPY package-lock.json ./ -RUN yarn install --silent -RUN yarn install react-scripts@3.4.1 -g --silent - -# add app -COPY . ./ - -# start app -CMD ["yarn", "start"] diff --git a/INSTALLATION.md b/INSTALLATION.md new file mode 100644 index 0000000000..5813b7d1cb --- /dev/null +++ b/INSTALLATION.md @@ -0,0 +1,337 @@ +# Talawa-Admin Installation + +This document provides instructions on how to set up and start a running instance of `talawa-admin` on your local system. The instructions are written to be followed in sequence so make sure to go through each of them step by step without skipping any sections. + +# Table of Contents + + + +- [Installation Steps Summary](#installation-steps-summary) +- [Prerequisites](#prerequisites) + - [Install git](#install-git) + - [Setting up this repository](#setting-up-this-repository) + - [Install node.js](#install-nodejs) + - [Install TypeScript](#install-typescript) + - [Install Required Packages](#install-required-packages) +- [Configuration](#configuration) + - [Creating .env file](#creating-env-file) + - [Setting up PORT in .env file](#setting-up-port-in-env-file) + - [Setting up REACT_APP_TALAWA_URL in .env file](#setting-up-react_app_talawa_url-in-env-file) + - [Setting up REACT_APP_BACKEND_WEBSOCKET_URL in .env file](#setting-up-react_app_backend_websocket_url-in-env-file) + - [Setting up REACT_APP_RECAPTCHA_SITE_KEY in .env file](#setting-up-react_app_recaptcha_site_key-in-env-file) + - [Setting up Compiletime and Runtime logs](#setting-up-compiletime-and-runtime-logs) +- [Post Configuration Steps](#post-configuration-steps) + - [Running Talawa-Admin](#running-talawa-admin) + - [Accessing Talawa-Admin](#accessing-talawa-admin) + - [Talawa-Admin Registration](#talawa-admin-registration) + - [Talawa-Admin Login](#talawa-admin-login) +- [Testing](#testing) + - [Running tests](#running-tests) + - [Debugging tests](#debugging-tests) + - [Linting code files](#linting-code-files) + - [Husky for Git Hooks](#husky-for-git-hooks) + - [pre-commit hook](#pre-commit-hook) + - [post-merge hook](#post-merge-hook) + + + +# Installation Steps Summary + +Installation is not difficult, but there are many steps. This is a brief explanation of what needs to be done: + +1. Install `git` +2. Download the code from GitHub using `git` +3. Install `node.js` (Node), the runtime environment the application will need to work. +4. Configure the Node Package Manager (`npm`) to automatically use the correct version of Node for our application. +5. Use `npm` to install TypeScript, the language the application is written in. +6. Install other supporting software such as the database. +7. Configure the application +8. Start the application + +These steps are explained in more detail in the sections that follow. + +# Prerequisites + +In this section we'll explain how to set up all the prerequisite software packages to get you up and running. + +## Install git + +The easiest way to get the latest copies of our code is to install the `git` package on your computer. + +Follow the setup guide for `git` on official [git docs](https://git-scm.com/downloads). Basic `git` knowledge is required for open source contribution so make sure you're comfortable with it. [Here's](https://youtu.be/apGV9Kg7ics) a good tutorial to get started with `git` and `github`. + +## Setting up this repository + +First you need a local copy of `talawa-admin`. Run the following command in the directory of choice on your local system. + +1. On your computer, navigate to the folder where you want to setup the repository. +2. Open a `cmd` (Windows) or `terminal` (Linux or MacOS) session in this folder. + 1. An easy way to do this is to right-click and choose appropriate option based on your OS. +3. **For Our Open Source Contributor Software Developers:** + + 1. Next, we'll fork and clone the `talawa-admin` repository. + 1. In your web browser, navigate to [https://github.com/PalisadoesFoundation/talawa-admin/](https://github.com/PalisadoesFoundation/talawa-admin/) and click on the `fork` button. It is placed on the right corner opposite the repository name `PalisadoesFoundation/talawa-admin`. + + ![Image with fork](public/markdown/images/install1.png) + + 1. You should now see `talawa-admin` under your repositories. It will be marked as forked from `PalisadoesFoundation/talawa-admin` + + ![Image of user's clone](public/markdown/images/install2.png) + + 1. Clone the repository to your local computer (replacing the values in `{{}}`): + ```bash + $ git clone https://github.com/{{YOUR GITHUB USERNAME}}/talawa-admin.git + cd talawa-admin + git checkout develop + ``` + - **Note:** Make sure to check out the `develop` branch + 1. You now have a local copy of the code files. For more detailed instructions on contributing code, and managing the versions of this repository with `git`, checkout our [CONTRIBUTING.md](./CONTRIBUTING.md) file. + +4. **Talawa Administrators:** + + 1. Clone the repository to your local computer using this command: + + ```bash + $ git clone https://github.com/PalisadoesFoundation/talawa-admin.git + ``` + +## Install node.js + +Best way to install and manage `node.js` is making use of node version managers. We recommend using `fnm`, which will be described in more detail later. + +Follow these steps to install the `node.js` packages in Windows, Linux and MacOS. + +1. For Windows: + 1. first install `node.js` from their website at https://nodejs.org + 1. When installing, don't click the option to install the `necessary tools`. These are not needed in our case. + 2. then install [fnm](https://github.com/Schniz/fnm). Please read all the steps in this section first. + 1. All the commands listed on this page will need to be run in a Windows terminal session in the `talawa-admin` directory. + 2. Install `fnm` using the `winget` option listed on the page. + 3. Setup `fnm` to automatically set the version of `node.js` to the version required for the repository using these steps: + 1. First, refer to the `fnm` web page's section on `Shell Setup` recommendations. + 2. Open a `Windows PowerShell` terminal window + 3. Run the recommended `Windows PowerShell` command to open `notepad`. + 4. Paste the recommended string into `notepad` + 5. Save the document. + 6. Exit `notepad` + 7. Exit PowerShell + 8. This will ensure that you are always using the correct version of `node.js` +2. For Linux and MacOS, use the terminal window. + 1. install `node.js` + 2. then install `fnm` + 1. Refer to the installation page's section on the `Shell Setup` recommendations. + 2. Run the respective recommended commands to setup your node environment + 3. This will ensure that you are always using the correct version of `node.js` + +## Install TypeScript + +TypeScript is a typed superset of JavaScript that compiles to plain JavaScript. It adds optional types, classes, and modules to JavaScript, and supports tools for large-scale JavaScript applications. + +To install TypeScript, you can use the `npm` command which comes with `node.js`: + +```bash +npm install -g typescript +``` + +This command installs TypeScript globally on your system so that it can be accessed from any project. + +## Install Required Packages + +Run the following command to install the packages and dependencies required by the app: + +``` +npm install +``` + +The prerequisites are now installed. The next step will be to get the app up and running. + +# Configuration + +It's important to configure Talawa-Admin. Here's how to do it. + +You can use our interactive setup script for the configuration. Use the following command for the same. + +``` +npm run setup +``` + +All the options in "setup" can be done manually as well and here's how to do it. - [Creating .env file](#creating-env-file) + +## Creating .env file + +A file named .env is required in the root directory of talawa-admin for storing environment variables used at runtime. It is not a part of the repo and you will have to create it. For a sample of `.env` file there is a file named `.env.example` in the root directory. Create a new `.env` file by copying the contents of the `.env.example` into `.env` file. Use this command: + +``` +cp .env.example .env +``` + +This `.env` file must be populated with the following environment variables for `talawa-admin` to work: + +| Variable | Description | +| ------------------------------- | ------------------------------------------------- | +| PORT | Custom port for Talawa-Admin development purposes | +| REACT_APP_TALAWA_URL | URL endpoint for talawa-api graphql service | +| REACT_APP_BACKEND_WEBSOCKET_URL | URL endpoint for websocket end point | +| REACT_APP_USE_RECAPTCHA | Whether you want to use reCAPTCHA or not | +| REACT_APP_RECAPTCHA_SITE_KEY | Site key for authentication using reCAPTCHA | + +Follow the instructions from the sections [Setting up PORT in .env file](#setting-up-port-in-env-file), [Setting up REACT_APP_TALAWA_URL in .env file](#setting-up-REACT_APP_TALAWA_URL-in-env-file), [Setting up REACT_APP_BACKEND_WEBSOCKET_URL in .env file](#setting-up-react_app_backend_websocket_url-in-env-file), [Setting up REACT_APP_RECAPTCHA_SITE_KEY in .env file](#setting-up-REACT_APP_RECAPTCHA_SITE_KEY-in-env-file) and [Setting up Compiletime and Runtime logs](#setting-up-compiletime-and-runtime-logs) to set up these environment variables. + +## Setting up PORT in .env file + +Add a custom port number for Talawa-Admin development purposes to the variable named `PORT` in the `.env` file. + +## Setting up REACT_APP_TALAWA_URL in .env file + +Add the endpoint for accessing talawa-api graphql service to the variable named `REACT_APP_TALAWA_URL` in the `.env` file. + +``` +REACT_APP_TALAWA_URL="http://API-IP-ADRESS:4000/graphql/" +``` + +If you are a software developer working on your local system, then the URL would be: + +``` +REACT_APP_TALAWA_URL="http://localhost:4000/graphql/" +``` + +If you are trying to access Talawa Admin from a remote host with the API URL containing "localhost", You will have to change the API URL to + +``` +REACT_APP_TALAWA_URL="http://YOUR-REMOTE-ADDRESS:4000/graphql/" +``` + +## Setting up REACT_APP_BACKEND_WEBSOCKET_URL in .env file + +The endpoint for accessing talawa-api WebSocket graphql service for handling subscriptions is automatically added to the variable named `REACT_APP_BACKEND_WEBSOCKET_URL` in the `.env` file. + +``` +REACT_APP_BACKEND_WEBSOCKET_URL="ws://API-IP-ADRESS:4000/graphql/" +``` + +If you are a software developer working on your local system, then the URL would be: + +``` +REACT_APP_BACKEND_WEBSOCKET_URL="ws://localhost:4000/graphql/" +``` + +If you are trying to access Talawa Admin from a remote host with the API URL containing "localhost", You will have to change the API URL to + +``` +REACT_APP_BACKEND_WEBSOCKET_URL="ws://YOUR-REMOTE-ADDRESS:4000/graphql/" +``` + +For additional details, please refer the `How to Access the Talawa-API URL` section in the INSTALLATION.md file found in the [Talawa-API repo](https://github.com/PalisadoesFoundation/talawa-api). + +## Setting up REACT_APP_RECAPTCHA_SITE_KEY in .env file + +You may not want to setup reCAPTCHA since the project will still work. Moreover, it is recommended to not set it up in development environment. + +Just skip to the [Post Configuration Steps](#post-configuration-steps) if you don't want to set it up. Else, read the following steps. + +If you want to setup Google reCAPTCHA now, you may refer to the `RECAPTCHA` section in the INSTALLATION.md file found in [Talawa-API repo](https://github.com/PalisadoesFoundation/talawa-api). + +`Talawa-admin` needs the `reCAPTCHA site key` for the `reCAPTCHA` service you set up during `talawa-api` installation as shown in this screenshot: + +![reCAPTCHA site key](./public/images/REACT_SITE_KEY.webp) + +Copy/paste this `reCAPTCHA site key` to the variable named `REACT_APP_RECAPTCHA_SITE_KEY` in `.env` file. + +``` +REACT_APP_RECAPTCHA_SITE_KEY="this_is_the_recaptcha_key" +``` + +## Setting up Compiletime and Runtime logs + +Set the `ALLOW_LOGS` to "YES" if you want warnings , info and error messages in your console or leave it blank if you dont need them or want to keep the console clean + +# Post Configuration Steps + +It's now time to start Talawa-Admin and get it running + +## Running Talawa-Admin + +Run the following command to start `talawa-admin` development server: + +``` +npm run serve +``` + +## Accessing Talawa-Admin + +By default `talawa-admin` runs on port `4321` on your system's localhost. It is available on the following endpoint: + +``` +http://localhost:4321/ +``` + +If you have specified a custom port number in your `.env` file, Talawa-Admin will run on the following endpoint: + +``` +http://localhost:${{customPort}}/ +``` + +Replace `${{customPort}}` with the actual custom port number you have configured in your `.env` file. + +## Talawa-Admin Registration + +The first time you navigate to the running talawa-admin's website you'll land at talawa-admin registration page. Sign up using whatever credentials you want and create the account. Make sure to remember the email and password you entered because they'll be used to sign you in later on. + +## Talawa-Admin Login + +Now sign in to talawa-admin using the `email` and `password` you used to sign up. + +# Testing + +It is important to test our code. If you are a contributor, please follow these steps. + +## Running tests + +You can run the tests for `talawa-admin` using this command: + +``` +npm run test +``` + +## Debugging tests + +You can see the output of failing tests in broswer by running `jest-preview` package before running your tests + +``` +npm run jest-preview +npm run test +``` + +You don't need to re-run the `npm run jest-preview` command each time, simply run the `npm run test` command if the Jest Preview server is already running in the background, it'll automatically detect any failing tests and show the preview at `http://localhost:3336` as shown in this screenshot - + +![Debugging Test Demo](./public/images/jest-preview.webp) + +## Linting code files + +You can lint your code files using this command: + +``` +npm run lint:fix +``` + +## Husky for Git Hooks + +We are using the package `Husky` to run git hooks that run according to different git workflows. + +#### pre-commit hook + +We run a pre-commit hook which automatically runs code quality checks each time you make a commit and also fixes some of the issues. This way you don't have to run them manually each time. + +If you don't want these pre-commit checks running on each commit, you can manually opt out of it using the `--no-verify` flag with your commit message as shown:- + + git commit -m "commit message" --no-verify + +#### post-merge hook + +We are also running a post-merge(post-pull) hook which will automatically run "npm install" only if there is any change made to pakage.json file so that the developer has all the required dependencies when pulling files from remote. + +If you don't want this hook to run, you can manually opt out of this using the `no verify` flag while using the merge command(git pull): + + git pull --no-verify + +
diff --git a/ISSUE_GUIDELINES.md b/ISSUE_GUIDELINES.md new file mode 100644 index 0000000000..5170de5839 --- /dev/null +++ b/ISSUE_GUIDELINES.md @@ -0,0 +1,59 @@ +# Issue Report Guidelines + +:+1::tada: First off, thanks for taking the time to contribute! :tada::+1: + +In order to give everyone a chance to submit a issues reports and contribute to the Talawa project, we have put restrictions in place. This section outlines the guidelines that should be imposed upon issue reports in the Talawa project. + +___ +## Table of Contents + + + +- [Issue Management](#issue-management) + - [New Issues](#new-issues) + - [Existing Issues](#existing-issues) + - [Feature Request Issues](#feature-request-issues) + - [Monitoring the Creation of New Issues](#monitoring-the-creation-of-new-issues) +- [General Guidelines](#general-guidelines) + + + +___ +## Issue Management + +In all cases please use the [GitHub open issue search](https://github.com/PalisadoesFoundation/talawa-admin/issues) to check whether the issue has already been reported. + +### New Issues +To create new issues follow these steps: + +1. Your issue may have already been created. Search for duplicate open issues before submitting yours.for similar deficiencies in the code.duplicate issues are created. +1. Verify whether the issue has been fixed by trying to reproduce it using the latest master or development branch in the repository. +1. Click on the [`New Issue`](https://github.com/PalisadoesFoundation/talawa-admin/issues/new/choose) button +1. Use the templates to create a standardized report of what needs to be done and why. +1. If you want to be assigned the issue that you have created, then add a comment immediately after submitting it. + +We welcome contributors who find new ways to make the code better. + +### Existing Issues + +You can also be a valuable contributor by searching for dormant issues. Here's how you can do that: + +1. **Previously Assigned Issues**: We regularly review issues and add a [`no-issue-activity`](https://github.com/PalisadoesFoundation/talawa-admin/issues?q=is%3Aissue+is%3Aopen+label%3Ano-issue-activity) label to them. Use the issue comments to ask whether the assignee is still working on the issue, and if not, ask for the issue to be assigned to you. +1. **Unassigned Issues**: If the issue is already reported and [not assigned to anyone](https://github.com/PalisadoesFoundation/talawa-admin/issues?q=is%3Aissue+is%3Aopen+no%3Aassignee) and you are interested in working on the issue then: + 1. Ask for the issue to be assigned to you in the issue comments + 2. Ask our contributors to assign it to you in `#talawa` slack channel. + +Working on these types of existing issues is a good way of getting started with the community. + +### Feature Request Issues + +Feature requests are welcome. But take a moment to find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the mentors of the merits of this feature. Please provide as much detail and context as possible. + +### Monitoring the Creation of New Issues +1. Join our `#talawa-github` slack channel for automatic issue and pull request updates. + +## General Guidelines + +1. Discuss issues in our various slack channels when necessary +2. Please do not derail or troll issues. +3. Keep the discussion on topic and respect the opinions of others. diff --git a/ISSUE_TEMPLATE.md b/ISSUE_TEMPLATE.md deleted file mode 100644 index c59e24c1ee..0000000000 --- a/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,38 +0,0 @@ - - -## Expected Behavior - - - -## Current Behavior - - - -## Possible Solution - - - -## Steps to Reproduce - - - - -1. -2. -3. -4. - -## Context (Environment) - - - - - - -## Detailed Description - - - -## Possible Implementation - - diff --git a/PR-guidelines.md b/PR-guidelines.md deleted file mode 100644 index c469028b0b..0000000000 --- a/PR-guidelines.md +++ /dev/null @@ -1,21 +0,0 @@ -# Pull Request Guidelines - -:+1::tada: First off, thanks for taking the time to contribute! :tada::+1: - -In order to give everyone a chance to submit a pull request and contribute to the Talawa API project, we have put restrictions in place. This section outlines the guidelines that should be imposed upon pull requests in the Talawa API project. - -1. Do not start working on any open issue and raise a PR unless it is assigned to you. -2. Pull requests must be based on [open issues](https://github.com/PalisadoesFoundation/talawa-admin/issues) available. -3. [Use this method to automatically close the issue when the PR is completed.](https://docs.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue) -4. Each contributor may only create one pull request at a time. We have this rule in place due to our limited resources - if everyone was allowed to post multiple pull requests we will not be able to review them properly. It is also better for contributors because you can focus on creating one quality PR - so spend time making sure it is as good as it can be. -5. If the pull request's code quality is not up to par, or it would break the app, it will more likely be closed. So please be careful when creating a PR. -6. Please follow the [PR template](https://github.com/PalisadoesFoundation/talawa-admin/blob/master/templates/pr-template.md). Ensure the PR title clearly describes the problem it is solving. In the description, include the relevant issue number, snapshots and videos after changes added. -7. If you are borrowing code, please disclose it. It is fine and sometimes even recommended to borrow code, but we need to know about it to assess your work. If we find out that your pull request contains a lot of code copied from elsewhere, we will close the pull request. -8. All pull request must have test units. If for some reason it is not possible to add tests, please let us know and explain why. In that case, you'll need to tell us what steps you followed to manually test your changes. -9. No Work In Progress. ONLY completed and working pull requests, and with test units, will be accepted. A WIP would fall under rule 4 and be closed immediately. -10. Please do not @mention contributors and mentors. Sometimes it takes time before we can review your pull request or answer your questions but we'll get to it sooner or later. @mentioning someone just adds to the pile of notifications we get and it won't make us look at your issue faster. -11. Do not force push. If you make changes to your pull request, please simply add a new commit as that makes it easy for us to review your new changes. If you force push, we'll have to review everything from the beginning. -12. PR should be small, easy to review and should follow standard coding styles. -13. If PR has conflicts because of recently added changes to the same file, resolve issues, test new changes and submit PR again for review. -14. PRs should be atomic. That is, they should address one item (issue or feature) -15. After submitting PR, if you are not replying within 48 hours then in that case we may need to assign issue to other contributors based on priority of the issue. diff --git a/PR_GUIDELINES.md b/PR_GUIDELINES.md new file mode 100644 index 0000000000..4c904c782d --- /dev/null +++ b/PR_GUIDELINES.md @@ -0,0 +1,69 @@ +# Pull Request Guidelines + +:+1::tada: First off, thanks for taking the time to contribute! :tada::+1: + +In order to give everyone a chance to submit a pull request and contribute to the Talawa project, we have put restrictions in place. This section outlines the guidelines that should be imposed upon pull requests in the Talawa project. + +# Table of Contents + + + +- [Pull Requests and Issues](#pull-requests-and-issues) +- [Linting and Formatting](#linting-and-formatting) +- [Testing](#testing) +- [Pull Request Processing](#pull-request-processing) + - [Only submit PRs against our `develop` branch, not the default `main` branch](#only-submit-prs-against-our-develop-branch-not-the-default-main-branch) + + + +## Pull Requests and Issues + +1. Do not start working on any open issue and raise a PR unless the issue is assigned to you. PRs that don't meet these guidelines will be closed. +1. Pull requests must be based on [open issues](https://github.com/PalisadoesFoundation/talawa-admin/issues) available. +1. [Use this method to automatically close the issue when the PR is completed.](https://docs.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue) + +## Linting and Formatting + +All the pull requests must have code that is properly linted and formatted, so that uniformity across the repository can be ensured. + +Before opening a PR, you can run the following scripts to automatically lint and format the code properly: + +``` +npm run lint:fix +npm run format:fix +``` + +Both of these scripts also have a `check` counterpart, which would be used by the GitHub CI to ensure that the code is properly formatted. +You can run the following scripts yourself to ensure that your pull request doesn't fail due to linting and formatting errors: + +``` +npm run lint:check +npm run format:check +``` + +## Testing + +1. All pull requests must have test units. If, for some reason, it is not possible to add tests, please let us know and explain why. In that case, you'll need to tell us what steps you followed to manually test your changes. +1. Please read our [CONTRIBUTING.md](CONTRIBUTING.md) document for details on our testing policy. + +## Pull Request Processing +These are key guidelines for the procedure: + +### Only submit PRs against our `develop` branch, not the default `main` branch + +1. Only submit PRs against our `develop` branch. The default is `main`, so you will have to modify this before submitting your PR for review. PRs made against `main` will be closed. +1. We do not accept draft Pull Requests. They will be closed if submitted. We focus on work that is ready for immediate review. +1. Removing assigned reviewers from your Pull Request will cause it to be closed. The quality of our code is very important to us. Therefore we make experienced maintainers of our code base review your code. Removing these assigned persons is not in the best interest of this goal. +1. If you have not done so already, please read the `Pull Requests and Issues` and `Testing` sections above. +1. Each contributor may only create one pull request at a time. We have this rule in place due to our limited resources - if everyone was allowed to post multiple pull requests, we would not be able to review them properly. It is also better for contributors because you can focus on creating one quality PR - so spend time making sure it is as good as it can be. +1. Upon successful push to the fork, check if all tests are passing; if not, fix the issues and then create a pull request. +1. If the pull request's code quality is not up to par, or it would break the app, it will more likely be closed. So please be careful when creating a PR. +1. Please follow the PR template provided. Ensure the PR title clearly describes the problem it is solving. In the description, include the relevant issue number, snapshots, and videos after changes are added. +1. If you are borrowing a code, please disclose it. It is fine and sometimes even recommended to borrow code, but we need to know about it to assess your work. If we find out that your pull request contains a lot of code copied from elsewhere, we will close the pull request. +1. No Work In Progress. ONLY completed and working pull requests and with respective test units will be accepted. A WIP would fall under rule 4 and be closed immediately. +1. Please do not @mention contributors and mentors. Sometimes it takes time before we can review your pull request or answer your questions, but we'll get to it sooner or later. @mentioning someone just adds to the pile of notifications we get and it won't make us look at your issue faster. +1. Do not force push. If you make changes to your pull request, please simply add a new commit, as that makes it easy for us to review your new changes. If you force push, we'll have to review everything from the beginning. +1. PR should be small, easy to review and should follow standard coding styles. +1. If PR has conflicts because of recently added changes to the same file, resolve issues, test new changes, and submit PR again for review. +1. PRs should be atomic. That is, they should address one item (issue or feature) +1. After submitting PR, if you are not replying within 48 hours, then in that case, we may need to assign the issue to other contributors based on the priority of the issue. diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 435ecf4ada..0000000000 --- a/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,15 +0,0 @@ -- **Please check if the PR fulfills these requirements** - -* [ ] The commit message follows our guidelines -* [ ] Tests for the changes have been added (for bug fixes / features) -* [ ] Docs have been added / updated (for bug fixes / features) - -- **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...) - -- **What is the current behavior?** (You can also link to an open issue here) - -- **What is the new behavior (if this is a feature change)?** - -- **Does this PR introduce a breaking change?** (What changes might users need to make in their application due to this PR?) - -- **Other information**: diff --git a/README.md b/README.md index 66d98dd25c..cbade9e407 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ # Talawa Admin +[💬 Join the community on Slack](https://github.com/PalisadoesFoundation/) + +![talawa-logo-lite-200x200](https://github.com/PalisadoesFoundation/talawa-admin/assets/16875803/26291ec5-d3c1-4135-8bc7-80885dff613d) + [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![GitHub stars](https://img.shields.io/github/stars/PalisadoesFoundation/talawa-admin.svg?style=social&label=Star&maxAge=2592000)](https://github.com/PalisadoesFoundation/talawa-admin) [![GitHub forks](https://img.shields.io/github/forks/PalisadoesFoundation/talawa-admin.svg?style=social&label=Fork&maxAge=2592000)](https://github.com/PalisadoesFoundation/talawa-admin) - -[![N|Solid](src/assets/talawa-logo-lite-200x200.png)](https://github.com/PalisadoesFoundation/talawa-admin) +[![codecov](https://codecov.io/gh/PalisadoesFoundation/talawa-admin/branch/develop/graph/badge.svg?token=II0R0RREES)](https://codecov.io/gh/PalisadoesFoundation/talawa-admin) Talawa is a modular open source project to manage group activities of both non-profit organizations and businesses. @@ -18,6 +21,16 @@ Core features include: `talawa` is based on the original `quito` code created by the [Palisadoes Foundation][pfd] as part of its annual Calico Challenge program. Calico provides paid summer internships for Jamaican university students to work on selected open source projects. They are mentored by software professionals and receive stipends based on the completion of predefined milestones. Calico was started in 2015. Visit [The Palisadoes Foundation's website](http://www.palisadoes.org/) for more details on its origin and activities. +# Table of Contents + + + +- [Talawa Components](#talawa-components) +- [Documentation](#documentation) +- [Videos](#videos) + + + # Talawa Components `talawa` has these major software components: @@ -29,44 +42,15 @@ Core features include: # Documentation -- The `talawa` documentation can be found [here](https://palisadoesfoundation.github.io/talawa-docs/). -- Want to contribute? Look at [CONTRIBUTING.md](CONTRIBUTING.md) to get started. -- Visit the [Talawa-Docs GitHub](https://github.com/PalisadoesFoundation/talawa-docs) to see the code. - -# Project Setup - -``` -yarn install -``` - -## Compiles and hot-reloads for development - -``` -yarn serve -``` - -## Compiles and minifies for production - -``` -yarn build -``` - -## Run your end-to-end tests - -``` -yarn test:e2e -``` - -## Lints and fixes files - -``` -yarn lint -``` - -## Customize configuration - -See [Configuration Reference](https://cli.vuejs.org/config/). +1. You can install the software for this repository using the steps in our [INSTALLATION.md](INSTALLATION.md) file. +1. Do you want to contribute to our code base? Look at our [CONTRIBUTING.md](CONTRIBUTING.md) file to get started. There you'll also find links to: + 1. Our code of conduct documentation in the [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) file. + 1. How we handle the processing of new and existing issues in our [ISSUE_GUIDELINES.md](ISSUE_GUIDELINES.md) file. + 1. The methodologies we use to manage our pull requests in our [PR_GUIDELINES.md](PR_GUIDELINES.md) file. +1. The `talawa` documentation can be found at our [docs.talawa.io](https://docs.talawa.io) site. + 1. It is automatically generated from the markdown files stored in our [Talawa-Docs GitHub repository](https://github.com/PalisadoesFoundation/talawa-docs). This makes it easy for you to update our documenation. -## Project setup using docker +# Videos -See [Docker Container](Docker_Container/README.md) +1. Visit our [YouTube Channel playlists](https://www.youtube.com/@PalisadoesOrganization/playlists) for more insights + 1. The "[Getting Started - Developers](https://www.youtube.com/watch?v=YpBUoHxEeyg&list=PLv50qHwThlJUIzscg9a80a9-HmAlmUdCF&index=1)" videos are extremely helpful for new open source contributors. diff --git a/config/babel.config.cjs b/config/babel.config.cjs new file mode 100644 index 0000000000..10cfe1914a --- /dev/null +++ b/config/babel.config.cjs @@ -0,0 +1,8 @@ +module.exports = { + presets: [ + '@babel/preset-env', // Transforms modern JavaScript + '@babel/preset-typescript', // Transforms TypeScript + '@babel/preset-react', // Transforms JSX + ], + plugins: ['babel-plugin-transform-import-meta'], +}; diff --git a/config/vite.config.ts b/config/vite.config.ts new file mode 100644 index 0000000000..71ce6c6f47 --- /dev/null +++ b/config/vite.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import viteTsconfigPaths from 'vite-tsconfig-paths'; +import svgrPlugin from 'vite-plugin-svgr'; +import EnvironmentPlugin from 'vite-plugin-environment'; + +export default defineConfig({ + // depending on your application, base can also be "/" + build: { + outDir: 'build', + }, + base: '', + plugins: [ + react(), + viteTsconfigPaths(), + EnvironmentPlugin('all'), + svgrPlugin({ + svgrOptions: { + icon: true, + // ...svgr options (https://react-svgr.com/docs/options/) + }, + }), + ], + server: { + // this ensures that the browser opens upon server start + open: true, + // this sets a default port to 3000 + port: 4321, + }, +}); diff --git a/dump.rdb b/dump.rdb new file mode 100644 index 0000000000..6d49ca7f96 Binary files /dev/null and b/dump.rdb differ diff --git a/index.html b/index.html new file mode 100644 index 0000000000..4375f88592 --- /dev/null +++ b/index.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + Talawa Admin + + +
+ + + diff --git a/issue-guidelines.md b/issue-guidelines.md deleted file mode 100644 index 5d626c332d..0000000000 --- a/issue-guidelines.md +++ /dev/null @@ -1,12 +0,0 @@ -# Issue Report Guidelines - -:+1::tada: First off, thanks for taking the time to contribute! :tada::+1: - -In order to give everyone a chance to submit a issues reports and contribute to the Talawa API project, we have put restrictions in place. This section outlines the guidelines that should be imposed upon issue reports in the Talawa API project. - -1. Use the [GitHub open issue search](https://github.com/PalisadoesFoundation/talawa-admin/issues) — check if the issue has already been reported. -2. If the issue is already reported and not assigned to anyone, if you are interested to work on the issue then ask mentors to assign issign to you in #talawa-api slack channel. -3. Check if the issue has been fixed — try to reproduce it using the latest master or development branch in the repository. -4. For newly found unfixed issues or features, start discussing it in #gsoc-newissues channel with mentors. Please do not derail or troll issues. Keep the discussion on topic and respect the opinions of others. -5. After mentor approval you can create a new issue by following [issue template](https://github.com/PalisadoesFoundation/talawa-admin/blob/master/templates/issue-template.md) available here. -6. Feature requests are welcome. But take a moment to find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the mentors of the merits of this feature. Please provide as much detail and context as possible. diff --git a/jest-preview.config.ts b/jest-preview.config.ts new file mode 100644 index 0000000000..0bcc13d4c7 --- /dev/null +++ b/jest-preview.config.ts @@ -0,0 +1,5 @@ +export default { + moduleNameMapper: { + '^@mui/(.*)$': '/node_modules/@mui/$1', + }, +}; diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000000..bd17983c75 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,71 @@ +export default { + roots: ['/src'], + collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/index.tsx'], + // setupFiles: ['react-app-polyfill/jsdom'], + setupFiles: ['whatwg-fetch'], + setupFilesAfterEnv: ['/src/setupTests.ts'], + testMatch: [ + '/src/**/__tests__/**/*.{js,jsx,ts,tsx}', + '/src/**/*.{spec,test}.{js,jsx,ts,tsx}', + ], + testEnvironment: 'jsdom', + transform: { + '^.+\\.(js|jsx|ts|tsx)$': [ + 'babel-jest', + { configFile: './config/babel.config.cjs' }, + ], // Use babel-jest for JavaScript and TypeScript files + '^.+\\.(css|scss|sass|less)$': 'jest-preview/transforms/css', // CSS transformations + '^(?!.*\\.(js|jsx|ts|tsx|css|json)$)': 'jest-preview/transforms/file', // File transformations + }, + transformIgnorePatterns: [ + '[/\\\\]node_modules[/\\\\].+\\.(js|jsx|mjs|cjs|ts|tsx)$', + ], + modulePaths: [ + '/Users/prathamesh/Desktop/Open-Source/palisadoes/talawa-admin/src', + '/src', + ], + moduleNameMapper: { + '^react-native$': 'react-native-web', + '^@dicebear/core$': '/scripts/__mocks__/@dicebear/core.ts', + '^@dicebear/collection$': + '/scripts/__mocks__/@dicebear/collection.ts', + '\\.svg\\?react$': '/scripts/__mocks__/fileMock.js', + '\\.svg$': '/scripts/__mocks__/fileMock.js', + '^@pdfme/generator$': '/scripts/__mocks__/@pdfme/generator.ts' + }, + moduleFileExtensions: [ + 'web.js', + 'js', + 'web.ts', + 'ts', + 'web.tsx', + 'tsx', + 'json', + 'web.jsx', + 'jsx', + 'node', + ], + // watchPlugins: [ + // 'jest-watch-typeahead/filename', + // 'jest-watch-typeahead/testname', + // ], + resetMocks: false, + coveragePathIgnorePatterns: [ + 'src/state/index.ts', + 'src/components/plugins/index.ts', + 'src/components/AddOn/support/services/Render.helper.ts', + 'src/components/SecuredRoute/SecuredRoute.tsx', + 'src/reportWebVitals.ts', + ], + coverageThreshold: { + global: { + lines: 20, + statements: 20, + }, + }, + testPathIgnorePatterns: [ + '/node_modules/', + '/build/', + '/public/', + ], +}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..0add51da52 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,19051 @@ +{ + "name": "talawa-admin", + "version": "3.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "talawa-admin", + "version": "3.0.0", + "dependencies": { + "@apollo/client": "^3.11.8", + "@apollo/link-error": "^2.0.0-beta.3", + "@apollo/react-testing": "^4.0.0", + "@dicebear/collection": "^9.2.2", + "@dicebear/core": "^9.2.2", + "@emotion/react": "^11.13.3", + "@emotion/styled": "^11.13.0", + "@mui/base": "^5.0.0-beta.61", + "@mui/icons-material": "^6.1.6", + "@mui/material": "^6.1.6", + "@mui/private-theming": "^6.1.6", + "@mui/system": "^6.1.6", + "@mui/x-charts": "^7.22.1", + "@mui/x-data-grid": "^7.22.1", + "@mui/x-date-pickers": "^7.22.1", + "@pdfme/generator": "^5.1.7", + "@pdfme/schemas": "^5.1.6", + "@reduxjs/toolkit": "^2.3.0", + "@vitejs/plugin-react": "^4.3.2", + "babel-plugin-transform-import-meta": "^2.2.1", + "bootstrap": "^5.3.3", + "chart.js": "^4.4.6", + "customize-cra": "^1.0.0", + "dayjs": "^1.11.13", + "dotenv": "^16.4.5", + "flag-icons": "^7.2.3", + "graphql": "^16.9.0", + "graphql-tag": "^2.12.6", + "graphql-ws": "^5.16.0", + "history": "^5.3.0", + "i18next": "^23.15.1", + "i18next-browser-languagedetector": "^8.0.0", + "i18next-http-backend": "^2.6.1", + "inquirer": "^8.0.0", + "js-cookie": "^3.0.1", + "markdown-toc": "^1.2.0", + "prettier": "^3.3.3", + "prop-types": "^15.8.1", + "react": "^18.3.1", + "react-beautiful-dnd": "^13.1.1", + "react-bootstrap": "^2.10.5", + "react-chartjs-2": "^5.2.0", + "react-datepicker": "^7.5.0", + "react-dom": "^18.3.1", + "react-google-recaptcha": "^3.1.0", + "react-i18next": "^15.0.2", + "react-icons": "^5.2.1", + "react-infinite-scroll-component": "^6.1.0", + "react-multi-carousel": "^2.8.5", + "react-redux": "^9.1.2", + "react-router-dom": "^6.27.0", + "react-toastify": "^10.0.6", + "react-tooltip": "^5.28.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "sanitize-html": "^2.13.0", + "typedoc": "^0.26.10", + "typedoc-plugin-markdown": "^4.2.10", + "typescript": "^5.6.3", + "vite": "^5.4.8", + "vite-plugin-environment": "^1.1.3", + "vite-tsconfig-paths": "^5.1.2", + "web-vitals": "^4.2.4" + }, + "devDependencies": { + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@babel/preset-env": "^7.25.4", + "@babel/preset-react": "^7.25.7", + "@babel/preset-typescript": "^7.26.0", + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^12.1.10", + "@types/inquirer": "^9.0.7", + "@types/jest": "^26.0.24", + "@types/js-cookie": "^3.0.6", + "@types/node": "^22.5.4", + "@types/node-fetch": "^2.6.10", + "@types/react": "^18.3.3", + "@types/react-beautiful-dnd": "^13.1.8", + "@types/react-bootstrap": "^0.32.37", + "@types/react-chartjs-2": "^2.5.7", + "@types/react-datepicker": "^7.0.0", + "@types/react-dom": "^18.3.1", + "@types/react-google-recaptcha": "^2.1.9", + "@types/react-router-dom": "^5.1.8", + "@types/sanitize-html": "^2.13.0", + "@typescript-eslint/eslint-plugin": "^8.11.0", + "@typescript-eslint/parser": "^8.5.0", + "babel-jest": "^29.7.0", + "cross-env": "^7.0.3", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.30.0", + "eslint-plugin-jest": "^28.8.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-react": "^7.37.1", + "eslint-plugin-tsdoc": "^0.3.0", + "husky": "^9.1.6", + "identity-obj-proxy": "^3.0.0", + "jest": "^27.4.5", + "jest-localstorage-mock": "^2.4.19", + "jest-location-mock": "^2.0.0", + "jest-preview": "^0.3.1", + "lint-staged": "^15.2.8", + "postcss-modules": "^6.0.0", + "sass": "^1.80.6", + "tsx": "^4.19.1", + "vite-plugin-svgr": "^4.2.0", + "whatwg-fetch": "^3.6.20" + }, + "engines": { + "node": ">=20.x" + } + }, + "node_modules/@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.0.tgz", + "integrity": "sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ampproject/remapping": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", + "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@apollo/client": { + "version": "3.11.8", + "resolved": "https://registry.npmjs.org/@apollo/client/-/client-3.11.8.tgz", + "integrity": "sha512-CgG1wbtMjsV2pRGe/eYITmV5B8lXUCYljB2gB/6jWTFQcrvirUVvKg7qtFdjYkQSFbIffU1IDyxgeaN81eTjbA==", + "dependencies": { + "@graphql-typed-document-node/core": "^3.1.1", + "@wry/caches": "^1.0.0", + "@wry/equality": "^0.5.6", + "@wry/trie": "^0.5.0", + "graphql-tag": "^2.12.6", + "hoist-non-react-statics": "^3.3.2", + "optimism": "^0.18.0", + "prop-types": "^15.7.2", + "rehackt": "^0.1.0", + "response-iterator": "^0.2.6", + "symbol-observable": "^4.0.0", + "ts-invariant": "^0.10.3", + "tslib": "^2.3.0", + "zen-observable-ts": "^1.2.5" + }, + "peerDependencies": { + "graphql": "^15.0.0 || ^16.0.0", + "graphql-ws": "^5.5.5", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0", + "subscriptions-transport-ws": "^0.9.0 || ^0.11.0" + }, + "peerDependenciesMeta": { + "graphql-ws": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "subscriptions-transport-ws": { + "optional": true + } + } + }, + "node_modules/@apollo/link-error": { + "version": "2.0.0-beta.3", + "resolved": "https://registry.npmjs.org/@apollo/link-error/-/link-error-2.0.0-beta.3.tgz", + "integrity": "sha512-blNBBi9+4SEfb4Bhn8cYqGFhb0C7MjqLiRwNdUqwGefl1w+G8Ze8pCLHAyPxXLcslirtht9LY0i6ZOpCzSXHCg==", + "dependencies": { + "@apollo/client": "^3.0.0-beta.23", + "tslib": "^1.9.3" + } + }, + "node_modules/@apollo/link-error/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/@apollo/react-testing": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@apollo/react-testing/-/react-testing-4.0.0.tgz", + "integrity": "sha512-P7Z/flUHpRRZYc3FkIqxZH9XD3FuP2Sgks1IXqGq2Zb7qI0aaTfVeRsLYmZNUcFOh2pTHxs0NXgPnH1VfYOpig==", + "dependencies": { + "@apollo/client": "latest" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.4.tgz", + "integrity": "sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", + "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.0", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-module-transforms": "^7.25.2", + "@babel/helpers": "^7.25.0", + "@babel/parser": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.2", + "@babel/types": "^7.25.2", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==" + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.2.tgz", + "integrity": "sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==", + "dependencies": { + "@babel/parser": "^7.26.2", + "@babel/types": "^7.26.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.24.7.tgz", + "integrity": "sha512-xZeCVVdwb4MsDBkkyZ64tReWYrLRHlMN72vP7Bdm3OUOuyFZExhsHUUnuWnm2/XOlAJzR0LfPpB56WXZn0X/lA==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.24.7", + "@babel/types": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz", + "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==", + "dependencies": { + "@babel/compat-data": "^7.25.2", + "@babel/helper-validator-option": "^7.24.8", + "browserslist": "^4.23.1", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", + "integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/traverse": "^7.25.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.2.tgz", + "integrity": "sha512-+wqVGP+DFmqwFD3EH6TMTfUNeqDehV3E/dl+Sd54eaXqm17tEUNbEIn4sVivVowbvUpOtIGxdo3GoXyDH9N/9g==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.24.7", + "regexpu-core": "^5.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz", + "integrity": "sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", + "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", + "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", + "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.0.tgz", + "integrity": "sha512-NhavI2eWEIz/H9dbrG0TuOicDhNexze43i5z7lEqwYm0WEZVTwnPpA0EafUTP7+6/W79HWIP2cTe3Z5NiSTVpw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-wrap-function": "^7.25.0", + "@babel/traverse": "^7.25.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.9.tgz", + "integrity": "sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==", + "dev": true, + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.9.tgz", + "integrity": "sha512-c6WHXuiaRsJTyHYLJV75t9IqsmTbItYfdj99PnzYGQZkYKvan5/2jKJ7gu31J3/BJ/A18grImSPModuyG/Eo0Q==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", + "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.0.tgz", + "integrity": "sha512-s6Q1ebqutSiZnEjaofc/UKDyC4SbzV5n5SrA2Gq8UawLycr3i04f1dX4OzoQVnexm6aOCh37SQNYlJ/8Ku+PMQ==", + "dev": true, + "dependencies": { + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.0", + "@babel/types": "^7.25.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.6.tgz", + "integrity": "sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==", + "dependencies": { + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.2.tgz", + "integrity": "sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==", + "dependencies": { + "@babel/types": "^7.26.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.25.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.3.tgz", + "integrity": "sha512-wUrcsxZg6rqBXG05HG1FPYgsP6EvwF4WpBbxIpWIIYnH8wG0gzx3yZY3dtEHas4sTAOGkbTsc9EGPxwff8lRoA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/traverse": "^7.25.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.0.tgz", + "integrity": "sha512-Bm4bH2qsX880b/3ziJ8KD711LT7z4u8CFudmjqle65AZj/HNUFhEf90dqYv6O86buWvSBmeQDjv0Tn2aF/bIBA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.0.tgz", + "integrity": "sha512-lXwdNZtTmeVOOFtwM/WDe7yg1PL8sYhRk/XH0FzbR2HDQ0xC+EnQ/JHeoMYSavtU115tnUk0q9CDyq8si+LMAA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.24.7.tgz", + "integrity": "sha512-+izXIbke1T33mY4MSNnrqhPXDz01WYhEf3yF5NbnUtkiNnm+XBZJl3kNfoK6NKmYlz/D07+l2GWVK/QfDkNCuQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.0.tgz", + "integrity": "sha512-tggFrk1AIShG/RUQbEwt2Tr/E+ObkfwrPjR6BjbRvsx24+PSjK8zrq0GWPNCjo8qpRx4DuJzlcvWJqlm+0h3kw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/traverse": "^7.25.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.11", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.11.tgz", + "integrity": "sha512-0QZ8qP/3RLDVBwBFoWAwCtgcDZJVwA5LUJRZU8x2YFfKNuFq161wK3cuGrALu5yiPu+vzwTAg/sMWVNeWeNyaw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.18.6", + "@babel/helper-create-class-features-plugin": "^7.21.0", + "@babel/helper-plugin-utils": "^7.20.2", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-namespace-from": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", + "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.25.6.tgz", + "integrity": "sha512-aABl0jHw9bZ2karQ/uUD6XP4u0SG22SJrOHFoL6XB1R7dTovOP4TzTlsxOYC5yQ1pdscVK2JTUnF6QL3ARoAiQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.25.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.25.6.tgz", + "integrity": "sha512-sXaDXaJN9SNLymBdlWFA+bjzBhFD617ZaFiY13dGt7TVslVvVgA6fkZOP7Ki3IGElC45lwHdOTrCtKZGVAWeLQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", + "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", + "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.24.7.tgz", + "integrity": "sha512-Dt9LQs6iEY++gXUwY03DNFat5C2NbO48jj+j/bSAz6b3HgPs39qcPiYt77fDObIcFwj3/C2ICX9YMwGflUoSHQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.4.tgz", + "integrity": "sha512-jz8cV2XDDTqjKPwVPJBIjORVEmSGYhdRa8e5k5+vN+uwcjSrSxUaebBRa4ko1jqNF2uxyg8G6XYk30Jv285xzg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-remap-async-to-generator": "^7.25.0", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/traverse": "^7.25.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.24.7.tgz", + "integrity": "sha512-SQY01PcJfmQ+4Ash7NE+rpbLFbmqA2GPIgqzxfFTL4t1FKRq4zTms/7htKpoCUI9OcFYgzqfmCdH53s6/jn5fA==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-remap-async-to-generator": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.24.7.tgz", + "integrity": "sha512-yO7RAz6EsVQDaBH18IDJcMB1HnrUn2FJ/Jslc/WtPPWcjhpUJXU/rjbwmluzp7v/ZzWcEhTMXELnnsz8djWDwQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.0.tgz", + "integrity": "sha512-yBQjYoOjXlFv9nlXb3f1casSHOZkWr29NX+zChVanLg5Nc157CrbEX9D7hxxtTpuFy7Q0YzmmWfJxzvps4kXrQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.4.tgz", + "integrity": "sha512-nZeZHyCWPfjkdU5pA/uHiTaDAFUEqkpzf1YoQT2NeSynCGYq9rxfyI3XpQbfx/a0hSnFH6TGlEXvae5Vi7GD8g==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.4", + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.24.7.tgz", + "integrity": "sha512-HMXK3WbBPpZQufbMG4B46A90PkuuhN9vBCb5T8+VAHqvAqvcLi+2cKoukcpmUYkszLhScU3l1iudhrks3DggRQ==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/plugin-syntax-class-static-block": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.4.tgz", + "integrity": "sha512-oexUfaQle2pF/b6E0dwsxQtAol9TLSO88kQvym6HHBWFliV2lGdrPieX+WgMRLSJDVzdYywk7jXbLPuO2KLTLg==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-replace-supers": "^7.25.0", + "@babel/traverse": "^7.25.4", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.24.7.tgz", + "integrity": "sha512-25cS7v+707Gu6Ds2oY6tCkUwsJ9YIDbggd9+cu9jzzDgiNq7hR/8dkzxWfKWnTic26vsI3EsCXNd4iEB6e8esQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/template": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.8.tgz", + "integrity": "sha512-36e87mfY8TnRxc7yc6M9g9gOB7rKgSahqkIKwLpz4Ppk2+zC2Cy1is0uwtuSG6AE4zlTOUa+7JGz9jCJGLqQFQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.24.7.tgz", + "integrity": "sha512-ZOA3W+1RRTSWvyqcMJDLqbchh7U4NRGqwRfFSVbOLS/ePIP4vHB5e8T8eXcuqyN1QkgKyj5wuW0lcS85v4CrSw==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.24.7.tgz", + "integrity": "sha512-JdYfXyCRihAe46jUIliuL2/s0x0wObgwwiGxw/UbgJBr20gQBThrokO4nYKgWkD7uBaqM7+9x5TU7NkExZJyzw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.0.tgz", + "integrity": "sha512-YLpb4LlYSc3sCUa35un84poXoraOiQucUTTu8X1j18JV+gNa8E0nyUf/CjZ171IRGr4jEguF+vzJU66QZhn29g==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.0", + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.24.7.tgz", + "integrity": "sha512-sc3X26PhZQDb3JhORmakcbvkeInvxz+A8oda99lj7J60QRuPZvNAk9wQlTBS1ZynelDrDmTU4pw1tyc5d5ZMUg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.24.7.tgz", + "integrity": "sha512-Rqe/vSc9OYgDajNIK35u7ot+KeCoetqQYFXM4Epf7M7ez3lWlOjrDjrwMei6caCVhfdw+mIKD4cgdGNy5JQotQ==", + "dev": true, + "dependencies": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.24.7.tgz", + "integrity": "sha512-v0K9uNYsPL3oXZ/7F9NNIbAj2jv1whUEtyA6aujhekLs56R++JDQuzRcP2/z4WX5Vg/c5lE9uWZA0/iUoFhLTA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.24.7.tgz", + "integrity": "sha512-wo9ogrDG1ITTTBsy46oGiN1dS9A7MROBTcYsfS8DtsImMkHk9JXJ3EWQM6X2SUw4x80uGPlwj0o00Uoc6nEE3g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.25.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.1.tgz", + "integrity": "sha512-TVVJVdW9RKMNgJJlLtHsKDTydjZAbwIsn6ySBPQaEAUU5+gVvlJt/9nRmqVbsV/IBanRjzWoaAQKLoamWVOUuA==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.24.8", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/traverse": "^7.25.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.24.7.tgz", + "integrity": "sha512-2yFnBGDvRuxAaE/f0vfBKvtnvvqU8tGpMHqMNpTN2oWMKIR3NqFkjaAgGwawhqK/pIN2T3XdjGPdaG0vDhOBGw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/plugin-syntax-json-strings": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.2.tgz", + "integrity": "sha512-HQI+HcTbm9ur3Z2DkO+jgESMAMcYLuN/A7NRw9juzxAezN9AvqvUTnpKP/9kkYANz6u7dFlAyOu44ejuGySlfw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.24.7.tgz", + "integrity": "sha512-4D2tpwlQ1odXmTEIFWy9ELJcZHqrStlzK/dAOWYyxX3zT0iXQB6banjgeOJQXzEc4S0E0a5A+hahxPaEFYftsw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.24.7.tgz", + "integrity": "sha512-T/hRC1uqrzXMKLQ6UCwMT85S3EvqaBXDGf0FaMf4446Qx9vKwlghvee0+uuZcDUCZU5RuNi4781UQ7R308zzBw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.24.7.tgz", + "integrity": "sha512-9+pB1qxV3vs/8Hdmz/CulFB8w2tuu6EB94JZFsjdqxQokwGa9Unap7Bo2gGBGIvPmDIVvQrom7r5m/TCDMURhg==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.25.9.tgz", + "integrity": "sha512-dwh2Ol1jWwL2MgkCzUSOvfmKElqQcuswAZypBSUsScMXvgdT8Ekq5YA6TtqpTVWH+4903NmboMuH1o9i8Rxlyg==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-simple-access": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.0.tgz", + "integrity": "sha512-YPJfjQPDXxyQWg/0+jHKj1llnY5f/R6a0p/vP4lPymxLu7Lvl4k2WMitqi08yxwQcCVUUdG9LCUj4TNEgAp3Jw==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.25.0", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", + "@babel/traverse": "^7.25.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.24.7.tgz", + "integrity": "sha512-3aytQvqJ/h9z4g8AsKPLvD4Zqi2qT+L3j7XoFFu1XBlZWEl2/1kWnhmAbxpLgPrHSY0M6UA02jyTiwUVtiKR6A==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.24.7.tgz", + "integrity": "sha512-/jr7h/EWeJtk1U/uz2jlsCioHkZk1JJZVcc8oQsJ1dUlaJD83f4/6Zeh2aHt9BIFokHIsSeDfhUmju0+1GPd6g==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.24.7.tgz", + "integrity": "sha512-RNKwfRIXg4Ls/8mMTza5oPF5RkOW8Wy/WgMAp1/F1yZ8mMbtwXW+HDoJiOsagWrAhI5f57Vncrmr9XeT4CVapA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.24.7.tgz", + "integrity": "sha512-Ts7xQVk1OEocqzm8rHMXHlxvsfZ0cEF2yomUqpKENHWMF4zKk175Y4q8H5knJes6PgYad50uuRmt3UJuhBw8pQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.24.7.tgz", + "integrity": "sha512-e6q1TiVUzvH9KRvicuxdBTUj4AdKSRwzIyFFnfnezpCfP2/7Qmbb8qbU2j7GODbl4JMkblitCQjKYUaX/qkkwA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/plugin-syntax-numeric-separator": "^7.10.4" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.7.tgz", + "integrity": "sha512-4QrHAr0aXQCEFni2q4DqKLD31n2DL+RxcwnNjDFkSG0eNQ/xCavnRkfCUjsyqGC2OviNJvZOF/mQqZBw7i2C5Q==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-transform-parameters": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.24.7.tgz", + "integrity": "sha512-A/vVLwN6lBrMFmMDmPPz0jnE6ZGx7Jq7d6sT/Ev4H65RER6pZ+kczlf1DthF5N0qaPHBsI7UXiE8Zy66nmAovg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-replace-supers": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.24.7.tgz", + "integrity": "sha512-uLEndKqP5BfBbC/5jTwPxLh9kqPWWgzN/f8w6UwAIirAEqiIVJWWY312X72Eub09g5KF9+Zn7+hT7sDxmhRuKA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.8.tgz", + "integrity": "sha512-5cTOLSMs9eypEy8JUVvIKOu6NgvbJMnpG62VpIHrTmROdQ+L5mDAaI40g25k5vXti55JWNX5jCkq3HZxXBQANw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", + "@babel/plugin-syntax-optional-chaining": "^7.8.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.24.7.tgz", + "integrity": "sha512-yGWW5Rr+sQOhK0Ot8hjDJuxU3XLRQGflvT4lhlSY0DFvdb3TwKaY26CJzHtYllU0vT9j58hc37ndFPsqT1SrzA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.4.tgz", + "integrity": "sha512-ao8BG7E2b/URaUQGqN3Tlsg+M3KlHY6rJ1O1gXAEUnZoyNQnvKyH87Kfg+FoxSeyWUB8ISZZsC91C44ZuBFytw==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.4", + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.24.7.tgz", + "integrity": "sha512-9z76mxwnwFxMyxZWEgdgECQglF2Q7cFLm0kMf8pGwt+GSJsY0cONKj/UuO4bOH0w/uAel3ekS4ra5CEAyJRmDA==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.24.7", + "@babel/helper-create-class-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.24.7.tgz", + "integrity": "sha512-EMi4MLQSHfd2nrCqQEWxFdha2gBCqU4ZcCng4WBGZ5CJL4bBRW0ptdqqDdeirGZcpALazVVNJqRmsO8/+oNCBA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.25.7.tgz", + "integrity": "sha512-r0QY7NVU8OnrwE+w2IWiRom0wwsTbjx4+xH2RTd7AVdof3uurXOF+/mXHQDRk+2jIvWgSaCHKMgggfvM4dyUGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.7.tgz", + "integrity": "sha512-vILAg5nwGlR9EXE8JIOX4NHXd49lrYbN8hnjffDtoULwpL9hUx/N55nqh2qd0q6FyNDfjl9V79ecKGvFbcSA0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.7", + "@babel/helper-module-imports": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/plugin-syntax-jsx": "^7.25.7", + "@babel/types": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.25.7.tgz", + "integrity": "sha512-5yd3lH1PWxzW6IZj+p+Y4OLQzz0/LzlOG8vGqonHfVR3euf1vyzyMUJk9Ac+m97BH46mFc/98t9PmYLyvgL3qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.24.7.tgz", + "integrity": "sha512-fOPQYbGSgH0HUp4UJO4sMBFjY6DuWq+2i8rixyUMb3CdGixs/gccURvYOAhajBdKDoGajFr3mUq5rH3phtkGzw==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.24.7.tgz", + "integrity": "sha512-J2z+MWzZHVOemyLweMqngXrgGC42jQ//R0KdxqkIz/OrbVIIlhFI3WigZ5fO+nwFvBlncr4MGapd8vTyc7RPNQ==", + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.25.7.tgz", + "integrity": "sha512-6YTHJ7yjjgYqGc8S+CbEXhLICODk0Tn92j+vNJo07HFk9t3bjFgAKxPLFhHwF2NjmQVSI1zBRfBWUeVBa2osfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.7", + "@babel/helper-plugin-utils": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.24.7.tgz", + "integrity": "sha512-lq3fvXPdimDrlg6LWBoqj+r/DEWgONuwjuOuQCSYgRroXDH/IdM1C0IZf59fL5cHLpjEH/O6opIRBbqv7ELnuA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7", + "regenerator-transform": "^0.15.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.24.7.tgz", + "integrity": "sha512-0DUq0pHcPKbjFZCfTss/pGkYMfy3vFWydkUBd9r0GHpIyfs2eCDENvqadMycRS9wZCXR41wucAfJHJmwA0UmoQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.24.7.tgz", + "integrity": "sha512-KsDsevZMDsigzbA09+vacnLpmPH4aWjcZjXdyFKGzpplxhbeB4wYtury3vglQkg6KM/xEPKt73eCjPPf1PgXBA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.24.7.tgz", + "integrity": "sha512-x96oO0I09dgMDxJaANcRyD4ellXFLLiWhuwDxKZX5g2rWP1bTPkBSwCYv96VDXVT1bD9aPj8tppr5ITIh8hBng==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.24.7.tgz", + "integrity": "sha512-kHPSIJc9v24zEml5geKg9Mjx5ULpfncj0wRpYtxbvKyTtHCYDkVE3aHQ03FrpEo4gEe2vrJJS1Y9CJTaThA52g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.24.7.tgz", + "integrity": "sha512-AfDTQmClklHCOLxtGoP7HkeMw56k1/bTQjwsfhL6pppo/M4TOBSq+jjBUBLmV/4oeFg4GWMavIl44ZeCtmmZTw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.8.tgz", + "integrity": "sha512-adNTUpDCVnmAE58VEqKlAA6ZBlNkMnWD0ZcW76lyNFN3MJniyGFZfNwERVk8Ap56MCnXztmDr19T4mPTztcuaw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.25.9.tgz", + "integrity": "sha512-7PbZQZP50tzv2KGGnhh82GSyMB01yKY9scIjf1a+GfZCtInOWqUH5+1EBU4t9fyR5Oykkkc9vFTs4OHrhHXljQ==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-syntax-typescript": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.24.7.tgz", + "integrity": "sha512-U3ap1gm5+4edc2Q/P+9VrBNhGkfnf+8ZqppY71Bo/pzZmXhhLdqgaUl6cuB07O1+AQJtCLfaOmswiNbSQ9ivhw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.24.7.tgz", + "integrity": "sha512-uH2O4OV5M9FZYQrwc7NdVmMxQJOCCzFeYudlZSzUAHRFeOujQefa92E74TQDVskNHCzOXoigEuoyzHDhaEaK5w==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.24.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.24.7.tgz", + "integrity": "sha512-hlQ96MBZSAXUq7ltkjtu3FJCCSMx/j629ns3hA3pXnBXjanNP0LHi+JpPeA81zaWgVK1VGH95Xuy7u0RyQ8kMg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.4.tgz", + "integrity": "sha512-qesBxiWkgN1Q+31xUE9RcMk79eOXXDCv6tfyGMRSs4RGlioSg2WVyQAm07k726cSE56pa+Kb0y9epX2qaXzTvA==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.2", + "@babel/helper-plugin-utils": "^7.24.8" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.25.4", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.25.4.tgz", + "integrity": "sha512-W9Gyo+KmcxjGahtt3t9fb14vFRWvPpu5pT6GBlovAK6BTBcxgjfVMSQCfJl4oi35ODrxP6xx2Wr8LNST57Mraw==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.25.4", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-validator-option": "^7.24.8", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.3", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.0", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.0", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.24.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.0", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-namespace-from": "^7.8.3", + "@babel/plugin-syntax-import-assertions": "^7.24.7", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.24.7", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoped-functions": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-class-static-block": "^7.24.7", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-computed-properties": "^7.24.7", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-dotall-regex": "^7.24.7", + "@babel/plugin-transform-duplicate-keys": "^7.24.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.0", + "@babel/plugin-transform-dynamic-import": "^7.24.7", + "@babel/plugin-transform-exponentiation-operator": "^7.24.7", + "@babel/plugin-transform-export-namespace-from": "^7.24.7", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-function-name": "^7.25.1", + "@babel/plugin-transform-json-strings": "^7.24.7", + "@babel/plugin-transform-literals": "^7.25.2", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-member-expression-literals": "^7.24.7", + "@babel/plugin-transform-modules-amd": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-modules-systemjs": "^7.25.0", + "@babel/plugin-transform-modules-umd": "^7.24.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-new-target": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-numeric-separator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-object-super": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.25.4", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-property-literals": "^7.24.7", + "@babel/plugin-transform-regenerator": "^7.24.7", + "@babel/plugin-transform-reserved-words": "^7.24.7", + "@babel/plugin-transform-shorthand-properties": "^7.24.7", + "@babel/plugin-transform-spread": "^7.24.7", + "@babel/plugin-transform-sticky-regex": "^7.24.7", + "@babel/plugin-transform-template-literals": "^7.24.7", + "@babel/plugin-transform-typeof-symbol": "^7.24.8", + "@babel/plugin-transform-unicode-escapes": "^7.24.7", + "@babel/plugin-transform-unicode-property-regex": "^7.24.7", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.25.4", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.10.6", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "core-js-compat": "^3.37.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.25.7", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.25.7.tgz", + "integrity": "sha512-GjV0/mUEEXpi1U5ZgDprMRRgajGMRW3G5FjMr5KLKD8nT2fTG8+h/klV3+6Dm5739QE+K5+2e91qFKAYI3pmRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.7", + "@babel/helper-validator-option": "^7.25.7", + "@babel/plugin-transform-react-display-name": "^7.25.7", + "@babel/plugin-transform-react-jsx": "^7.25.7", + "@babel/plugin-transform-react-jsx-development": "^7.25.7", + "@babel/plugin-transform-react-pure-annotations": "^7.25.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.26.0.tgz", + "integrity": "sha512-NMk1IGZ5I/oHhoXEElcm+xUnL/szL6xflkFZmoEU9xj1qSJXpiS7rsspYo92B4DRCDvZn2erT5LdsCeXAKNCkg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-syntax-jsx": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.25.9", + "@babel/plugin-transform-typescript": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==", + "dev": true + }, + "node_modules/@babel/runtime": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", + "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime/node_modules/regenerator-runtime": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz", + "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==" + }, + "node_modules/@babel/template": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", + "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz", + "integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==", + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/generator": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/template": "^7.25.9", + "@babel/types": "^7.25.9", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz", + "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, + "node_modules/@dicebear/adventurer": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-9.2.2.tgz", + "integrity": "sha512-WjBXCP9EXbUul2zC3BS2/R3/4diw1uh/lU4jTEnujK1mhqwIwanFboIMzQsasNNL/xf+m3OHN7MUNJfHZ1fLZA==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/adventurer-neutral": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/adventurer-neutral/-/adventurer-neutral-9.2.2.tgz", + "integrity": "sha512-XVAjhUWjav6luTZ7txz8zVJU/H0DiUy4uU1Z7IO5MDO6kWvum+If1+0OUgEWYZwM+RDI7rt2CgVP910DyZGd1w==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/avataaars": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/avataaars/-/avataaars-9.2.2.tgz", + "integrity": "sha512-WqJPQEt0OhBybTpI0TqU1uD1pSk9M2+VPIwvBye/dXo46b+0jHGpftmxjQwk6tX8z0+mRko8pwV5n+cWht1/+w==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/avataaars-neutral": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/avataaars-neutral/-/avataaars-neutral-9.2.2.tgz", + "integrity": "sha512-pRj16P27dFDBI3LtdiHUDwIXIGndHAbZf5AxaMkn6/+0X93mVQ/btVJDXyW0G96WCsyC88wKAWr6/KJotPxU6Q==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-ears": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/big-ears/-/big-ears-9.2.2.tgz", + "integrity": "sha512-hz4UXdPq4qqZpu0YVvlqM4RDFhk5i0WgPcuwj/MOLlgTjuj63uHUhCQSk6ZiW1DQOs12qpwUBMGWVHxBRBas9g==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-ears-neutral": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/big-ears-neutral/-/big-ears-neutral-9.2.2.tgz", + "integrity": "sha512-IPHt8fi3dv9cyfBJBZ4s8T+PhFCrQvOCf91iRHBT3iOLNPdyZpI5GNLmGiV0XMAvIDP5NvA5+f6wdoBLhYhbDA==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/big-smile": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/big-smile/-/big-smile-9.2.2.tgz", + "integrity": "sha512-D4td0GL8or1nTNnXvZqkEXlzyqzGPWs3znOnm1HIohtFTeIwXm72Ob2lNDsaQJSJvXmVlwaQQ0CCTvyCl8Stjw==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/bottts": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/bottts/-/bottts-9.2.2.tgz", + "integrity": "sha512-wugFkzw8JNWV1nftq/Wp/vmQsLAXDxrMtRK3AoMODuUpSVoP3EHRUfKS043xggOsQFvoj0HZ7kadmhn0AMLf5A==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/bottts-neutral": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/bottts-neutral/-/bottts-neutral-9.2.2.tgz", + "integrity": "sha512-lSgpqmSJtlnyxVuUgNdBwyzuA0O9xa5zRJtz7x2KyWbicXir5iYdX0MVMCkp1EDvlcxm9rGJsclktugOyakTlw==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/collection": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/collection/-/collection-9.2.2.tgz", + "integrity": "sha512-vZAmXhPWCK3sf8Fj9/QflFC6XOLroJOT5K1HdnzHaPboEvffUQideGCrrEamnJtlH0iF0ZDXh8gqmwy2fu+yHA==", + "dependencies": { + "@dicebear/adventurer": "9.2.2", + "@dicebear/adventurer-neutral": "9.2.2", + "@dicebear/avataaars": "9.2.2", + "@dicebear/avataaars-neutral": "9.2.2", + "@dicebear/big-ears": "9.2.2", + "@dicebear/big-ears-neutral": "9.2.2", + "@dicebear/big-smile": "9.2.2", + "@dicebear/bottts": "9.2.2", + "@dicebear/bottts-neutral": "9.2.2", + "@dicebear/croodles": "9.2.2", + "@dicebear/croodles-neutral": "9.2.2", + "@dicebear/dylan": "9.2.2", + "@dicebear/fun-emoji": "9.2.2", + "@dicebear/glass": "9.2.2", + "@dicebear/icons": "9.2.2", + "@dicebear/identicon": "9.2.2", + "@dicebear/initials": "9.2.2", + "@dicebear/lorelei": "9.2.2", + "@dicebear/lorelei-neutral": "9.2.2", + "@dicebear/micah": "9.2.2", + "@dicebear/miniavs": "9.2.2", + "@dicebear/notionists": "9.2.2", + "@dicebear/notionists-neutral": "9.2.2", + "@dicebear/open-peeps": "9.2.2", + "@dicebear/personas": "9.2.2", + "@dicebear/pixel-art": "9.2.2", + "@dicebear/pixel-art-neutral": "9.2.2", + "@dicebear/rings": "9.2.2", + "@dicebear/shapes": "9.2.2", + "@dicebear/thumbs": "9.2.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/core": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.2.2.tgz", + "integrity": "sha512-ROhgHG249dPtcXgBHcqPEsDeAPRPRD/9d+tZCjLYyueO+cXDlIA8dUlxpwIVcOuZFvCyW6RJtqo8BhNAi16pIQ==", + "dependencies": { + "@types/json-schema": "^7.0.11" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@dicebear/croodles": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/croodles/-/croodles-9.2.2.tgz", + "integrity": "sha512-OzvAXQWsOgMwL3Sl+lBxCubqSOWoBJpC78c4TKnNTS21rR63TtXUyVdLLzgKVN4YHRnvMgtPf8F/W9YAgIDK4w==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/croodles-neutral": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/croodles-neutral/-/croodles-neutral-9.2.2.tgz", + "integrity": "sha512-/4mNirxoQ+z1kHXnpDRbJ1JV1ZgXogeTeNp0MaFYxocCgHfJ7ckNM23EE1I7akoo9pqPxrKlaeNzGAjKHdS9vA==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/dylan": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/dylan/-/dylan-9.2.2.tgz", + "integrity": "sha512-s7e3XliC1YXP+Wykj+j5kwdOWFRXFzYHYk/PB4oZ1F3sJandXiG0HS4chaNu4EoP0yZgKyFMUVTGZx+o6tMaYg==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/fun-emoji": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/fun-emoji/-/fun-emoji-9.2.2.tgz", + "integrity": "sha512-M+rYTpB3lfwz18f+/i+ggNwNWUoEj58SJqXJ1wr7Jh/4E5uL+NmJg9JGwYNaVtGbCFrKAjSaILNUWGQSFgMfog==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/glass": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/glass/-/glass-9.2.2.tgz", + "integrity": "sha512-imCMxcg+XScHYtQq2MUv1lCzhQSCUglMlPSezKEpXhTxgbgUpmGlSGVkOfmX5EEc7SQowKkF1W/1gNk6CXvBaQ==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/icons": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/icons/-/icons-9.2.2.tgz", + "integrity": "sha512-Tqq2OVCdS7J02DNw58xwlgLGl40sWEckbqXT3qRvIF63FfVq+wQZBGuhuiyAURcSgvsc3h2oQeYFi9iXh7HTOA==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/identicon": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/identicon/-/identicon-9.2.2.tgz", + "integrity": "sha512-POVKFulIrcuZf3rdAgxYaSm2XUg/TJg3tg9zq9150reEGPpzWR7ijyJ03dzAADPzS3DExfdYVT9+z3JKwwJnTQ==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/initials": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/initials/-/initials-9.2.2.tgz", + "integrity": "sha512-/xNnsEmsstWjmF77htAOuwOMhFlP6eBVXgcgFlTl/CCH/Oc6H7t0vwX1he8KLQBBzjGpvJcvIAn4Wh9rE4D5/A==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/lorelei": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/lorelei/-/lorelei-9.2.2.tgz", + "integrity": "sha512-koXqVr/vcWUPo00VP5H6Czsit+uF1tmwd2NK7Q/e34/9Sd1f4QLLxHjjBNm/iNjCI1+UNTOvZ2Qqu0N5eo7Flw==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/lorelei-neutral": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/lorelei-neutral/-/lorelei-neutral-9.2.2.tgz", + "integrity": "sha512-Eys9Os6nt2Xll7Mvu66CfRR2YggTopWcmFcRZ9pPdohS96kT0MsLI2iTcfZXQ51K8hvT3IbwoGc86W8n0cDxAQ==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/micah": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/micah/-/micah-9.2.2.tgz", + "integrity": "sha512-NCajcJV5yw8uMKiACp694w1T/UyYme2CUEzyTzWHgWnQ+drAuCcH8gpAoLWd67viNdQB/MTpNlaelUgTjmI4AQ==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/miniavs": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/miniavs/-/miniavs-9.2.2.tgz", + "integrity": "sha512-vvkWXttdw+KHF3j+9qcUFzK+P0nbNnImGjvN48wwkPIh2h08WWFq0MnoOls4IHwUJC4GXBjWtiyVoCxz6hhtOA==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/notionists": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/notionists/-/notionists-9.2.2.tgz", + "integrity": "sha512-Z9orRaHoj7Y9Ap4wEu8XOrFACsG1KbbBQUPV1R50uh6AHwsyNrm4cS84ICoGLvxgLNHHOae3YCjd8aMu2z19zg==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/notionists-neutral": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/notionists-neutral/-/notionists-neutral-9.2.2.tgz", + "integrity": "sha512-AhOzk+lz6kB4uxGun8AJhV+W1nttnMlxmxd+5KbQ/txCIziYIaeD3il44wsAGegEpGFvAZyMYtR/jjfHcem3TA==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/open-peeps": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/open-peeps/-/open-peeps-9.2.2.tgz", + "integrity": "sha512-6PeQDHYyjvKrGSl/gP+RE5dSYAQGKpcGnM65HorgyTIugZK7STo0W4hvEycedupZ3MCCEH8x/XyiChKM2sHXog==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/personas": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/personas/-/personas-9.2.2.tgz", + "integrity": "sha512-705+ObNLC0w1fcgE/Utav+8bqO+Esu53TXegpX5j7trGEoIMf2bThqJGHuhknZ3+T2az3Wr89cGyOGlI0KLzLA==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/pixel-art": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/pixel-art/-/pixel-art-9.2.2.tgz", + "integrity": "sha512-BvbFdrpzQl04+Y9UsWP63YGug+ENGC7GMG88qbEFWxb/IqRavGa4H3D0T4Zl2PSLiw7f2Ctv98bsCQZ1PtCznQ==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/pixel-art-neutral": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/pixel-art-neutral/-/pixel-art-neutral-9.2.2.tgz", + "integrity": "sha512-CdUY77H6Aj7dKLW3hdkv7tu0XQJArUjaWoXihQxlhl3oVYplWaoyu9omYy5pl8HTqs8YgVTGljjMXYoFuK0JUw==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/rings": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/rings/-/rings-9.2.2.tgz", + "integrity": "sha512-eD1J1k364Arny+UlvGrk12HP/XGG6WxPSm4BarFqdJGSV45XOZlwqoi7FlcMr9r9yvE/nGL8OizbwMYusEEdjw==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/shapes": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/shapes/-/shapes-9.2.2.tgz", + "integrity": "sha512-e741NNWBa7fg0BjomxXa0fFPME2XCIR0FA+VHdq9AD2taTGHEPsg5x1QJhCRdK6ww85yeu3V3ucpZXdSrHVw5Q==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@dicebear/thumbs": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@dicebear/thumbs/-/thumbs-9.2.2.tgz", + "integrity": "sha512-FkPLDNu7n5kThLSk7lR/0cz/NkUqgGdZGfLZv6fLkGNGtv6W+e2vZaO7HCXVwIgJ+II+kImN41zVIZ6Jlll7pQ==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^9.0.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.12.0.tgz", + "integrity": "sha512-y2WQb+oP8Jqvvclh8Q55gLUyb7UFvgv7eJfsj7td5TToBrIUtPay2kMrZi4xjq9qw2vD0ZR5fSho0yqoFgX7Rw==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/serialize": "^1.2.0", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/cache": { + "version": "11.13.1", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.13.1.tgz", + "integrity": "sha512-iqouYkuEblRcXmylXIwwOodiEK5Ifl7JcX7o6V4jI3iW4mLXX3dmt5xwBtIkJiQEXFAI+pC8X0i67yiPkH9Ucw==", + "license": "MIT", + "dependencies": { + "@emotion/memoize": "^0.9.0", + "@emotion/sheet": "^1.4.0", + "@emotion/utils": "^1.4.0", + "@emotion/weak-memoize": "^0.4.0", + "stylis": "4.2.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.0.tgz", + "integrity": "sha512-SHetuSLvJDzuNbOdtPVbq6yMMMlLoW5Q94uDqJZqy50gcmAjxFkVqmzqSGEFq9gT2iMuIeKV1PXVWmvUhuZLlQ==", + "dependencies": { + "@emotion/memoize": "^0.9.0" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==" + }, + "node_modules/@emotion/react": { + "version": "11.13.3", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.13.3.tgz", + "integrity": "sha512-lIsdU6JNrmYfJ5EbUCf4xW1ovy5wKQ2CkPRM4xogziOxH1nXxBSjpC9YqbFAP7circxMfYp+6x676BqWcEiixg==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.12.0", + "@emotion/cache": "^11.13.0", + "@emotion/serialize": "^1.3.1", + "@emotion/use-insertion-effect-with-fallbacks": "^1.1.0", + "@emotion/utils": "^1.4.0", + "@emotion/weak-memoize": "^0.4.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.3.2.tgz", + "integrity": "sha512-grVnMvVPK9yUVE6rkKfAJlYZgo0cu3l9iMC77V7DW6E1DUIrU68pSEXRmFZFOFB1QFo57TncmOcvcbMDWsL4yA==", + "dependencies": { + "@emotion/hash": "^0.9.2", + "@emotion/memoize": "^0.9.0", + "@emotion/unitless": "^0.10.0", + "@emotion/utils": "^1.4.1", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz", + "integrity": "sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==", + "license": "MIT" + }, + "node_modules/@emotion/styled": { + "version": "11.13.0", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.13.0.tgz", + "integrity": "sha512-tkzkY7nQhW/zC4hztlwucpT8QEZ6eUzpXDRhww/Eej4tFfO0FxQYWRyg/c5CCXa4d/f174kqeXYjuQRnhzf6dA==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.12.0", + "@emotion/is-prop-valid": "^1.3.0", + "@emotion/serialize": "^1.3.0", + "@emotion/use-insertion-effect-with-fallbacks": "^1.1.0", + "@emotion/utils": "^1.4.0" + }, + "peerDependencies": { + "@emotion/react": "^11.0.0-rc.0", + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/unitless": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.10.0.tgz", + "integrity": "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.1.0.tgz", + "integrity": "sha512-+wBOcIV5snwGgI2ya3u99D7/FJquOIniQT1IKyDsBmEgwvpxMNeS65Oib7OnE2d2aY+3BU4OiH+0Wchf8yk3Hw==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.4.1.tgz", + "integrity": "sha512-BymCXzCG3r72VKJxaYVwOXATqXIZ85cuvg0YOUDxMGNrKc1DJRZk8MgV5wyXRyEayIMd4FuXJIUgTBXvDNW5cA==" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", + "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.11.0.tgz", + "integrity": "sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0", + "peer": true + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@eslint/eslintrc/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", + "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.1.tgz", + "integrity": "sha512-42UH54oPZHPdRHdw6BgoBD6cg/eVTmVrFcgeRDM3jbO7uxSoipVcmcIGFcA5jmOHO5apcyvBhkSKES3fQJnu7A==", + "dependencies": { + "@floating-ui/utils": "^0.2.0" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.4.tgz", + "integrity": "sha512-0G8R+zOvQsAG1pg2Q99P21jiqxqGBW1iRe/iXHsBRBxnpXKFI8QwbB4x5KmYLggNO5m34IQgOIu9SCRfR/WWiQ==", + "dependencies": { + "@floating-ui/core": "^1.0.0", + "@floating-ui/utils": "^0.2.0" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.26.25", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.25.tgz", + "integrity": "sha512-hZOmgN0NTOzOuZxI1oIrDu3Gcl8WViIkvPMpB4xdd4QD6xAMtwgwr3VPoiyH/bLtRcS1cDnhxLSD1NsMJmwh/A==", + "dependencies": { + "@floating-ui/react-dom": "^2.1.2", + "@floating-ui/utils": "^0.2.8", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==" + }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.11.14", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", + "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.2", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jedmao/location": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@jedmao/location/-/location-3.0.0.tgz", + "integrity": "sha512-p7mzNlgJbCioUYLUEKds3cQG4CHONVFJNYqMe6ocEtENCL/jYmMo1Q3ApwsMmU+L0ZkaDJEyv4HokaByLoPwlQ==", + "dev": true + }, + "node_modules/@jest/console": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz", + "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/console/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/core": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-27.5.1.tgz", + "integrity": "sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ==", + "dev": true, + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/reporters": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.8.1", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^27.5.1", + "jest-config": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-resolve-dependencies": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "jest-watcher": "^27.5.1", + "micromatch": "^4.0.4", + "rimraf": "^3.0.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/core/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/environment": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz", + "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==", + "dev": true, + "dependencies": { + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz", + "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "@sinonjs/fake-timers": "^8.0.1", + "@types/node": "*", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.5.1.tgz", + "integrity": "sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/types": "^27.5.1", + "expect": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-27.5.1.tgz", + "integrity": "sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.2", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^5.1.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-haste-map": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "slash": "^3.0.0", + "source-map": "^0.6.0", + "string-length": "^4.0.1", + "terminal-link": "^2.0.0", + "v8-to-istanbul": "^8.1.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/reporters/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@jest/source-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz", + "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9", + "source-map": "^0.6.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/source-map/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@jest/test-result": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz", + "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==", + "dev": true, + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-27.5.1.tgz", + "integrity": "sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ==", + "dev": true, + "dependencies": { + "@jest/test-result": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-runtime": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz", + "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.1.0", + "@jest/types": "^27.5.1", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^1.4.0", + "fast-json-stable-stringify": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-util": "^27.5.1", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "source-map": "^0.6.1", + "write-file-atomic": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/transform/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jest/transform/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@jest/types": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz", + "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^16.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@jest/types/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", + "integrity": "sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==", + "license": "MIT" + }, + "node_modules/@microsoft/tsdoc": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.0.tgz", + "integrity": "sha512-HZpPoABogPvjeJOdzCOSJsXeL/SMCBgBZMVC3X3d7YYp2gf31MfxhUoYUNwf1ERPJOnQc0wkFn9trqI6ZEdZuA==", + "dev": true + }, + "node_modules/@microsoft/tsdoc-config": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc-config/-/tsdoc-config-0.17.0.tgz", + "integrity": "sha512-v/EYRXnCAIHxOHW+Plb6OWuUoMotxTN0GLatnpOb1xq0KuTNw/WI3pamJx/UbsoJP5k9MCw1QxvvhPcF9pH3Zg==", + "dev": true, + "dependencies": { + "@microsoft/tsdoc": "0.15.0", + "ajv": "~8.12.0", + "jju": "~1.4.0", + "resolve": "~1.22.2" + } + }, + "node_modules/@mui/base": { + "version": "5.0.0-beta.61", + "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.61.tgz", + "integrity": "sha512-YaMOTXS3ecDNGsPKa6UdlJ8loFLvcL9+VbpCK3hfk71OaNauZRp4Yf7KeXDYr7Ms3M/XBD3SaiR6JMr6vYtfDg==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@floating-ui/react-dom": "^2.1.1", + "@mui/types": "^7.2.19", + "@mui/utils": "^6.1.6", + "@popperjs/core": "^2.11.8", + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/core-downloads-tracker": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.1.6.tgz", + "integrity": "sha512-nz1SlR9TdBYYPz4qKoNasMPRiGb4PaIHFkzLzhju0YVYS5QSuFF2+n7CsiHMIDcHv3piPu/xDWI53ruhOqvZwQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + } + }, + "node_modules/@mui/icons-material": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.1.6.tgz", + "integrity": "sha512-5r9urIL2lxXb/sPN3LFfFYEibsXJUb986HhhIeu1gOcte460pwdSiEhBSxkAuyT8Dj7jvu9MjqSBmSumQELo8A==", + "dependencies": { + "@babel/runtime": "^7.26.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@mui/material": "^6.1.6", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/material": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.1.6.tgz", + "integrity": "sha512-1yvejiQ/601l5AK3uIdUlAVElyCxoqKnl7QA+2oFB/2qYPWfRwDgavW/MoywS5Y2gZEslcJKhe0s2F3IthgFgw==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/core-downloads-tracker": "^6.1.6", + "@mui/system": "^6.1.6", + "@mui/types": "^7.2.19", + "@mui/utils": "^6.1.6", + "@popperjs/core": "^2.11.8", + "@types/react-transition-group": "^4.4.11", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1", + "react-is": "^18.3.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@mui/material-pigment-css": "^6.1.6", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@mui/material-pigment-css": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/private-theming": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.1.6.tgz", + "integrity": "sha512-ioAiFckaD/fJSnTrUMWgjl9HYBWt7ixCh7zZw7gDZ+Tae7NuprNV6QJK95EidDT7K0GetR2rU3kAeIR61Myttw==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/utils": "^6.1.6", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/styled-engine": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.1.6.tgz", + "integrity": "sha512-I+yS1cSuSvHnZDBO7e7VHxTWpj+R7XlSZvTC4lS/OIbUNJOMMSd3UDP6V2sfwzAdmdDNBi7NGCRv2SZ6O9hGDA==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@emotion/cache": "^11.13.1", + "@emotion/serialize": "^1.3.2", + "@emotion/sheet": "^1.4.0", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.4.1", + "@emotion/styled": "^11.3.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/system": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.1.6.tgz", + "integrity": "sha512-qOf1VUE9wK8syiB0BBCp82oNBAVPYdj4Trh+G1s+L+ImYiKlubWhhqlnvWt3xqMevR+D2h1CXzA1vhX2FvA+VQ==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/private-theming": "^6.1.6", + "@mui/styled-engine": "^6.1.6", + "@mui/types": "^7.2.19", + "@mui/utils": "^6.1.6", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.5.0", + "@emotion/styled": "^11.3.0", + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/types": { + "version": "7.2.19", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.19.tgz", + "integrity": "sha512-6XpZEM/Q3epK9RN8ENoXuygnqUQxE+siN/6rGRi2iwJPgBUR25mphYQ9ZI87plGh58YoZ5pp40bFvKYOCDJ3tA==", + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/utils": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.1.6.tgz", + "integrity": "sha512-sBS6D9mJECtELASLM+18WUcXF6RH3zNxBRFeyCRg8wad6NbyNrdxLuwK+Ikvc38sTZwBzAz691HmSofLqHd9sQ==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mui/types": "^7.2.19", + "@types/prop-types": "^15.7.13", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-is": "^18.3.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@mui/x-charts": { + "version": "7.22.1", + "resolved": "https://registry.npmjs.org/@mui/x-charts/-/x-charts-7.22.1.tgz", + "integrity": "sha512-zgr8CN4yLen5puqaX7Haj5+AoVG7E13HHsIiDoEAuQvuFDF0gKTxTTdLSKXqhd1qJUIIzJaztZtrr3YCVrENqw==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0", + "@mui/x-charts-vendor": "7.20.0", + "@mui/x-internals": "7.21.0", + "@react-spring/rafz": "^9.7.5", + "@react-spring/web": "^9.7.5", + "clsx": "^2.1.1", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0", + "@mui/system": "^5.15.14 || ^6.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/x-charts-vendor": { + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/@mui/x-charts-vendor/-/x-charts-vendor-7.20.0.tgz", + "integrity": "sha512-pzlh7z/7KKs5o0Kk0oPcB+sY0+Dg7Q7RzqQowDQjpy5Slz6qqGsgOB5YUzn0L+2yRmvASc4Pe0914Ao3tMBogg==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@types/d3-color": "^3.1.3", + "@types/d3-delaunay": "^6.0.4", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-scale": "^4.0.8", + "@types/d3-shape": "^3.1.6", + "@types/d3-time": "^3.0.3", + "d3-color": "^3.1.0", + "d3-delaunay": "^6.0.4", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.2.0", + "d3-time": "^3.1.0", + "delaunator": "^5.0.1", + "robust-predicates": "^3.0.2" + } + }, + "node_modules/@mui/x-data-grid": { + "version": "7.22.1", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.22.1.tgz", + "integrity": "sha512-YHF96MEv7ACG/VuiycZjEAPH7cZLNuV2+bi/MyR1t/e6E6LTolYFykvjSFq+Imz1mYbW4+9mEvrHZsIKL5KKIQ==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0", + "@mui/x-internals": "7.21.0", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "reselect": "^5.1.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0", + "@mui/system": "^5.15.14 || ^6.0.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + } + } + }, + "node_modules/@mui/x-date-pickers": { + "version": "7.22.1", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.22.1.tgz", + "integrity": "sha512-VBgicE+7PvJrdHSL6HyieHT6a/0dENH8RaMIM2VwUFrGoZzvik50WNwY5U+Hip1BwZLIEvlqtNRQIIj6kgBR6Q==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0", + "@mui/x-internals": "7.21.0", + "@types/react-transition-group": "^4.4.11", + "clsx": "^2.1.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "@emotion/react": "^11.9.0", + "@emotion/styled": "^11.8.1", + "@mui/material": "^5.15.14 || ^6.0.0", + "@mui/system": "^5.15.14 || ^6.0.0", + "date-fns": "^2.25.0 || ^3.2.0 || ^4.0.0", + "date-fns-jalali": "^2.13.0-0 || ^3.2.0-0", + "dayjs": "^1.10.7", + "luxon": "^3.0.2", + "moment": "^2.29.4", + "moment-hijri": "^2.1.2", + "moment-jalaali": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0", + "react": "^17.0.0 || ^18.0.0", + "react-dom": "^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@emotion/react": { + "optional": true + }, + "@emotion/styled": { + "optional": true + }, + "date-fns": { + "optional": true + }, + "date-fns-jalali": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + }, + "moment-hijri": { + "optional": true + }, + "moment-jalaali": { + "optional": true + } + } + }, + "node_modules/@mui/x-internals": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.21.0.tgz", + "integrity": "sha512-94YNyZ0BhK5Z+Tkr90RKf47IVCW8R/1MvdUhh6MCQg6sZa74jsX+x+gEZ4kzuCqOsuyTyxikeQ8vVuCIQiP7UQ==", + "dependencies": { + "@babel/runtime": "^7.25.7", + "@mui/utils": "^5.16.6 || ^6.0.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz", + "integrity": "sha512-i0GV1yJnm2n3Yq1qw6QrUrd/LI9bE8WEBOTtOkpCXHHdyN3TAGgqAK/DAT05z4fq2x04cARXt2pDmjWjL92iTQ==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.0", + "@parcel/watcher-darwin-arm64": "2.5.0", + "@parcel/watcher-darwin-x64": "2.5.0", + "@parcel/watcher-freebsd-x64": "2.5.0", + "@parcel/watcher-linux-arm-glibc": "2.5.0", + "@parcel/watcher-linux-arm-musl": "2.5.0", + "@parcel/watcher-linux-arm64-glibc": "2.5.0", + "@parcel/watcher-linux-arm64-musl": "2.5.0", + "@parcel/watcher-linux-x64-glibc": "2.5.0", + "@parcel/watcher-linux-x64-musl": "2.5.0", + "@parcel/watcher-win32-arm64": "2.5.0", + "@parcel/watcher-win32-ia32": "2.5.0", + "@parcel/watcher-win32-x64": "2.5.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz", + "integrity": "sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.0.tgz", + "integrity": "sha512-hyZ3TANnzGfLpRA2s/4U1kbw2ZI4qGxaRJbBH2DCSREFfubMswheh8TeiC1sGZ3z2jUf3s37P0BBlrD3sjVTUw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.0.tgz", + "integrity": "sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.0.tgz", + "integrity": "sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.0.tgz", + "integrity": "sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz", + "integrity": "sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz", + "integrity": "sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz", + "integrity": "sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz", + "integrity": "sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz", + "integrity": "sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz", + "integrity": "sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.0.tgz", + "integrity": "sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.0.tgz", + "integrity": "sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, + "node_modules/@pdfme/common": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@pdfme/common/-/common-1.2.6.tgz", + "integrity": "sha512-ROmQ/iMUdmFS2QXD/kKDdcU5T6H3azDs2b1hE/OXs8531BPZ9ABbu9+1NRZQoNK4U/zP2F+Osb/B8ckr9lAmGg==", + "peer": true, + "dependencies": { + "buffer": "^6.0.3", + "fontkit": "^2.0.2", + "zod": "^3.20.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pdfme/generator": { + "version": "5.1.7", + "resolved": "https://registry.npmjs.org/@pdfme/generator/-/generator-5.1.7.tgz", + "integrity": "sha512-dVbVWzuiTL6c0HcGmKiuTp77ClZIQOneKXc6ysYpY518kbR6YlxPstdqigFJfFL02P09kIStLrugrS18B3aVuA==", + "dependencies": { + "@pdfme/pdf-lib": "^1.18.3", + "atob": "^2.1.2", + "fontkit": "^2.0.2" + }, + "peerDependencies": { + "@pdfme/common": "latest", + "@pdfme/schemas": "latest" + } + }, + "node_modules/@pdfme/pdf-lib": { + "version": "1.18.3", + "resolved": "https://registry.npmjs.org/@pdfme/pdf-lib/-/pdf-lib-1.18.3.tgz", + "integrity": "sha512-Zy4lRrxhDo7pbexNCvn+nzO5gLRc1XOXQyFOHC1FJoaHzrZ3GDd4OZwR5AaGIlCwV7ocPP2+iiLFA5wQcx3s9w==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "color": "^4.2.3", + "node-html-better-parser": "^1.4.0", + "pako": "^1.0.11" + } + }, + "node_modules/@pdfme/schemas": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/@pdfme/schemas/-/schemas-5.1.6.tgz", + "integrity": "sha512-QAsWZEfe8tB3xmaj35xsGf5kSjFzMJc4F90kBbudEq8/Pkcx+0yznD7+zQK0OEj7uLD3frmXyR1OVX6dBWHBpQ==", + "license": "MIT", + "dependencies": { + "@pdfme/pdf-lib": "^1.18.3", + "air-datepicker": "^3.5.3", + "bwip-js": "^4.1.1", + "date-fns": "^4.1.0", + "fast-xml-parser": "^4.3.2", + "fontkit": "^2.0.2" + }, + "peerDependencies": { + "@pdfme/common": "latest" + } + }, + "node_modules/@pkgr/core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", + "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.21", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", + "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", + "dev": true + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@react-aria/ssr": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.5.tgz", + "integrity": "sha512-xEwGKoysu+oXulibNUSkXf8itW0npHHTa6c4AyYeZIJyRoegeteYuFpZUBPtIDE8RfHdNsSmE1ssOkxRnwbkuQ==", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@react-aria/ssr/node_modules/@swc/helpers": { + "version": "0.5.12", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.12.tgz", + "integrity": "sha512-KMZNXiGibsW9kvZAO1Pam2JPTDBm+KSHMMHWdsyI/1DbIZjT2A6Gy3hblVXUMEDvUAKq+e0vL0X0o54owWji7g==", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@react-spring/animated": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.5.tgz", + "integrity": "sha512-Tqrwz7pIlsSDITzxoLS3n/v/YCUHQdOIKtOJf4yL6kYVSDTSmVK1LI1Q3M/uu2Sx4X3pIWF3xLUhlsA6SPNTNg==", + "dependencies": { + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/core": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/core/-/core-9.7.5.tgz", + "integrity": "sha512-rmEqcxRcu7dWh7MnCcMXLvrf6/SDlSokLaLTxiPlAYi11nN3B5oiCUAblO72o+9z/87j2uzxa2Inm8UbLjXA+w==", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-spring/donate" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/rafz": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/rafz/-/rafz-9.7.5.tgz", + "integrity": "sha512-5ZenDQMC48wjUzPAm1EtwQ5Ot3bLIAwwqP2w2owG5KoNdNHpEJV263nGhCeKKmuA3vG2zLLOdu3or6kuDjA6Aw==" + }, + "node_modules/@react-spring/shared": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/shared/-/shared-9.7.5.tgz", + "integrity": "sha512-wdtoJrhUeeyD/PP/zo+np2s1Z820Ohr/BbuVYv+3dVLW7WctoiN7std8rISoYoHpUXtbkpesSKuPIw/6U1w1Pw==", + "dependencies": { + "@react-spring/rafz": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@react-spring/types": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/types/-/types-9.7.5.tgz", + "integrity": "sha512-HVj7LrZ4ReHWBimBvu2SKND3cDVUPWKLqRTmWe/fNY6o1owGOX0cAHbdPDTMelgBlVbrTKrre6lFkhqGZErK/g==" + }, + "node_modules/@react-spring/web": { + "version": "9.7.5", + "resolved": "https://registry.npmjs.org/@react-spring/web/-/web-9.7.5.tgz", + "integrity": "sha512-lmvqGwpe+CSttsWNZVr+Dg62adtKhauGwLyGE/RRyZ8AAMLgb9x3NDMA5RMElXo+IMyTkPp7nxTB8ZQlmhb6JQ==", + "dependencies": { + "@react-spring/animated": "~9.7.5", + "@react-spring/core": "~9.7.5", + "@react-spring/shared": "~9.7.5", + "@react-spring/types": "~9.7.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.3.0.tgz", + "integrity": "sha512-WC7Yd6cNGfHx8zf+iu+Q1UPTfEcXhQ+ATi7CV1hlrSAaQBdlPzg7Ww/wJHNQem7qG9rxmWoFCDCPubSvFObGzA==", + "dependencies": { + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@remix-run/router": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz", + "integrity": "sha512-mUnk8rPJBI9loFDZ+YzPGdeniYK+FTmRD1TMCz7ev2SNIozyKKpnGgsxO34u6Z4z/t0ITuu7voi/AshfsGsgFg==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.8.0.tgz", + "integrity": "sha512-xJEOXUOTmT4FngTmhdjKFRrVVF0hwCLNPdatLCHkyS4dkiSK12cEu1Y0fjxktjJrdst9jJIc5J6ihMJCoWEN/g==", + "dependencies": { + "@babel/runtime": "^7.21.0", + "@popperjs/core": "^2.11.6", + "@react-aria/ssr": "^3.5.0", + "@restart/hooks": "^0.4.9", + "@types/warning": "^3.0.0", + "dequal": "^2.0.3", + "dom-helpers": "^5.2.0", + "uncontrollable": "^8.0.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/@restart/ui/node_modules/uncontrollable": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", + "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", + "peerDependencies": { + "react": ">=16.14.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.3.tgz", + "integrity": "sha512-MmKSfaB9GX+zXl6E8z4koOr/xU63AMVleLEa64v7R0QF/ZloMs5vcD1sHgM64GXXS1csaJutG+ddtzcueI/BLg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.3.tgz", + "integrity": "sha512-zrt8ecH07PE3sB4jPOggweBjJMzI1JG5xI2DIsUbkA+7K+Gkjys6eV7i9pOenNSDJH3eOr/jLb/PzqtmdwDq5g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.3.tgz", + "integrity": "sha512-L1M0vKGO5ASKntqtsFEjTq/fD91vAqnzeaF6sfNAy55aD+Hi2pBI5DKwCO+UNDQHWsDViJLqshxOahXyLSh3EA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.3.tgz", + "integrity": "sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.3.tgz", + "integrity": "sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.3.tgz", + "integrity": "sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.3.tgz", + "integrity": "sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.3.tgz", + "integrity": "sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.3.tgz", + "integrity": "sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.3.tgz", + "integrity": "sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.3.tgz", + "integrity": "sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.3.tgz", + "integrity": "sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.3.tgz", + "integrity": "sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.3.tgz", + "integrity": "sha512-nMIdKnfZfzn1Vsk+RuOvl43ONTZXoAPUUxgcU0tXooqg4YrAqzfKzVenqqk2g5efWh46/D28cKFrOzDSW28gTA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.3.tgz", + "integrity": "sha512-fOvu7PCQjAj4eWDEuD8Xz5gpzFqXzGlxHZozHP4b9Jxv9APtdxL6STqztDzMLuRXEc4UpXGGhx029Xgm91QBeA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true + }, + "node_modules/@shikijs/core": { + "version": "1.17.6", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.17.6.tgz", + "integrity": "sha512-9ztslig6/YmCg/XwESAXbKjAjOhaq6HVced9NY6qcbDz1X5g/S90Wco2vMjBNX/6V71ASkzri76JewSGPa7kiQ==", + "dependencies": { + "@shikijs/engine-javascript": "1.17.6", + "@shikijs/engine-oniguruma": "1.17.6", + "@shikijs/types": "1.17.6", + "@shikijs/vscode-textmate": "^9.2.2", + "@types/hast": "^3.0.4", + "hast-util-to-html": "^9.0.2" + } + }, + "node_modules/@shikijs/engine-javascript": { + "version": "1.17.6", + "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-1.17.6.tgz", + "integrity": "sha512-5EEZj8tVcierNxm4V0UMS2PVoflb0UJPalWWV8l9rRg+oOfnr5VivqBJbkyq5grltVPvByIXvVbY8GSM/356jQ==", + "dependencies": { + "@shikijs/types": "1.17.6", + "oniguruma-to-js": "0.4.3" + } + }, + "node_modules/@shikijs/engine-oniguruma": { + "version": "1.17.6", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-1.17.6.tgz", + "integrity": "sha512-NLfWDMXFYe0nDHFbEoyZdz89aIIey3bTfF3zLYSUNTXks5s4uinZVmuPOFf1HfTeGqIn8uErJSBc3VnpJO7Alw==", + "dependencies": { + "@shikijs/types": "1.17.6", + "@shikijs/vscode-textmate": "^9.2.2" + } + }, + "node_modules/@shikijs/types": { + "version": "1.17.6", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-1.17.6.tgz", + "integrity": "sha512-ndTFa2TJi2w51ddKQDn3Jy8f6K4E5Q2x3dA3Hmsd3+YmxDQ10UWHjcw7VbVbKzv3VcUvYPLy+z9neqytSzUMUg==", + "dependencies": { + "@shikijs/vscode-textmate": "^9.2.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/@shikijs/vscode-textmate": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-9.2.2.tgz", + "integrity": "sha512-TMp15K+GGYrWlZM8+Lnj9EaHEFmOen0WJBrfa17hF7taDOYthuPPV0GWzfd/9iMij0akS/8Yw2ikquH7uVi/fg==" + }, + "node_modules/@sindresorhus/is": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-0.14.0.tgz", + "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sinonjs/commons": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", + "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz", + "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^1.7.0" + } + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-6.5.1.tgz", + "integrity": "sha512-9PYGcXrAxitycIjRmZB+Q0JaN07GZIWaTBIGQzfaZv+qr1n8X1XUEJ5rZ/vx6OVD9RRYlrNnXWExQXcmZeD/BQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-6.5.1.tgz", + "integrity": "sha512-8DPaVVE3fd5JKuIC29dqyMB54sA6mfgki2H2+swh+zNJoynC8pMPzOkidqHOSc6Wj032fhl8Z0TVn1GiPpAiJg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-6.5.1.tgz", + "integrity": "sha512-FwOEi0Il72iAzlkaHrlemVurgSQRDFbk0OC8dSvD5fSBPHltNh7JtLsxmZUhjYBZo2PpcU/RJvvi6Q0l7O7ogw==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-6.5.1.tgz", + "integrity": "sha512-gWGsiwjb4tw+ITOJ86ndY/DZZ6cuXMNE/SjcDRg+HLuCmwpcjOktwRF9WgAiycTqJD/QXqL2f8IzE2Rzh7aVXA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-6.5.1.tgz", + "integrity": "sha512-2jT3nTayyYP7kI6aGutkyfJ7UMGtuguD72OjeGLwVNyfPRBD8zQthlvL+fAbAKk5n9ZNcvFkp/b1lZ7VsYqVJg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-6.5.1.tgz", + "integrity": "sha512-a1p6LF5Jt33O3rZoVRBqdxL350oge54iZWHNI6LJB5tQ7EelvD/Mb1mfBiZNAan0dt4i3VArkFRjA4iObuNykQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-6.5.1.tgz", + "integrity": "sha512-6127fvO/FF2oi5EzSQOAjo1LE3OtNVh11R+/8FXa+mHx1ptAaS4cknIjnUA7e6j6fwGGJ17NzaTJFUwOV2zwCw==", + "dev": true, + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "^6.5.1", + "@svgr/babel-plugin-remove-jsx-attribute": "*", + "@svgr/babel-plugin-remove-jsx-empty-expression": "*", + "@svgr/babel-plugin-replace-jsx-attribute-value": "^6.5.1", + "@svgr/babel-plugin-svg-dynamic-title": "^6.5.1", + "@svgr/babel-plugin-svg-em-dimensions": "^6.5.1", + "@svgr/babel-plugin-transform-react-native-svg": "^6.5.1", + "@svgr/babel-plugin-transform-svg-component": "^6.5.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-6.5.1.tgz", + "integrity": "sha512-/xdLSWxK5QkqG524ONSjvg3V/FkNyCv538OIBdQqPNaAta3AsXj/Bd2FbvR87yMbXO2hFSWiAe/Q6IkVPDw+mw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.19.6", + "@svgr/babel-preset": "^6.5.1", + "@svgr/plugin-jsx": "^6.5.1", + "camelcase": "^6.2.0", + "cosmiconfig": "^7.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/core/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-6.5.1.tgz", + "integrity": "sha512-1hnUxxjd83EAxbL4a0JDJoD3Dao3hmjvyvyEV8PzWmLK3B9m9NPlW7GKjFyoWE8nM7HnXzPcmmSyOW8yOddSXw==", + "dev": true, + "dependencies": { + "@babel/types": "^7.20.0", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "6.5.1", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-6.5.1.tgz", + "integrity": "sha512-+UdQxI3jgtSjCykNSlEMuy1jSRQlGC7pqBCPvkG/2dATdWo082zHTTK3uhnAju2/6XpE6B5mZ3z4Z8Ns01S8Gw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.19.6", + "@svgr/babel-preset": "^6.5.1", + "@svgr/hast-util-to-babel-ast": "^6.5.1", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "^6.0.0" + } + }, + "node_modules/@swc/helpers": { + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.4.14.tgz", + "integrity": "sha512-4C7nX/dvpzB7za4Ql9K81xK3HPxCpHMgwTZVyf+9JQ6VUbn9jjZVN7/Nkdz/Ugzs2CSjqnL/UPXroiVBVHUWUw==", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", + "integrity": "sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==", + "dev": true, + "dependencies": { + "defer-to-connect": "^1.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "peer": true + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.5.0.tgz", + "integrity": "sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.0.1.tgz", + "integrity": "sha512-dSmwJVtJXmku+iocRhWOUFbrERC76TX2Mnf0ATODz8brzAZrMBbzLwQixlBSanZxR6LddK3eiwpSFZgDET1URg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0", + "@types/react-dom": "^18.0.0", + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "12.8.3", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.8.3.tgz", + "integrity": "sha512-IR0iWbFkgd56Bu5ZI/ej8yQwrkCv8Qydx6RzwbKz9faXazR/+5tvYKsZQgyXJiwgpcva127YO6JcWy7YlCfofQ==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tootallnate/once": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", + "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.4.tgz", + "integrity": "sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz", + "integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.1", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.1.tgz", + "integrity": "sha512-MitHFXnhtgwsGZWtT68URpOvLN4EREih1u3QtQiN4VdAxWKRVvGCSvw/Qth0M0Qq3pJpnGOu5JaM/ydK7OGbqg==", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", + "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", + "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==" + }, + "node_modules/@types/eslint": { + "version": "8.44.0", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.0.tgz", + "integrity": "sha512-gsF+c/0XOguWgaOgvFs+xnnRqt9GwgTvIks36WpE6ueeI4KCEHHd8K/CKHqhOqrJKsYH8m27kRzQEvWXAwXUTw==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.6", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.6.tgz", + "integrity": "sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "dev": true + }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, + "node_modules/@types/inquirer": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/@types/inquirer/-/inquirer-9.0.7.tgz", + "integrity": "sha512-Q0zyBupO6NxGRZut/JdmqYKOnN95Eg5V8Csg3PGKkP+FnvsUZx1jAyK7fztIszxxMuoBA6E3KXWvdZVXIpx60g==", + "dev": true, + "dependencies": { + "@types/through": "*", + "rxjs": "^7.2.0" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz", + "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==", + "dev": true + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz", + "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "26.0.24", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.24.tgz", + "integrity": "sha512-E/X5Vib8BWqZNRlDxj9vYXhsDwPYbPINqKF9BsnSoon4RQ0D9moEuLD8txgyypFLH7J4+Lho9Nr/c8H0Fi+17w==", + "dev": true, + "dependencies": { + "jest-diff": "^26.0.0", + "pretty-format": "^26.0.0" + } + }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true + }, + "node_modules/@types/json-schema": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz", + "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/node": { + "version": "22.5.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.5.4.tgz", + "integrity": "sha512-FDuKUJQm/ju9fT/SeX/6+gBzoPzlVCzfzmGkwKvRHQVxi4BntVbyIwf6a4Xn62mrvndLiml6z/UBXIdEVjQLXg==", + "devOptional": true, + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==", + "dev": true, + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/node-fetch/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@types/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" + }, + "node_modules/@types/prettier": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", + "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", + "dev": true + }, + "node_modules/@types/prop-types": { + "version": "15.7.13", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", + "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==" + }, + "node_modules/@types/react": { + "version": "18.3.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", + "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-beautiful-dnd": { + "version": "13.1.8", + "resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.8.tgz", + "integrity": "sha512-E3TyFsro9pQuK4r8S/OL6G99eq7p8v29sX0PM7oT8Z+PJfZvSQTx4zTQbUJ+QZXioAF0e7TGBEcA1XhYhCweyQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/react-bootstrap": { + "version": "0.32.37", + "resolved": "https://registry.npmjs.org/@types/react-bootstrap/-/react-bootstrap-0.32.37.tgz", + "integrity": "sha512-CVHj++uxsj1pRnM3RQ/NAXcWj+JwJZ3MqQ28sS1OQUD1sI2gRlbeAjRT+ak2nuwL+CY+gtnIsMaIDq0RNfN0PA==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/react-chartjs-2": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@types/react-chartjs-2/-/react-chartjs-2-2.5.7.tgz", + "integrity": "sha512-waqYqiNULIVUqaKO7MGUpFmWrVtH7gVPOzqwV4y4zgUyu/JiDwC005PpveO442HKnby9kLgp3t1SB2sld+ACLw==", + "deprecated": "This is a stub types definition for react-chartjs-2 (https://github.com/gor181/react-chartjs-2). react-chartjs-2 provides its own type definitions, so you don't need @types/react-chartjs-2 installed!", + "dev": true, + "license": "MIT", + "dependencies": { + "react-chartjs-2": "*" + } + }, + "node_modules/@types/react-datepicker": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-7.0.0.tgz", + "integrity": "sha512-4tWwOUq589tozyQPBVEqGNng5DaZkomx5IVNuur868yYdgjH6RaL373/HKiVt1IDoNNXYiTGspm1F7kjrarM8Q==", + "deprecated": "This is a stub types definition. react-datepicker provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "react-datepicker": "*" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/react-google-recaptcha": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@types/react-google-recaptcha/-/react-google-recaptcha-2.1.9.tgz", + "integrity": "sha512-nT31LrBDuoSZJN4QuwtQSF3O89FVHC4jLhM+NtKEmVF5R1e8OY0Jo4//x2Yapn2aNHguwgX5doAq8Zo+Ehd0ug==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/react-redux": { + "version": "7.1.34", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.34.tgz", + "integrity": "sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, + "node_modules/@types/react-redux/node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "dev": true, + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "node_modules/@types/react-transition-group": { + "version": "4.4.11", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.11.tgz", + "integrity": "sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/sanitize-html": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@types/sanitize-html/-/sanitize-html-2.13.0.tgz", + "integrity": "sha512-X31WxbvW9TjIhZZNyNBZ/p5ax4ti7qsNDBDEnH4zAgmEh35YnFD1UiS6z9Cd34kKm0LslFW0KPmTQzu/oGtsqQ==", + "dev": true, + "dependencies": { + "htmlparser2": "^8.0.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz", + "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==", + "dev": true + }, + "node_modules/@types/through": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/@types/through/-/through-0.0.33.tgz", + "integrity": "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" + }, + "node_modules/@types/warning": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", + "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==" + }, + "node_modules/@types/yargs": { + "version": "16.0.5", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.5.tgz", + "integrity": "sha512-AxO/ADJOBFJScHbWhq2xAhlWP24rY4aCEG/NFaMvbT3X2MgRsLjhjQwsn0Zi5zn0LG9jUhCCZMeX9Dkuw6k+vQ==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz", + "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==", + "dev": true + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", + "integrity": "sha512-KhGn2LjW1PJT2A/GfDpiyOfS4a8xHQv2myUagTM5+zsormOmBlYsnQ6pobJ8XxJmh6hnHwa2Mbe3fPrDJoDhbA==", + "dev": true, + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/type-utils": "8.11.0", + "@typescript-eslint/utils": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", + "graphemer": "^1.4.0", + "ignore": "^5.3.1", + "natural-compare": "^1.4.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz", + "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "8.5.0", + "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/typescript-estree": "8.5.0", + "@typescript-eslint/visitor-keys": "8.5.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz", + "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/visitor-keys": "8.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz", + "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz", + "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "8.5.0", + "@typescript-eslint/visitor-keys": "8.5.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz", + "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.5.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.11.0.tgz", + "integrity": "sha512-Uholz7tWhXmA4r6epo+vaeV7yjdKy5QFCERMjs1kMVsLRKIrSdM6o21W2He9ftp5PP6aWOVpD5zvrvuHZC0bMQ==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.11.0.tgz", + "integrity": "sha512-ItiMfJS6pQU0NIKAaybBKkuVzo6IdnAhPFZA/2Mba/uBjuPQPet/8+zh5GtLHwmuFRShZx+8lhIs7/QeDHflOg==", + "dev": true, + "dependencies": { + "@typescript-eslint/typescript-estree": "8.11.0", + "@typescript-eslint/utils": "8.11.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.11.0.tgz", + "integrity": "sha512-tn6sNMHf6EBAYMvmPUaKaVeYvhUsrE6x+bXQTxjQRp360h1giATU0WvgeEys1spbvb5R+VpNOZ+XJmjD8wOUHw==", + "dev": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.11.0.tgz", + "integrity": "sha512-yHC3s1z1RCHoCz5t06gf7jH24rr3vns08XXhfEqzYpd6Hll3z/3g23JRi0jM8A47UFKNc3u/y5KIMx8Ynbjohg==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/visitor-keys": "8.11.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^1.3.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.11.0.tgz", + "integrity": "sha512-CYiX6WZcbXNJV7UNB4PLDIBtSdRmRI/nb0FMyqHPTQD1rMjA0foPLaPUV39C/MxkTd/QKSeX+Gb34PPsDVC35g==", + "dev": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@typescript-eslint/scope-manager": "8.11.0", + "@typescript-eslint/types": "8.11.0", + "@typescript-eslint/typescript-estree": "8.11.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.11.0.tgz", + "integrity": "sha512-EaewX6lxSjRJnc+99+dqzTeoDZUfyrA52d2/HRrkI830kgovWsmIiTfmr0NZorzqic7ga+1bS60lRBUgR3n/Bw==", + "dev": true, + "dependencies": { + "@typescript-eslint/types": "8.11.0", + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", + "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.2.tgz", + "integrity": "sha512-hieu+o05v4glEBucTcKMK3dlES0OeJlD9YVOAPraVMOInBCwzumaIFiUjr4bHK7NPgnAHgiskUoceKercrN8vg==", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0" + } + }, + "node_modules/@vitejs/plugin-react/node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@wry/caches": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", + "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/context": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz", + "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/equality": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz", + "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@wry/trie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz", + "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", + "devOptional": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-globals": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", + "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", + "dev": true, + "dependencies": { + "acorn": "^7.1.1", + "acorn-walk": "^7.1.1" + } + }, + "node_modules/acorn-globals/node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", + "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/air-datepicker": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/air-datepicker/-/air-datepicker-3.5.3.tgz", + "integrity": "sha512-Elf9gLhv/jidN1+TfeRJYMQRUfYx5apXw2dY5DuAMPRnNtQ4Iw9fTTJK772osmXSUB9xQ2Y8Q1Pt6pgBOQLPQw==", + "license": "MIT" + }, + "node_modules/ajv": { + "version": "8.12.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", + "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "dev": true, + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-red": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-red/-/ansi-red-0.1.1.tgz", + "integrity": "sha512-ewaIr5y+9CUTGFwZfpECUbFlGcC0GCw1oqR9RI6h1gQCd9Aj2GxSckCnPsVJnmfMZbwFYE+leZGASgkWl06Jow==", + "dependencies": { + "ansi-wrap": "0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansi-wrap": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", + "integrity": "sha512-ZyznvL8k/FZeQHr2T6LzcJ/+vBApDnMNZvfVFy3At0knswWd6rJ3/0Hhmpu8oqa6C92npmozs890sX9Dl6q+Qw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", + "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true + }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/autolinker": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/autolinker/-/autolinker-0.28.1.tgz", + "integrity": "sha512-zQAFO1Dlsn69eXaO6+7YZc+v84aquQKbwpzCE3L0stj56ERn9hutFxPopViLjo9G+rWwjozRhgS5KJ25Xy19cQ==", + "dependencies": { + "gulp-header": "^1.7.1" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-jest/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-jest/node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-jest/node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-jest/node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "node_modules/babel-jest/node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/babel-jest/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/babel-jest/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/babel-jest/node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/babel-jest/node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-jest/node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-jest/node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-jest/node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/babel-jest/node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.11", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.11.tgz", + "integrity": "sha512-sMEJ27L0gRHShOh5G54uAAPaiCOygY/5ratXuiyb2G46FmlSpc9eFCzYVyDiPxfNbwzA7mYahmjQc5q+CZQ09Q==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.22.6", + "@babel/helper-define-polyfill-provider": "^0.6.2", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", + "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.2", + "core-js-compat": "^3.38.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.2.tgz", + "integrity": "sha512-2R25rQZWP63nGwaAswvDazbPXfrM3HwVoBXK6HcqeKrSrL/JqcC/rDcf95l4r7LXLyxDXc8uQDa064GubtCABg==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-transform-import-meta": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-import-meta/-/babel-plugin-transform-import-meta-2.2.1.tgz", + "integrity": "sha512-AxNh27Pcg8Kt112RGa3Vod2QS2YXKKJ6+nSvRtv7qQTJAdx0MZa4UHZ4lnxHUWA2MNbLuZQv5FVab4P1CoLOWw==", + "dependencies": { + "@babel/template": "^7.4.4", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@babel/core": "^7.10.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", + "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", + "dev": true, + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.8.3", + "@babel/plugin-syntax-import-meta": "^7.8.3", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.8.3", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-top-level-await": "^7.8.3" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bl/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bootstrap": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.3.tgz", + "integrity": "sha512-8HLCdWgyoMguSO9o+aH+iuZ+aht+mzW0u3HIMzVu7Srrpv7EBBxTnrFlSCskwdY1+EOFQSm7uMJhNQHkdPcmjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, + "node_modules/boxen": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/boxen/-/boxen-5.1.2.tgz", + "integrity": "sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==", + "dev": true, + "dependencies": { + "ansi-align": "^3.0.0", + "camelcase": "^6.2.0", + "chalk": "^4.1.0", + "cli-boxes": "^2.2.1", + "string-width": "^4.2.2", + "type-fest": "^0.20.2", + "widest-line": "^3.1.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boxen/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/boxen/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "devOptional": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/brotli": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz", + "integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==", + "dependencies": { + "base64-js": "^1.1.2" + } + }, + "node_modules/browser-process-hrtime": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", + "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==", + "dev": true + }, + "node_modules/browserslist": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", + "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "caniuse-lite": "^1.0.30001646", + "electron-to-chromium": "^1.5.4", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "peer": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "node_modules/bwip-js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/bwip-js/-/bwip-js-4.5.1.tgz", + "integrity": "sha512-83yQCKiIftz5YonnsTh6wIkFoHHWl+B/XaGWD1UdRw7aB6XP9JtyYP9n8sRy3m5rzL+Ch/RUPnu28UW0RrPZUA==", + "license": "MIT", + "bin": { + "bwip-js": "bin/bwip-js.js" + } + }, + "node_modules/cacheable-request": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz", + "integrity": "sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==", + "dev": true, + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^3.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^4.1.0", + "responselike": "^1.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-request/node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cacheable-request/node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-request/node_modules/normalize-url": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-4.5.1.tgz", + "integrity": "sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001660", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001660.tgz", + "integrity": "sha512-GacvNTTuATm26qC74pt+ad1fW15mlQ/zuTzzY1ZoIzECTP8HURDfF43kNxPgf7H1jmelCBQTTbBNxdSXOA7Bqg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ] + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chardet": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", + "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==" + }, + "node_modules/chart.js": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.6.tgz", + "integrity": "sha512-8Y406zevUPbbIBA/HRk33khEmQPk5+cxeflWE/2rx1NJsjVWMPw/9mSP9rxHP5eqi6LNoPBVMfZHxbwLSgldYA==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/ci-info": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", + "integrity": "sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", + "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", + "dev": true + }, + "node_modules/classnames": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz", + "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==" + }, + "node_modules/cli-boxes": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-2.2.1.tgz", + "integrity": "sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", + "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", + "dev": true + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/cli-width": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-3.0.0.tgz", + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==", + "engines": { + "node": ">= 10" + } + }, + "node_modules/cliui": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz", + "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^7.0.0" + } + }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/coffee-script": { + "version": "1.12.7", + "resolved": "https://registry.npmjs.org/coffee-script/-/coffee-script-1.12.7.tgz", + "integrity": "sha512-fLeEhqwymYat/MpTPUjSKHVYYl0ec2mOyALEMLmzr5i1isuG+6jfI2j2d5oBO3VIzgUXgBVIcOT9uH1TFxBckw==", + "deprecated": "CoffeeScript on NPM has moved to \"coffeescript\" (no hyphen)", + "bin": { + "cake": "bin/cake", + "coffee": "bin/coffee" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true + }, + "node_modules/color": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", + "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1", + "color-string": "^1.9.0" + }, + "engines": { + "node": ">=12.5.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/color-string": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", + "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", + "license": "MIT", + "dependencies": { + "color-name": "^1.0.0", + "simple-swizzle": "^0.2.2" + } + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/concat-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/concat-stream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/concat-with-sourcemaps": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/concat-with-sourcemaps/-/concat-with-sourcemaps-1.1.0.tgz", + "integrity": "sha512-4gEjHJFT9e+2W/77h/DS5SGUgwDaOwprX8L/gl5+3ixnzkVJJsZWDSelmN3Oilw3LNDZjZV0yqH1hLG3k6nghg==", + "dependencies": { + "source-map": "^0.6.1" + } + }, + "node_modules/concat-with-sourcemaps/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/configstore": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/configstore/-/configstore-5.0.1.tgz", + "integrity": "sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA==", + "dev": true, + "dependencies": { + "dot-prop": "^5.2.0", + "graceful-fs": "^4.1.2", + "make-dir": "^3.0.0", + "unique-string": "^2.0.0", + "write-file-atomic": "^3.0.0", + "xdg-basedir": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "node_modules/core-js-compat": { + "version": "3.38.1", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.1.tgz", + "integrity": "sha512-JRH6gfXxGmrzF3tZ57lFx97YARxCXPaMzPo6jELZhv88pBH5VXpQ+y0znKGlFnzuaihqhLbefxSJxWJMPtfDzw==", + "dev": true, + "dependencies": { + "browserslist": "^4.23.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "dependencies": { + "node-fetch": "^2.6.12" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz", + "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "dependencies": { + "tiny-invariant": "^1.0.6" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssom": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", + "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==", + "dev": true + }, + "node_modules/cssstyle": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz", + "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==", + "dev": true, + "dependencies": { + "cssom": "~0.3.6" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cssstyle/node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "dev": true + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" + }, + "node_modules/customize-cra": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/customize-cra/-/customize-cra-1.0.0.tgz", + "integrity": "sha512-DbtaLuy59224U+xCiukkxSq8clq++MOtJ1Et7LED1fLszWe88EoblEYFBJ895sB1mC6B4uu3xPT/IjClELhMbA==", + "dependencies": { + "lodash.flow": "^3.5.0" + } + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==", + "dependencies": { + "delaunator": "5" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", + "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", + "dev": true, + "dependencies": { + "abab": "^2.0.3", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==" + }, + "node_modules/debug": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true + }, + "node_modules/decompress-response": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", + "integrity": "sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA==", + "dev": true, + "dependencies": { + "mimic-response": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/dedent": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", + "integrity": "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==", + "dev": true + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "peer": true + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defaults/node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/defer-to-connect": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.3.tgz", + "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==", + "dev": true + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delaunator": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", + "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "dependencies": { + "robust-predicates": "^3.0.2" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-file": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", + "integrity": "sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dfa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz", + "integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==" + }, + "node_modules/diacritics-map": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/diacritics-map/-/diacritics-map-0.1.0.tgz", + "integrity": "sha512-3omnDTYrGigU0i4cJjvaKwD52B8aoqyX/NEIkukFFkogBemsIbhSa1O414fpTp5nuszJG6lvQ5vBvDVNCbSsaQ==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/diff-sequences": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", + "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==", + "dev": true, + "engines": { + "node": ">= 10.14.2" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "peer": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "peer": true + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ] + }, + "node_modules/domexception": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", + "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", + "dev": true, + "dependencies": { + "webidl-conversions": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/domexception/node_modules/webidl-conversions": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", + "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", + "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dot-prop": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", + "integrity": "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==", + "dev": true, + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/duplexer3": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.5.tgz", + "integrity": "sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==", + "dev": true + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.19", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.19.tgz", + "integrity": "sha512-kpLJJi3zxTR1U828P+LIUDZ5ohixyo68/IcYOHLqnbTPr/wdgn4i1ECvmALN9E16JPA6cvCG5UG79gVwVdEK5w==" + }, + "node_modules/emittery": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz", + "integrity": "sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "optional": true, + "peer": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", + "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "dev": true, + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "hasown": "^2.0.2", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.1", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.3", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.13", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.9", + "string.prototype.trimend": "^1.0.8", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.6", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.15" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.19.tgz", + "integrity": "sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "globalthis": "^1.0.3", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.7", + "iterator.prototype": "^1.1.2", + "safe-array-concat": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-goat": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-2.1.1.tgz", + "integrity": "sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escodegen": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz", + "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==", + "dev": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^5.2.0", + "esutils": "^2.0.2" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=6.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/escodegen/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/escodegen/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", + "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.0", + "@humanwhocodes/config-array": "^0.11.14", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-prettier": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", + "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", + "dev": true, + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.30.0.tgz", + "integrity": "sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw==", + "dev": true, + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.8", + "array.prototype.findlastindex": "^1.2.5", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.9.0", + "hasown": "^2.0.2", + "is-core-module": "^2.15.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.0", + "semver": "^6.3.1", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-jest": { + "version": "28.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-28.8.0.tgz", + "integrity": "sha512-Tubj1hooFxCl52G4qQu0edzV/+EZzPUeN8p2NnW5uu4fbDs+Yo7+qDVDc4/oG3FbCqEBmu/OC3LSsyiU22oghw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "engines": { + "node": "^16.10.0 || ^18.12.0 || >=20.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^6.0.0 || ^7.0.0 || ^8.0.0", + "eslint": "^7.0.0 || ^8.0.0 || ^9.0.0", + "jest": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz", + "integrity": "sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw==", + "dev": true, + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.9.1" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": "*", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.1.tgz", + "integrity": "sha512-xwTnwDqzbDRA8uJ7BMxPs/EXRB3i8ZfnOIp8BsxEQkT0nHPp+WWceqGgo6rKb9ctNi8GJLDT4Go5HAWELa/WMg==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.2", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.0.19", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.8", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.0", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.11", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint-plugin-react/node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-plugin-react/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-plugin-tsdoc": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-tsdoc/-/eslint-plugin-tsdoc-0.3.0.tgz", + "integrity": "sha512-0MuFdBrrJVBjT/gyhkP2BqpD0np1NxNLfQ38xXDlSs/KVVpKI2A6vN7jx2Rve/CyUsvOsMGwp9KKrinv7q9g3A==", + "dev": true, + "dependencies": { + "@microsoft/tsdoc": "0.15.0", + "@microsoft/tsdoc-config": "0.17.0" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "peer": true + }, + "node_modules/eslint/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/eslint/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "peer": true, + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "peer": true, + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/eslint/node_modules/globals": { + "version": "13.20.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", + "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", + "dev": true, + "peer": true, + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "peer": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "peer": true + }, + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "peer": true, + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "peer": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "peer": true, + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "dev": true, + "peer": true, + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esquery/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "peer": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expand-range": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz", + "integrity": "sha512-AFASGfIlnIbkKPQwX1yHaDjFvh/1gyKJODme52V6IORh69uEYgZp0o9C+qsIGNVEiuuhQU0CSSl++Rlegg1qvA==", + "dependencies": { + "fill-range": "^2.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-range/node_modules/fill-range": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-2.2.4.tgz", + "integrity": "sha512-cnrcCbj01+j2gTG921VZPnHbjmdAf8oQV/iGeV2kZxGSyfYjjTyY79ErsK1WJWMpw6DaApEX72binqJE+/d+5Q==", + "dependencies": { + "is-number": "^2.1.0", + "isobject": "^2.0.0", + "randomatic": "^3.0.0", + "repeat-element": "^1.1.2", + "repeat-string": "^1.5.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-range/node_modules/is-number": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz", + "integrity": "sha512-QUzH43Gfb9+5yckcrSA0VBDwEtDUchrk4F6tfJZQuNzDJbEDB9cZNzSfXGQ1jqmdDY/kl41lUOWM9syA8z8jlg==", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-range/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/expand-range/node_modules/isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==", + "dependencies": { + "isarray": "1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-range/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expand-tilde": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", + "integrity": "sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==", + "dev": true, + "dependencies": { + "homedir-polyfill": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz", + "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/external-editor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", + "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", + "dependencies": { + "chardet": "^0.7.0", + "iconv-lite": "^0.4.24", + "tmp": "^0.0.33" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/external-editor/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "peer": true + }, + "node_modules/fast-xml-parser": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.4.1.tgz", + "integrity": "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/figures/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "peer": true, + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "devOptional": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "dev": true, + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true + }, + "node_modules/find-node-modules": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/find-node-modules/-/find-node-modules-2.1.3.tgz", + "integrity": "sha512-UC2I2+nx1ZuOBclWVNdcnbDR5dlrOdVb7xNjmT/lHE+LsgztWks3dG7boJ37yTS/venXw84B/mAW9uHVoC5QRg==", + "dev": true, + "dependencies": { + "findup-sync": "^4.0.0", + "merge": "^2.1.1" + } + }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/findup-sync": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-4.0.0.tgz", + "integrity": "sha512-6jvvn/12IC4quLBL1KNokxC7wWTvYncaVUYSoxWw7YykPLuRrnv4qdHcSOywOI5RpkOVGeQRtWM8/q+G6W6qfQ==", + "dev": true, + "dependencies": { + "detect-file": "^1.0.0", + "is-glob": "^4.0.0", + "micromatch": "^4.0.2", + "resolve-dir": "^1.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/flag-icons": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.2.3.tgz", + "integrity": "sha512-X2gUdteNuqdNqob2KKTJTS+ZCvyWeLCtDz9Ty8uJP17Y4o82Y+U/Vd4JNrdwTAjagYsRznOn9DZ+E/Q52qbmqg==" + }, + "node_modules/flat-cache": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", + "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "dev": true, + "peer": true, + "dependencies": { + "flatted": "^3.1.0", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", + "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", + "dev": true, + "peer": true + }, + "node_modules/fontkit": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.2.tgz", + "integrity": "sha512-jc4k5Yr8iov8QfS6u8w2CnHWVmbOGtdBtOXMze5Y+QD966Rx6PEVWXSEGwXlsDlKtu1G12cJjcsybnqhSk/+LA==", + "dependencies": { + "@swc/helpers": "^0.4.2", + "brotli": "^1.3.2", + "clone": "^2.1.2", + "dfa": "^1.2.0", + "fast-deep-equal": "^3.1.3", + "restructure": "^3.0.0", + "tiny-inflate": "^1.0.3", + "unicode-properties": "^1.4.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/form-data": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", + "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "dev": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generic-names": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/generic-names/-/generic-names-4.0.0.tgz", + "integrity": "sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==", + "dev": true, + "dependencies": { + "loader-utils": "^3.2.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.2.0.tgz", + "integrity": "sha512-2nk+7SIVb14QrgXFHcm84tD4bKQz0RxPuMT8Ag5KPOq7J5fEmAg0UbXdTOSHqNuHSU28k55qnceesxXRZGzKWA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.5.tgz", + "integrity": "sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==", + "dev": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/global-dirs": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", + "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", + "dev": true, + "dependencies": { + "ini": "2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/global-dirs/node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "engines": { + "node": ">=4" + } + }, + "node_modules/globalthis": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", + "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==" + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/got/-/got-9.6.0.tgz", + "integrity": "sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==", + "dev": true, + "dependencies": { + "@sindresorhus/is": "^0.14.0", + "@szmarczak/http-timer": "^1.1.2", + "cacheable-request": "^6.0.0", + "decompress-response": "^3.3.0", + "duplexer3": "^0.1.4", + "get-stream": "^4.1.0", + "lowercase-keys": "^1.0.1", + "mimic-response": "^1.0.1", + "p-cancelable": "^1.0.0", + "to-readable-stream": "^1.0.0", + "url-parse-lax": "^3.0.0" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/got/node_modules/get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true + }, + "node_modules/graphql": { + "version": "16.9.0", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.9.0.tgz", + "integrity": "sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, + "node_modules/graphql-tag": { + "version": "2.12.6", + "resolved": "https://registry.npmjs.org/graphql-tag/-/graphql-tag-2.12.6.tgz", + "integrity": "sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": "^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0" + } + }, + "node_modules/graphql-ws": { + "version": "5.16.0", + "resolved": "https://registry.npmjs.org/graphql-ws/-/graphql-ws-5.16.0.tgz", + "integrity": "sha512-Ju2RCU2dQMgSKtArPbEtsK5gNLnsQyTNIo/T7cZNp96niC1x0KdJNZV0TIoilceBPQwfb5itrGl8pkFeOUMl4A==", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "graphql": ">=0.11 <=16" + } + }, + "node_modules/gray-matter": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-2.1.1.tgz", + "integrity": "sha512-vbmvP1Fe/fxuT2QuLVcqb2BfK7upGhhbLIt9/owWEvPYrZZEkelLcq2HqzxosV+PQ67dUFLaAeNpH7C4hhICAA==", + "dependencies": { + "ansi-red": "^0.1.1", + "coffee-script": "^1.12.4", + "extend-shallow": "^2.0.1", + "js-yaml": "^3.8.1", + "toml": "^2.3.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gray-matter/node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gray-matter/node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gulp-header": { + "version": "1.8.12", + "resolved": "https://registry.npmjs.org/gulp-header/-/gulp-header-1.8.12.tgz", + "integrity": "sha512-lh9HLdb53sC7XIZOYzTXM4lFuXElv3EVkSDhsd7DoJBj7hm+Ni7D3qYbb+Rr8DuM8nRanBvkVO9d7askreXGnQ==", + "deprecated": "Removed event-stream from gulp-header", + "dependencies": { + "concat-with-sourcemaps": "*", + "lodash.template": "^4.4.0", + "through2": "^2.0.0" + } + }, + "node_modules/harmony-reflect": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz", + "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==", + "dev": true + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-yarn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-2.1.0.tgz", + "integrity": "sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-to-html": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.2.tgz", + "integrity": "sha512-RP5wNpj5nm1Z8cloDv4Sl4RS8jH5HYa0v93YB6Wb4poEzgMo/dAAL0KcT4974dCjcNG5pkLqTImeFHHCwwfY3g==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-whitespace": "^3.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "stringify-entities": "^4.0.0", + "zwitch": "^2.0.4" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/history": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz", + "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==", + "dependencies": { + "@babel/runtime": "^7.7.6" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/homedir-polyfill": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz", + "integrity": "sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==", + "dev": true, + "dependencies": { + "parse-passwd": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", + "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^1.0.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/html-entities": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.5.2.tgz", + "integrity": "sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ] + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "dev": true + }, + "node_modules/http-proxy-agent": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", + "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "dev": true, + "dependencies": { + "@tootallnate/once": "1", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/husky": { + "version": "9.1.6", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.6.tgz", + "integrity": "sha512-sqbjZKK7kf44hfdE94EoX8MZNk0n7HeW37O4YrVGCF4wzgQjp+akPAkfUK5LZ6KuR/6sqeAVuXHji+RzQgOn5A==", + "dev": true, + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, + "node_modules/i18next": { + "version": "23.15.1", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-23.15.1.tgz", + "integrity": "sha512-wB4abZ3uK7EWodYisHl/asf8UYEhrI/vj/8aoSsrj/ZDxj4/UXPOa1KvFt1Fq5hkUHquNqwFlDprmjZ8iySgYA==", + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-browser-languagedetector": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.0.tgz", + "integrity": "sha512-zhXdJXTTCoG39QsrOCiOabnWj2jecouOqbchu3EfhtSHxIB5Uugnm9JaizenOy39h7ne3+fLikIjeW88+rgszw==", + "dependencies": { + "@babel/runtime": "^7.23.2" + } + }, + "node_modules/i18next-http-backend": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-2.6.1.tgz", + "integrity": "sha512-rCilMAnlEQNeKOZY1+x8wLM5IpYOj10guGvEpeC59tNjj6MMreLIjIW8D1RclhD3ifLwn6d/Y9HEM1RUE6DSog==", + "dependencies": { + "cross-fetch": "4.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "optional": true, + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/identity-obj-proxy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", + "integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==", + "dev": true, + "dependencies": { + "harmony-reflect": "^1.4.6" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/immutable": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz", + "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==", + "devOptional": true + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-lazy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-2.1.0.tgz", + "integrity": "sha512-m7ZEHgtw69qOGw+jwxXkHlrlIPdTGkyh66zXZ1ajZbxkDBNjSY/LGbmjc7h0s2ELsUDTAhFr55TrPSSqJGPG0A==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/import-local": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", + "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "dev": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, + "node_modules/inquirer": { + "version": "8.2.6", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", + "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", + "dependencies": { + "ansi-escapes": "^4.2.1", + "chalk": "^4.1.1", + "cli-cursor": "^3.1.0", + "cli-width": "^3.0.0", + "external-editor": "^3.0.3", + "figures": "^3.0.0", + "lodash": "^4.17.21", + "mute-stream": "0.0.8", + "ora": "^5.4.1", + "run-async": "^2.4.0", + "rxjs": "^7.5.5", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0", + "through": "^2.3.6", + "wrap-ansi": "^6.0.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/inquirer/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/inquirer/node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/inquirer/node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/inquirer/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/internal-slot": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, + "node_modules/is-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", + "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", + "dev": true, + "dependencies": { + "ci-info": "^2.0.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-ci/node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true + }, + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "dev": true, + "dependencies": { + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "devOptional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", + "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "devOptional": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-installed-globally": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", + "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", + "dev": true, + "dependencies": { + "global-dirs": "^3.0.0", + "is-path-inside": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-npm": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-5.0.0.tgz", + "integrity": "sha512-WW/rQLOazUq+ST/bCAVBp/2oMERWLsR7OrKyt052dNDk4DHcDE0/7QSXITlmi+VBcV13DfIbysG3tZJm5RfdBA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "devOptional": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-obj": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-2.0.0.tgz", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dev": true, + "dependencies": { + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.3.tgz", + "integrity": "sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-yarn-global": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.3.0.tgz", + "integrity": "sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw==", + "dev": true + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz", + "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz", + "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^3.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.5.tgz", + "integrity": "sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", + "integrity": "sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "reflect.getprototypeof": "^1.0.4", + "set-function-name": "^2.0.1" + } + }, + "node_modules/jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz", + "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==", + "dev": true, + "dependencies": { + "@jest/core": "^27.5.1", + "import-local": "^3.0.2", + "jest-cli": "^27.5.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz", + "integrity": "sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "execa": "^5.0.0", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-27.5.1.tgz", + "integrity": "sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^0.7.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-circus/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-circus/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-circus/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, + "node_modules/jest-cli": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-27.5.1.tgz", + "integrity": "sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw==", + "dev": true, + "dependencies": { + "@jest/core": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "import-local": "^3.0.2", + "jest-config": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "prompts": "^2.0.1", + "yargs": "^16.2.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-cli/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-config": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-27.5.1.tgz", + "integrity": "sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.8.0", + "@jest/test-sequencer": "^27.5.1", + "@jest/types": "^27.5.1", + "babel-jest": "^27.5.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.1", + "graceful-fs": "^4.2.9", + "jest-circus": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-jasmine2": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runner": "^27.5.1", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/babel-jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz", + "integrity": "sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==", + "dev": true, + "dependencies": { + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/jest-config/node_modules/babel-plugin-jest-hoist": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-27.5.1.tgz", + "integrity": "sha512-50wCwD5EMNW4aRpOwtqzyZHIewTYNxLA4nhB+09d8BIssfNfzBRhkBIHiaPv1Si226TQSvp8gxAJm2iY2qs2hQ==", + "dev": true, + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.0.0", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-config/node_modules/babel-preset-jest": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-27.5.1.tgz", + "integrity": "sha512-Nptf2FzlPCWYuJg41HBqXVT8ym6bXOevuCTbhxlUpjwtysGaIWFvDEjp4y+G7fl13FgOdjs7P/DmErqH7da0Ag==", + "dev": true, + "dependencies": { + "babel-plugin-jest-hoist": "^27.5.1", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/jest-config/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-config/node_modules/jest-environment-node": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-27.5.1.tgz", + "integrity": "sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-config/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-config/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-config/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, + "node_modules/jest-diff": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz", + "integrity": "sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^26.6.2", + "jest-get-type": "^26.3.0", + "pretty-format": "^26.6.2" + }, + "engines": { + "node": ">= 10.14.2" + } + }, + "node_modules/jest-diff/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/jest-get-type": { + "version": "26.3.0", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", + "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", + "dev": true, + "engines": { + "node": ">= 10.14.2" + } + }, + "node_modules/jest-docblock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-27.5.1.tgz", + "integrity": "sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ==", + "dev": true, + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-docblock/node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-each": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz", + "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-each/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-each/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-each/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-each/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, + "node_modules/jest-environment-jsdom": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz", + "integrity": "sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1", + "jsdom": "^16.6.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz", + "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==", + "dev": true, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz", + "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "@types/graceful-fs": "^4.1.2", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^27.5.1", + "jest-serializer": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "micromatch": "^4.0.4", + "walker": "^1.0.7" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-jasmine2": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz", + "integrity": "sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "expect": "^27.5.1", + "is-generator-fn": "^2.0.0", + "jest-each": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "pretty-format": "^27.5.1", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-jasmine2/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-jasmine2/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-jasmine2/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, + "node_modules/jest-leak-detector": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz", + "integrity": "sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ==", + "dev": true, + "dependencies": { + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-leak-detector/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-leak-detector/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-leak-detector/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, + "node_modules/jest-localstorage-mock": { + "version": "2.4.26", + "resolved": "https://registry.npmjs.org/jest-localstorage-mock/-/jest-localstorage-mock-2.4.26.tgz", + "integrity": "sha512-owAJrYnjulVlMIXOYQIPRCCn3MmqI3GzgfZCXdD3/pmwrIvFMXcKVWZ+aMc44IzaASapg0Z4SEFxR+v5qxDA2w==", + "dev": true, + "engines": { + "node": ">=6.16.0" + } + }, + "node_modules/jest-location-mock": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jest-location-mock/-/jest-location-mock-2.0.0.tgz", + "integrity": "sha512-loakfclgY/y65/2i4s0fcdlZY3hRPfwNnmzRsGFQYQryiaow2DEIGTLXIPI8cAO1Is36xsVLVkIzgvhQ+FXHdw==", + "dev": true, + "dependencies": { + "@jedmao/location": "^3.0.0", + "jest-diff": "^29.6.4" + }, + "engines": { + "node": "^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-location-mock/node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-location-mock/node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, + "node_modules/jest-location-mock/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-location-mock/node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-location-mock/node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-location-mock/node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-location-mock/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-location-mock/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz", + "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/diff-sequences": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", + "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", + "dev": true, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/jest-diff": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", + "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, + "node_modules/jest-message-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz", + "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^27.5.1", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^27.5.1", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-message-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-message-util/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, + "node_modules/jest-mock": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz", + "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-preview": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/jest-preview/-/jest-preview-0.3.1.tgz", + "integrity": "sha512-gRR4shnXFSh8tdNaIncJC98d1zXD7w7LA52HQC0bu0DsPb+FXVEg+NQh9GTbO+n6/SCgcZNQAVt4MeCfsIkBPA==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@svgr/core": "^6.2.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "chokidar": "^3.5.3", + "commander": "^9.2.0", + "connect": "^3.7.0", + "find-node-modules": "^2.1.3", + "open": "^8.4.0", + "postcss-import": "^14.1.0", + "postcss-load-config": "^4.0.1", + "sirv": "^2.0.2", + "slash": "^3.0.0", + "string-hash": "^1.1.3", + "update-notifier": "^5.1.0", + "ws": "^8.5.0" + }, + "bin": { + "jest-preview": "cli/index.js" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/jest-preview" + } + }, + "node_modules/jest-preview/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-preview/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-regex-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz", + "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==", + "dev": true, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz", + "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^27.5.1", + "jest-validate": "^27.5.1", + "resolve": "^1.20.0", + "resolve.exports": "^1.1.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-27.5.1.tgz", + "integrity": "sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-snapshot": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-resolve/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-runner": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-27.5.1.tgz", + "integrity": "sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ==", + "dev": true, + "dependencies": { + "@jest/console": "^27.5.1", + "@jest/environment": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.8.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "jest-environment-node": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-leak-detector": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-runtime": "^27.5.1", + "jest-util": "^27.5.1", + "jest-worker": "^27.5.1", + "source-map-support": "^0.5.6", + "throat": "^6.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runner/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-runner/node_modules/jest-environment-node": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-27.5.1.tgz", + "integrity": "sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "jest-mock": "^27.5.1", + "jest-util": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz", + "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==", + "dev": true, + "dependencies": { + "@jest/environment": "^27.5.1", + "@jest/fake-timers": "^27.5.1", + "@jest/globals": "^27.5.1", + "@jest/source-map": "^27.5.1", + "@jest/test-result": "^27.5.1", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "execa": "^5.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-mock": "^27.5.1", + "jest-regex-util": "^27.5.1", + "jest-resolve": "^27.5.1", + "jest-snapshot": "^27.5.1", + "jest-util": "^27.5.1", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-runtime/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-serializer": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz", + "integrity": "sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==", + "dev": true, + "dependencies": { + "@types/node": "*", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz", + "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.7.2", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/traverse": "^7.7.2", + "@babel/types": "^7.0.0", + "@jest/transform": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/babel__traverse": "^7.0.4", + "@types/prettier": "^2.1.5", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^27.5.1", + "graceful-fs": "^4.2.9", + "jest-diff": "^27.5.1", + "jest-get-type": "^27.5.1", + "jest-haste-map": "^27.5.1", + "jest-matcher-utils": "^27.5.1", + "jest-message-util": "^27.5.1", + "jest-util": "^27.5.1", + "natural-compare": "^1.4.0", + "pretty-format": "^27.5.1", + "semver": "^7.3.2" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-snapshot/node_modules/diff-sequences": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz", + "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==", + "dev": true, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/jest-diff": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz", + "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^27.5.1", + "jest-get-type": "^27.5.1", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-snapshot/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, + "node_modules/jest-util": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz", + "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-util/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-validate": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz", + "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==", + "dev": true, + "dependencies": { + "@jest/types": "^27.5.1", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^27.5.1", + "leven": "^3.1.0", + "pretty-format": "^27.5.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-validate/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-validate/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-validate/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-validate/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, + "node_modules/jest-watcher": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-27.5.1.tgz", + "integrity": "sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw==", + "dev": true, + "dependencies": { + "@jest/test-result": "^27.5.1", + "@jest/types": "^27.5.1", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "jest-util": "^27.5.1", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/jest-watcher/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jju": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", + "integrity": "sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==", + "dev": true + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "engines": { + "node": ">=14" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "16.7.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", + "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==", + "dev": true, + "dependencies": { + "abab": "^2.0.5", + "acorn": "^8.2.4", + "acorn-globals": "^6.0.0", + "cssom": "^0.4.4", + "cssstyle": "^2.3.0", + "data-urls": "^2.0.0", + "decimal.js": "^10.2.1", + "domexception": "^2.0.1", + "escodegen": "^2.0.0", + "form-data": "^3.0.0", + "html-encoding-sniffer": "^2.0.1", + "http-proxy-agent": "^4.0.1", + "https-proxy-agent": "^5.0.0", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.0", + "parse5": "6.0.1", + "saxes": "^5.0.1", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.0.0", + "w3c-hr-time": "^1.0.2", + "w3c-xmlserializer": "^2.0.0", + "webidl-conversions": "^6.1.0", + "whatwg-encoding": "^1.0.5", + "whatwg-mimetype": "^2.3.0", + "whatwg-url": "^8.5.0", + "ws": "^7.4.6", + "xml-name-validator": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", + "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==", + "dev": true + }, + "node_modules/jsdom/node_modules/ws": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "dev": true, + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.0.tgz", + "integrity": "sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ==", + "dev": true + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "peer": true + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", + "integrity": "sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==", + "dev": true, + "dependencies": { + "json-buffer": "3.0.0" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/latest-version": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-5.1.0.tgz", + "integrity": "sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==", + "dev": true, + "dependencies": { + "package-json": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lazy-cache": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-2.0.2.tgz", + "integrity": "sha512-7vp2Acd2+Kz4XkzxGxaB1FWOi8KjWIWsgdfD5MCb86DWvlLqhRPM+d6Pro3iNEL5VT9mstz5hKAlcd+QR6H3aA==", + "dependencies": { + "set-getter": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lint-staged": { + "version": "15.2.8", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.2.8.tgz", + "integrity": "sha512-PUWFf2zQzsd9EFU+kM1d7UP+AZDbKFKuj+9JNVTBkhUFhbg4MAt6WfyMMwBfM4lYqd4D2Jwac5iuTu9rVj4zCQ==", + "dev": true, + "dependencies": { + "chalk": "~5.3.0", + "commander": "~12.1.0", + "debug": "~4.3.6", + "execa": "~8.0.1", + "lilconfig": "~3.1.2", + "listr2": "~8.2.4", + "micromatch": "~4.0.7", + "pidtree": "~0.6.0", + "string-argv": "~0.3.2", + "yaml": "~2.5.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/lint-staged/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/lint-staged/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/lilconfig": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lint-staged/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/npm-run-path": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.2.0.tgz", + "integrity": "sha512-W4/tgAXFqFA0iL7fk0+uQ3g7wkL8xJmx3XdK0VGb4cHW//eZTtKGvFBBoRKVTpY7n6ze4NL9ly7rgXcHufqXKg==", + "dev": true, + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lint-staged/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/yaml": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.0.tgz", + "integrity": "sha512-2wWLbGbYDiSqqIKoPjar3MPgB94ErzCtrNE1FdqGuaO0pi2JGjmE8aW8TDZwzU7vuxcGRdL/4gPQwQ7hD5AMSw==", + "dev": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/list-item": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/list-item/-/list-item-1.1.1.tgz", + "integrity": "sha512-S3D0WZ4J6hyM8o5SNKWaMYB1ALSacPZ2nHGEuCjmHZ+dc03gFeNZoNDcqfcnO4vDhTZmNrqrpYZCdXsRh22bzw==", + "dependencies": { + "expand-range": "^1.8.1", + "extend-shallow": "^2.0.1", + "is-number": "^2.1.0", + "repeat-string": "^1.5.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/list-item/node_modules/extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/list-item/node_modules/is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/list-item/node_modules/is-number": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-2.1.0.tgz", + "integrity": "sha512-QUzH43Gfb9+5yckcrSA0VBDwEtDUchrk4F6tfJZQuNzDJbEDB9cZNzSfXGQ1jqmdDY/kl41lUOWM9syA8z8jlg==", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/list-item/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/listr2": { + "version": "8.2.4", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.4.tgz", + "integrity": "sha512-opevsywziHd3zHCVQGAj8zu+Z3yHNkkoYhWIGnq54RrCVwLz0MozotJEDnKsIBLvkfLGN6BLOyAeRrYI0pKA4g==", + "dev": true, + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", + "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", + "dev": true + }, + "node_modules/listr2/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/loader-utils": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-3.2.1.tgz", + "integrity": "sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw==", + "dev": true, + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash._reinterpolate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", + "integrity": "sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA==" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "dev": true + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true + }, + "node_modules/lodash.flow": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/lodash.flow/-/lodash.flow-3.5.0.tgz", + "integrity": "sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw==" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "peer": true + }, + "node_modules/lodash.template": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-4.5.0.tgz", + "integrity": "sha512-84vYFxIkmidUiFxidA/KjjH9pAycqW+h980j7Fuz5qxRtO9pgB7MDFTdys1N7A5mcucRiDyEq4fusljItR1T/A==", + "dependencies": { + "lodash._reinterpolate": "^3.0.0", + "lodash.templatesettings": "^4.0.0" + } + }, + "node_modules/lodash.templatesettings": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.templatesettings/-/lodash.templatesettings-4.2.0.tgz", + "integrity": "sha512-stgLz+i3Aa9mZgnjr/O+v9ruKZsPsndy7qPZOchbqk2cnTU1ZaldKK+v7m54WoKIyxiuMZTKT2H81F8BeAc3ZQ==", + "dependencies": { + "lodash._reinterpolate": "^3.0.0" + } + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", + "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", + "dev": true, + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", + "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", + "dev": true + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", + "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", + "dev": true, + "dependencies": { + "get-east-asian-width": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", + "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", + "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lowercase-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz", + "integrity": "sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==" + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "dev": true, + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "node_modules/markdown-link": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/markdown-link/-/markdown-link-0.1.1.tgz", + "integrity": "sha512-TurLymbyLyo+kAUUAV9ggR9EPcDjP/ctlv9QAFiqUH7c+t6FlsbivPo9OKTU8xdOx9oNd2drW/Fi5RRElQbUqA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/markdown-toc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/markdown-toc/-/markdown-toc-1.2.0.tgz", + "integrity": "sha512-eOsq7EGd3asV0oBfmyqngeEIhrbkc7XVP63OwcJBIhH2EpG2PzFcbZdhy1jutXSlRBBVMNXHvMtSr5LAxSUvUg==", + "dependencies": { + "concat-stream": "^1.5.2", + "diacritics-map": "^0.1.0", + "gray-matter": "^2.1.0", + "lazy-cache": "^2.0.2", + "list-item": "^1.1.1", + "markdown-link": "^0.1.1", + "minimist": "^1.2.0", + "mixin-deep": "^1.1.3", + "object.pick": "^1.2.0", + "remarkable": "^1.7.1", + "repeat-string": "^1.6.1", + "strip-color": "^0.1.0" + }, + "bin": { + "markdown-toc": "cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/math-random": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/math-random/-/math-random-1.0.4.tgz", + "integrity": "sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==" + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", + "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==" + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, + "node_modules/merge": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/merge/-/merge-2.1.1.tgz", + "integrity": "sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==", + "dev": true + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.0.tgz", + "integrity": "sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.0.tgz", + "integrity": "sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.0.tgz", + "integrity": "sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.0.tgz", + "integrity": "sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.0.tgz", + "integrity": "sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "devOptional": true, + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mixin-deep": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", + "dependencies": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/mrmime": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", + "integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, + "node_modules/mute-stream": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "optional": true + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/node-html-better-parser": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.4.4.tgz", + "integrity": "sha512-uvlqL1uMU7m/aIY9WsGM0jDW7gVFIuFSWS6f2rlJeL7K1ZzKnA3B8cNbUGw9ywwYm9W7W2ooi0iQ7aI29aQmPw==", + "license": "MIT", + "dependencies": { + "html-entities": "^2.3.2" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true + }, + "node_modules/node-releases": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nwsapi": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", + "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==", + "dev": true + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", + "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz", + "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha512-tqa/UMy/CCoYmj+H5qc07qvSL9dqcs/WZENZ1JbtWBlATP+iVOe778gE6MSijnyCnORzDuX6hU+LA4SZ09YjFQ==", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object.values": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.0.tgz", + "integrity": "sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "dev": true, + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/oniguruma-to-js": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/oniguruma-to-js/-/oniguruma-to-js-0.4.3.tgz", + "integrity": "sha512-X0jWUcAlxORhOqqBREgPMgnshB7ZGYszBNspP+tS9hPD3l13CdaXcHbgImoHUHlrvGx/7AvFEkTRhAGYh+jzjQ==", + "dependencies": { + "regex": "^4.3.2" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optimism": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/optimism/-/optimism-0.18.0.tgz", + "integrity": "sha512-tGn8+REwLRNFnb9WmcY5IfpOqeX2kpaYJ1s6Ae3mn12AeydLkR3j+jSCmVQFoXqU8D41PAJ1RG1rCRNWmNZVmQ==", + "dependencies": { + "@wry/caches": "^1.0.0", + "@wry/context": "^0.7.0", + "@wry/trie": "^0.4.3", + "tslib": "^2.3.0" + } + }, + "node_modules/optimism/node_modules/@wry/trie": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.4.3.tgz", + "integrity": "sha512-I6bHwH0fSf6RqQcnnXLJKhkSXG45MFral3GxPaY4uAl0LYDZM+YDVDAiU9bYwjTuysy1S0IeecWtmq1SZA3M1w==", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/optionator": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "dev": true, + "peer": true, + "dependencies": { + "@aashutoshrathi/word-wrap": "^1.2.3", + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/ora/node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ora/node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-cancelable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-1.1.0.tgz", + "integrity": "sha512-s73XxOZ4zpt1edZYZzvhqFa6uvQc1vwUa0K0BdtIZgQMAJj9IbebH+JkgKZc9h+B05PKHLOTl4ajG1BmNrVZlw==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/package-json/-/package-json-6.5.0.tgz", + "integrity": "sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ==", + "dev": true, + "dependencies": { + "got": "^9.6.0", + "registry-auth-token": "^4.0.0", + "registry-url": "^5.0.0", + "semver": "^6.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-passwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", + "integrity": "sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "devOptional": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.4.45", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.45.tgz", + "integrity": "sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.1", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-14.1.0.tgz", + "integrity": "sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==", + "dev": true, + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.1.tgz", + "integrity": "sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==", + "dev": true, + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^2.1.1" + }, + "engines": { + "node": ">= 14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/yaml": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", + "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==", + "dev": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/postcss-modules": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules/-/postcss-modules-6.0.0.tgz", + "integrity": "sha512-7DGfnlyi/ju82BRzTIjWS5C4Tafmzl3R79YP/PASiocj+aa6yYphHhhKUOEoXQToId5rgyFgJ88+ccOUydjBXQ==", + "dev": true, + "dependencies": { + "generic-names": "^4.0.0", + "icss-utils": "^5.1.0", + "lodash.camelcase": "^4.3.0", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "string-hash": "^1.1.1" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "dev": true, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.5.tgz", + "integrity": "sha512-6MieY7sIfTK0hYfafw1OMEG+2bg8Q1ocHCpoWLqOKj3JXlKu4G7btkmM/B7lFubYkYWmRSPLZi5chid63ZaZYw==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^6.0.2", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.0.tgz", + "integrity": "sha512-oq+g1ssrsZOsx9M96c5w8laRmvEu9C3adDSjI8oTcbfkrTE8hx/zfyobUoWIxaKPO8bt6S62kxpw5GqypEw1QQ==", + "dev": true, + "dependencies": { + "postcss-selector-parser": "^6.0.4" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "peer": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prepend-http": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-2.0.0.tgz", + "integrity": "sha512-ravE6m9Atw9Z/jjttRUZ+clIXogdghyZAuWJ3qEzjT+jI/dL1ifAqhZeC5VHzQp1MSt1+jxKkFNemj/iO7tVUA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pretty-format": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", + "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "dev": true, + "dependencies": { + "@jest/types": "^26.6.2", + "ansi-regex": "^5.0.0", + "ansi-styles": "^4.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/pretty-format/node_modules/@jest/types": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", + "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^15.0.0", + "chalk": "^4.0.0" + }, + "engines": { + "node": ">= 10.14.2" + } + }, + "node_modules/pretty-format/node_modules/@types/yargs": { + "version": "15.0.15", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.15.tgz", + "integrity": "sha512-IziEYMU9XoVj8hWg7k+UJrXALkGFjWJhn5QFEv9q4p+v40oZhSuC135M38st8XPjICL7Ey4TV64ferBGUoJhBg==", + "dev": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/pretty-format/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "dependencies": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, + "node_modules/prop-types-extra/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, + "node_modules/property-information": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true + }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/pupa": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/pupa/-/pupa-2.1.1.tgz", + "integrity": "sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==", + "dev": true, + "dependencies": { + "escape-goat": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==" + }, + "node_modules/randomatic": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-3.1.1.tgz", + "integrity": "sha512-TuDE5KxZ0J461RVjrJZCJc+J+zCkTb1MbH9AQUq68sMhOMcy9jLcb3BrZKgp9q9Ncltdg4QVqWrH02W2EFFVYw==", + "dependencies": { + "is-number": "^4.0.0", + "kind-of": "^6.0.0", + "math-random": "^1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/randomatic/node_modules/is-number": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-4.0.0.tgz", + "integrity": "sha512-rSklcAIlf1OmFdyAqbnWTLVelsQ58uvZ66S/ZyawjWqIviTWCjg2PzVGw8WUA+nNuPTqb4wgA+NszrJ+08LlgQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-async-script": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz", + "integrity": "sha512-bCpkbm9JiAuMGhkqoAiC0lLkb40DJ0HOEJIku+9JDjxX3Rcs+ztEOG13wbrOskt3n2DTrjshhaQ/iay+SnGg5Q==", + "dependencies": { + "hoist-non-react-statics": "^3.3.0", + "prop-types": "^15.5.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, + "node_modules/react-beautiful-dnd": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz", + "integrity": "sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==", + "dependencies": { + "@babel/runtime": "^7.9.2", + "css-box-model": "^1.2.0", + "memoize-one": "^5.1.1", + "raf-schd": "^4.0.2", + "react-redux": "^7.2.0", + "redux": "^4.0.4", + "use-memo-one": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.5 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.5 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-beautiful-dnd/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, + "node_modules/react-beautiful-dnd/node_modules/react-redux": { + "version": "7.2.9", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", + "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", + "dependencies": { + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "peerDependencies": { + "react": "^16.8.3 || ^17 || ^18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/react-beautiful-dnd/node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/react-bootstrap": { + "version": "2.10.5", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.5.tgz", + "integrity": "sha512-XueAOEn64RRkZ0s6yzUTdpFtdUXs5L5491QU//8ZcODKJNDLt/r01tNyriZccjgRImH1REynUc9pqjiRMpDLWQ==", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@restart/hooks": "^0.4.9", + "@restart/ui": "^1.6.9", + "@types/react-transition-group": "^4.4.6", + "classnames": "^2.3.2", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.5", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "@types/react": ">=16.14.8", + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-chartjs-2": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz", + "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-datepicker": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-7.5.0.tgz", + "integrity": "sha512-6MzeamV8cWSOcduwePHfGqY40acuGlS1cG//ePHT6bVbLxWyqngaStenfH03n1wbzOibFggF66kWaBTb1SbTtQ==", + "dependencies": { + "@floating-ui/react": "^0.26.23", + "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17 || ^18", + "react-dom": "^16.9.0 || ^17 || ^18" + } + }, + "node_modules/react-datepicker/node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-google-recaptcha": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-google-recaptcha/-/react-google-recaptcha-3.1.0.tgz", + "integrity": "sha512-cYW2/DWas8nEKZGD7SCu9BSuVz8iOcOLHChHyi7upUuVhkpkhYG/6N3KDiTQ3XAiZ2UAZkfvYKMfAHOzBOcGEg==", + "dependencies": { + "prop-types": "^15.5.0", + "react-async-script": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.4.1" + } + }, + "node_modules/react-i18next": { + "version": "15.0.2", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.0.2.tgz", + "integrity": "sha512-z0W3/RES9Idv3MmJUcf0mDNeeMOUXe+xoL0kPfQPbDoZHmni/XsIoq5zgT2MCFUiau283GuBUK578uD/mkAbLQ==", + "dependencies": { + "@babel/runtime": "^7.25.0", + "html-parse-stringify": "^3.0.1" + }, + "peerDependencies": { + "i18next": ">= 23.2.3", + "react": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/react-icons": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.2.1.tgz", + "integrity": "sha512-zdbW5GstTzXaVKvGSyTaBalt7HSfuK5ovrzlpyiWHAFXndXTdd/1hdDHI4xBM1Mn7YriT6aqESucFl9kEXzrdw==", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-infinite-scroll-component": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/react-infinite-scroll-component/-/react-infinite-scroll-component-6.1.0.tgz", + "integrity": "sha512-SQu5nCqy8DxQWpnUVLx7V7b7LcA37aM7tvoWjTLZp1dk6EJibM5/4EJKzOnl07/BsM1Y40sKLuqjCwwH/xV0TQ==", + "dependencies": { + "throttle-debounce": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==" + }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, + "node_modules/react-multi-carousel": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/react-multi-carousel/-/react-multi-carousel-2.8.5.tgz", + "integrity": "sha512-C5DAvJkfzR2JK9YixZ3oyF9x6R4LW6nzTpIXrl9Oujxi4uqP9SzVVCjl+JLM3tSdqdjAx/oWZK3dTVBSR73Q+w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/react-redux": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.1.2.tgz", + "integrity": "sha512-0OA4dhM1W48l3uzmv6B7TXPCGmokUU4p1M44DGN2/D9a1FjVPukVjER1PcPX97jIg6aUeLq1XJo1IpfbgULn0w==", + "dependencies": { + "@types/use-sync-external-store": "^0.0.3", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25", + "react": "^18.0", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.27.0.tgz", + "integrity": "sha512-YA+HGZXz4jaAkVoYBE98VQl+nVzI+cVI2Oj/06F5ZM+0u3TgedN9Y9kmMRo2mnkSK2nCpNQn0DVob4HCsY/WLw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.20.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.27.0.tgz", + "integrity": "sha512-+bvtFWMC0DgAFrfKXKG9Fc+BcXWRUO1aJIihbB79xaeq0v5UzfvnM5houGUm1Y461WVRcgAQ+Clh5rdb1eCx4g==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.20.0", + "react-router": "6.27.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-toastify": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-10.0.6.tgz", + "integrity": "sha512-yYjp+omCDf9lhZcrZHKbSq7YMuK0zcYkDFTzfRFgTXkTFHZ1ToxwAonzA4JI5CxA91JpjFLmwEsZEgfYfOqI1A==", + "license": "MIT", + "dependencies": { + "clsx": "^2.1.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-tooltip": { + "version": "5.28.0", + "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.28.0.tgz", + "integrity": "sha512-R5cO3JPPXk6FRbBHMO0rI9nkUG/JKfalBSQfZedZYzmqaZQgq7GLzF8vcCWx6IhUCKg0yPqJhXIzmIO5ff15xg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.6.1", + "classnames": "^2.3.0" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", + "integrity": "sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.1", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "globalthis": "^1.0.3", + "which-builtin-type": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.0.tgz", + "integrity": "sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==", + "dev": true, + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-transform": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", + "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, + "node_modules/regex": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/regex/-/regex-4.3.2.tgz", + "integrity": "sha512-kK/AA3A9K6q2js89+VMymcboLOlF5lZRCYJv3gzszXFHBr6kO6qLGzbm+UIugBEV8SMMKCTR59txoY6ctRHYVw==" + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexpu-core": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", + "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==", + "dev": true, + "dependencies": { + "@babel/regjsgen": "^0.8.0", + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.1.0", + "regjsparser": "^0.9.1", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/registry-auth-token": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-4.2.2.tgz", + "integrity": "sha512-PC5ZysNb42zpFME6D/XlIgtNGdTl8bBOCw90xQLVMpzuuubJKYDWFAEuUNc+Cn8Z8724tg2SDhDRrkVEsqfDMg==", + "dev": true, + "dependencies": { + "rc": "1.2.8" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/registry-url": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-5.1.0.tgz", + "integrity": "sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw==", + "dev": true, + "dependencies": { + "rc": "^1.2.8" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/regjsparser": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", + "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", + "dev": true, + "dependencies": { + "jsesc": "~0.5.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/regjsparser/node_modules/jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + } + }, + "node_modules/rehackt": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/rehackt/-/rehackt-0.1.0.tgz", + "integrity": "sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw==", + "peerDependencies": { + "@types/react": "*", + "react": "*" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/remarkable": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/remarkable/-/remarkable-1.7.4.tgz", + "integrity": "sha512-e6NKUXgX95whv7IgddywbeN/ItCkWbISmc2DiqHJb0wTrqZIexqdco5b8Z3XZoo/48IdNVKM9ZCvTPJ4F5uvhg==", + "dependencies": { + "argparse": "^1.0.10", + "autolinker": "~0.28.0" + }, + "bin": { + "remarkable": "bin/remarkable.js" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/repeat-element": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz", + "integrity": "sha512-LFiNfRcSu7KK3evMyYOuCzv3L10TW7yC1G2/+StMjK8Y6Vqd2MG7r/Qjw4ghtuCOjFvlnms/iMmLqpvW/ES/WQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==" + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-dir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", + "integrity": "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg==", + "dev": true, + "dependencies": { + "expand-tilde": "^2.0.0", + "global-modules": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-dir/node_modules/global-modules": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", + "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", + "dev": true, + "dependencies": { + "global-prefix": "^1.0.1", + "is-windows": "^1.0.1", + "resolve-dir": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-dir/node_modules/global-prefix": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", + "integrity": "sha512-5lsx1NUDHtSjfg0eHlmYvZKv8/nVqX4ckFbM+FrGcQ+04KWcWFo9P5MxPZYSzUvyzmdTbI7Eix8Q4IbELDqzKg==", + "dev": true, + "dependencies": { + "expand-tilde": "^2.0.2", + "homedir-polyfill": "^1.0.1", + "ini": "^1.3.4", + "is-windows": "^1.0.1", + "which": "^1.2.14" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-dir/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/resolve.exports": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.1.tgz", + "integrity": "sha512-/NtpHNDN7jWhAaQ9BvBUYZ6YTXsRBgfqWFWP7BZBaoMJO/I3G5OFzvTuWNlZC3aPjins1F+TNrLKsGbH4rfsRQ==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/response-iterator": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/response-iterator/-/response-iterator-0.2.6.tgz", + "integrity": "sha512-pVzEEzrsg23Sh053rmDUvLSkGXluZio0qu8VT6ukrYuvtjVfCbDZH9d6PGXb8HZfzdNZt8feXv/jvUzlhRgLnw==", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/responselike": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-1.0.2.tgz", + "integrity": "sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ==", + "dev": true, + "dependencies": { + "lowercase-keys": "^1.0.0" + } + }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/restructure": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.0.tgz", + "integrity": "sha512-Xj8/MEIhhfj9X2rmD9iJ4Gga9EFqVlpMj3vfLnV2r/Mh5jRMryNV+6lWh9GdJtDBcBSPIqzRdfBQ3wDtNFv/uw==" + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/robust-predicates": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", + "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==" + }, + "node_modules/rollup": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.3.tgz", + "integrity": "sha512-7sqRtBNnEbcBtMeRVc6VRsJMmpI+JU1z9VTvW8D4gXIYQFz0aLcsE6rRkyghZkLfEgUZgVvOG7A5CVz/VW5GIA==", + "dependencies": { + "@types/estree": "1.0.5" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.21.3", + "@rollup/rollup-android-arm64": "4.21.3", + "@rollup/rollup-darwin-arm64": "4.21.3", + "@rollup/rollup-darwin-x64": "4.21.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.21.3", + "@rollup/rollup-linux-arm-musleabihf": "4.21.3", + "@rollup/rollup-linux-arm64-gnu": "4.21.3", + "@rollup/rollup-linux-arm64-musl": "4.21.3", + "@rollup/rollup-linux-powerpc64le-gnu": "4.21.3", + "@rollup/rollup-linux-riscv64-gnu": "4.21.3", + "@rollup/rollup-linux-s390x-gnu": "4.21.3", + "@rollup/rollup-linux-x64-gnu": "4.21.3", + "@rollup/rollup-linux-x64-musl": "4.21.3", + "@rollup/rollup-win32-arm64-msvc": "4.21.3", + "@rollup/rollup-win32-ia32-msvc": "4.21.3", + "@rollup/rollup-win32-x64-msvc": "4.21.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.3.tgz", + "integrity": "sha512-P0UxIOrKNBFTQaXTxOH4RxuEBVCgEA5UTNV6Yz7z9QHnUJ7eLX9reOd/NYMO3+XZO2cco19mXTxDMXxit4R/eQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/run-async": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", + "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safe-regex-test": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-regex": "^1.1.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/sanitize-html": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.13.0.tgz", + "integrity": "sha512-Xff91Z+4Mz5QiNSLdLWwjgBDm5b1RU6xBT0+12rapjiaR7SwfRdjw8f+6Rir2MXKLrDicRFHdb51hGOAxmsUIA==", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^8.0.0", + "is-plain-object": "^5.0.0", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + } + }, + "node_modules/sanitize-html/node_modules/is-plain-object": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz", + "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sass": { + "version": "1.80.6", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.80.6.tgz", + "integrity": "sha512-ccZgdHNiBF1NHBsWvacvT5rju3y1d/Eu+8Ex6c21nHp2lZGLBEtuwc415QfiI1PJa1TpCo3iXwwSRjRpn2Ckjg==", + "devOptional": true, + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^4.0.0", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass/node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "devOptional": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sass/node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "devOptional": true, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-diff": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-3.1.1.tgz", + "integrity": "sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg==", + "dev": true, + "dependencies": { + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/semver-diff/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-getter": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/set-getter/-/set-getter-0.1.1.tgz", + "integrity": "sha512-9sVWOy+gthr+0G9DzqqLaYNA7+5OKkSmcqjL9cBpDEaZrr3ShQlyX2cZ/O/ozE41oxn/Tt0LGEM/w4Rub3A3gw==", + "dependencies": { + "to-object-path": "^0.3.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/shiki": { + "version": "1.17.6", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.17.6.tgz", + "integrity": "sha512-RejGugKpDM75vh6YtF9R771acxHRDikC/01kxsUGW+Pnaz3pTY+c8aZB5CnD7p0vuFPs1HaoAIU/4E+NCfS+mQ==", + "dependencies": { + "@shikijs/core": "1.17.6", + "@shikijs/engine-javascript": "1.17.6", + "@shikijs/engine-oniguruma": "1.17.6", + "@shikijs/types": "1.17.6", + "@shikijs/vscode-textmate": "^9.2.2", + "@types/hast": "^3.0.4" + } + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, + "node_modules/simple-swizzle": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", + "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.3.1" + } + }, + "node_modules/simple-swizzle/node_modules/is-arrayish": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", + "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==", + "license": "MIT" + }, + "node_modules/sirv": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.3.tgz", + "integrity": "sha512-O9jm9BsID1P+0HOi81VpXPoDxYP374pkOLzACAoyUQ/3OUVndNpsz6wMnY2z+yOxzbllCKZrM+9QrWsv4THnyA==", + "dev": true, + "dependencies": { + "@polka/url": "^1.0.0-next.20", + "mrmime": "^1.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "dev": true, + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "devOptional": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "devOptional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-hash": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/string-hash/-/string-hash-1.1.3.tgz", + "integrity": "sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==", + "dev": true + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz", + "integrity": "sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.7", + "regexp.prototype.flags": "^1.5.2", + "set-function-name": "^2.0.2", + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", + "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", + "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-color": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/strip-color/-/strip-color-0.1.0.tgz", + "integrity": "sha512-p9LsUieSjWNNAxVCXLeilaDlmuUOrDS5/dF9znM1nZc7EGX5+zEFC0bEevsNIaldjlks+2jns5Siz6F9iK6jwA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.0.5.tgz", + "integrity": "sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "dev": true + }, + "node_modules/symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, + "node_modules/synckit": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.9.1.tgz", + "integrity": "sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A==", + "dev": true, + "dependencies": { + "@pkgr/core": "^0.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, + "node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "dev": true, + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.32.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.32.0.tgz", + "integrity": "sha512-v3Gtw3IzpBJ0ugkxEX8U0W6+TnPKRRCWGh1jC/iM/e3Ki5+qvO1L1EAZ56bZasc64aXHwRHNIQEzm6//i5cemQ==", + "optional": true, + "peer": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "optional": true, + "peer": true + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "peer": true + }, + "node_modules/throat": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.2.tgz", + "integrity": "sha512-WKexMoJj3vEuK0yFEapj8y64V0A6xcuPuK9Gt1d0R+dzCSJc0lHqQytAbSB4cDAK0dWh4T0E2ETkoLE2WZ41OQ==", + "dev": true + }, + "node_modules/throttle-debounce": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-2.3.0.tgz", + "integrity": "sha512-H7oLPV0P7+jgvrk+6mwwwBDmxTaxnu9HMXmloNLXwnNO0ZxZ31Orah2n8lU1eMPvsaowP2CX+USCgyovXfdOFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" + }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/through2/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/through2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/through2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/through2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/tiny-inflate": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", + "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, + "node_modules/tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dependencies": { + "os-tmpdir": "~1.0.2" + }, + "engines": { + "node": ">=0.6.0" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true + }, + "node_modules/to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==", + "dependencies": { + "kind-of": "^3.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-object-path/node_modules/kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", + "dependencies": { + "is-buffer": "^1.1.5" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/to-readable-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/to-readable-stream/-/to-readable-stream-1.0.0.tgz", + "integrity": "sha512-Iq25XBt6zD5npPhlLVXGFN3/gyR2/qODcKNNyTMd4vbm39HUaOiAM4PMq0eMVC/Tkxz+Zjdsc55g9yyz+Yq00Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "devOptional": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toml": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/toml/-/toml-2.3.6.tgz", + "integrity": "sha512-gVweAectJU3ebq//Ferr2JUY4WKSDe5N+z0FvjDncLGyHmIDoxgY/2Ie4qfEIDm4IS7OA6Rmdm7pdEEdMcV/xQ==" + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", + "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/tr46": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", + "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", + "dev": true, + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-invariant": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/ts-invariant/-/ts-invariant-0.10.3.tgz", + "integrity": "sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/tslib": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", + "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" + }, + "node_modules/tsx": { + "version": "4.19.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.1.tgz", + "integrity": "sha512-0flMz1lh74BR4wOvBjuh9olbnwqCPc35OOlfyzHba0Dc+QNUeWX/Gq2YTbnwcWPO3BMd8fkzRVrHcsR+a7z7rA==", + "dev": true, + "dependencies": { + "esbuild": "~0.23.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", + "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", + "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", + "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", + "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", + "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", + "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", + "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", + "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", + "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", + "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", + "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", + "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", + "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", + "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", + "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", + "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", + "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", + "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", + "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", + "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", + "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", + "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", + "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", + "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.23.1", + "@esbuild/android-arm": "0.23.1", + "@esbuild/android-arm64": "0.23.1", + "@esbuild/android-x64": "0.23.1", + "@esbuild/darwin-arm64": "0.23.1", + "@esbuild/darwin-x64": "0.23.1", + "@esbuild/freebsd-arm64": "0.23.1", + "@esbuild/freebsd-x64": "0.23.1", + "@esbuild/linux-arm": "0.23.1", + "@esbuild/linux-arm64": "0.23.1", + "@esbuild/linux-ia32": "0.23.1", + "@esbuild/linux-loong64": "0.23.1", + "@esbuild/linux-mips64el": "0.23.1", + "@esbuild/linux-ppc64": "0.23.1", + "@esbuild/linux-riscv64": "0.23.1", + "@esbuild/linux-s390x": "0.23.1", + "@esbuild/linux-x64": "0.23.1", + "@esbuild/netbsd-x64": "0.23.1", + "@esbuild/openbsd-arm64": "0.23.1", + "@esbuild/openbsd-x64": "0.23.1", + "@esbuild/sunos-x64": "0.23.1", + "@esbuild/win32-arm64": "0.23.1", + "@esbuild/win32-ia32": "0.23.1", + "@esbuild/win32-x64": "0.23.1" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "peer": true, + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", + "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "dev": true, + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/typedoc": { + "version": "0.26.10", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.26.10.tgz", + "integrity": "sha512-xLmVKJ8S21t+JeuQLNueebEuTVphx6IrP06CdV7+0WVflUSW3SPmR+h1fnWVdAR/FQePEgsSWCUHXqKKjzuUAw==", + "dependencies": { + "lunr": "^2.3.9", + "markdown-it": "^14.1.0", + "minimatch": "^9.0.5", + "shiki": "^1.16.2", + "yaml": "^2.5.1" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "typescript": "4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x" + } + }, + "node_modules/typedoc-plugin-markdown": { + "version": "4.2.10", + "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-4.2.10.tgz", + "integrity": "sha512-PLX3pc1/7z13UJm4TDE9vo9jWGcClFUErXXtd5LdnoLjV6mynPpqZLU992DwMGFSRqJFZeKbVyqlNNeNHnk2tQ==", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "typedoc": "0.26.x" + } + }, + "node_modules/typedoc/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/typedoc/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/typedoc/node_modules/yaml": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "devOptional": true + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", + "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz", + "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-properties": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz", + "integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==", + "dependencies": { + "base64-js": "^1.3.0", + "unicode-trie": "^2.0.0" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-trie": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz", + "integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==", + "dependencies": { + "pako": "^0.2.5", + "tiny-inflate": "^1.0.0" + } + }, + "node_modules/unicode-trie/node_modules/pako": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz", + "integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==" + }, + "node_modules/unique-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz", + "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==", + "dev": true, + "dependencies": { + "crypto-random-string": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "escalade": "^3.1.2", + "picocolors": "^1.0.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/update-notifier": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-5.1.0.tgz", + "integrity": "sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw==", + "dev": true, + "dependencies": { + "boxen": "^5.0.0", + "chalk": "^4.1.0", + "configstore": "^5.0.1", + "has-yarn": "^2.1.0", + "import-lazy": "^2.1.0", + "is-ci": "^2.0.0", + "is-installed-globally": "^0.4.0", + "is-npm": "^5.0.0", + "is-yarn-global": "^0.3.0", + "latest-version": "^5.1.0", + "pupa": "^2.1.1", + "semver": "^7.3.4", + "semver-diff": "^3.1.1", + "xdg-basedir": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/yeoman/update-notifier?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/url-parse-lax": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/url-parse-lax/-/url-parse-lax-3.0.0.tgz", + "integrity": "sha512-NjFKA0DidqPa5ciFcSrXnAltTtzz84ogy+NebPvfEgAck0+TNg4UJ4IN+fB7zRZfbgUf0syOo9MDxFkDSMuFaQ==", + "dev": true, + "dependencies": { + "prepend-http": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/use-memo-one": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz", + "integrity": "sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz", + "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz", + "integrity": "sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==", + "dev": true, + "dependencies": { + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^1.6.0", + "source-map": "^0.7.3" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/v8-to-istanbul/node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", + "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "5.4.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.8.tgz", + "integrity": "sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-plugin-environment": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vite-plugin-environment/-/vite-plugin-environment-1.1.3.tgz", + "integrity": "sha512-9LBhB0lx+2lXVBEWxFZC+WO7PKEyE/ykJ7EPWCq95NEcCpblxamTbs5Dm3DLBGzwODpJMEnzQywJU8fw6XGGGA==", + "peerDependencies": { + "vite": ">= 2.7" + } + }, + "node_modules/vite-plugin-svgr": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-4.2.0.tgz", + "integrity": "sha512-SC7+FfVtNQk7So0XMjrrtLAbEC8qjFPifyD7+fs/E6aaNdVde6umlVVh0QuwDLdOMu7vp5RiGFsB70nj5yo0XA==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.5", + "@svgr/core": "^8.1.0", + "@svgr/plugin-jsx": "^8.1.0" + }, + "peerDependencies": { + "vite": "^2.6.0 || 3 || 4 || 5" + } + }, + "node_modules/vite-plugin-svgr/node_modules/@rollup/pluginutils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.0.tgz", + "integrity": "sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/vite-plugin-svgr/node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/vite-plugin-svgr/node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/vite-plugin-svgr/node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/vite-plugin-svgr/node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/vite-plugin-svgr/node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/vite-plugin-svgr/node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/vite-plugin-svgr/node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "dev": true, + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/vite-plugin-svgr/node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/vite-plugin-svgr/node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "dev": true, + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/vite-plugin-svgr/node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "dev": true, + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/vite-plugin-svgr/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true + }, + "node_modules/vite-plugin-svgr/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vite-plugin-svgr/node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vite-plugin-svgr/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "node_modules/vite-plugin-svgr/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/vite-tsconfig-paths": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.2.tgz", + "integrity": "sha512-gEIbKfJzSEv0yR3XS2QEocKetONoWkbROj6hGx0FHM18qKUojhvcokQsxQx5nMkelZq2n37zbSGCJn+FSODSjA==", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vite-tsconfig-paths/node_modules/tsconfck": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.3.tgz", + "integrity": "sha512-ulNZP1SVpRDesxeMLON/LtWM8HIgAJEIVpVVhBM6gsmvQ8+Rh+ZG7FWGvHh7Ah3pRABwVJWklWCr/BTZSv0xnQ==", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/w3c-hr-time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", + "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", + "deprecated": "Use your platform's native performance.now() and performance.timeOrigin.", + "dev": true, + "dependencies": { + "browser-process-hrtime": "^1.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", + "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==" + }, + "node_modules/webidl-conversions": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", + "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", + "dev": true, + "engines": { + "node": ">=10.4" + } + }, + "node_modules/whatwg-encoding": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", + "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", + "dev": true, + "dependencies": { + "iconv-lite": "0.4.24" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "dev": true + }, + "node_modules/whatwg-mimetype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", + "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==", + "dev": true + }, + "node_modules/whatwg-url": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", + "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", + "dev": true, + "dependencies": { + "lodash": "^4.7.0", + "tr46": "^2.1.0", + "webidl-conversions": "^6.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.4.tgz", + "integrity": "sha512-bppkmBSsHFmIMSl8BO9TbsyzsvGjVoppt8xUiGzwiu/bhDCGxnpOKCxgqj6GuyHE0mINMDecBFPlOm2hzY084w==", + "dev": true, + "dependencies": { + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.0.5", + "is-finalizationregistry": "^1.0.2", + "is-generator-function": "^1.0.10", + "is-regex": "^1.1.4", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.15" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", + "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", + "dev": true, + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "dev": true, + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "dev": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xdg-basedir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-4.0.0.tgz", + "integrity": "sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", + "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", + "dev": true + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" + }, + "node_modules/yaml": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", + "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yargs": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", + "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==", + "dev": true, + "dependencies": { + "cliui": "^7.0.2", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^20.2.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs-parser": { + "version": "20.2.9", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", + "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zen-observable": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", + "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==" + }, + "node_modules/zen-observable-ts": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/zen-observable-ts/-/zen-observable-ts-1.2.5.tgz", + "integrity": "sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==", + "dependencies": { + "zen-observable": "0.8.15" + } + }, + "node_modules/zod": { + "version": "3.22.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", + "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/package.json b/package.json index 6526d77a59..892a1331e4 100644 --- a/package.json +++ b/package.json @@ -1,34 +1,92 @@ { - "name": "my-app", - "version": "0.1.0", + "name": "talawa-admin", + "version": "3.0.0", "private": true, + "type": "module", + "config-overrides-path": "scripts/config-overrides", "dependencies": { - "@testing-library/jest-dom": "^5.11.4", - "@testing-library/react": "^11.1.0", - "@testing-library/user-event": "^12.1.10", - "@types/jest": "^26.0.15", - "@types/node": "^12.0.0", - "@types/react": "^17.0.0", - "@types/react-dom": "^17.0.0", - "eslint-config-prettier": "^8.3.0", - "eslint-plugin-prettier": "^3.4.0", - "prettier": "^2.3.0", - "react": "^17.0.2", - "react-bootstrap": "^1.5.2", - "react-dom": "^17.0.2", - "react-router-dom": "^5.2.0", - "react-scripts": "4.0.3", - "typescript": "^4.2.4", - "web-vitals": "^1.0.1" + "@apollo/client": "^3.11.8", + "@apollo/link-error": "^2.0.0-beta.3", + "@apollo/react-testing": "^4.0.0", + "@dicebear/collection": "^9.2.2", + "@dicebear/core": "^9.2.2", + "@emotion/react": "^11.13.3", + "@emotion/styled": "^11.13.0", + "@mui/base": "^5.0.0-beta.61", + "@mui/icons-material": "^6.1.6", + "@mui/material": "^6.1.6", + "@mui/private-theming": "^6.1.6", + "@mui/system": "^6.1.6", + "@mui/x-charts": "^7.22.1", + "@mui/x-data-grid": "^7.22.1", + "@mui/x-date-pickers": "^7.22.1", + "@pdfme/schemas": "^5.1.6", + "chart.js": "^4.4.6", + "@pdfme/generator": "^5.1.7", + "@reduxjs/toolkit": "^2.3.0", + "@vitejs/plugin-react": "^4.3.2", + "babel-plugin-transform-import-meta": "^2.2.1", + "bootstrap": "^5.3.3", + "customize-cra": "^1.0.0", + "dayjs": "^1.11.13", + "dotenv": "^16.4.5", + "flag-icons": "^7.2.3", + "graphql": "^16.9.0", + "graphql-tag": "^2.12.6", + "graphql-ws": "^5.16.0", + "history": "^5.3.0", + "i18next": "^23.15.1", + "i18next-browser-languagedetector": "^8.0.0", + "i18next-http-backend": "^2.6.1", + "inquirer": "^8.0.0", + "js-cookie": "^3.0.1", + "markdown-toc": "^1.2.0", + "prettier": "^3.3.3", + "prop-types": "^15.8.1", + "react": "^18.3.1", + "react-beautiful-dnd": "^13.1.1", + "react-bootstrap": "^2.10.5", + "react-chartjs-2": "^5.2.0", + "react-datepicker": "^7.5.0", + "react-dom": "^18.3.1", + "react-google-recaptcha": "^3.1.0", + "react-i18next": "^15.0.2", + "react-icons": "^5.2.1", + "react-infinite-scroll-component": "^6.1.0", + "react-multi-carousel": "^2.8.5", + "react-redux": "^9.1.2", + "react-router-dom": "^6.27.0", + "react-toastify": "^10.0.6", + "react-tooltip": "^5.28.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "sanitize-html": "^2.13.0", + "typedoc": "^0.26.10", + "typedoc-plugin-markdown": "^4.2.10", + "typescript": "^5.6.3", + "vite": "^5.4.8", + "vite-plugin-environment": "^1.1.3", + "vite-tsconfig-paths": "^5.1.2", + "web-vitals": "^4.2.4" }, "scripts": { - "serve": "react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", + "serve": "cross-env ESLINT_NO_DEV_ERRORS=true vite --config config/vite.config.ts", + "build": "tsc && vite build --config config/vite.config.ts", + "preview": "vite preview --config config/vite.config.ts", + "test": "cross-env NODE_ENV=test jest --env=./scripts/custom-test-env.js --watchAll --coverage", "eject": "react-scripts eject", - "lint": "eslint 'src/*.{ts,tsx}'", - "lint-fix": "eslint 'src/*.{ts,tsx}' --fix", - "format": "prettier --write \"**/*.{ts,tsx,json,scss,css}\"" + "lint:check": "eslint \"**/*.{ts,tsx}\" --max-warnings=0 && python .github/workflows/eslint_disable_check.py", + "lint:fix": "eslint --fix \"**/*.{ts,tsx}\"", + "format:fix": "prettier --write \"**/*.{ts,tsx,json,scss,css}\"", + "format:check": "prettier --check \"**/*.{ts,tsx,json,scss,css}\"", + "check-tsdoc": "node .github/workflows/check-tsdoc.js", + "typecheck": "tsc --project tsconfig.json --noEmit", + "prepare": "husky install", + "jest-preview": "jest-preview", + "update:toc": "node scripts/githooks/update-toc.js", + "lint-staged": "lint-staged --concurrent false", + "setup": "tsx setup.ts", + "check-localstorage": "node scripts/githooks/check-localstorage-usage.js" }, "eslintConfig": { "extends": [ @@ -49,8 +107,63 @@ ] }, "devDependencies": { - "@typescript-eslint/eslint-plugin": "^4.24.0", - "@typescript-eslint/parser": "^4.24.0", - "eslint-plugin-react-hooks": "^4.2.0" + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@babel/preset-env": "^7.25.4", + "@babel/preset-react": "^7.25.7", + "@babel/preset-typescript": "^7.26.0", + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^12.1.10", + "@types/inquirer": "^9.0.7", + "@types/jest": "^26.0.24", + "@types/js-cookie": "^3.0.6", + "@types/node": "^22.5.4", + "@types/node-fetch": "^2.6.10", + "@types/react": "^18.3.3", + "@types/react-beautiful-dnd": "^13.1.8", + "@types/react-chartjs-2": "^2.5.7", + "@types/react-bootstrap": "^0.32.37", + "@types/react-datepicker": "^7.0.0", + "@types/react-dom": "^18.3.1", + "@types/react-google-recaptcha": "^2.1.9", + "@types/react-router-dom": "^5.1.8", + "@types/sanitize-html": "^2.13.0", + "@typescript-eslint/eslint-plugin": "^8.11.0", + "@typescript-eslint/parser": "^8.5.0", + "babel-jest": "^29.7.0", + "cross-env": "^7.0.3", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-import": "^2.30.0", + "eslint-plugin-jest": "^28.8.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-react": "^7.37.1", + "eslint-plugin-tsdoc": "^0.3.0", + "husky": "^9.1.6", + "identity-obj-proxy": "^3.0.0", + "jest": "^27.4.5", + "jest-localstorage-mock": "^2.4.19", + "jest-location-mock": "^2.0.0", + "jest-preview": "^0.3.1", + "lint-staged": "^15.2.8", + "postcss-modules": "^6.0.0", + "sass": "^1.80.6", + "tsx": "^4.19.1", + "vite-plugin-svgr": "^4.2.0", + "whatwg-fetch": "^3.6.20" + }, + "resolutions": { + "@apollo/client": "^3.4.0-beta.19", + "@types/react": "17.0.2", + "@types/react-dom": "17.0.2", + "graphql": "^16.5.0" + }, + "engines": { + "node": ">=20.x" + }, + "lint-staged": { + "**/*.{ts, tsx, json, scss, css}": [ + "prettier --write" + ], + "**/*.{ts, tsx, json}": "eslint --fix" } } diff --git a/public/favicon.ico b/public/favicon.ico index a11777cc47..0675af2934 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon_palisadoes.ico b/public/favicon_palisadoes.ico new file mode 100644 index 0000000000..0675af2934 Binary files /dev/null and b/public/favicon_palisadoes.ico differ diff --git a/public/images/REACT_SITE_KEY.webp b/public/images/REACT_SITE_KEY.webp new file mode 100644 index 0000000000..9afe77c4d5 Binary files /dev/null and b/public/images/REACT_SITE_KEY.webp differ diff --git a/public/images/jest-preview.webp b/public/images/jest-preview.webp new file mode 100644 index 0000000000..a5810192af Binary files /dev/null and b/public/images/jest-preview.webp differ diff --git a/public/logo512.png b/public/images/logo512.png similarity index 100% rename from public/logo512.png rename to public/images/logo512.png diff --git a/public/images/svg/angleDown.svg b/public/images/svg/angleDown.svg new file mode 100644 index 0000000000..0dfea5e56c --- /dev/null +++ b/public/images/svg/angleDown.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/svg/arrow-left.svg b/public/images/svg/arrow-left.svg new file mode 100644 index 0000000000..395c38fc25 --- /dev/null +++ b/public/images/svg/arrow-left.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/svg/arrow-right.svg b/public/images/svg/arrow-right.svg new file mode 100644 index 0000000000..bf754cec86 --- /dev/null +++ b/public/images/svg/arrow-right.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/svg/attendees.svg b/public/images/svg/attendees.svg new file mode 100644 index 0000000000..3864a85b7e --- /dev/null +++ b/public/images/svg/attendees.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/svg/feedback.svg b/public/images/svg/feedback.svg new file mode 100644 index 0000000000..649e84a1c9 --- /dev/null +++ b/public/images/svg/feedback.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/svg/profiledefault.svg b/public/images/svg/profiledefault.svg new file mode 100644 index 0000000000..a321caaeab --- /dev/null +++ b/public/images/svg/profiledefault.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/svg/up-down.svg b/public/images/svg/up-down.svg new file mode 100644 index 0000000000..1dd7fdab1d --- /dev/null +++ b/public/images/svg/up-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/index.html b/public/index.html deleted file mode 100644 index 72527d8e95..0000000000 --- a/public/index.html +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - Talawa Admin - - -
- - diff --git a/public/locales/en/common.json b/public/locales/en/common.json new file mode 100644 index 0000000000..6c8882bcfd --- /dev/null +++ b/public/locales/en/common.json @@ -0,0 +1,98 @@ +{ + "firstName": "First Name", + "lastName": "Last Name", + "searchByName": "Search By Name", + "loading": "Loading...", + "endOfResults": "End of results", + "noResultsFoundFor": "No results found for ", + "edit": "Edit", + "admins": "Admins", + "admin": "ADMIN", + "user": "USER", + "superAdmin": "SUPERADMIN", + "members": "Members", + "logout": "Logout", + "login": "Login", + "register": "Register", + "menu": "Menu", + "settings": "Settings", + "users": "Users", + "requests": "Requests", + "OR": "OR", + "cancel": "Cancel", + "close": "Close", + "create": "Create", + "delete": "Delete", + "done": "Done", + "yes": "Yes", + "no": "No", + "filter": "Filter", + "search": "Search", + "description": "Description", + "saveChanges": "Save Changes", + "gender": "Gender", + "resetChanges": "Reset Changes", + "displayImage": "Display Image", + "enterEmail": "Enter Email", + "emailAddress": "Email Address", + "email": "Email", + "name": "Name", + "desc": "Description", + "enterPassword": "Enter Password", + "password": "Password", + "confirmPassword": "Confirm Password", + "forgotPassword": "Forgot Password ?", + "talawaAdminPortal": "Talawa Admin Portal", + "address": "Address", + "location": "Location", + "enterLocation": "Enter Location", + "joined": "Joined", + "startDate": "Start Date", + "endDate": "End Date", + "startTime": "Start Time", + "endTime": "End Time", + "My Organizations": "My Organizations", + "Dashboard": "Dashboard", + "People": "People", + "Events": "Events", + "Venues": "Venues", + "Action Items": "Action Items", + "Posts": "Posts", + "Block/Unblock": "Block/Unblock", + "Advertisement": "Advertisement", + "Funds": "Funds", + "Membership Requests": "Membership Requests", + "Plugins": "Plugins", + "Plugin Store": "Plugin Store", + "Settings": "Settings", + "createdOn": "Created On", + "createdBy": "Created By", + "usersRole": "User's Role", + "changeRole": "Change Role", + "action": "Action", + "removeUser": "Remove User", + "remove": "Remove", + "viewProfile": "View Profile", + "profile": "Profile", + "noFiltersApplied": "No filters applied", + "manage": "Manage", + "searchResultsFor": "Search results for {{text}}", + "none": "None", + "Donate": "Donate", + "addedSuccessfully": "{{item}} added Successfully", + "updatedSuccessfully": "{{item}} updated Successfully", + "removedSuccessfully": "{{item}} removed Successfully", + "successfullyUpdated": "Successfully Updated", + "sessionWarning": "Your session will expire soon due to inactivity. Please interact with the page to extend your session.", + "sessionLogOut": "Your session has expired due to inactivity. Please log in again to continue.", + "sort": "Sort", + "all": "All", + "active": "Active", + "disabled": "Disabled", + "pending": "Pending", + "completed": "Completed", + "late": "Late", + "createdLatest": "Created Latest", + "createdEarliest": "Created Earliest", + "searchBy": "Search by {{item}}" +} diff --git a/public/locales/en/errors.json b/public/locales/en/errors.json new file mode 100644 index 0000000000..752c0db750 --- /dev/null +++ b/public/locales/en/errors.json @@ -0,0 +1,11 @@ +{ + "talawaApiUnavailable": "Talawa-API service is unavailable!. Is it running? Check your network connectivity too.", + "notFound": "Not found", + "unknownError": "An unknown error occurred. Please try again later. {{msg}}", + "notAuthorised": "Sorry! you are not Authorised!", + "errorSendingMail": "Error sending mail", + "emailNotRegistered": "Email not registered", + "notFoundMsg": "Oops! The Page you requested was not found!", + "errorOccurredCouldntCreate": "An error occurred. Couldn't create {{entity}}", + "errorLoading": "Error occured while loading {{entity}} data" +} diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json new file mode 100644 index 0000000000..5da5ef49ee --- /dev/null +++ b/public/locales/en/translation.json @@ -0,0 +1,1483 @@ +{ + "leaderboard": { + "title": "Leaderboard", + "searchByVolunteer": "Search By Volunteer", + "mostHours": "Most Hours", + "leastHours": "Least Hours", + "timeFrame": "Time Frame", + "allTime": "All Time", + "weekly": "This Week", + "monthly": "This Month", + "yearly": "This Year", + "noVolunteers": "No Volunteers Found!" + }, + "loginPage": { + "title": "Talawa Admin", + "fromPalisadoes": "An open source application by Palisadoes Foundation volunteers", + "userLogin": "User Login", + "atleast_8_char_long": "Atleast 8 Character long", + "atleast_6_char_long": "Atleast 6 Character long", + "firstName_invalid": "First name should contain only lower and upper case letters", + "lastName_invalid": "Last name should contain only lower and upper case letters", + "password_invalid": "Password should contain atleast one lowercase letter, one uppercase letter, one numeric value and one special character", + "email_invalid": "Email should have atleast 8 characters", + "Password_and_Confirm_password_mismatches.": "Password and Confirm password mismatches.", + "doNotOwnAnAccount": "Do not own an account?", + "captchaError": "Captcha Error!", + "Please_check_the_captcha": "Please, check the captcha.", + "Something_went_wrong": "Something went wrong, Please try after sometime.", + "passwordMismatches": "Password and Confirm password mismatches.", + "fillCorrectly": "Fill all the Details Correctly.", + "successfullyRegistered": "Successfully Registered. Please wait until you will be approved.", + "lowercase_check": "Atleast one lowercase letter", + "uppercase_check": "Atleast one uppercase letter", + "numeric_value_check": "Atleaset one numeric value", + "special_char_check": "Atleast one special character", + "selectOrg": "Select an organization", + "afterRegister": "Successfully registered. Please wait for admin to approve your request.", + "talawa_portal": "talawa_portal", + "login": "login", + "register": "register", + "firstName": "firstName", + "lastName": "lastName", + "email": "email", + "password": "password", + "confirmPassword": "confirmPassword", + "forgotPassword": "forgotPassword", + "enterEmail": "enterEmail", + "enterPassword": "enterPassword", + "talawaApiUnavailable": "talawaApiUnavailable", + "notAuthorised": "notAuthorised", + "notFound": "notFound", + "OR": "OR", + "admin": "admin", + "user": "user", + "loading": "loading" + }, + "userLoginPage": { + "title": "Talawa Admin", + "fromPalisadoes": "An open source application by Palisadoes Foundation volunteers", + "atleast_8_char_long": "Atleast 8 Character long", + "Password_and_Confirm_password_mismatches.": "Password and Confirm password mismatches.", + "doNotOwnAnAccount": "Do not own an account?", + "captchaError": "Captcha Error!", + "Please_check_the_captcha": "Please, check the captcha.", + "Something_went_wrong": "Something went wrong, Please try after sometime.", + "passwordMismatches": "Password and Confirm password mismatches.", + "fillCorrectly": "Fill all the Details Correctly.", + "successfullyRegistered": "Successfully Registered. Please wait until you will be approved.", + "userLogin": "User Login", + "afterRegister": "Successfully registered. Please wait for admin to approve your request.", + "selectOrg": "Select an organization", + "talawa_portal": "talawa_portal", + "login": "login", + "register": "register", + "firstName": "firstName", + "lastName": "lastName", + "email": "email", + "password": "password", + "confirmPassword": "confirmPassword", + "forgotPassword": "forgotPassword", + "enterEmail": "enterEmail", + "enterPassword": "enterPassword", + "talawaApiUnavailable": "talawaApiUnavailable", + "notAuthorised": "notAuthorised", + "notFound": "notFound", + "OR": "OR", + "loading": "loading" + }, + "latestEvents": { + "eventCardTitle": "Upcoming Events", + "eventCardSeeAll": "See All", + "noEvents": "No Upcoming Events" + }, + "latestPosts": { + "latestPostsTitle": "Latest Posts", + "seeAllLink": "See All", + "noPostsCreated": "No Posts Created" + }, + "listNavbar": { + "roles": "Roles", + "talawa_portal": "talawa_portal", + "requests": "requests", + "logout": "logout" + }, + "leftDrawer": { + "my organizations": "My Organizations", + "requests": "Membership Requests", + "communityProfile": "Community Profile", + "talawaAdminPortal": "talawaAdminPortal", + "menu": "menu", + "users": "users", + "logout": "logout" + }, + "leftDrawerOrg": { + "Dashboard": "Dashboard", + "People": "People", + "Events": "Events", + "Contributions": "Contributions", + "Posts": "Posts", + "Block/Unblock": "Block/Unblock", + "Plugins": "Plugins", + "Plugin Store": "Plugin Store", + "Advertisement": "Advertisements", + "allOrganizations": "All Organizations", + "yourOrganization": "Your Organization", + "notification": "Notification", + "language": "Language", + "notifications": "Notifications", + "spamsThe": "spams the", + "group": "group", + "noNotifications": "No Notifications", + "talawaAdminPortal": "talawaAdminPortal", + "menu": "menu", + "talawa_portal": "talawa_portal", + "settings": "settings", + "logout": "logout", + "close": "close" + }, + "orgList": { + "title": "Talawa Organizations", + "you": "You", + "designation": "Designation", + "my organizations": "My Organizations", + "createOrganization": "Create Organization", + "createSampleOrganization": "Create Sample Organization", + "city": "City", + "countryCode": "Country Code", + "dependentLocality": "Dependent Locality", + "line1": "Line 1", + "line2": "Line 2", + "postalCode": "Postal Code", + "sortingCode": "Sorting code", + "state": "State / Province", + "userRegistrationRequired": "User Registration Required", + "visibleInSearch": "Visible In Search", + "enterName": "Enter Name", + "sort": "Sort", + "Latest": "Latest", + "Earliest": "Earliest", + "noOrgErrorTitle": "Organizations Not Found", + "sampleOrgDuplicate": "Only one sample organization allowed", + "noOrgErrorDescription": "Please create an organization through dashboard", + "manageFeatures": "Manage Features", + "manageFeaturesInfo": "Creation Successful ! Please select features that you want to enale for this organization from the plugin store.", + "goToStore": "Go to Plugin Store", + "enableEverything": "Enable Everything", + "sampleOrgSuccess": "Sample Organization Successfully Created", + "name": "name", + "email": "email", + "searchByName": "searchByName", + "description": "description", + "location": "location", + "address": "address", + "displayImage": "displayImage", + "filter": "filter", + "cancel": "cancel", + "endOfResults": "endOfResults", + "noResultsFoundFor": "noResultsFoundFor", + "OR": "OR" + }, + "orgListCard": { + "manage": "Manage", + "sampleOrganization": "Sample Organization", + "admins": "admins", + "members": "members" + }, + "paginationList": { + "rowsPerPage": "rows per page", + "all": "All" + }, + "requests": { + "title": "Membership Requests", + "sl_no": "Sl. No.", + "accept": "Accept", + "reject": "Reject", + "searchRequests": "Search membership requests", + "noOrgError": "Organizations not found, please create an organization through dashboard", + "noRequestsFound": "No Membership Requests Found", + "acceptedSuccessfully": "Request accepted successfully", + "rejectedSuccessfully": "Request rejected successfully", + "noOrgErrorTitle": "Organizations Not Found", + "noOrgErrorDescription": "Please create an organization through dashboard", + "name": "name", + "email": "email", + "endOfResults": "endOfResults", + "noResultsFoundFor": "noResultsFoundFor" + }, + "users": { + "title": "Talawa Roles", + "joined_organizations": "Joined Organizations", + "blocked_organizations": "Blocked Organizations", + "orgJoinedBy": "Organizations Joined By", + "orgThatBlocked": "Organizations That Blocked", + "hasNotJoinedAnyOrg": "has not joined any organization", + "isNotBlockedByAnyOrg": "is not blocked by any organization", + "searchByOrgName": "Search By Organization Name", + "view": "View", + "enterName": "Enter Name", + "loadingUsers": "Loading Users...", + "noUserFound": "No User Found", + "sort": "Sort", + "Newest": "Newest First", + "Oldest": "Oldest First", + "noOrgError": "Organizations not found, please create an organization through dashboard", + "roleUpdated": "Role Updated.", + "joinNow": "Join Now", + "visit": "Visit", + "withdraw": "Widthdraw", + "removeUserFrom": "Remove User from {{org}}", + "removeConfirmation": "Are you sure you want to remove '{{name}}' from organization '{{org}}'?", + "searchByName": "searchByName", + "users": "users", + "name": "name", + "email": "email", + "endOfResults": "endOfResults", + "admin": "admin", + "superAdmin": "superAdmin", + "user": "user", + "filter": "filter", + "noResultsFoundFor": "noResultsFoundFor", + "talawaApiUnavailable": "talawaApiUnavailable", + "cancel": "cancel", + "admins": "admins", + "members": "members", + "orgJoined": "orgJoined", + "MembershipRequestSent": "MembershipRequestSent", + "AlreadyJoined": "AlreadyJoined", + "errorOccured": "errorOccured" + }, + "communityProfile": { + "title": "Community Profile", + "editProfile": "Edit Profile", + "communityProfileInfo": "These details will appear on the login/signup screen for you and your community members", + "communityName": "Community Name", + "wesiteLink": "Website Link", + "logo": "Logo", + "social": "Social Media Links", + "url": "Enter url", + "profileChangedMsg": "Successfully updated the Profile Details.", + "resetData": "Successfully reset the Profile Details." + }, + "dashboard": { + "title": "Dashboard", + "about": "About", + "deleteThisOrganization": "Delete This Organization", + "statistics": "Statistics", + "posts": "Posts", + "events": "Events", + "blockedUsers": "Blocked Users", + "viewAll": "View All", + "upcomingEvents": "Upcoming Events", + "noUpcomingEvents": "No Upcoming Events", + "latestPosts": "Latest Posts", + "noPostsPresent": "No Posts Present", + "membershipRequests": "Membership requests", + "noMembershipRequests": "No Membership requests present", + "location": "location", + "members": "members", + "admins": "admins", + "requests": "requests", + "talawaApiUnavailable": "talawaApiUnavailable", + "volunteerRankings": "Volunteer Rankings", + "noVolunteers": "No Volunteers Found!" + }, + "organizationPeople": { + "title": "Talawa Members", + "filterByName": "Filter by Name", + "filterByLocation": "Filter by Location", + "filterByEvent": "Filter by Event", + "searchName": "Enter Name", + "searchevent": "Enter Event", + "searchFullName": "Enter Full Name", + "people": "People", + "sort": "Search by Role", + "actions": "Actions", + "addMembers": "Add Members", + "existingUser": "Existing User", + "newUser": "New User", + "enterFirstName": "Enter your first name", + "enterLastName": "Enter your last name", + "enterConfirmPassword": "Enter Password to confirm", + "organization": "Organization", + "invalidDetailsMessage": "Please enter valid details.", + "members": "members", + "admins": "admins", + "users": "users", + "searchFirstName": "searchFirstName", + "searchLastName": "searchLastName", + "firstName": "firstName", + "lastName": "lastName", + "emailAddress": "emailAddress", + "enterEmail": "enterEmail", + "password": "password", + "enterPassword": "enterPassword", + "confirmPassword": "confirmPassword", + "create": "create", + "cancel": "cancel", + "user": "user", + "profile": "profile" + }, + "organizationTags": { + "title": "Organization Tags", + "createTag": "Create a new tag", + "manageTag": "Manage", + "editTag": "Edit", + "removeTag": "Remove", + "tagDetails": "Tag Details", + "tagName": "Name", + "tagType": "Type", + "tagNamePlaceholder": "Write the name of the tag", + "tagCreationSuccess": "New tag created successfully", + "tagUpdationSuccess": "Tag updated successfully", + "tagRemovalSuccess": "Tag deleted successfully", + "noTagsFound": "No tags found", + "removeUserTag": "Delete Tag", + "removeUserTagMessage": "Do you want to delete this tag?", + "addChildTag": "Add a Sub Tag", + "enterTagName": "Enter Tag Name" + }, + "manageTag": { + "title": "Tag Details", + "addPeopleToTag": "Add People to tag", + "viewProfile": "View", + "noAssignedMembersFound": "No one assigned", + "unassignUserTag": "Unassign Tag", + "unassignUserTagMessage": "Do you want to remove the tag from this user?", + "successfullyUnassigned": "Tag unassigned from user", + "addPeople": "Add People", + "add": "Add", + "subTags": "Sub Tags", + "successfullyAssignedToPeople": "Tag assigned successfully", + "errorOccurredWhileLoadingMembers": "Error occured while loading members", + "userName": "User Name", + "actions": "Actions", + "noOneSelected": "No One Selected", + "assignToTags": "Assign to Tags", + "removeFromTags": "Remove from Tags", + "assign": "Assign", + "remove": "Remove", + "successfullyAssignedToTags": "Successfully Assigned to Tags", + "successfullyRemovedFromTags": "Successfully Removed from Tags", + "errorOccurredWhileLoadingOrganizationUserTags": "Error occurred while loading organization tags", + "errorOccurredWhileLoadingSubTags": "Error occurred while loading subTags tags", + "removeUserTag": "Delete Tag", + "removeUserTagMessage": "Do you want to delete this tag? It delete all the sub tags and all the associations.", + "tagDetails": "Tag Details", + "tagName": "Name", + "tagUpdationSuccess": "Tag updated successfully", + "tagRemovalSuccess": "Tag deleted successfully", + "noTagSelected": "No Tag Selected", + "changeNameToEdit": "Change the name to make an update", + "selectTag": "Select Tag", + "collapse": "Collapse", + "expand": "Expand", + "tagNamePlaceholder": "Write the name of the tag", + "allTags": "All Tags", + "noMoreMembersFound": "No more members found" + }, + "userListCard": { + "addAdmin": "Add Admin", + "addedAsAdmin": "User is added as admin.", + "joined": "joined", + "talawaApiUnavailable": "talawaApiUnavailable" + }, + "orgAdminListCard": { + "remove": "Remove", + "removeAdmin": "Remove Admin", + "removeAdminMsg": "Do you want to remove this admin?", + "adminRemoved": "The admin is removed.", + "joined": "joined", + "no": "no", + "yes": "yes", + "talawaApiUnavailable": "talawaApiUnavailable" + }, + "orgPeopleListCard": { + "remove": "Remove", + "removeMember": "Remove Member", + "removeMemberMsg": "Do you want to remove this member?", + "memberRemoved": "The Member is removed", + "joined": "joined", + "no": "no", + "yes": "yes", + "talawaApiUnavailable": "talawaApiUnavailable" + }, + "organizationEvents": { + "title": "Events", + "filterByTitle": "Filter by Title", + "filterByLocation": "Filter by Location", + "filterByDescription": "Filter by Description", + "addEvent": "Add Event", + "eventDetails": "Event Details", + "eventTitle": "Title", + "allDay": "All Day", + "recurringEvent": "Recurring Event", + "isPublic": "Is Public", + "isRegistrable": "Is Registrable", + "createEvent": "Create Event", + "enterFilter": "Enter Filter", + "enterTitle": "Enter Title", + "enterDescrip": "Enter Description", + "searchEventName": "Search Event Name", + "searchMemberName": "Search Member Name", + "eventType": "Event Type", + "eventCreated": "Congratulations! The Event is created.", + "customRecurrence": "Custom Recurrence", + "repeatsEvery": "Repeats Every", + "repeatsOn": "Repeats On", + "ends": "Ends", + "never": "Never", + "on": "On", + "after": "After", + "occurences": "occurences", + "startTime": "startTime", + "endTime": "endTime", + "eventLocation": "eventLocation", + "events": "events", + "description": "description", + "location": "location", + "startDate": "startDate", + "endDate": "endDate", + "talawaApiUnavailable": "talawaApiUnavailable", + "done": "done" + }, + "organizationActionItems": { + "actionItemCategory": "Action Item Category", + "actionItemDetails": "Action Item Details", + "actionItemCompleted": "Action Item Completed", + "assignee": "Assignee", + "assigner": "Assigner", + "assignmentDate": "Assignment Date", + "active": "Active", + "clearFilters": "Clear Filters", + "completionDate": "Completion Date", + "createActionItem": "Create Action Item", + "deleteActionItem": "Delete Action Item", + "deleteActionItemMsg": "Do you want to remove this action item?", + "details": "Details", + "dueDate": "Due Date", + "earliest": "Earliest", + "editActionItem": "Edit Action Item", + "isCompleted": "Completed", + "latest": "Latest", + "makeActive": "Active", + "noActionItems": "No Action Items", + "options": "Options", + "preCompletionNotes": "Notes", + "actionItemActive": "Active", + "markCompletion": "Mark Completion", + "actionItemStatus": "Action Item Status", + "postCompletionNotes": "Completion Notes", + "selectActionItemCategory": "Select an action item category", + "selectAssignee": "Select an assignee", + "status": "Status", + "successfulCreation": "Action Item created successfully", + "successfulUpdation": "Action Item updated successfully", + "successfulDeletion": "Action Item deleted successfully", + "title": "Action Items", + "category": "Category", + "allottedHours": "Allotted Hours", + "latestDueDate": "Latest Due Date", + "earliestDueDate": "Earliest Due Date", + "updateActionItem": "Update Action Item", + "noneUpdated": "None of the fields were updated", + "updateStatusMsg": "Are you sure you want to mark this action item as pending?", + "close": "close", + "eventActionItems": "eventActionItems", + "no": "no", + "yes": "yes", + "individuals": "Individuals", + "groups": "Groups", + "assignTo": "Assign To", + "volunteers": "Volunteers", + "volunteerGroups": "Volunteer Groups" + }, + "organizationAgendaCategory": { + "agendaCategoryDetails": "Agenda Category Details", + "updateAgendaCategory": "Update Agenda Category", + "title": "Agenda Categories", + "name": "Category", + "description": "Description", + "createdBy": "Created By", + "options": "Options", + "createAgendaCategory": "Create Agenda Category", + "noAgendaCategories": "No Agenda Categories", + "update": "Update", + "agendaCategoryCreated": "Agenda Category created successfully", + "agendaCategoryUpdated": "Agenda Category updated successfully", + "agendaCategoryDeleted": "Agenda Category deleted successfully", + "deleteAgendaCategory": "Delete Agenda Category", + "deleteAgendaCategoryMsg": "Do you want to remove this agenda category?" + }, + "agendaItems": { + "agendaItemDetails": "Agenda Item Details", + "updateAgendaItem": "Update Agenda Item", + "title": "Title", + "enterTitle": "Enter Title", + "sequence": "Sequence", + "description": "Description", + "enterDescription": "Enter Description", + "category": "Agenda Category", + "attachments": "Attachments", + "attachmentLimit": "Add any image file or video file upto 10MB", + "fileSizeExceedsLimit": "File size exceeds the limit which is 10MB", + "urls": "URLs", + "url": "add link to URL", + "enterUrl": "https://example.com", + "invalidUrl": "Please enter a valid URL", + "link": "Link", + "createdBy": "Created By", + "regular": "Regular", + "note": "Note", + "duration": "Duration", + "enterDuration": "mm:ss", + "options": "Options", + "createAgendaItem": "Create Agenda Item", + "noAgendaItems": "No Agenda Items", + "selectAgendaItemCategory": "Select an agenda item category", + "update": "Update", + "delete": "Delete", + "agendaItemCreated": "Agenda Item created successfully", + "agendaItemUpdated": "Agenda Item updated successfully", + "agendaItemDeleted": "Agenda Item deleted successfully", + "deleteAgendaItem": "Delete Agenda Item", + "deleteAgendaItemMsg": "Do you want to remove this agenda item?" + }, + "eventListCard": { + "deleteEvent": "Delete Event", + "deleteEventMsg": "Do you want to remove this event?", + "editEvent": "Edit Event", + "eventTitle": "Title", + "alreadyRegistered": "Already registered", + "allDay": "All Day", + "recurringEvent": "Recurring Event", + "isPublic": "Is Public", + "isRegistrable": "Is Registrable", + "updatePost": "Update Post", + "eventDetails": "Event Details", + "eventDeleted": "Event deleted successfully.", + "eventUpdated": "Event updated successfully.", + "thisInstance": "This Instance", + "thisAndFollowingInstances": "This & Following Instances", + "allInstances": "All Instances", + "customRecurrence": "Custom Recurrence", + "repeatsEvery": "Repeats Every", + "repeatsOn": "Repeats On", + "ends": "Ends", + "never": "Never", + "on": "On", + "after": "After", + "occurences": "occurences", + "startTime": "startTime", + "endTime": "endTime", + "location": "location", + "no": "no", + "yes": "yes", + "description": "description", + "startDate": "startDate", + "endDate": "endDate", + "registerEvent": "registerEvent", + "close": "close", + "talawaApiUnavailable": "talawaApiUnavailable", + "done": "done" + }, + "funds": { + "title": "Funds", + "createFund": "Create Fund", + "fundName": "Fund Name", + "fundId": "Fund (Reference) ID", + "taxDeductible": "Tax Deductible", + "default": "Default", + "archived": "Archived", + "fundCreate": "Create Fund", + "fundUpdate": "Update Fund", + "fundDelete": "Delete Fund", + "noFundsFound": "No Funds Found", + "createdBy": "Created By", + "createdOn": "Created On", + "status": "Status", + "fundCreated": "Fund created successfully", + "fundUpdated": "Fund updated successfully", + "fundDeleted": "Fund deleted successfully", + "deleteFundMsg": "Are you sure you want to delete this fund?", + "createdLatest": "Created Latest", + "createdEarliest": "Created Earliest", + "viewCampaigns": "View Campaigns", + "searchByName": "searchByName" + }, + "fundCampaign": { + "title": "Fundraising Campaigns", + "campaignName": "Campaign Name", + "campaignOptions": "Options", + "fundingGoal": "Funding Goal", + "addCampaign": "Add Campaign", + "createdCampaign": "Campaign created successfully", + "updatedCampaign": "Campaign updated successfully", + "deletedCampaign": "Campaign deleted successfully", + "deleteCampaignMsg": "Are you sure you want to delete this campaign?", + "noCampaigns": "No Campaigns Found", + "createCampaign": "Create Campaign", + "updateCampaign": "Update Campaign", + "deleteCampaign": "Delete Campaign", + "currency": "Currency", + "selectCurrency": "Select Currency", + "searchFullName": "Search By Name", + "viewPledges": "View Pledges", + "noCampaignsFound": "No Campaigns Found", + "latestEndDate": "Latest End Date", + "earliestEndDate": "Earliest End Date", + "lowestGoal": "Lowest Goal", + "highestGoal": "Highest Goal" + }, + "pledges": { + "title": "Fund Campaign Pledges", + "pledgeAmount": "Pledge Amount", + "pledgeOptions": "Options", + "pledgeCreated": "Pledge created successfully", + "pledgeUpdated": "Pledge updated successfully", + "pledgeDeleted": "Pledge deleted successfully", + "addPledge": "Add Pledge", + "createPledge": "Create Pledge", + "currency": "Currency", + "selectCurrency": "Select Currency", + "updatePledge": "Update Pledge", + "deletePledge": "Delete Pledge", + "amount": "Amount", + "editPledge": "Edit Pledge", + "deletePledgeMsg": "Are you sure you want to delete this pledge?", + "noPledges": "No Pledges Found", + "searchPledger": "Search By Pledgers", + "highestAmount": "Highest Amount", + "lowestAmount": "Lowest Amount", + "latestEndDate": "Latest End Date", + "earliestEndDate": "Earliest End Date", + "campaigns": "Campaigns", + "pledges": "Pledges", + "endsOn": "Ends on", + "raisedAmount": "Raised amount ", + "pledgedAmount": "Pledged amount", + "startDate": "startDate", + "endDate": "endDate" + }, + "orgPost": { + "title": "Posts", + "searchPost": "Search Post", + "posts": "Posts", + "createPost": "Create Post", + "postDetails": "Post Details", + "postTitle1": "Write title of the post", + "postTitle": "Title", + "addMedia": "Upload Media", + "information": "Information", + "information1": "Write information of the post", + "addPost": "Add Post", + "searchTitle": "Search By Title", + "searchText": "Search By Text", + "ptitle": "Post Title", + "postDes": "What do you to talk about?", + "Title": "Title", + "Text": "Text", + "searchBy": "Search By", + "Oldest": "Oldest First", + "Latest": "Latest First", + "sortPost": "Sort Post", + "tag": " Your browser does not support the video tag", + "postCreatedSuccess": "Congratulations! You have Posted Something.", + "pinPost": "Pin post", + "Next": "Next Page", + "Previous": "Previous Page", + "cancel": "cancel" + }, + "postNotFound": { + "post": "Post", + "not found!": "Not Found!", + "organization": "Organization", + "post not found!": "Post Not Found!", + "organization not found!": "Organization Not Found!" + }, + "userNotFound": { + "not found!": "Not Found!", + "roles": "Roles", + "user not found!": "User Not Found!", + "member not found!": "Member Not Found!", + "admin not found!": "Admin Not Found!", + "roles not found!": "Roles Not Found!", + "user": "user" + }, + "orgPostCard": { + "author": "Author", + "imageURL": "Image URL", + "videoURL": "Video URL", + "deletePost": "Delete Post", + "deletePostMsg": "Do you want to remove this post?", + "editPost": "Edit Post", + "postTitle": "Title", + "postTitle1": "Edit title of the post", + "information1": "Edit information of the post", + "information": "Information", + "image": "Image", + "video": "Video", + "updatePost": "Update Post", + "postDeleted": "Post deleted successfully.", + "postUpdated": "Post Updated successfully.", + "tag": " Your browser does not support the video tag", + "pin": "Pin Post", + "edit": "edit", + "no": "no", + "yes": "yes", + "close": "close", + "talawaApiUnavailable": "talawaApiUnavailable" + }, + "blockUnblockUser": { + "title": "Block/Unblock User", + "pageName": "Block/Unblock", + "listOfUsers": "List of Users who spammed", + "block_unblock": "Block/Unblock", + "unblock": "UnBlock", + "block": "Block", + "orgName": "Enter Name", + "blockedSuccessfully": "User blocked successfully", + "Un-BlockedSuccessfully": "User Un-Blocked successfully", + "allMembers": "All Members", + "blockedUsers": "Blocked Users", + "searchByFirstName": "Search By First Name", + "searchByLastName": "Search By Last Name", + "noSpammerFound": "No spammer found", + "searchByName": "searchByName", + "name": "name", + "email": "email", + "talawaApiUnavailable": "talawaApiUnavailable", + "noResultsFoundFor": "noResultsFoundFor" + }, + "eventManagement": { + "title": "Event Management Dashboard", + "dashboard": "Dashboard", + "registrants": "Registrants", + "attendance": "Attendance", + "actions": "Actions", + "agendas": "Agendas", + "statistics": "Statistics", + "to": "TO", + "volunteers": "Volunteers" + }, + "eventAttendance": { + "historical_statistics": "Historical Statistics", + "Search member": "Search member", + "Member Name": "Member Name", + "Status": "Status", + "Events Attended": "Events Attended", + "Task Assigned": "Task Assigned", + "Member": "Member", + "Admin": "Admin", + "loading": "Loading...", + "noAttendees": "Attendees not Found" + }, + "onSpotAttendee": { + "title": "On-spot Attendee", + "enterFirstName": "Enter First Name", + "enterLastName": "Enter Last Name", + "enterEmail": "Enter Email", + "enterPhoneNo": "Enter Phone Number", + "selectGender": "Select Gender", + "invalidDetailsMessage": "Please fill in all required fields", + "orgIdMissing": "Organization ID is missing. Please try again.", + "attendeeAddedSuccess": "Attendee added successfully!", + "addAttendee": "Add", + "phoneNumber": "Phone No.", + "addingAttendee": "Adding...", + "male": "Male", + "female": "Female", + "other": "Other" + }, + "forgotPassword": { + "title": "Talawa Forgot Password", + "registeredEmail": "Registered Email", + "getOtp": "Get OTP", + "enterOtp": "Enter OTP", + "enterNewPassword": "Enter New Password", + "cofirmNewPassword": "Confirm New Password", + "changePassword": "Change Password", + "backToLogin": "Back to Login", + "userOtp": "e.g. 12345", + "emailNotRegistered": "Email is not registered.", + "errorSendingMail": "Error in sending mail.", + "passwordMismatches": "Password and Confirm password mismatches.", + "passwordChanges": "Password changes successfully.", + "OTPsent": "OTP is sent to your registered email.", + "forgotPassword": "forgotPassword", + "password": "password", + "talawaApiUnavailable": "talawaApiUnavailable" + }, + "pageNotFound": { + "title": "404 Not Found", + "talawaAdmin": "Talawa Admin", + "talawaUser": "Talawa User", + "404": "404", + "notFoundMsg": "Oops! The Page you requested was not found!", + "backToHome": "Back to Home" + }, + "orgContribution": { + "title": "Talawa Contributions", + "filterByName": "Filter by Name", + "filterByTransId": "Filter by Trans. ID", + "recentStats": "Recent Stats", + "contribution": "Contribution", + "orgname": "Enter Name", + "searchtransaction": "Enter Transaction ID" + }, + "contriStats": { + "recentContribution": "Recent Contribution", + "highestContribution": "Highest Contribution", + "totalContribution": "Total Contribution" + }, + "orgContriCards": { + "date": "Date", + "transactionId": "Transaction ID", + "amount": "Amount" + }, + "orgSettings": { + "title": "Settings", + "general": "General", + "actionItemCategories": "Action Item Categories", + "updateOrganization": "Update Organization", + "seeRequest": "See Request", + "noData": "No data", + "otherSettings": "Other Settings", + "changeLanguage": "Change Language", + "manageCustomFields": "Manage Custom Fields", + "agendaItemCategories": "Agenda Item Categories" + }, + "deleteOrg": { + "deleteOrganization": "Delete Organization", + "deleteSampleOrganization": "Delete Sample Organization", + "deleteMsg": "Do you want to delete this organization?", + "confirmDelete": "Confirm Delete", + "longDelOrgMsg": "By clicking on Delete Organization button the organization will be permanently deleted along with its events, tags and all related data.", + "successfullyDeletedSampleOrganization": "Successfully deleted sample Organization", + "cancel": "cancel" + }, + "userUpdate": { + "appLanguageCode": "Default Language", + "userType": "User Type", + "firstName": "firstName", + "lastName": "lastName", + "email": "email", + "password": "password", + "admin": "admin", + "superAdmin": "superAdmin", + "displayImage": "displayImage", + "saveChanges": "saveChanges", + "cancel": "cancel" + }, + "userPasswordUpdate": { + "previousPassword": "Previous Password", + "newPassword": "New Password", + "confirmNewPassword": "Confirm New Password", + "passCantBeEmpty": "Password can't be empty", + "passNoMatch": "New and Confirm password do not match.", + "saveChanges": "saveChanges", + "cancel": "cancel" + }, + "orgDelete": { + "deleteOrg": "Delete Org" + }, + "membershipRequest": { + "accept": "Accept", + "reject": "Reject", + "memberAdded": "it is accepted", + "joined": "joined", + "talawaApiUnavailable": "talawaApiUnavailable" + }, + "orgUpdate": { + "city": "City", + "countryCode": "Country Code", + "line1": "Line 1", + "line2": "Line 2", + "postalCode": "Postal Code", + "dependentLocality": "Dependent Locality", + "sortingCode": "Sorting code", + "state": "State / Province", + "userRegistrationRequired": "User Registration Required", + "isVisibleInSearch": "Visible in Search", + "enterNameOrganization": "Enter Organization Name", + "successfulUpdated": "Organization updated successfully", + "name": "name", + "description": "description", + "location": "location", + "address": "address", + "displayImage": "displayImage", + "saveChanges": "saveChanges", + "cancel": "cancel", + "talawaApiUnavailable": "talawaApiUnavailable" + }, + "addOnRegister": { + "addNew": "Add New", + "addPlugin": "Add Plugin", + "pluginName": "Plugin Name", + "creatorName": "Creator Name", + "pluginDesc": "Plugin Description", + "pName": "Ex: Donations", + "cName": "Ex: john Doe", + "pDesc": "This Plugin enables UI for", + "close": "close", + "register": "register" + }, + "addOnStore": { + "title": "Add On Store", + "searchName": "Ex: Donations", + "search": "Search", + "enable": "Enabled", + "disable": "Disabled", + "pHeading": "Plugins", + "install": "Installed", + "available": "Available", + "pMessage": "Plugin does not exists", + "filter": "Filters" + }, + "addOnEntry": { + "enable": "Enabled", + "install": "Install", + "uninstall": "Uninstall", + "uninstallMsg": "This feature is now removed from your organization", + "installMsg": "This feature is now enabled in your organization" + }, + "memberDetail": { + "title": "User Details", + "addAdmin": "Add Admin", + "noeventsAttended": "No Events Attended", + "alreadyIsAdmin": "Member is already an Admin", + "organizations": "Organizations", + "events": "Events", + "role": "Role", + "createdOn": "Created on", + "main": "Main", + "firstName": "First name", + "lastName": "Last name", + "language": "Language", + "gender": "Gender", + "birthDate": "Birth Date", + "educationGrade": "Educational Grade", + "employmentStatus": "Employment Status", + "maritalStatus": "Marital Status", + "phone": "Phone", + "countryCode": "Country Code", + "state": "State", + "city": "City", + "personalInfoHeading": "Personal Information", + "viewAll": "View All", + "eventsAttended": "Events Attended", + "contactInfoHeading": "Contact Information", + "actionsHeading": "Actions", + "personalDetailsHeading": "Profile Details", + "appLanguageCode": "Choose Language", + "deleteUser": "Delete User", + "pluginCreationAllowed": "Plugin creation allowed", + "created": "Created", + "adminForOrganizations": "Admin for organizations", + "membershipRequests": "Membership requests", + "adminForEvents": "Admin for events", + "addedAsAdmin": "User is added as admin.", + "userType": "User Type", + "email": "email", + "displayImage": "displayImage", + "address": "address", + "delete": "delete", + "saveChanges": "saveChanges", + "joined": "joined", + "talawaApiUnavailable": "talawaApiUnavailable", + "unassignUserTag": "Unassign Tag", + "unassignUserTagMessage": "Do you want to remove the tag from this user?", + "successfullyUnassigned": "Tag unassigned from user", + "tagsAssigned": "Tags Assigned", + "noTagsAssigned": "No Tags Assigned" + }, + "people": { + "title": "People", + "searchUsers": "Search users" + }, + "userLogin": { + "login": "Login", + "loginIntoYourAccount": "Login into your account", + "invalidDetailsMessage": "Please enter a valid email and password.", + "notAuthorised": "Sorry! you are not Authorised!", + "invalidCredentials": "Entered credentials are incorrect. Please enter valid credentials.", + "forgotPassword": "forgotPassword", + "emailAddress": "emailAddress", + "enterEmail": "enterEmail", + "password": "password", + "enterPassword": "enterPassword", + "register": "register", + "talawaApiUnavailable": "talawaApiUnavailable" + }, + "userRegister": { + "enterFirstName": "Enter your first name", + "enterLastName": "Enter your last name", + "enterConfirmPassword": "Enter Password to confirm", + "alreadyhaveAnAccount": "Already have an account?", + "login": "Login", + "afterRegister": "Successfully registered. Please wait for admin to approve your request.", + "passwordNotMatch": "Password doesn't match. Confirm Password and try again.", + "invalidDetailsMessage": "Please enter valid details.", + "register": "register", + "firstName": "firstName", + "lastName": "lastName", + "emailAddress": "emailAddress", + "enterEmail": "enterEmail", + "password": "password", + "enterPassword": "enterPassword", + "confirmPassword": "confirmPassword", + "talawaApiUnavailable": "talawaApiUnavailable" + }, + "userNavbar": { + "talawa": "Talawa", + "home": "Home", + "people": "People", + "events": "Events", + "chat": "Chat", + "donate": "Donate", + "language": "Language", + "settings": "settings", + "logout": "logout", + "close": "close" + }, + "userOrganizations": { + "allOrganizations": "All Organizations", + "joinedOrganizations": "Joined Organizations", + "createdOrganizations": "Created Organizations", + "selectOrganization": "Select an organization", + "searchUsers": "Search users", + "nothingToShow": "Nothing to show here.", + "organizations": "Organizations", + "search": "search", + "filter": "filter", + "searchByName": "searchByName", + "searchOrganizations": "Search Organization" + }, + "userSidebarOrg": { + "yourOrganizations": "Your Organizations", + "noOrganizations": "You haven't joined any organization yet.", + "viewAll": "View all", + "talawaUserPortal": "Talawa User Portal", + "my organizations": "My Organizations", + "users": "Users", + "requests": "Requests", + "communityProfile": "Community Profile", + "logout": "Logout", + "settings": "Settings", + "chat": "Chat", + "menu": "menu" + }, + "organizationSidebar": { + "viewAll": "View all", + "events": "Events", + "noEvents": "No Events to show", + "noMembers": "No Members to show", + "members": "members" + }, + "postCard": { + "likes": "Likes", + "comments": "Comments", + "viewPost": "View Post", + "editPost": "Edit Post", + "postedOn": "Posted on {{date}}" + }, + "home": { + "posts": "Posts", + "post": "Post", + "title": "Posts", + "textArea": "Something on your mind?", + "feed": "Feed", + "loading": "Loading", + "pinnedPosts": "Pinned Posts", + "yourFeed": "Your Feed", + "nothingToShowHere": "Nothing to show here", + "somethingOnYourMind": "Something on your mind?", + "addPost": "Add Post", + "startPost": "Start a post", + "media": "Media", + "event": "Event", + "article": "Article", + "postNowVisibleInFeed": "Post now visible in feed" + }, + "settings": { + "eventAttended": "Events Attended", + "noeventsAttended": "No Events Attended", + "profileSettings": "Profile Settings", + "gender": "Gender", + "phoneNumber": "Phone Number", + "chooseFile": "Choose File", + "birthDate": "Birth Date", + "grade": "Educational Grade", + "empStatus": "Employment Status", + "maritalStatus": "Marital Status", + "state": "City/State", + "country": "Country", + "resetChanges": "Reset Changes", + "profileDetails": "Profile Details", + "deleteUserMessage": "By clicking on Delete User button your user will be permanently deleted along with its events, tags and all related data.", + "copyLink": "Copy Profile Link", + "deleteUser": "Delete User", + "otherSettings": "Other Settings", + "changeLanguage": "Change Language", + "sgender": "Select gender", + "gradePlaceholder": "Enter Grade", + "sEmpStatus": "Select employement status", + "female": "Female", + "male": "Male", + "employed": "Employed", + "other": "Other", + "sMaritalStatus": "Select marital status", + "unemployed": "Unemployed", + "married": "Married", + "single": "Single", + "widowed": "Widowed", + "divorced": "Divorced", + "engaged": "Engaged", + "separated": "Separated", + "grade1": "Grade 1", + "grade2": "Grade 2", + "grade3": "Grade 3", + "grade4": "Grade 4", + "grade5": "Grade 5", + "grade6": "Grade 6", + "grade7": "Grade 7", + "grade8": "Grade 8", + "grade9": "Grade 9", + "grade10": "Grade 10", + "grade11": "Grade 11", + "grade12": "Grade 12", + "graduate": "Graduate", + "kg": "KG", + "preKg": "Pre-KG", + "noGrade": "No Grade", + "fullTime": "Full Time", + "partTime": "Part Time", + "selectCountry": "Select a country", + "enterState": "Enter City or State", + "settings": "settings", + "firstName": "firstName", + "lastName": "lastName", + "emailAddress": "emailAddress", + "displayImage": "displayImage", + "address": "address", + "saveChanges": "saveChanges", + "joined": "joined" + }, + "donate": { + "title": "Donations", + "donations": "Donations", + "searchDonations": "Search donations", + "donateForThe": "Donate for the", + "amount": "Amount", + "yourPreviousDonations": "Your Previous Donations", + "donate": "Donate", + "nothingToShow": "Nothing to show here.", + "success": "Donation Successful", + "invalidAmount": "Please enter a numerical value for the donation amount.", + "donationAmountDescription": "Please enter the numerical value for the donation amount.", + "donationOutOfRange": "Donation amount must be between {{min}} and {{max}}.", + "donateTo": "donateTo" + }, + "userEvents": { + "title": "Events", + "nothingToShow": "Nothing to show here.", + "createEvent": "Create Event", + "recurring": "Recurring Event", + "listView": "List View", + "calendarView": "Calendar View", + "allDay": "All Day", + "eventCreated": "Event created and posted successfully.", + "eventDetails": "Event Details", + "eventTitle": "Title", + "enterTitle": "Enter Title", + "enterDescription": "Enter Description", + "publicEvent": "Is Public", + "registerable": "Is Registerable", + "monthlyCalendarView": "Monthly Calendar", + "yearlyCalendarView": "Yearly Calender", + "startTime": "startTime", + "endTime": "endTime", + "enterLocation": "enterLocation", + "search": "search", + "cancel": "cancel", + "create": "create", + "eventDescription": "eventDescription", + "eventLocation": "eventLocation", + "startDate": "startDate", + "endDate": "endDate" + }, + "userEventCard": { + "starts": "Starts", + "ends": "Ends", + "creator": "Creator", + "alreadyRegistered": "Already registered", + "location": "location", + "register": "register" + }, + "advertisement": { + "title": "Advertisements", + "activeAds": "Active Campaigns", + "archievedAds": "Completed Campaigns", + "pMessage": "Ads not present for this campaign.", + "validLink": "Link is valid", + "invalidLink": "Link is invalid", + "Rname": "Enter name of Advertisement", + "Rtype": "Select type of Advertisement", + "Rmedia": "Provide media content to be displayed", + "RstartDate": "Select Start Date", + "RendDate": "Select End Date", + "RClose": "Close the window", + "addNew": "Create new advertisement", + "EXname": "Ex. Cookie Shop", + "EXlink": "Ex. http://yourwebsite.com/photo", + "createAdvertisement": "Create Advertisement", + "deleteAdvertisement": "Delete Advertisement", + "deleteAdvertisementMsg": "Do you want to remove this advertisement?", + "view": "View", + "editAdvertisement": "Edit Advertisement", + "advertisementDeleted": "Advertisement deleted successfully.", + "endDateGreaterOrEqual": "End Date should be greater than or equal to Start Date", + "advertisementCreated": "Advertisement created successfully.", + "pHeading": "pHeading", + "delete": "delete", + "close": "close", + "no": "no", + "yes": "yes", + "edit": "edit", + "saveChanges": "saveChanges", + "endOfResults": "endOfResults" + }, + "userChat": { + "chat": "Chat", + "search": "Search", + "messages": "Messages", + "contacts": "Contacts" + }, + "userChatRoom": { + "selectContact": "Select a contact to start conversation", + "sendMessage": "Send Message" + }, + "orgProfileField": { + "loading": "Loading...", + "noCustomField": "No custom fields available", + "customFieldName": "Field Name", + "enterCustomFieldName": "Enter Field name", + "customFieldType": "Field Type", + "Remove Custom Field": "Remove Custom Field", + "fieldSuccessMessage": "Field added successfully", + "fieldRemovalSuccess": "Field removed successfully", + "String": "String", + "Boolean": "Boolean", + "Date": "Date", + "Number": "Number", + "saveChanges": "saveChanges" + }, + "orgActionItemCategories": { + "enableButton": "Enable", + "disableButton": "Disable", + "updateActionItemCategory": "Update", + "actionItemCategoryName": "Name", + "categoryDetails": "Category Details", + "enterName": "Enter Name", + "successfulCreation": "Action Item Category created successfully", + "successfulUpdation": "Action Item Category updated successfully", + "sameNameConflict": "Please change the name to make an update", + "categoryEnabled": "Action Item Category Enabled", + "categoryDisabled": "Action Item Category Disabled", + "noActionItemCategories": "No Action Item Categories", + "status": "Status", + "categoryDeleted": "Action Item Category deleted successfully", + "deleteCategory": "Delete Category", + "deleteCategoryMsg": "Are you sure you want to delete this Action Item Category?", + "createButton": "createButton", + "editButton": "editButton" + }, + "organizationVenues": { + "title": "Venues", + "addVenue": "Add Venue", + "venueDetails": "Venue Details", + "venueName": "Name of the Venue", + "enterVenueName": "Enter Venue Name", + "enterVenueDesc": "Enter Venue Description", + "capacity": "Capacity", + "enterVenueCapacity": "Enter Venue Capacity", + "image": "Venue Image", + "uploadVenueImage": "Upload Venue Image", + "createVenue": "Create Venue", + "venueAdded": "Venue added Successfully", + "editVenue": "Update Venue", + "venueUpdated": "Venue details updated successfully", + "sort": "Sort", + "highestCapacity": "Highest Capacity", + "lowestCapacity": "Lowest Capacity", + "noVenues": "No Venues Found!", + "view": "View", + "venueTitleError": "Venue title cannot be empty!", + "venueCapacityError": "Capacity must be a positive number!", + "searchBy": "Search By", + "description": "description", + "edit": "edit", + "delete": "delete", + "name": "name", + "desc": "desc" + }, + "addMember": { + "title": "Add Member", + "addMembers": "Add Members", + "existingUser": "Existing User", + "newUser": "New User", + "searchFullName": "Search by Full Name", + "enterFirstName": "Enter First Name", + "enterLastName": "Enter Last Name", + "enterConfirmPassword": "Enter Confirm Password", + "organization": "Organization", + "invalidDetailsMessage": "Please provide all required details.", + "passwordNotMatch": "Passwords do not match.", + "addMember": "Add Member", + "firstName": "firstName", + "lastName": "lastName", + "emailAddress": "emailAddress", + "enterEmail": "enterEmail", + "password": "password", + "enterPassword": "enterPassword", + "confirmPassword": "confirmPassword", + "cancel": "cancel", + "create": "create", + "user": "user", + "profile": "profile" + }, + "eventActionItems": { + "title": "Action Items", + "createActionItem": "Create Action Item", + "actionItemCategory": "Action Item Category", + "selectActionItemCategory": "Select an action item category", + "selectAssignee": "Select an assignee", + "assignee": "Assignee", + "assigner": "Assigner", + "preCompletionNotes": "Notes", + "postCompletionNotes": "Completion Notes", + "assignmentDate": "Assignment Date", + "status": "Status", + "actionItemActive": "Active", + "actionItemStatus": "Action Item Status", + "actionItemCompleted": "Action Item Completed", + "markCompletion": "Mark Completion", + "actionItemDetails": "Action Item Details", + "dueDate": "Due Date", + "completionDate": "Completion Date", + "editActionItem": "Edit Action Item", + "deleteActionItem": "Delete Action Item", + "deleteActionItemMsg": "Do you want to remove this action item?", + "successfulDeletion": "Action Item deleted successfully", + "successfulCreation": "Action Item created successfully", + "successfulUpdation": "Action Item updated successfully", + "notes": "Notes", + "save": "Save", + "yes": "yes", + "no": "no" + }, + "checkIn": { + "errorCheckingIn": "Error checking in", + "checkedInSuccessfully": "Checked in successfully" + }, + "eventRegistrantsModal": { + "errorAddingAttendee": "Error adding attendee", + "errorRemovingAttendee": "Error removing attendee" + }, + "userCampaigns": { + "title": "Fundraising Campaigns", + "searchByName": "Search by Name...", + "searchBy": "Search by", + "pledgers": "Pledgers", + "campaigns": "Campaigns", + "myPledges": "My Pledges", + "lowestAmount": "Lowest Amount", + "highestAmount": "Highest Amount", + "lowestGoal": "Lowest Goal", + "highestGoal": "Highest Goal", + "latestEndDate": "Latest End Date", + "earliestEndDate": "Earliest End Date", + "addPledge": "Add Pledge", + "viewPledges": "View Pledges", + "noPledges": "No Pledges Found", + "noCampaigns": "No Campaigns Found" + }, + "userPledges": { + "title": "My Pledges" + }, + "eventVolunteers": { + "volunteers": "Volunteers", + "volunteer": "Volunteer", + "volunteerGroups": "Volunteer Groups", + "individuals": "Individuals", + "groups": "Groups", + "status": "Status", + "noVolunteers": "No Volunteers", + "noVolunteerGroups": "No Volunteer Groups", + "add": "Add", + "mostHoursVolunteered": "Most Hours Volunteered", + "leastHoursVolunteered": "Least Hours Volunteered", + "accepted": "Accepted", + "addVolunteer": "Add Volunteer", + "removeVolunteer": "Remove Volunteer", + "volunteerAdded": "Volunteer added successfully", + "volunteerRemoved": "Volunteer removed successfully", + "volunteerGroupCreated": "Volunteer group created successfully", + "volunteerGroupUpdated": "Volunteer group updated successfully", + "volunteerGroupDeleted": "Volunteer group deleted successfully", + "removeVolunteerMsg": "Are you sure you want to remove this Volunteer?", + "deleteVolunteerGroupMsg": "Are you sure you want to delete this Volunteer Group?", + "leader": "Leader", + "group": "Group", + "createGroup": "Create Group", + "updateGroup": "Update Group", + "deleteGroup": "Delete Group", + "volunteersRequired": "Volunteers Required", + "volunteerDetails": "Volunteer Details", + "hoursVolunteered": "Hours Volunteered", + "groupDetails": "Group Details", + "creator": "Creator", + "requests": "Requests", + "noRequests": "No Requests", + "latest": "Latest", + "earliest": "Earliest", + "requestAccepted": "Request accepted successfully", + "requestRejected": "Request rejected successfully", + "details": "Details", + "manageGroup": "Manage Group", + "mostVolunteers": "Most Volunteers", + "leastVolunteers": "Least Volunteers" + }, + "userVolunteer": { + "title": "Volunteership", + "name": "Title", + "upcomingEvents": "Upcoming Events", + "requests": "Requests", + "invitations": "Invitations", + "groups": "Volunteer Groups", + "actions": "Actions", + "searchByName": "Search by Name", + "latestEndDate": "Latest End Date", + "earliestEndDate": "Earliest End Date", + "noEvents": "No Upcoming Events", + "volunteer": "Volunteer", + "volunteered": "Volunteered", + "join": "Join", + "joined": "Joined", + "searchByEventName": "Search by Event title", + "filter": "Filter", + "groupInvite": "Group Invite", + "individualInvite": "Individual Invite", + "noInvitations": "No Invitations", + "accept": "Accept", + "reject": "Reject", + "receivedLatest": "Received Latest", + "receivedEarliest": "Received Earliest", + "invitationAccepted": "Invitation accepted successfully", + "invitationRejected": "Invitation rejected successfully", + "volunteerSuccess": "Requested to volunteer successfully", + "recurring": "Recurring", + "groupInvitationSubject": "Invitation to join volunteer group", + "eventInvitationSubject": "Invitation to volunteer for event" + } +} diff --git a/public/locales/fr/common.json b/public/locales/fr/common.json new file mode 100644 index 0000000000..132a913c7d --- /dev/null +++ b/public/locales/fr/common.json @@ -0,0 +1,98 @@ +{ + "firstName": "Prénom", + "lastName": "Nom de famille", + "searchByName": "Rechercher par nom", + "loading": "Chargement...", + "endOfResults": "Fin des résultats", + "noResultsFoundFor": "Aucun résultat trouvé pour ", + "edit": "Modifier", + "admins": "Administrateurs", + "admin": "ADMINISTRATEUR", + "user": "UTILISATEUR", + "superAdmin": "SUPERADMIN", + "members": "Membres", + "logout": "Se déconnecter", + "login": "Se connecter", + "register": "Registre", + "menu": "Menu", + "settings": "Paramètres", + "users": "Utilisateurs", + "requests": "Demandes", + "OR": "OU", + "cancel": "Annuler", + "close": "Fermer", + "create": "Créer", + "delete": "Supprimer", + "done": "Fait", + "yes": "Oui", + "no": "Non", + "filter": "Filtre", + "search": "Recherche", + "description": "Description", + "saveChanges": "Sauvegarder les modifications", + "resetChanges": "Réinitialiser les modifications", + "displayImage": "Afficher l'image", + "enterEmail": "Entrez l'e-mail", + "emailAddress": "Adresse e-mail", + "email": "E-mail", + "name": "Nom", + "gender": "Genre", + "desc": "Description", + "enterPassword": "Entrer le mot de passe", + "password": "Mot de passe", + "confirmPassword": "Confirmez le mot de passe", + "forgotPassword": "Mot de passe oublié ?", + "talawaAdminPortal": "Portail d'administration Talawa", + "address": "Adresse", + "location": "Emplacement", + "enterLocation": "Entrez l'emplacement", + "joined": "Rejoint", + "startDate": "Date de début", + "endDate": "Date de fin", + "startTime": "Heure de début", + "endTime": "Heure de fin", + "My Organizations": "Mes Organisations", + "Dashboard": "Tableau de Bord", + "People": "Personnes", + "Events": "Événements", + "Venues": "Lieux", + "Action Items": "Éléments d'Action", + "Posts": "Publications", + "Block/Unblock": "Bloquer/Débloquer", + "Advertisement": "Publicité", + "Funds": "Fonds", + "Membership Requests": "Demandes d'Adhésion", + "Plugins": "Plugins", + "Plugin Store": "Magasin de Plugins", + "Settings": "Paramètres", + "createdOn": "Créé Le", + "createdBy": "Créé Par", + "usersRole": "Rôle de l'Utilisateur", + "changeRole": "Changer de Rôle", + "action": "Action", + "removeUser": "Supprimer l'Utilisateur", + "remove": "Supprimer", + "viewProfile": "Voir le Profil", + "profile": "Profil", + "noFiltersApplied": "Aucun filtre appliqué", + "manage": "Gérer", + "searchResultsFor": "Résultats de recherche pour {{text}}", + "none": "Aucun", + "sort": "Trier", + "Donate": "Faire un don", + "addedSuccessfully": "{{item}} ajouté avec succès", + "updatedSuccessfully": "{{item}} mis à jour avec succès", + "removedSuccessfully": "{{item}} supprimé avec succès", + "successfullyUpdated": "Mis à jour avec succès", + "sessionWarning": "Votre session expirera bientôt en raison de l'inactivité. Veuillez interagir avec la page pour prolonger votre session.", + "sessionLogOut": "Votre session a expiré en raison de l'inactivité. Veuillez vous reconnecter pour continuer.", + "all": "Tous", + "active": "Actif", + "disabled": "Désactivé", + "pending": "En attente", + "completed": "Complété", + "late": "En retard", + "createdLatest": "Créé le plus récemment", + "createdEarliest": "Créé le plus tôt", + "searchBy": "Rechercher par {{item}}" +} diff --git a/public/locales/fr/errors.json b/public/locales/fr/errors.json new file mode 100644 index 0000000000..e9a7cf4fd9 --- /dev/null +++ b/public/locales/fr/errors.json @@ -0,0 +1,11 @@ +{ + "talawaApiUnavailable": "Le service Talawa-API n'est pas disponible !. ", + "notFound": "Pas trouvé", + "unknownError": "Une erreur inconnue est survenue. {{msg}}", + "notAuthorised": "Désolé! ", + "errorSendingMail": "Erreur lors de l'envoi du courrier", + "emailNotRegistered": "Email non enregistré", + "notFoundMsg": "Oops! ", + "errorOccurredCouldntCreate": "Une erreur s'est produite. Impossible de créer {{entity}}", + "errorLoading": "Une erreur s'est produite lors du chargement des données {{entity}}" +} diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json new file mode 100644 index 0000000000..d089aefeb5 --- /dev/null +++ b/public/locales/fr/translation.json @@ -0,0 +1,1483 @@ +{ + "leaderboard": { + "title": "Tableau des Leaders", + "searchByVolunteer": "Recherche par Bénévole", + "mostHours": "Le Plus d'Heures", + "leastHours": "Le Moins d'Heures", + "timeFrame": "Période", + "allTime": "Tout le Temps", + "weekly": "Cette Semaine", + "monthly": "Ce Mois", + "yearly": "Cette Année", + "noVolunteers": "Aucun Bénévole Trouvé!" + }, + "loginPage": { + "title": "Administrateur Talawa", + "fromPalisadoes": "Une application open source réalisée par les bénévoles de la Fondation Palisadoes", + "userLogin": "Utilisateur en ligne", + "atleast_8_char_long": "Au moins 8 caractères", + "atleast_6_char_long": "Au moins 6 caractères", + "firstName_invalid": "Le prénom ne doit contenir que des lettres minuscules et majuscules", + "lastName_invalid": "Le nom de famille ne doit contenir que des lettres minuscules et majuscules", + "password_invalid": "Le mot de passe doit contenir au moins une lettre minuscule, une lettre majuscule, une valeur numérique et un caractère spécial", + "email_invalid": "L'e-mail doit contenir au moins 8 caractères", + "Password_and_Confirm_password_mismatches.": "Mot de passe et Confirmer les incompatibilités de mot de passe.", + "doNotOwnAnAccount": "Vous ne possédez pas de compte ?", + "captchaError": "Erreur CAPTCHA!", + "Please_check_the_captcha": "S'il vous plaît, vérifiez le captcha.", + "Something_went_wrong": "Quelque chose s'est mal passé. Veuillez réessayer plus tard.", + "passwordMismatches": "Mot de passe et Confirmer les incompatibilités de mot de passe.", + "fillCorrectly": "Remplissez correctement tous les détails.", + "successfullyRegistered": "Enregistré avec succès. ", + "lowercase_check": "Au moins une lettre minuscule", + "uppercase_check": "Au moins une lettre majuscule", + "numeric_value_check": "Au moins une valeur numérique", + "special_char_check": "Au moins un caractère spécial", + "selectOrg": "Sélectionnez une organisation", + "afterRegister": "Enregistré avec succès. ", + "talawa_portal": "Portail Talawa", + "login": "Connexion", + "register": "S'inscrire", + "firstName": "Prénom", + "lastName": "Nom de famille", + "email": "E-mail", + "password": "Mot de passe", + "confirmPassword": "Confirmer le mot de passe", + "forgotPassword": "Mot de passe oublié", + "enterEmail": "Entrer l'e-mail", + "enterPassword": "Entrer le mot de passe", + "talawaApiUnavailable": "API Talawa indisponible", + "notAuthorised": "Non autorisé", + "notFound": "Non trouvé", + "OR": "OU", + "admin": "Administrateur", + "user": "Utilisateur", + "loading": "Chargement" + }, + "userLoginPage": { + "title": "Administrateur Talawa", + "fromPalisadoes": "Une application open source réalisée par les bénévoles de la Fondation Palisadoes", + "atleast_8_char_long": "Au moins 8 caractères", + "Password_and_Confirm_password_mismatches.": "Mot de passe et Confirmer les incompatibilités de mot de passe.", + "doNotOwnAnAccount": "Vous ne possédez pas de compte ?", + "captchaError": "Erreur CAPTCHA!", + "Please_check_the_captcha": "S'il vous plaît, vérifiez le captcha.", + "Something_went_wrong": "Quelque chose s'est mal passé. Veuillez réessayer plus tard.", + "passwordMismatches": "Mot de passe et Confirmer les incompatibilités de mot de passe.", + "fillCorrectly": "Remplissez correctement tous les détails.", + "successfullyRegistered": "Enregistré avec succès. ", + "userLogin": "Utilisateur en ligne", + "afterRegister": "Enregistré avec succès. ", + "selectOrg": "Sélectionnez une organisation", + "talawa_portal": "Portail Talawa", + "login": "Connexion", + "register": "S'inscrire", + "firstName": "Prénom", + "lastName": "Nom de famille", + "email": "E-mail", + "password": "Mot de passe", + "confirmPassword": "Confirmer le mot de passe", + "forgotPassword": "Mot de passe oublié", + "enterEmail": "Entrer l'e-mail", + "enterPassword": "Entrer le mot de passe", + "talawaApiUnavailable": "API Talawa indisponible", + "notAuthorised": "Non autorisé", + "notFound": "Non trouvé", + "OR": "OU", + "loading": "Chargement" + }, + "latestEvents": { + "eventCardTitle": "évènements à venir", + "eventCardSeeAll": "Voir tout", + "noEvents": "Aucun événement à venir" + }, + "latestPosts": { + "latestPostsTitle": "Derniers messages", + "seeAllLink": "Voir tout", + "noPostsCreated": "Aucun message créé" + }, + "listNavbar": { + "roles": "Les rôles", + "talawa_portal": "Portail Talawa", + "requests": "Demandes", + "logout": "Déconnexion" + }, + "leftDrawer": { + "my organizations": "Mes organisations", + "requests": "Demandes d'adhésion", + "communityProfile": "Profil de la communauté", + "talawaAdminPortal": "Portail Administrateur Talawa", + "menu": "Menu", + "users": "Utilisateurs", + "logout": "Déconnexion" + }, + "leftDrawerOrg": { + "Dashboard": "Tableau de bord", + "People": "Personnes", + "Events": "Événements", + "Contributions": "Contributions", + "Posts": "Des postes", + "Block/Unblock": "Bloquer/Débloquer", + "Plugins": "Plugins", + "Plugin Store": "Magasin de plugins", + "Advertisement": "Annonces", + "allOrganizations": "Toutes les organisations", + "yourOrganization": "Ton organisation", + "notification": "Notification", + "language": "Langue", + "notifications": "Notifications", + "spamsThe": "spamme le", + "group": "groupe", + "noNotifications": "Aucune notification", + "talawaAdminPortal": "Portail Administrateur Talawa", + "menu": "Menu", + "talawa_portal": "Portail Talawa", + "settings": "Paramètres", + "logout": "Déconnexion", + "close": "Fermer" + }, + "orgList": { + "title": "Organisations Talawa", + "you": "Toi", + "designation": "Désignation", + "my organizations": "Mes organisations", + "createOrganization": "Créer une organisation", + "createSampleOrganization": "Créer un exemple d'organisation", + "city": "Ville", + "countryCode": "Code postal", + "dependentLocality": "Localité dépendante", + "line1": "Ligne 1", + "line2": "Ligne 2", + "postalCode": "Code Postal", + "sortingCode": "Code de tri", + "state": "État/Province", + "userRegistrationRequired": "Inscription de l'utilisateur requise", + "visibleInSearch": "Visible dans la recherche", + "enterName": "Entrez le nom", + "sort": "Trier", + "Latest": "Dernier", + "Earliest": "Le plus tôt", + "noOrgErrorTitle": "Organisations introuvables", + "sampleOrgDuplicate": "Un seul échantillon d’organisation autorisé", + "noOrgErrorDescription": "Veuillez créer une organisation via le tableau de bord", + "manageFeatures": "Gérer les fonctionnalités", + "manageFeaturesInfo": "Création réussie ! ", + "goToStore": "Accédez à la boutique de plugins", + "enableEverything": "Activer tout", + "sampleOrgSuccess": "Exemple d'organisation créée avec succès", + "name": "Nom", + "email": "E-mail", + "searchByName": "Rechercher par nom", + "description": "Description", + "location": "Emplacement", + "address": "Adresse", + "displayImage": "Image d'affichage", + "filter": "Filtrer", + "cancel": "Annuler", + "endOfResults": "Fin des résultats", + "noResultsFoundFor": "Aucun résultat trouvé pour", + "OR": "OU" + }, + "orgListCard": { + "manage": "Gérer", + "sampleOrganization": "Exemple d'organisation", + "admins": "Administrateurs", + "members": "Membres" + }, + "paginationList": { + "rowsPerPage": "lignes par page", + "all": "Tous" + }, + "requests": { + "title": "Demandes d'adhésion", + "sl_no": "Sl. ", + "accept": "Accepter", + "reject": "Rejeter", + "searchRequests": "Rechercher des demandes d'adhésion", + "noOrgError": "Organisations introuvables, veuillez créer une organisation via le tableau de bord", + "noRequestsFound": "Aucune demande d'adhésion trouvée", + "acceptedSuccessfully": "Demande acceptée avec succès", + "rejectedSuccessfully": "Demande rejetée avec succès", + "noOrgErrorTitle": "Organisations introuvables", + "noOrgErrorDescription": "Veuillez créer une organisation via le tableau de bord", + "name": "Nom", + "email": "E-mail", + "endOfResults": "Fin des résultats", + "noResultsFoundFor": "Aucun résultat trouvé pour" + }, + "users": { + "title": "Rôles Talawa", + "joined_organizations": "Organisations rejointes", + "blocked_organizations": "Organisations bloquées", + "orgJoinedBy": "Organisations rejointes par", + "orgThatBlocked": "Organisations qui ont bloqué", + "hasNotJoinedAnyOrg": "n'a rejoint aucune organisation", + "isNotBlockedByAnyOrg": "n'est bloqué par aucune organisation", + "searchByOrgName": "Rechercher par nom d'organisation", + "view": "Voir", + "enterName": "Entrez le nom", + "loadingUsers": "Chargement des utilisateurs...", + "noUserFound": "Aucun utilisateur trouvé", + "sort": "Trier", + "Newest": "Le plus récent d'abord", + "Oldest": "Le plus âgé en premier", + "noOrgError": "Organisations introuvables, veuillez créer une organisation via le tableau de bord", + "roleUpdated": "Rôle mis à jour.", + "joinNow": "Adhérer maintenant", + "visit": "Visite", + "withdraw": "Largeur de tirage", + "removeUserFrom": "Supprimer l'Utilisateur de {{org}}", + "removeConfirmation": "Êtes-vous sûr de vouloir supprimer '{{name}}' de l'organisation '{{org}}' ?", + "searchByName": "Rechercher par nom", + "users": "Utilisateurs", + "name": "Nom", + "email": "E-mail", + "endOfResults": "Fin des résultats", + "admin": "Administrateur", + "superAdmin": "Super Administrateur", + "user": "Utilisateur", + "filter": "Filtrer", + "noResultsFoundFor": "Aucun résultat trouvé pour", + "talawaApiUnavailable": "API Talawa indisponible", + "cancel": "Annuler", + "admins": "Administrateurs", + "members": "Membres", + "orgJoined": "Organisation Rejointe", + "MembershipRequestSent": "Demande d'adhésion envoyée", + "AlreadyJoined": "Déjà rejoint", + "errorOccured": "Une erreur s'est produite" + }, + "communityProfile": { + "title": "Profil de la communauté", + "editProfile": "Editer le profil", + "communityProfileInfo": "Ces détails apparaîtront sur l'écran de connexion/inscription pour vous et les membres de votre communauté.", + "communityName": "Nom de la communauté", + "wesiteLink": "Lien de site Web", + "logo": "Logo", + "social": "Liens vers les réseaux sociaux", + "url": "Entrer l'URL", + "profileChangedMsg": "Les détails du profil ont été mis à jour avec succès.", + "resetData": "Réinitialisez avec succès les détails du profil." + }, + "dashboard": { + "title": "Tableau de bord", + "about": "À propos", + "deleteThisOrganization": "Supprimer cette organisation", + "statistics": "Statistiques", + "posts": "Des postes", + "events": "Événements", + "blockedUsers": "Utilisateurs bloqués", + "viewAll": "Voir tout", + "upcomingEvents": "évènements à venir", + "noUpcomingEvents": "Aucun événement à venir", + "latestPosts": "Derniers messages", + "noPostsPresent": "Aucun message présent", + "membershipRequests": "Demandes d'adhésion", + "noMembershipRequests": "Aucune demande d'adhésion présente", + "location": "Emplacement", + "members": "Membres", + "admins": "Administrateurs", + "requests": "Demandes", + "talawaApiUnavailable": "API Talawa indisponible", + "volunteerRankings": "Classement des Bénévoles", + "noVolunteers": "Aucun Bénévole Trouvé!" + }, + "organizationPeople": { + "title": "Membres Talawa", + "filterByName": "Filtrer par nom", + "filterByLocation": "Filtrer par emplacement", + "filterByEvent": "Filtrer par événement", + "searchName": "Entrez le nom", + "searchevent": "Entrer l'événement", + "searchFullName": "Entrez le nom complet", + "people": "Personnes", + "sort": "Recherche par rôle", + "actions": "Actions", + "addMembers": "Ajouter des membres", + "existingUser": "Utilisateur existant", + "newUser": "Nouvel utilisateur", + "enterFirstName": "Entrez votre prénom", + "enterLastName": "Entrez votre nom de famille", + "enterConfirmPassword": "Entrez votre mot de passe pour confirmer", + "organization": "Organisation", + "user": "Utilisateur", + "profile": "Profil", + "invalidDetailsMessage": "Veuillez saisir des informations valides.", + "members": "Membres", + "admins": "Administrateurs", + "users": "Utilisateurs", + "searchFirstName": "Rechercher par prénom", + "searchLastName": "Rechercher par nom de famille", + "firstName": "Prénom", + "lastName": "Nom de famille", + "emailAddress": "Adresse e-mail", + "enterEmail": "Entrer l'e-mail", + "password": "Mot de passe", + "enterPassword": "Entrer le mot de passe", + "confirmPassword": "Confirmer le mot de passe", + "create": "Créer", + "cancel": "Annuler" + }, + "organizationTags": { + "title": "Étiquettes d'Organisation", + "createTag": "Créer une nouvelle étiquette", + "manageTag": "Gérer", + "editTag": "Modifier", + "removeTag": "Supprimer", + "tagDetails": "Détails de l'Étiquette", + "tagName": "Nom", + "tagType": "Type", + "tagNamePlaceholder": "Écrire le nom de l'étiquette", + "tagCreationSuccess": "Nouvelle étiquette créée avec succès", + "tagUpdationSuccess": "Étiquette mise à jour avec succès", + "tagRemovalSuccess": "Étiquette supprimée avec succès", + "noTagsFound": "Aucune étiquette trouvée", + "removeUserTag": "Supprimer l'Étiquette", + "removeUserTagMessage": "Voulez-vous supprimer cette étiquette ?", + "addChildTag": "Ajouter une Sous-Étiquette", + "enterTagName": "Entrez le nom de l'étiquette" + }, + "manageTag": { + "title": "Détails de l'étiquette", + "addPeopleToTag": "Ajouter des personnes à l'étiquette", + "viewProfile": "Voir", + "noAssignedMembersFound": "Aucun membre assigné", + "unassignUserTag": "Désassigner l'étiquette", + "unassignUserTagMessage": "Voulez-vous retirer l'étiquette de cet utilisateur?", + "successfullyUnassigned": "Étiquette retirée de l'utilisateur", + "addPeople": "Ajouter des personnes", + "add": "Ajouter", + "subTags": "Sous-étiquettes", + "successfullyAssignedToPeople": "Étiquette attribuée avec succès", + "errorOccurredWhileLoadingMembers": "Erreur survenue lors du chargement des membres", + "userName": "Nom d'utilisateur", + "actions": "Actions", + "noOneSelected": "Personne sélectionnée", + "assignToTags": "Attribuer aux étiquettes", + "removeFromTags": "Retirer des étiquettes", + "assign": "Attribuer", + "remove": "Retirer", + "successfullyAssignedToTags": "Attribué aux étiquettes avec succès", + "successfullyRemovedFromTags": "Retiré des étiquettes avec succès", + "errorOccurredWhileLoadingOrganizationUserTags": "Erreur lors du chargement des étiquettes de l'organisation", + "errorOccurredWhileLoadingSubTags": "Une erreur s'est produite lors du chargement des sous-étiquettes", + "removeUserTag": "Supprimer l'étiquette", + "removeUserTagMessage": "Voulez-vous supprimer cette étiquette ? Cela supprimera toutes les sous-étiquettes et toutes les associations.", + "tagDetails": "Détails de l'étiquette", + "tagName": "Nom de l'étiquette", + "tagUpdationSuccess": "Étiquette mise à jour avec succès", + "tagRemovalSuccess": "Étiquette supprimée avec succès", + "noTagSelected": "Aucune étiquette sélectionnée", + "changeNameToEdit": "Modifiez le nom pour faire une mise à jour", + "selectTag": "Sélectionner l'étiquette", + "collapse": "Réduire", + "expand": "Développer", + "tagNamePlaceholder": "Écrire le nom de l'étiquette", + "allTags": "Toutes les étiquettes", + "noMoreMembersFound": "Aucun autre membre trouvé" + }, + "userListCard": { + "addAdmin": "Ajouter un administrateur", + "addedAsAdmin": "L'utilisateur est ajouté en tant qu'administrateur.", + "joined": "Rejoint", + "talawaApiUnavailable": "API Talawa indisponible" + }, + "orgAdminListCard": { + "remove": "Retirer", + "removeAdmin": "Supprimer l'administrateur", + "removeAdminMsg": "Voulez-vous supprimer cet administrateur ?", + "adminRemoved": "L'administrateur est supprimé.", + "joined": "Rejoint", + "no": "Non", + "yes": "Oui", + "talawaApiUnavailable": "API Talawa indisponible" + }, + "orgPeopleListCard": { + "remove": "Retirer", + "removeMember": "Supprimer un membre", + "removeMemberMsg": "Voulez-vous supprimer ce membre ?", + "memberRemoved": "Le membre est supprimé", + "joined": "Rejoint", + "no": "Non", + "yes": "Oui", + "talawaApiUnavailable": "API Talawa indisponible" + }, + "organizationEvents": { + "title": "Événements", + "filterByTitle": "Filtrer par titre", + "filterByLocation": "Filtrer par emplacement", + "filterByDescription": "Filtrer par description", + "addEvent": "Ajouter un évènement", + "eventDetails": "Détails de l'évènement", + "eventTitle": "Titre", + "startTime": "Heure de début", + "endTime": "Heure de fin", + "allDay": "Toute la journée", + "recurringEvent": "Événement récurrent", + "searchMemberName": "Rechercher le nom du membre", + "isPublic": "est public", + "isRegistrable": "Est enregistrable", + "createEvent": "Créer un évènement", + "enterFilter": "Entrer le filtre", + "enterTitle": "Entrez le titre", + "enterDescrip": "Entrez la description", + "eventLocation": "Entrez l'emplacement", + "searchEventName": "Rechercher le nom de l'événement", + "eventType": "Type d'événement", + "eventCreated": "Toutes nos félicitations! ", + "customRecurrence": "Récurrence personnalisée", + "repeatsEvery": "Se répète tous les", + "repeatsOn": "Répétition activée", + "ends": "Prend fin", + "never": "Jamais", + "on": "Sur", + "after": "Après", + "occurences": "événements", + "events": "Événements", + "description": "Description", + "location": "Emplacement", + "startDate": "Date de début", + "endDate": "Date de fin", + "talawaApiUnavailable": "API Talawa indisponible", + "done": "Fait" + }, + "organizationActionItems": { + "actionItemCategory": "Catégorie de l'Action", + "actionItemDetails": "Détails de l'Action", + "actionItemCompleted": "Action Terminée", + "assignee": "Attribué à", + "assigner": "Assignateur", + "assignmentDate": "Date d'Attribution", + "active": "Actif", + "clearFilters": "Effacer les Filtres", + "completionDate": "Date de Complétion", + "createActionItem": "Créer une Action", + "deleteActionItem": "Supprimer l'Action", + "deleteActionItemMsg": "Voulez-vous supprimer cette action?", + "details": "Détails", + "dueDate": "Date d'Échéance", + "earliest": "Le Plus Ancien", + "editActionItem": "Modifier l'Action", + "isCompleted": "Terminé", + "latest": "Le Plus Récent", + "makeActive": "Rendre Actif", + "noActionItems": "Aucune Action", + "options": "Options", + "preCompletionNotes": "Notes Pré-Complétion", + "actionItemActive": "Action Active", + "markCompletion": "Marquer comme Terminé", + "actionItemStatus": "État de l'Action", + "postCompletionNotes": "Notes Post-Complétion", + "selectActionItemCategory": "Sélectionnez une Catégorie d'Action", + "selectAssignee": "Sélectionner un Attribué", + "status": "Statut", + "successfulCreation": "Action créée avec succès", + "successfulUpdation": "Action mise à jour avec succès", + "successfulDeletion": "Action supprimée avec succès", + "title": "Actions", + "category": "Catégorie", + "allottedHours": "Heures Attribuées", + "latestDueDate": "Date d'Échéance la Plus Récente", + "earliestDueDate": "Date d'Échéance la Plus Ancienne", + "updateActionItem": "Mettre à Jour l'Action", + "noneUpdated": "Aucun champ n'a été mis à jour", + "updateStatusMsg": "Voulez-vous vraiment marquer cette action comme en attente?", + "close": "Fermer", + "eventActionItems": "Actions de l'Événement", + "no": "Non", + "yes": "Oui", + "individuals": "Individus", + "groups": "Groupes", + "assignTo": "Attribuer à", + "volunteers": "Bénévoles", + "volunteerGroups": "Groupes de Bénévoles" + }, + "organizationAgendaCategory": { + "agendaCategoryDetails": "Détails de la catégorie d'ordre du jour", + "updateAgendaCategory": "Mettre à jour la catégorie d'ordre du jour", + "title": "Catégories d'ordre du jour", + "name": "Catégorie", + "description": "Description", + "createdBy": "Créé par", + "options": "Options", + "createAgendaCategory": "Créer une catégorie d'ordre du jour", + "noAgendaCategories": "Aucune catégorie d'ordre du jour", + "update": "Mettre à jour", + "agendaCategoryCreated": "Catégorie d'ordre du jour créée avec succès", + "agendaCategoryUpdated": "Catégorie d'ordre du jour mise à jour avec succès", + "agendaCategoryDeleted": "Catégorie d'ordre du jour supprimée avec succès", + "deleteAgendaCategory": "Supprimer la catégorie d'ordre du jour", + "deleteAgendaCategoryMsg": "Souhaitez-vous supprimer cette catégorie d'ordre du jour ?" + }, + "agendaItems": { + "agendaItemDetails": "Détails du point de l'ordre du jour", + "updateAgendaItem": "Mettre à jour le point de l'ordre du jour", + "title": "Titre", + "enterTitle": "Entrer le titre", + "sequence": "Ordre", + "description": "Description", + "enterDescription": "Entrer la description", + "category": "Catégorie de l'ordre du jour", + "attachments": "Pièces jointes", + "attachmentLimit": "Ajouter un fichier image ou vidéo jusqu'à 10 Mo", + "fileSizeExceedsLimit": "La taille du fichier dépasse la limite de 10 Mo", + "urls": "URL", + "url": "Ajouter un lien vers l'URL", + "enterUrl": "https://example.com", + "invalidUrl": "Veuillez saisir une URL valide", + "link": "Lien", + "createdBy": "Créé par", + "regular": "Régulier", + "note": "Note", + "duration": "Durée", + "enterDuration": "mm:ss", + "options": "Options", + "createAgendaItem": "Créer un point à l'ordre du jour", + "noAgendaItems": "Aucun point à l'ordre du jour", + "selectAgendaItemCategory": "Sélectionner une catégorie de point de l'ordre du jour", + "update": "Mettre à jour", + "delete": "Supprimer", + "agendaItemCreated": "Point de l'ordre du jour créé avec succès", + "agendaItemUpdated": "Point de l'ordre du jour mis à jour avec succès", + "agendaItemDeleted": "Point de l'ordre du jour supprimé avec succès", + "deleteAgendaItem": "Supprimer le point de l'ordre du jour", + "deleteAgendaItemMsg": "Voulez-vous supprimer ce point de l'ordre du jour ?" + }, + "eventListCard": { + "deleteEvent": "Supprimer l'événement", + "deleteEventMsg": "Voulez-vous supprimer cet événement ?", + "editEvent": "Modifier l'événement", + "eventTitle": "Titre", + "alreadyRegistered": "Déjà enregistré", + "startTime": "Heure de début", + "endTime": "Heure de fin", + "allDay": "Toute la journée", + "recurringEvent": "Événement récurrent", + "isPublic": "est public", + "isRegistrable": "Est enregistrable", + "updatePost": "Mettre à jour le message", + "eventDetails": "Détails de l'évènement", + "eventDeleted": "Événement supprimé avec succès.", + "eventUpdated": "Événement mis à jour avec succès.", + "thisInstance": "Cette instance", + "thisAndFollowingInstances": "Instances présentes et suivantes", + "allInstances": "Toutes les instances", + "customRecurrence": "Récurrence personnalisée", + "repeatsEvery": "Se répète tous les", + "repeatsOn": "Répétition activée", + "ends": "Prend fin", + "never": "Jamais", + "on": "Sur", + "after": "Après", + "occurences": "événements", + "location": "Lieu", + "no": "Non", + "yes": "Oui", + "description": "Description", + "startDate": "Date de début", + "endDate": "Date de fin", + "registerEvent": "Inscrire à l'événement", + "close": "Fermer", + "talawaApiUnavailable": "API Talawa non disponible", + "done": "Terminé" + }, + "funds": { + "title": "Fonds", + "createFund": "Créer un fonds", + "fundName": "Nom du fonds", + "fundId": "ID de référence du fonds", + "taxDeductible": "Déductible d'impôt", + "default": "Par défaut", + "archived": "Archivé", + "fundCreate": "Créer un fonds", + "fundUpdate": "Mettre à jour le fonds", + "fundDelete": "Supprimer le fonds", + "searchByName": "Rechercher par nom", + "noFundsFound": "Aucun fonds trouvé", + "createdBy": "Créé par", + "createdOn": "Créé le", + "status": "Statut", + "fundCreated": "Fonds créé avec succès", + "fundUpdated": "Fonds mis à jour avec succès", + "fundDeleted": "Fonds supprimé avec succès", + "deleteFundMsg": "Êtes-vous sûr de vouloir supprimer ce fonds ?", + "createdLatest": "Créé le plus récemment", + "createdEarliest": "Créé le plus tôt", + "viewCampaigns": "Voir les campagnes" + }, + "fundCampaign": { + "title": "Campagnes de collecte de fonds", + "campaignName": "Nom de la campagne", + "campaignOptions": "Options", + "fundingGoal": "Objectif de financement", + "addCampaign": "Ajouter une campagne", + "createdCampaign": "Campagne créée avec succès", + "updatedCampaign": "Campagne mise à jour avec succès", + "deletedCampaign": "Campagne supprimée avec succès", + "deleteCampaignMsg": "Êtes-vous sûr de vouloir supprimer cette campagne ?", + "noCampaigns": "Aucune campagne trouvée", + "createCampaign": "Créer une campagne", + "updateCampaign": "Mettre à jour la campagne", + "deleteCampaign": "Supprimer la campagne", + "currency": "Devise", + "selectCurrency": "Sélectionner la devise", + "searchFullName": "Rechercher par nom", + "viewPledges": "Voir les promesses de dons", + "noCampaignsFound": "Aucune campagne trouvée", + "latestEndDate": "Dernière date de fin", + "earliestEndDate": "Date de fin la plus ancienne", + "lowestGoal": "Objectif le plus bas", + "highestGoal": "Objectif le plus élevé" + }, + "pledges": { + "title": "Engagements de campagne de financement", + "pledgeAmount": "Montant de la promesse de don", + "pledgeOptions": "Possibilités", + "pledgeCreated": "Engagement créé avec succès", + "pledgeUpdated": "Engagement mis à jour avec succès", + "pledgeDeleted": "Engagement supprimé avec succès", + "addPledge": "Ajouter un engagement", + "createPledge": "Créer un engagement", + "currency": "Devise", + "selectCurrency": "Sélectionnez la devise", + "updatePledge": "Engagement de mise à jour", + "deletePledge": "Supprimer l'engagement", + "amount": "Montant", + "editPledge": "Modifier l'engagement", + "deletePledgeMsg": "Etes-vous sûr de vouloir supprimer cet engagement ?", + "noPledges": "Aucun engagement trouvé", + "searchPledger": "Rechercher par les bailleurs de fonds", + "highestAmount": "Montant le plus élevé", + "lowestAmount": "Montant le plus bas", + "latestEndDate": "Date de fin la plus récente", + "earliestEndDate": "Date de fin la plus proche", + "campaigns": "Campagnes", + "pledges": "Promesses de dons", + "endsOn": "Se termine le", + "raisedAmount": "Montant collecté", + "pledgedAmount": "Montant promis", + "startDate": "Date de début", + "endDate": "Date de fin" + }, + "orgPost": { + "title": "Des postes", + "searchPost": "Rechercher un article", + "posts": "Des postes", + "createPost": "Créer un message", + "postDetails": "Détails du message", + "postTitle1": "Écrivez le titre du message", + "postTitle": "Titre", + "addMedia": "Télécharger des médias", + "information": "Information", + "information1": "Écrire les informations du message", + "addPost": "Ajouter un message", + "searchTitle": "Recherche par titre", + "searchText": "Recherche par texte", + "ptitle": "Titre de l'article", + "postDes": "De quoi veux-tu parler ?", + "Title": "Titre", + "Text": "Texte", + "searchBy": "Recherché par", + "Oldest": "Le plus âgé en premier", + "Latest": "Dernier premier", + "sortPost": "Trier le message", + "tag": " Votre navigateur ne prend pas en charge la balise vidéo", + "postCreatedSuccess": "Toutes nos félicitations! ", + "pinPost": "Épingler le message", + "Next": "Page suivante", + "Previous": "Page précédente", + "cancel": "Annuler" + }, + "postNotFound": { + "post": "Poste", + "not found!": "Pas trouvé!", + "organization": "Organisation", + "post not found!": "Message introuvable !", + "organization not found!": "Organisation introuvable !" + }, + "userNotFound": { + "not found!": "Pas trouvé!", + "roles": "Les rôles", + "user not found!": "Utilisateur non trouvé!", + "member not found!": "Membre introuvable!", + "admin not found!": "Administrateur introuvable !", + "roles not found!": "Rôles introuvables !", + "user": "Utilisateur" + }, + "orgPostCard": { + "author": "Auteur", + "imageURL": "URL de l'image", + "videoURL": "URL de la vidéo", + "deletePost": "Supprimer le message", + "deletePostMsg": "Voulez-vous supprimer ce message?", + "editPost": "Modifier le message", + "postTitle": "Titre", + "postTitle1": "Modifier le titre du message", + "information1": "Modifier les informations du message", + "information": "Information", + "image": "Image", + "video": "Vidéo", + "updatePost": "Mettre à jour le message", + "postDeleted": "Message supprimé avec succès.", + "postUpdated": "Post mis à jour avec succès.", + "tag": " Votre navigateur ne prend pas en charge la balise vidéo", + "pin": "Épingler le message", + "edit": "Modifier", + "no": "Non", + "yes": "Oui", + "close": "Fermer", + "talawaApiUnavailable": "API Talawa non disponible" + }, + "blockUnblockUser": { + "title": "Bloquer/débloquer un utilisateur", + "pageName": "Bloquer/Débloquer", + "listOfUsers": "Liste des utilisateurs qui ont spammé", + "block_unblock": "Bloquer/Débloquer", + "unblock": "Débloquer", + "block": "Bloc", + "orgName": "Entrez le nom", + "blockedSuccessfully": "Utilisateur bloqué avec succès", + "Un-BlockedSuccessfully": "Utilisateur débloqué avec succès", + "allMembers": "Tous les membres", + "blockedUsers": "Utilisateurs bloqués", + "searchByFirstName": "Recherche par prénom", + "searchByLastName": "Rechercher par nom de famille", + "noSpammerFound": "Aucun spammeur trouvé", + "searchByName": "Rechercher par nom", + "name": "Nom", + "email": "Email", + "talawaApiUnavailable": "API Talawa non disponible", + "noResultsFoundFor": "Aucun résultat trouvé pour" + }, + "eventManagement": { + "title": "Gestion d'événements", + "dashboard": "Tableau de bord", + "registrants": "Inscrits", + "attendance": "Présence", + "actions": "Actions", + "agendas": "Ordres du jour", + "statistics": "Statistiques", + "to": "À", + "volunteers": "Bénévoles" + }, + "forgotPassword": { + "title": "Talawa Mot de passe oublié", + "registeredEmail": "Email enregistré", + "getOtp": "Obtenir OTP", + "enterOtp": "Entrez OTP", + "enterNewPassword": "Entrez un nouveau mot de passe", + "cofirmNewPassword": "Confirmer le nouveau mot de passe", + "changePassword": "Changer le mot de passe", + "backToLogin": "Retour connexion", + "userOtp": "par exemple. ", + "emailNotRegistered": "L'e-mail n'est pas enregistré.", + "errorSendingMail": "Erreur lors de l'envoi du courrier.", + "passwordMismatches": "Mot de passe et Confirmer les incompatibilités de mot de passe.", + "passwordChanges": "Le mot de passe a été modifié avec succès.", + "OTPsent": "OTP est envoyé à votre adresse e-mail enregistrée.", + "forgotPassword": "Mot de passe oublié", + "password": "Mot de passe", + "talawaApiUnavailable": "API Talawa non disponible" + }, + "pageNotFound": { + "404": "404", + "title": "404 introuvable", + "talawaAdmin": "Administrateur Talawa", + "talawaUser": "Utilisateur Talawa", + "notFoundMsg": "Oops! ", + "backToHome": "De retour à la maison" + }, + "orgContribution": { + "title": "Contributions de Talawa", + "filterByName": "Filtrer par nom", + "filterByTransId": "Filtrer par Trans. ", + "recentStats": "Statistiques récentes", + "contribution": "Contribution", + "orgname": "Entrez le nom", + "searchtransaction": "Entrez l'ID de la transaction" + }, + "contriStats": { + "recentContribution": "Contribution récente", + "highestContribution": "Contribution la plus élevée", + "totalContribution": "Contribution totale" + }, + "orgContriCards": { + "date": "Date", + "transactionId": "identifiant de transaction", + "amount": "Montant" + }, + "orgSettings": { + "title": "Paramètres", + "general": "Général", + "actionItemCategories": "Catégories d'éléments d'action", + "updateOrganization": "Mettre à jour l'organisation", + "seeRequest": "Voir la demande", + "noData": "Aucune donnée", + "otherSettings": "Autres paramètres", + "changeLanguage": "Changer de langue", + "manageCustomFields": "Gérer les champs personnalisés", + "agendaItemCategories": "Catégories d'éléments d'agenda" + }, + "deleteOrg": { + "deleteOrganization": "Supprimer l'organisation", + "deleteSampleOrganization": "Supprimer un exemple d'organisation", + "deleteMsg": "Voulez-vous supprimer cette organisation ?", + "confirmDelete": "Confirmation de la suppression", + "longDelOrgMsg": "En cliquant sur le bouton Supprimer l'organisation, l'organisation sera définitivement supprimée ainsi que ses événements, balises et toutes les données associées.", + "successfullyDeletedSampleOrganization": "Exemple d'organisation supprimé avec succès", + "cancel": "Annuler" + }, + "userUpdate": { + "appLanguageCode": "Langage par défaut", + "userType": "Type d'utilisateur", + "firstName": "Prénom", + "lastName": "Nom de famille", + "email": "Email", + "password": "Mot de passe", + "admin": "Admin", + "superAdmin": "Super Admin", + "displayImage": "Image d'affichage", + "saveChanges": "Enregistrer les modifications", + "cancel": "Annuler" + }, + "userPasswordUpdate": { + "previousPassword": "Mot de passe précédent", + "newPassword": "nouveau mot de passe", + "confirmNewPassword": "Confirmer le nouveau mot de passe", + "passCantBeEmpty": "Le mot de passe ne peut pas être vide", + "passNoMatch": "Le nouveau mot de passe et la confirmation du mot de passe ne correspondent pas.", + "saveChanges": "Enregistrer les modifications", + "cancel": "Annuler" + }, + "orgDelete": { + "deleteOrg": "Supprimer l'organisation" + }, + "membershipRequest": { + "accept": "Accepter", + "reject": "Rejeter", + "memberAdded": "c'est accepté", + "joined": "Rejoint", + "talawaApiUnavailable": "API Talawa non disponible" + }, + "orgUpdate": { + "city": "Ville", + "countryCode": "Code postal", + "line1": "Ligne 1", + "line2": "Ligne 2", + "postalCode": "Code Postal", + "dependentLocality": "Localité dépendante", + "sortingCode": "Code de tri", + "state": "État/Province", + "userRegistrationRequired": "Inscription de l'utilisateur requise", + "isVisibleInSearch": "Visible dans la recherche", + "enterNameOrganization": "Entrez le nom de l'organisation", + "successfulUpdated": "Organisation mise à jour avec succès", + "name": "Nom", + "description": "Description", + "location": "Lieu", + "address": "Adresse", + "displayImage": "Image d'affichage", + "saveChanges": "Enregistrer les modifications", + "cancel": "Annuler", + "talawaApiUnavailable": "API Talawa non disponible" + }, + "addOnRegister": { + "addNew": "Ajouter un nouveau", + "addPlugin": "Ajouter un plugin", + "pluginName": "Nom du plugin", + "creatorName": "Nom du créateur", + "pluginDesc": "Description du plugin", + "pName": "Ex : Dons", + "cName": "Ex : John Doe", + "pDesc": "Ce plugin active l'interface utilisateur pour", + "close": "Fermer", + "register": "Inscription" + }, + "addOnStore": { + "title": "Ajouter sur la boutique", + "searchName": "Ex : Dons", + "search": "Rechercher", + "enable": "Activé", + "disable": "Désactivé", + "pHeading": "Plugins", + "install": "installée", + "available": "Disponible", + "pMessage": "Le plugin n'existe pas", + "filter": "filtrer" + }, + "addOnEntry": { + "enable": "Activé", + "install": "Installer", + "uninstall": "Désinstaller", + "uninstallMsg": "Cette fonctionnalité est désormais supprimée de votre organisation", + "installMsg": "Cette fonctionnalité est désormais activée dans votre organisation" + }, + "memberDetail": { + "title": "Détails de l'utilisateur", + "addAdmin": "Ajouter un administrateur", + "noeventsAttended": "Aucun événement assisté", + "alreadyIsAdmin": "Le membre est déjà un administrateur", + "organizations": "Organisations", + "events": "Événements", + "role": "Rôle", + "createdOn": "Créé sur", + "main": "Principal", + "firstName": "Prénom", + "lastName": "Nom de famille", + "language": "Langue", + "gender": "Genre", + "birthDate": "Date de naissance", + "educationGrade": "Niveau d'éducation", + "employmentStatus": "Statut d'emploi", + "maritalStatus": "État civil", + "phone": "Téléphone", + "countryCode": "Code postal", + "state": "État", + "city": "Ville", + "personalInfoHeading": "Informations personnelles", + "eventsAttended": "Événements attenus", + "viewAll": "Voir tout", + "contactInfoHeading": "Coordonnées", + "actionsHeading": "Actions", + "personalDetailsHeading": "Détails du profil", + "appLanguageCode": "Choisissez la langue", + "deleteUser": "Supprimer l'utilisateur", + "pluginCreationAllowed": "Création de plugin autorisée", + "created": "Créé", + "adminForOrganizations": "Administrateur pour les organisations", + "membershipRequests": "Demandes d'adhésion", + "adminForEvents": "Administrateur pour les événements", + "addedAsAdmin": "L'utilisateur est ajouté en tant qu'administrateur.", + "userType": "Type d'utilisateur", + "email": "Email", + "displayImage": "Image d'affichage", + "address": "Adresse", + "delete": "Supprimer", + "saveChanges": "Enregistrer les modifications", + "joined": "Rejoint", + "talawaApiUnavailable": "API Talawa non disponible", + "unassignUserTag": "Désassigner l'étiquette", + "unassignUserTagMessage": "Voulez-vous retirer l'étiquette de cet utilisateur?", + "successfullyUnassigned": "Étiquette retirée de l'utilisateur", + "tagsAssigned": "étiquettes assignées", + "noTagsAssigned": "Aucune étiquette assignée" + }, + "people": { + "title": "Personnes", + "searchUsers": "Rechercher des utilisateurs" + }, + "userLogin": { + "login": "Se connecter", + "loginIntoYourAccount": "Connectez-vous à votre compte", + "invalidDetailsMessage": "Veuillez entrer un email et un mot de passe valides.", + "notAuthorised": "Désolé! ", + "invalidCredentials": "Les informations d'identification saisies sont incorrectes. ", + "forgotPassword": "Mot de passe oublié", + "emailAddress": "Adresse email", + "enterEmail": "Entrer l'email", + "password": "Mot de passe", + "enterPassword": "Entrer le mot de passe", + "register": "Inscription", + "talawaApiUnavailable": "API Talawa non disponible" + }, + "userRegister": { + "enterFirstName": "Entrez votre prénom", + "enterLastName": "Entrez votre nom de famille", + "enterConfirmPassword": "Entrez votre mot de passe pour confirmer", + "alreadyhaveAnAccount": "Vous avez déjà un compte?", + "login": "Se connecter", + "afterRegister": "Enregistré avec succès. ", + "passwordNotMatch": "Le mot de passe ne correspond pas. ", + "invalidDetailsMessage": "Veuillez saisir des informations valides.", + "register": "Inscription", + "firstName": "Prénom", + "lastName": "Nom de famille", + "emailAddress": "Adresse email", + "enterEmail": "Entrer l'email", + "password": "Mot de passe", + "enterPassword": "Entrer le mot de passe", + "confirmPassword": "Confirmer le mot de passe", + "talawaApiUnavailable": "API Talawa non disponible" + }, + "userNavbar": { + "talawa": "Talawa", + "home": "Maison", + "people": "Personnes", + "events": "Événements", + "chat": "Chat", + "donate": "Faire un don", + "language": "Langue", + "settings": "Paramètres", + "logout": "Déconnexion", + "close": "Fermer" + }, + "userOrganizations": { + "allOrganizations": "Toutes les organisations", + "joinedOrganizations": "Organisations rejointes", + "createdOrganizations": "Organisations créées", + "selectOrganization": "Sélectionnez une organisation", + "searchUsers": "Rechercher des utilisateurs", + "nothingToShow": "Rien à montrer ici.", + "organizations": "Organisations", + "search": "Rechercher", + "filter": "Filtrer", + "searchByName": "Rechercher par nom", + "searchOrganizations": "Rechercher Organisations" + }, + "userSidebarOrg": { + "yourOrganizations": "Vos organisations", + "noOrganizations": "Vous n'avez encore rejoint aucune organisation.", + "viewAll": "Voir tout", + "talawaUserPortal": "Portail utilisateur Talawa", + "my organizations": "Mes organisations", + "communityProfile": "Profil de la communauté", + "users": "utilisateurs", + "requests": "demandes", + "logout": "se déconnecter", + "settings": "paramètres", + "chat": "discussion", + "menu": "Menu" + }, + "organizationSidebar": { + "viewAll": "Voir tout", + "events": "Événements", + "noEvents": "Aucun événement à afficher", + "noMembers": "Aucun membre à afficher", + "members": "Membres" + }, + "postCard": { + "likes": "Aime", + "comments": "commentaires", + "viewPost": "Voir le message", + "editPost": "Modifier le message", + "postedOn": "Publié le {{date}}" + }, + "home": { + "posts": "Des postes", + "post": "Poste", + "title": "Titre", + "textArea": "Quelque chose vous préoccupe ?", + "feed": "Alimentation", + "loading": "Chargement", + "pinnedPosts": "Messages épinglés", + "yourFeed": "Votre flux", + "nothingToShowHere": "Rien à montrer ici", + "somethingOnYourMind": "Quelque chose vous préoccupe ?", + "addPost": "Ajouter un message", + "startPost": "Démarrer un message", + "media": "Médias", + "event": "Événement", + "article": "Article", + "postNowVisibleInFeed": "Le post est maintenant visible dans le fil d'actualité" + }, + "eventAttendance": { + "historical_statistics": "Statistiques historiques", + "Search member": "Rechercher un membre", + "Member Name": "Nom du membre", + "Status": "Statut", + "Events Attended": "Événements assistés", + "Task Assigned": "Tâche assignée", + "Member": "Membre", + "Admin": "Administrateur", + "loading": "Chargement...", + "noAttendees": "Aucun participant trouvé" + }, + "onSpotAttendee": { + "title": "Participant sur place", + "enterFirstName": "Entrez le prénom", + "enterLastName": "Entrez le nom de famille", + "enterEmail": "Entrez l'e-mail", + "enterPhoneNo": "Entrez le numéro de téléphone", + "selectGender": "Sélectionnez le sexe", + "invalidDetailsMessage": "Veuillez remplir tous les champs obligatoires", + "orgIdMissing": "L'ID de l'organisation est manquant. Veuillez réessayer.", + "attendeeAddedSuccess": "Participant ajouté avec succès!", + "addAttendee": "Ajouter", + "phoneNumber": "Numéro de téléphone", + "addingAttendee": "Ajout en cours...", + "male": "Homme", + "female": "Femme", + "other": "Autre" + }, + "settings": { + "eventAttended": "Événements Assistés", + "noeventsAttended": "Aucun événement assisté", + "profileSettings": "Paramètres de profil", + "gender": "Genre", + "phoneNumber": "Numéro de téléphone", + "chooseFile": "Choisir le fichier", + "birthDate": "Date de naissance", + "grade": "Niveau d'éducation", + "empStatus": "Statut d'emploi", + "maritalStatus": "État civil", + "state": "Ville/État", + "country": "Pays", + "resetChanges": "Réinitialiser les modifications", + "profileDetails": "Détails du profil", + "deleteUserMessage": "En cliquant sur le bouton Supprimer l'utilisateur, votre utilisateur sera définitivement supprimé ainsi que ses événements, balises et toutes les données associées.", + "copyLink": "Copier le lien du profil", + "deleteUser": "Supprimer l'utilisateur", + "otherSettings": "Autres réglages", + "changeLanguage": "Changer de langue", + "sgender": "Sélectionnez le sexe", + "gradePlaceholder": "Entrez la note", + "sEmpStatus": "Sélectionnez le statut d'emploi", + "female": "Femelle", + "male": "Mâle", + "employed": "Employé", + "other": "Autre", + "sMaritalStatus": "Sélectionnez l'état civil", + "unemployed": "Sans emploi", + "married": "Marié", + "single": "Célibataire", + "widowed": "Veuf", + "divorced": "Divorcé", + "engaged": "Engagé", + "separated": "Séparé", + "grade1": "1re année", + "grade2": "2e année", + "grade3": "3e année", + "grade4": "Niveau 4", + "grade5": "Niveau 5", + "grade6": "6ème année", + "grade7": "7e année", + "grade8": "8e année", + "grade9": "9e année", + "grade10": "10 e année", + "grade11": "11e année", + "grade12": "12 e année", + "graduate": "Diplômé", + "kg": "KG", + "preKg": "Pré-KG", + "noGrade": "Aucune note", + "fullTime": "À temps plein", + "partTime": "À temps partiel", + "selectCountry": "Choisissez un pays", + "enterState": "Entrez la ville ou l'état", + "settings": "Paramètres", + "firstName": "Prénom", + "lastName": "Nom de famille", + "emailAddress": "Adresse email", + "displayImage": "Image d'affichage", + "address": "Adresse", + "saveChanges": "Enregistrer les modifications", + "joined": "Rejoint" + }, + "donate": { + "title": "Des dons", + "donations": "Des dons", + "searchDonations": "Rechercher des dons", + "donateForThe": "Faites un don pour le", + "amount": "Montant", + "yourPreviousDonations": "Vos dons précédents", + "donate": "Faire un don", + "nothingToShow": "Rien à montrer ici.", + "success": "Don réussi", + "invalidAmount": "Veuillez saisir une valeur numérique pour le montant du don.", + "donationAmountDescription": "Veuillez saisir la valeur numérique du montant du don.", + "donationOutOfRange": "Le montant du don doit être compris entre {{min}} et {{max}}.", + "donateTo": "Faire un don à" + }, + "userEvents": { + "title": "Événements", + "nothingToShow": "Rien à montrer ici.", + "createEvent": "Créer un évènement", + "recurring": "Événement récurrent", + "startTime": "Heure de début", + "endTime": "Heure de fin", + "listView": "Vue en liste", + "calendarView": "Vue du calendrier", + "allDay": "Toute la journée", + "eventCreated": "Événement créé et publié avec succès.", + "eventDetails": "Détails de l'évènement", + "eventTitle": "Titre", + "enterTitle": "Entrez le titre", + "enterDescription": "Entrez la description", + "enterLocation": "Entrez l'emplacement", + "publicEvent": "est public", + "registerable": "Est enregistrable", + "monthlyCalendarView": "Calendrier mensuel", + "yearlyCalendarView": "Calendrier annuel", + "search": "Rechercher", + "cancel": "Annuler", + "create": "Créer", + "eventDescription": "Description de l'événement", + "eventLocation": "Lieu de l'événement", + "startDate": "Date de début", + "endDate": "Date de fin" + }, + "userEventCard": { + "starts": "Départs", + "ends": "Prend fin", + "creator": "Créateur", + "alreadyRegistered": "Déjà enregistré", + "location": "Lieu", + "register": "Inscription" + }, + "advertisement": { + "title": "Annonces", + "activeAds": "Campagnes actives", + "archievedAds": "Campagnes terminées", + "pMessage": "Annonces non présentes pour cette campagne.", + "validLink": "Le lien est valide", + "invalidLink": "Le lien n'est pas valide", + "Rname": "Entrez le nom de l'annonce", + "Rtype": "Sélectionnez le type de publicité", + "Rmedia": "Fournir du contenu multimédia à afficher", + "RstartDate": "Sélectionnez la date de début", + "RendDate": "Sélectionnez la date de fin", + "RClose": "Ferme la fenêtre", + "addNew": "Créer une nouvelle annonce", + "EXname": "Ex. ", + "EXlink": "Ex. ", + "createAdvertisement": "Créer une publicité", + "deleteAdvertisement": "Supprimer la publicité", + "deleteAdvertisementMsg": "Voulez-vous supprimer cette publicité ?", + "view": "Voir", + "editAdvertisement": "Modifier l'annonce", + "advertisementDeleted": "Publicité supprimée avec succès.", + "endDateGreaterOrEqual": "La date de fin doit être supérieure ou égale à la date de début", + "advertisementCreated": "Publicité créée avec succès.", + "pHeading": "Titre principal", + "delete": "Supprimer", + "close": "Fermer", + "no": "Non", + "yes": "Oui", + "edit": "Modifier", + "saveChanges": "Enregistrer les modifications", + "endOfResults": "Fin des résultats" + }, + "userChat": { + "chat": "Chat", + "contacts": "Contacts", + "search": "rechercher", + "messages": "messages" + }, + "userChatRoom": { + "selectContact": "Sélectionnez un contact pour démarrer la conversation", + "sendMessage": "Envoyer le message" + }, + "orgProfileField": { + "loading": "Chargement...", + "noCustomField": "Aucun champ personnalisé disponible", + "customFieldName": "Nom de domaine", + "enterCustomFieldName": "Entrez le nom du champ", + "customFieldType": "Type de champ", + "Remove Custom Field": "Supprimer le champ personnalisé", + "fieldSuccessMessage": "Champ ajouté avec succès", + "fieldRemovalSuccess": "Champ supprimé avec succès", + "String": "Chaîne", + "Boolean": "Booléen", + "Date": "Date", + "Number": "Nombre", + "saveChanges": "Enregistrer les modifications" + }, + "orgActionItemCategories": { + "enableButton": "Activer", + "disableButton": "Désactiver", + "updateActionItemCategory": "Mise à jour", + "actionItemCategoryName": "Nom", + "categoryDetails": "Détails de la catégorie", + "enterName": "Entrez le nom", + "successfulCreation": "Catégorie d'élément d'action créée avec succès", + "successfulUpdation": "Catégorie d'élément d'action mise à jour avec succès", + "sameNameConflict": "Veuillez changer le nom pour effectuer une mise à jour", + "categoryEnabled": "Catégorie d'élément d'action activée", + "categoryDisabled": "Catégorie d'élément d'action désactivée", + "noActionItemCategories": "Aucune catégorie d'élément d'action", + "status": "Statut", + "categoryDeleted": "Catégorie d'élément d'action supprimée avec succès", + "deleteCategory": "Supprimer la catégorie", + "deleteCategoryMsg": "Êtes-vous sûr de vouloir supprimer cette catégorie d'élément d'action ?", + "createButton": "Créer", + "editButton": "Modifier" + }, + "organizationVenues": { + "title": "Lieux", + "addVenue": "Ajouter un lieu", + "venueDetails": "Détails du lieu", + "venueName": "Nom du lieu", + "enterVenueName": "Entrez le nom du lieu", + "enterVenueDesc": "Entrez la description du lieu", + "capacity": "Capacité", + "enterVenueCapacity": "Entrez la capacité du site", + "image": "Image du lieu", + "uploadVenueImage": "Télécharger l'image du lieu", + "createVenue": "Créer un lieu", + "venueAdded": "Lieu ajouté avec succès", + "editVenue": "Mettre à jour le lieu", + "venueUpdated": "Les détails du lieu ont été mis à jour avec succès", + "sort": "Trier", + "highestCapacity": "Capacité la plus élevée", + "lowestCapacity": "Capacité la plus basse", + "noVenues": "Aucun lieu trouvé !", + "view": "Voir", + "venueTitleError": "Le titre du lieu ne peut pas être vide !", + "venueCapacityError": "La capacité doit être un nombre positif !", + "searchBy": "Recherché par", + "description": "Description", + "edit": "Modifier", + "delete": "Supprimer", + "name": "Nom", + "desc": "Description" + }, + "addMember": { + "title": "Ajouter un membre", + "addMembers": "Ajouter des membres", + "existingUser": "Utilisateur existant", + "newUser": "Nouvel utilisateur", + "searchFullName": "Rechercher par nom complet", + "enterFirstName": "Entrez votre prénom", + "enterLastName": "Entrer le nom de famille", + "enterConfirmPassword": "Entrez Confirmer le mot de passe", + "organization": "Organisation", + "invalidDetailsMessage": "Veuillez fournir tous les détails requis.", + "passwordNotMatch": "Les mots de passe ne correspondent pas.", + "addMember": "Ajouter un membre", + "firstName": "Prénom", + "lastName": "Nom de famille", + "emailAddress": "Adresse email", + "enterEmail": "Entrer l'email", + "password": "Mot de passe", + "enterPassword": "Entrer le mot de passe", + "confirmPassword": "Confirmer le mot de passe", + "cancel": "Annuler", + "create": "Créer", + "user": "Utilisateur", + "profile": "Profil" + }, + "eventActionItems": { + "title": "Éléments d'action", + "createActionItem": "Créer des éléments d'action", + "actionItemCategory": "Catégorie d'élément d'action", + "selectActionItemCategory": "Sélectionnez une catégorie d'élément d'action", + "selectAssignee": "Sélectionnez un responsable", + "preCompletionNotes": "Remarques", + "postCompletionNotes": "Notes d'achèvement", + "actionItemDetails": "Détails de l'action", + "dueDate": "Date d'échéance", + "completionDate": "Date d'achèvement", + "editActionItem": "Modifier l'élément d'action", + "deleteActionItem": "Supprimer l'élément d'action", + "deleteActionItemMsg": "Voulez-vous supprimer cette action ?", + "successfulDeletion": "Élément d'action supprimé avec succès", + "successfulCreation": "Élément d'action créé avec succès", + "successfulUpdation": "Élément d'action mis à jour avec succès", + "notes": "Remarques", + "assignee": "Cessionnaire", + "assigner": "Assigner", + "assignmentDate": "Date d'affectation", + "status": "Statut", + "actionItemActive": "Actif", + "actionItemStatus": "Statut de l'action", + "actionItemCompleted": "Élément d'action terminé", + "markCompletion": "Marquer l'achèvement", + "save": "Sauvegarder", + "yes": "Oui", + "no": "Non" + }, + "checkIn": { + "errorCheckingIn": "Erreur lors de l'enregistrement", + "checkedInSuccessfully": "Enregistrement réussi" + }, + "eventRegistrantsModal": { + "errorAddingAttendee": "Erreur lors de l'ajout du participant", + "errorRemovingAttendee": "Erreur lors de la suppression du participant" + }, + "userCampaigns": { + "title": "Campagnes de financement", + "searchByName": "Rechercher par nom...", + "searchBy": "Rechercher par", + "pledgers": "Contributeurs", + "campaigns": "Campagnes", + "myPledges": "Mes Promesses", + "lowestAmount": "Montant le plus bas", + "highestAmount": "Montant le plus élevé", + "lowestGoal": "Objectif le plus bas", + "highestGoal": "Objectif le plus élevé", + "latestEndDate": "Date de fin la plus tardive", + "earliestEndDate": "Date de fin la plus proche", + "addPledge": "Ajouter une promesse", + "viewPledges": "Voir les promesses", + "noPledges": "Aucune promesse trouvée", + "noCampaigns": "Aucune campagne trouvée" + }, + "userPledges": { + "title": "Mes Promesses" + }, + "eventVolunteers": { + "volunteers": "Bénévoles", + "volunteer": "Bénévole", + "volunteerGroups": "Groupes de Bénévoles", + "individuals": "Individus", + "groups": "Groupes", + "status": "Statut", + "noVolunteers": "Aucun Bénévole", + "noVolunteerGroups": "Aucun Groupe de Bénévoles", + "add": "Ajouter", + "mostHoursVolunteered": "Le Plus d'Heures de Bénévolat", + "leastHoursVolunteered": "Le Moins d'Heures de Bénévolat", + "accepted": "Accepté", + "addVolunteer": "Ajouter un Bénévole", + "removeVolunteer": "Supprimer le Bénévole", + "volunteerAdded": "Bénévole ajouté avec succès", + "volunteerRemoved": "Bénévole supprimé avec succès", + "volunteerGroupCreated": "Groupe de bénévoles créé avec succès", + "volunteerGroupUpdated": "Groupe de bénévoles mis à jour avec succès", + "volunteerGroupDeleted": "Groupe de bénévoles supprimé avec succès", + "removeVolunteerMsg": "Êtes-vous sûr de vouloir supprimer ce bénévole?", + "deleteVolunteerGroupMsg": "Êtes-vous sûr de vouloir supprimer ce groupe de bénévoles?", + "leader": "Chef", + "group": "Groupe", + "createGroup": "Créer un Groupe", + "updateGroup": "Mettre à Jour le Groupe", + "deleteGroup": "Supprimer le Groupe", + "volunteersRequired": "Bénévoles Requis", + "volunteerDetails": "Détails du Bénévole", + "hoursVolunteered": "Heures de Bénévolat", + "groupDetails": "Détails du Groupe", + "creator": "Créateur", + "requests": "Demandes", + "noRequests": "Aucune Demande", + "latest": "Le Plus Récent", + "earliest": "Le Plus Ancien", + "requestAccepted": "Demande acceptée avec succès", + "requestRejected": "Demande rejetée avec succès", + "details": "Détails", + "manageGroup": "Gérer le Groupe", + "mostVolunteers": "Le plus de bénévoles", + "leastVolunteers": "Le moins de bénévoles" + }, + "userVolunteer": { + "title": "Volontariat", + "name": "Titre", + "upcomingEvents": "Événements à Venir", + "requests": "Demandes", + "invitations": "Invitations", + "groups": "Groupes de Bénévoles", + "actions": "Actions", + "searchByName": "Rechercher par Nom", + "latestEndDate": "Date de Fin la Plus Récente", + "earliestEndDate": "Date de Fin la Plus Ancienne", + "noEvents": "Aucun Événement à Venir", + "volunteer": "Bénévole", + "volunteered": "A Bénévolé", + "join": "Rejoindre", + "joined": "Rejoint", + "searchByEventName": "Rechercher par Titre d'Événement", + "filter": "Filtrer", + "groupInvite": "Invitation de Groupe", + "individualInvite": "Invitation Individuelle", + "noInvitations": "Aucune Invitation", + "accept": "Accepter", + "reject": "Rejeter", + "receivedLatest": "Reçu le Plus Récemment", + "receivedEarliest": "Reçu en Premier", + "invitationAccepted": "Invitation acceptée avec succès", + "invitationRejected": "Invitation rejetée avec succès", + "volunteerSuccess": "Demande de bénévolat envoyée avec succès", + "recurring": "Récurrent", + "groupInvitationSubject": "Invitation à rejoindre le groupe de bénévoles", + "eventInvitationSubject": "Invitation à faire du bénévolat pour l'événement" + } +} diff --git a/public/locales/hi/common.json b/public/locales/hi/common.json new file mode 100644 index 0000000000..f312424f1d --- /dev/null +++ b/public/locales/hi/common.json @@ -0,0 +1,98 @@ +{ + "firstName": "पहला नाम", + "lastName": "उपनाम", + "searchByName": "नाम से खोजें", + "loading": "लोड हो रहा है...", + "endOfResults": "परिणाम का अंत", + "noResultsFoundFor": "का कोई परिणाम नहीं मिला ", + "edit": "संपादन करना", + "admins": "व्यवस्थापक", + "admin": "व्यवस्थापक", + "user": "उपयोगकर्ता", + "superAdmin": "सुपरएडमिन", + "members": "सदस्यों", + "logout": "लॉग आउट", + "login": "लॉग इन करें", + "register": "पंजीकरण करवाना", + "menu": "मेन्यू", + "settings": "समायोजन", + "users": "उपयोगकर्ताओं", + "requests": "अनुरोध", + "OR": "या", + "cancel": "रद्द करना", + "close": "बंद करना", + "create": "बनाएं", + "delete": "मिटाना", + "done": "हो गया", + "yes": "हाँ", + "no": "नहीं", + "filter": "फ़िल्टर", + "gender": "लिंग", + "search": "खोज", + "description": "विवरण", + "saveChanges": "परिवर्तनों को सुरक्षित करें", + "resetChanges": "परिवर्तनों को रीसेट करें", + "displayImage": "प्रदर्शन छवि", + "enterEmail": "ईमेल दर्ज करें", + "emailAddress": "मेल पता", + "email": "ईमेल", + "name": "नाम", + "desc": "विवरण", + "enterPassword": "पास वर्ड दर्ज करें", + "password": "पासवर्ड", + "confirmPassword": "पासवर्ड की पुष्टि कीजिये", + "forgotPassword": "पासवर्ड भूल गए ?", + "talawaAdminPortal": "तलावा एडमिन पोर्टल", + "address": "पता", + "location": "जगह", + "enterLocation": "स्थान दर्ज करें", + "joined": "में शामिल हो गए", + "startDate": "आरंभ करने की तिथि", + "endDate": "अंतिम तिथि", + "startTime": "समय शुरू", + "endTime": "अंत समय", + "My Organizations": "मेरे संगठन", + "Dashboard": "डैशबोर्ड", + "People": "लोग", + "Events": "कार्यक्रम", + "Venues": "स्थल", + "Action Items": "कार्य आइटम", + "Posts": "पोस्ट", + "Block/Unblock": "ब्लॉक/अनब्लॉक", + "Advertisement": "विज्ञापन", + "Funds": "निधि", + "Membership Requests": "सदस्यता अनुरोध", + "Plugins": "प्लगइन्स", + "Plugin Store": "प्लगइन स्टोर", + "Settings": "सेटिंग्स", + "createdOn": "बनाया गया", + "createdBy": "के द्वारा बनाया गया", + "usersRole": "उपयोगकर्ता की भूमिका", + "changeRole": "भूमिका बदलें", + "action": "क्रिया", + "removeUser": "उपयोगकर्ता हटाएं", + "remove": "हटाएं", + "viewProfile": "प्रोफ़ाइल देखें", + "profile": "प्रोफ़ाइल", + "noFiltersApplied": "कोई फ़िल्टर लागू नहीं हैं", + "manage": "प्रबंधित करें", + "searchResultsFor": "{{text}} के लिए खोज परिणाम", + "none": "कोई नहीं", + "sort": "क्रम से लगाना", + "Donate": "दान करें", + "addedSuccessfully": "{{item}} सफलतापूर्वक जोड़ा गया", + "updatedSuccessfully": "{{item}} सफलतापूर्वक अपडेट किया गया", + "removedSuccessfully": "{{item}} सफलतापूर्वक हटाया गया", + "sessionWarning": "आपका सत्र निष्क्रियता के कारण जल्द ही समाप्त हो जाएगा। कृपया अपने सत्र को बढ़ाने के लिए पृष्ठ के साथ बातचीत करें।", + "sessionLogOut": "निष्क्रियता के कारण आपका सत्र समाप्त हो गया है। कृपया जारी रखने के लिए पुनः लॉगिन करें।", + "successfullyUpdated": "सफलतापूर्वक अपडेट किया गया", + "all": "सभी", + "active": "सक्रिय", + "disabled": "अक्षम", + "pending": "लंबित", + "completed": "पूरा हुआ", + "late": "देर से", + "createdLatest": "नवीनतम बनाया गया", + "createdEarliest": "सबसे पहले बनाया गया", + "searchBy": "के द्वारा खोजें {{item}}" +} diff --git a/public/locales/hi/errors.json b/public/locales/hi/errors.json new file mode 100644 index 0000000000..63b6c3f5d3 --- /dev/null +++ b/public/locales/hi/errors.json @@ -0,0 +1,11 @@ +{ + "talawaApiUnavailable": "तलवा-एपीआई सेवा उपलब्ध नहीं है! ", + "notFound": "नहीं मिला", + "unknownError": "एक अज्ञात त्रुटि हुई। {{msg}}", + "notAuthorised": "क्षमा मांगना! ", + "errorSendingMail": "मेल भेजने में त्रुटि", + "emailNotRegistered": "ईमेल पंजीकृत नहीं है", + "notFoundMsg": "उफ़! ", + "errorOccurredCouldntCreate": "एक त्रुटि हुई। {{entity}} नहीं बना सके", + "errorLoading": "{{entity}} डेटा लोड करते समय त्रुटि हुई" +} diff --git a/public/locales/hi/translation.json b/public/locales/hi/translation.json new file mode 100644 index 0000000000..2645340b23 --- /dev/null +++ b/public/locales/hi/translation.json @@ -0,0 +1,1483 @@ +{ + "leaderboard": { + "title": "लीडरबोर्ड", + "searchByVolunteer": "स्वयंसेवक द्वारा खोजें", + "mostHours": "सबसे अधिक घंटे", + "leastHours": "सबसे कम घंटे", + "timeFrame": "समय सीमा", + "allTime": "सभी समय", + "weekly": "इस सप्ताह", + "monthly": "इस माह", + "yearly": "इस वर्ष", + "noVolunteers": "कोई स्वयंसेवक नहीं मिला!" + }, + "loginPage": { + "title": "तालावा व्यवस्थापक", + "fromPalisadoes": "Palisadoes फाउंडेशन स्वयंसेवकों द्वारा विकसित एक ओपन-सोर्स एप्लिकेशन", + "userLogin": "उपयोगकर्ता लॉगिन", + "atleast_8_char_long": "कम से कम 8 अक्षर लंबे", + "atleast_6_char_long": "कम से कम 6 अक्षर लंबे", + "firstName_invalid": "पहला नाम केवल छोटे और बड़े अक्षरों को शामिल कर सकता है", + "lastName_invalid": "अंतिम नाम केवल छोटे और बड़े अक्षरों को शामिल कर सकता है", + "password_invalid": "पासवर्ड में कम से कम 1 छोटा अक्षर, 1 बड़ा अक्षर, 1 संख्या और 1 विशेष अक्षर होना चाहिए", + "email_invalid": "ईमेल में कम से कम 8 अक्षर होने चाहिए", + "Password_and_Confirm_password_mismatches.": "पासवर्ड और पुष्टि पासवर्ड मेल नहीं खाते।", + "doNotOwnAnAccount": "कोई खाता नहीं है?", + "captchaError": "कैप्चा त्रुटि!", + "Please_check_the_captcha": "कृपया कैप्चा जांचें।", + "Something_went_wrong": "कुछ गलत हो गया, कृपया बाद में पुनः प्रयास करें।", + "passwordMismatches": "पासवर्ड और पुष्टि पासवर्ड मेल नहीं खाते।", + "fillCorrectly": "सभी विवरण सही ढंग से भरें।", + "successfullyRegistered": "सफलतापूर्वक पंजीकृत।", + "lowercase_check": "कम से कम एक छोटा अक्षर", + "uppercase_check": "कम से कम एक बड़ा अक्षर", + "numeric_value_check": "कम से कम एक संख्या", + "special_char_check": "कम से कम एक विशेष अक्षर", + "selectOrg": "एक संगठन चुनें", + "afterRegister": "सफलतापूर्वक पंजीकृत।", + "talawa_portal": "तालावा पोर्टल", + "login": "लॉगिन", + "register": "पंजीकरण", + "firstName": "पहला नाम", + "lastName": "अंतिम नाम", + "email": "ईमेल", + "password": "पासवर्ड", + "confirmPassword": "पुष्टि पासवर्ड", + "forgotPassword": "पासवर्ड भूल गए", + "enterEmail": "ईमेल दर्ज करें", + "enterPassword": "पासवर्ड दर्ज करें", + "talawaApiUnavailable": "तालावा एपीआई अनुपलब्ध", + "notAuthorised": "अनधिकृत", + "notFound": "नहीं मिला", + "OR": "या", + "admin": "व्यवस्थापक", + "user": "उपयोगकर्ता", + "loading": "लोड हो रहा है" + }, + "userLoginPage": { + "title": "तालावा व्यवस्थापक", + "fromPalisadoes": "Palisadoes फाउंडेशन स्वयंसेवकों द्वारा विकसित एक ओपन-सोर्स एप्लिकेशन", + "atleast_8_char_long": "कम से कम 8 अक्षर लंबे", + "Password_and_Confirm_password_mismatches.": "पासवर्ड और पुष्टि पासवर्ड मेल नहीं खाते।", + "doNotOwnAnAccount": "कोई खाता नहीं है?", + "captchaError": "कैप्चा त्रुटि!", + "Please_check_the_captcha": "कृपया कैप्चा जांचें।", + "Something_went_wrong": "कुछ गलत हो गया, कृपया बाद में पुनः प्रयास करें।", + "passwordMismatches": "पासवर्ड और पुष्टि पासवर्ड मेल नहीं खाते।", + "fillCorrectly": "सभी विवरण सही ढंग से भरें।", + "successfullyRegistered": "सफलतापूर्वक पंजीकृत।", + "userLogin": "उपयोगकर्ता लॉगिन", + "afterRegister": "सफलतापूर्वक पंजीकृत।", + "selectOrg": "एक संगठन चुनें", + "talawa_portal": "तालावा पोर्टल", + "login": "लॉगिन", + "register": "पंजीकरण", + "firstName": "पहला नाम", + "lastName": "अंतिम नाम", + "email": "ईमेल", + "password": "पासवर्ड", + "confirmPassword": "पुष्टि पासवर्ड", + "forgotPassword": "पासवर्ड भूल गए", + "enterEmail": "ईमेल दर्ज करें", + "enterPassword": "पासवर्ड दर्ज करें", + "talawaApiUnavailable": "तालावा एपीआई अनुपलब्ध", + "notAuthorised": "अनधिकृत", + "notFound": "नहीं मिला", + "OR": "या", + "loading": "लोड हो रहा है" + }, + "latestEvents": { + "eventCardTitle": "आगामी घटनाएँ", + "eventCardSeeAll": "सभी देखें", + "noEvents": "कोई आगामी घटनाएँ नहीं हैं" + }, + "latestPosts": { + "latestPostsTitle": "नवीनतम पोस्ट", + "seeAllLink": "सभी देखें", + "noPostsCreated": "कोई पोस्ट नहीं बनाई गई" + }, + "listNavbar": { + "roles": "भूमिकाएँ", + "talawa_portal": "तालावा पोर्टल", + "requests": "अनुरोध", + "logout": "लॉगआउट" + }, + "leftDrawer": { + "my organizations": "मेरे संगठन", + "requests": "सदस्यता अनुरोध", + "communityProfile": "समुदाय प्रोफ़ाइल", + "talawaAdminPortal": "तालावा व्यवस्थापक पोर्टल", + "menu": "मेनू", + "users": "उपयोगकर्ता", + "logout": "लॉगआउट" + }, + "leftDrawerOrg": { + "Dashboard": "डैशबोर्ड", + "People": "लोग", + "Events": "घटनाएँ", + "Contributions": "योगदान", + "Posts": "पोस्ट", + "Block/Unblock": "ब्लॉक/अनब्लॉक", + "Plugins": "प्लगइन्स", + "Plugin Store": "प्लगइन स्टोर", + "Advertisement": "विज्ञापन", + "allOrganizations": "सभी संगठन", + "yourOrganization": "आपका संगठन", + "notification": "सूचना", + "language": "भाषा", + "notifications": "सूचनाएँ", + "spamsThe": "स्पैम", + "group": "समूह", + "noNotifications": "कोई सूचनाएँ नहीं", + "talawaAdminPortal": "तालावा व्यवस्थापक पोर्टल", + "menu": "मेनू", + "talawa_portal": "तालावा पोर्टल", + "settings": "सेटिंग्स", + "logout": "लॉगआउट", + "close": "बंद करें" + }, + "orgList": { + "title": "तालावा संगठन", + "you": "आप", + "designation": "पदनाम", + "my organizations": "मेरे संगठन", + "createOrganization": "संगठन बनाएं", + "createSampleOrganization": "नमूना संगठन बनाएं", + "city": "शहर", + "countryCode": "देश कोड", + "dependentLocality": "निर्भर स्थान", + "line1": "लाइन 1", + "line2": "लाइन 2", + "postalCode": "पोस्टल कोड", + "sortingCode": "सॉर्टिंग कोड", + "state": "राज्य", + "userRegistrationRequired": "उपयोगकर्ता पंजीकरण आवश्यक", + "visibleInSearch": "खोज में दिखाई दे", + "enterName": "नाम दर्ज करें", + "sort": "प्रकार", + "Latest": "नवीनतम", + "Earliest": "सबसे पहले", + "noOrgErrorTitle": "संगठन नहीं मिला", + "sampleOrgDuplicate": "केवल एक नमूना संगठन की अनुमति है", + "noOrgErrorDescription": "कृपया डैशबोर्ड के माध्यम से संगठन बनाएं", + "manageFeatures": "विशेषताएं प्रबंधित करें", + "manageFeaturesInfo": "सफलतापूर्वक बनाया गया!", + "goToStore": "प्लगइन स्टोर पर जाएं", + "enableEverything": "सब कुछ सक्षम करें", + "sampleOrgSuccess": "नमूना संगठन सफलतापूर्वक बनाया गया", + "name": "नाम", + "email": "ईमेल", + "searchByName": "नाम से खोजें", + "description": "विवरण", + "location": "स्थान", + "address": "पता", + "displayImage": "छवि प्रदर्शित करें", + "filter": "फ़िल्टर", + "cancel": "रद्द करें", + "endOfResults": "परिणाम समाप्त", + "noResultsFoundFor": "कोई परिणाम नहीं मिला", + "OR": "या" + }, + "orgListCard": { + "manage": "प्रबंधित करें", + "sampleOrganization": "संगठन नमूना", + "admins": "प्रशासक", + "members": "सदस्य" + }, + "paginationList": { + "rowsPerPage": "प्रति पृष्ठ पंक्तियाँ", + "all": "सभी" + }, + "requests": { + "title": "सदस्यता अनुरोध", + "sl_no": "क्रम संख्या", + "accept": "स्वीकार करें", + "reject": "अस्वीकार करें", + "searchRequests": "सदस्यता अनुरोध खोजें", + "noOrgError": "संगठन नहीं मिला, कृपया डैशबोर्ड के माध्यम से संगठन बनाएं", + "noRequestsFound": "कोई सदस्यता अनुरोध नहीं मिला", + "acceptedSuccessfully": "अनुरोध सफलतापूर्वक स्वीकार किया गया", + "rejectedSuccessfully": "अनुरोध सफलतापूर्वक अस्वीकार किया गया", + "noOrgErrorTitle": "संगठन नहीं मिला", + "noOrgErrorDescription": "कृपया डैशबोर्ड के माध्यम से संगठन बनाएं", + "name": "नाम", + "email": "ईमेल", + "endOfResults": "परिणाम समाप्त", + "noResultsFoundFor": "कोई परिणाम नहीं मिला" + }, + "users": { + "title": "तालावा भूमिकाएँ", + "joined_organizations": "संगठनों में शामिल हुए", + "blocked_organizations": "अवरुद्ध संगठन", + "orgJoinedBy": "द्वारा शामिल संगठन", + "orgThatBlocked": "अवरुद्ध संगठन", + "hasNotJoinedAnyOrg": "किसी भी संगठन में शामिल नहीं हुआ", + "isNotBlockedByAnyOrg": "किसी भी संगठन द्वारा अवरुद्ध नहीं किया गया", + "searchByOrgName": "संगठन के नाम से खोजें", + "view": "दृश्य", + "enterName": "नाम दर्ज करें", + "loadingUsers": "उपयोगकर्ताओं को लोड कर रहा है...", + "noUserFound": "कोई उपयोगकर्ता नहीं मिला", + "sort": "प्रकार", + "Newest": "नवीनतम", + "Oldest": "सबसे पुराना", + "noOrgError": "संगठन नहीं मिला, कृपया डैशबोर्ड के माध्यम से संगठन बनाएं", + "roleUpdated": "भूमिका अपडेट की गई।", + "joinNow": "अभी शामिल हों", + "visit": "यात्रा", + "withdraw": "वापस लें", + "removeUserFrom": "{{org}} से उपयोगकर्ता को हटाएं", + "removeConfirmation": "क्या आप वाकई '{{name}}' को संगठन '{{org}}' से हटाना चाहते हैं?", + "searchByName": "नाम से खोजें", + "users": "उपयोगकर्ता", + "name": "नाम", + "email": "ईमेल", + "endOfResults": "परिणाम समाप्त", + "admin": "प्रशासक", + "superAdmin": "सुपर प्रशासक", + "user": "उपयोगकर्ता", + "filter": "फ़िल्टर", + "noResultsFoundFor": "कोई परिणाम नहीं मिला", + "talawaApiUnavailable": "तालावा एपीआई अनुपलब्ध", + "cancel": "रद्द करें", + "admins": "प्रशासक", + "members": "सदस्य", + "orgJoined": "संगठन में शामिल", + "MembershipRequestSent": "सदस्यता अनुरोध भेजा गया", + "AlreadyJoined": "पहले से शामिल", + "errorOccured": "त्रुटि हुई" + }, + "communityProfile": { + "title": "समुदाय प्रोफ़ाइल", + "editProfile": "प्रोफ़ाइल संपादित करें", + "communityProfileInfo": "ये विवरण आपके और आपके समुदाय के सदस्यों के लॉगिन/पंजीकरण स्क्रीन पर दिखाई देंगे", + "communityName": "समुदाय का नाम", + "wesiteLink": "वेबसाइट लिंक", + "logo": "लोगो", + "social": "सोशल मीडिया लिंक", + "url": "यूआरएल दर्ज करें", + "profileChangedMsg": "प्रोफ़ाइल विवरण सफलतापूर्वक अपडेट किया गया।", + "resetData": "प्रोफ़ाइल विवरण सफलतापूर्वक रीसेट किया गया।" + }, + "dashboard": { + "title": "डैशबोर्ड", + "about": "के बारे में", + "deleteThisOrganization": "इस संगठन को हटाएं", + "statistics": "आंकड़े", + "posts": "पोस्ट", + "events": "घटनाएँ", + "blockedUsers": "अवरुद्ध उपयोगकर्ता", + "viewAll": "सभी देखें", + "upcomingEvents": "आगामी घटनाएँ", + "noUpcomingEvents": "कोई आगामी घटनाएँ नहीं हैं", + "latestPosts": "नवीनतम पोस्ट", + "noPostsPresent": "कोई पोस्ट नहीं हैं", + "membershipRequests": "सदस्यता अनुरोध", + "noMembershipRequests": "कोई सदस्यता अनुरोध नहीं हैं", + "location": "स्थान", + "members": "सदस्य", + "admins": "प्रशासक", + "requests": "अनुरोध", + "talawaApiUnavailable": "तालावा एपीआई अनुपलब्ध", + "volunteerRankings": "स्वयंसेवक रैंकिंग", + "noVolunteers": "कोई स्वयंसेवक नहीं मिला!" + }, + "organizationPeople": { + "title": "तालावा सदस्य", + "filterByName": "नाम से फ़िल्टर करें", + "filterByLocation": "स्थान से फ़िल्टर करें", + "filterByEvent": "घटना से फ़िल्टर करें", + "searchName": "नाम दर्ज करें", + "searchevent": "घटना दर्ज करें", + "searchFullName": "पूरा नाम दर्ज करें", + "people": "लोग", + "sort": "भूमिका से खोजें", + "actions": "क्रियाएँ", + "addMembers": "सदस्य जोड़ें", + "existingUser": "मौजूदा उपयोगकर्ता", + "newUser": "नया उपयोगकर्ता", + "enterFirstName": "अपना पहला नाम दर्ज करें", + "enterLastName": "अपना अंतिम नाम दर्ज करें", + "enterConfirmPassword": "अपना पासवर्ड पुष्टि के लिए दर्ज करें", + "organization": "संगठन", + "invalidDetailsMessage": "कृपया मान्य विवरण दर्ज करें।", + "members": "सदस्य", + "admins": "प्रशासक", + "users": "उपयोगकर्ता", + "searchFirstName": "प्रथम नाम खोजें", + "searchLastName": "अंतिम नाम खोजें", + "firstName": "प्रथम नाम", + "lastName": "अंतिम नाम", + "emailAddress": "ईमेल पता", + "enterEmail": "ईमेल दर्ज करें", + "password": "पासवर्ड", + "enterPassword": "पासवर्ड दर्ज करें", + "confirmPassword": "पासवर्ड की पुष्टि करें", + "user": "उपयोगकर्ता", + "profile": "प्रोफ़ाइल", + "create": "बनाएं", + "cancel": "रद्द करें" + }, + "organizationTags": { + "title": "संस्थान टैग", + "createTag": "नया टैग बनाएँ", + "manageTag": "प्रबंधित करें", + "editTag": "संपादित करें", + "removeTag": "हटाएँ", + "tagDetails": "टैग विवरण", + "tagName": "नाम", + "tagType": "प्रकार", + "tagNamePlaceholder": "टैग का नाम लिखें", + "tagCreationSuccess": "नई टैग सफलतापूर्वक बनाई गई", + "tagUpdationSuccess": "टैग सफलतापूर्वक अपडेट की गई", + "tagRemovalSuccess": "टैग सफलतापूर्वक हटाई गई", + "noTagsFound": "कोई टैग नहीं मिला", + "removeUserTag": "टैग हटाएँ", + "removeUserTagMessage": "क्या आप इस टैग को हटाना चाहते हैं?", + "addChildTag": "उप-टैग जोड़ें", + "enterTagName": "टैग का नाम दर्ज करें" + }, + "manageTag": { + "title": "टैग विवरण", + "addPeopleToTag": "टैग में लोगों को जोड़ें", + "viewProfile": "देखें", + "noAssignedMembersFound": "कोई असाइन किए गए सदस्य नहीं मिले", + "unassignUserTag": "टैग को हटाएं", + "unassignUserTagMessage": "क्या आप इस उपयोगकर्ता से टैग हटाना चाहते हैं?", + "successfullyUnassigned": "उपयोगकर्ता से टैग हटा दिया गया", + "addPeople": "लोगों को जोड़ें", + "add": "जोड़ें", + "subTags": "उप-टैग्स", + "successfullyAssignedToPeople": "टैग सफलतापूर्वक असाइन किया गया", + "errorOccurredWhileLoadingMembers": "सदस्यों को लोड करते समय त्रुटि हुई", + "userName": "उपयोगकर्ता नाम", + "actions": "क्रियाएँ", + "noOneSelected": "कोई चयनित नहीं", + "assignToTags": "टैग्स को असाइन करें", + "removeFromTags": "टैग्स से हटाएं", + "assign": "असाइन करें", + "remove": "हटाएं", + "successfullyAssignedToTags": "सफलतापूर्वक टैग्स को असाइन किया गया", + "successfullyRemovedFromTags": "सफलतापूर्वक टैग्स से हटाया गया", + "errorOccurredWhileLoadingOrganizationUserTags": "संगठन टैग्स को लोड करते समय त्रुटि हुई", + "errorOccurredWhileLoadingSubTags": "उप-टैग लोड करते समय त्रुटि हुई", + "removeUserTag": "टैग हटाएं", + "removeUserTagMessage": "क्या आप इस टैग को हटाना चाहते हैं? यह सभी उप-टैग्स और सभी संबंधों को हटा देगा।", + "tagDetails": "टैग विवरण", + "tagName": "नाम", + "tagUpdationSuccess": "टैग सफलतापूर्वक अपडेट की गई", + "tagRemovalSuccess": "टैग सफलतापूर्वक हटाई गई", + "noTagSelected": "कोई टैग चयनित नहीं", + "changeNameToEdit": "अपडेट करने के लिए नाम बदलें", + "selectTag": "टैग चुनें", + "collapse": "संक्षिप्त करें", + "expand": "विस्तारित करें", + "tagNamePlaceholder": "टैग का नाम लिखें", + "allTags": "सभी टैग", + "noMoreMembersFound": "कोई और सदस्य नहीं मिला" + }, + "userListCard": { + "addAdmin": "व्यवस्थापक जोड़ें", + "addedAsAdmin": "उपयोगकर्ता को व्यवस्थापक के रूप में जोड़ा गया है.", + "joined": "शामिल हुए", + "talawaApiUnavailable": "Talawa API अनुपलब्ध" + }, + "orgAdminListCard": { + "remove": "निकालना", + "removeAdmin": "व्यवस्थापक हटाएँ", + "removeAdminMsg": "क्या आप इस व्यवस्थापक को हटाना चाहते हैं?", + "adminRemoved": "व्यवस्थापक को हटा दिया गया है.", + "joined": "शामिल हुए", + "no": "नहीं", + "yes": "हाँ", + "talawaApiUnavailable": "Talawa API अनुपलब्ध" + }, + "orgPeopleListCard": { + "remove": "निकालना", + "removeMember": "सदस्य हटाएँ", + "removeMemberMsg": "क्या आप इस सदस्य को हटाना चाहते हैं?", + "memberRemoved": "सदस्य को हटा दिया गया है", + "joined": "शामिल हुए", + "no": "नहीं", + "yes": "हाँ", + "talawaApiUnavailable": "Talawa API अनुपलब्ध" + }, + "organizationEvents": { + "title": "आयोजन", + "filterByTitle": "शीर्षक के अनुसार फ़िल्टर करें", + "filterByLocation": "स्थान के अनुसार फ़िल्टर करें", + "filterByDescription": "विवरण के अनुसार फ़िल्टर करें", + "addEvent": "कार्यक्रम जोड़ें", + "eventDetails": "घटना की जानकारी", + "searchMemberName": "सदस्य का नाम खोजें", + "eventTitle": "शीर्षक", + "startTime": "समय शुरू", + "endTime": "अंत समय", + "allDay": "पूरे दिन", + "recurringEvent": "पुनरावर्ती ईवेंट", + "isPublic": "सार्वजनिक है", + "isRegistrable": "पंजीकरण योग्य है", + "createEvent": "कार्यक्रम बनाएँ", + "enterFilter": "फ़िल्टर दर्ज करें", + "enterTitle": "शीर्षक दर्ज करें", + "enterDescrip": "विवरण दर्ज करें", + "eventLocation": "स्थान दर्ज करें", + "searchEventName": "ईवेंट का नाम खोजें", + "eventType": "घटना प्रकार", + "eventCreated": "बधाई हो! ", + "customRecurrence": "कस्टम पुनरावृत्ति", + "repeatsEvery": "प्रत्येक को दोहराता है", + "repeatsOn": "पर दोहराता है", + "ends": "समाप्त होता है", + "never": "कभी नहीं", + "on": "पर", + "after": "बाद", + "occurences": "घटनाओं", + "events": "कार्यक्रम", + "description": "विवरण", + "location": "स्थान", + "startDate": "प्रारंभ तिथि", + "endDate": "समाप्ति तिथि", + "talawaApiUnavailable": "Talawa API अनुपलब्ध", + "done": "पूर्ण" + }, + "organizationActionItems": { + "actionItemCategory": "क्रिया वस्तु श्रेणी", + "actionItemDetails": "क्रिया वस्तु विवरण", + "actionItemCompleted": "क्रिया वस्तु पूरी", + "assignee": "प्राप्तकर्ता", + "assigner": "सौंपने वाला", + "assignmentDate": "आवंटन तिथि", + "active": "सक्रिय", + "clearFilters": "फ़िल्टर साफ़ करें", + "completionDate": "पूर्णता तिथि", + "createActionItem": "क्रिया वस्तु बनाएँ", + "deleteActionItem": "क्रिया वस्तु हटाएँ", + "deleteActionItemMsg": "क्या आप इस क्रिया वस्तु को हटाना चाहते हैं?", + "details": "विवरण", + "dueDate": "समाप्ति तिथि", + "earliest": "सबसे पहले", + "editActionItem": "क्रिया वस्तु संपादित करें", + "isCompleted": "पूर्ण", + "latest": "नवीनतम", + "makeActive": "सक्रिय बनाएं", + "noActionItems": "कोई क्रिया वस्तु नहीं", + "options": "विकल्प", + "preCompletionNotes": "पूर्व-पूर्णता नोट्स", + "actionItemActive": "सक्रिय क्रिया वस्तु", + "markCompletion": "पूर्णता को चिह्नित करें", + "actionItemStatus": "क्रिया वस्तु स्थिति", + "postCompletionNotes": "पूर्णता के बाद नोट्स", + "selectActionItemCategory": "क्रिया वस्तु श्रेणी चुनें", + "selectAssignee": "प्राप्तकर्ता चुनें", + "status": "स्थिति", + "successfulCreation": "क्रिया वस्तु सफलतापूर्वक बनाई गई", + "successfulUpdation": "क्रिया वस्तु सफलतापूर्वक अद्यतन की गई", + "successfulDeletion": "क्रिया वस्तु सफलतापूर्वक हटाई गई", + "title": "क्रिया वस्तुएँ", + "category": "श्रेणी", + "allottedHours": "आवंटित घंटे", + "latestDueDate": "नवीनतम समाप्ति तिथि", + "earliestDueDate": "प्रारंभिक समाप्ति तिथि", + "updateActionItem": "क्रिया वस्तु अपडेट करें", + "noneUpdated": "कोई फ़ील्ड अपडेट नहीं की गई", + "updateStatusMsg": "क्या आप वाकई इस क्रिया वस्तु को लंबित के रूप में चिह्नित करना चाहते हैं?", + "close": "बंद करें", + "eventActionItems": "घटना क्रिया वस्तुएं", + "no": "नहीं", + "yes": "हाँ", + "individuals": "व्यक्तियों", + "groups": "समूहों", + "assignTo": "सौंपें", + "volunteers": "स्वयंसेवक", + "volunteerGroups": "स्वयंसेवक समूह" + }, + "organizationAgendaCategory": { + "agendaCategoryDetails": "एजेंडा श्रेणी विवरण", + "updateAgendaCategory": "एजेंडा श्रेणी अपडेट करें", + "title": "एजेंडा श्रेणियाँ", + "name": "श्रेणी", + "description": "विवरण", + "createdBy": "द्वारा बनाया गया", + "options": "विकल्प", + "createAgendaCategory": "एजेंडा श्रेणी बनाएं", + "noAgendaCategories": "कोई एजेंडा श्रेणी नहीं", + "update": "अपडेट करें", + "agendaCategoryCreated": "एजेंडा श्रेणी सफलतापूर्वक बनाई गई", + "agendaCategoryUpdated": "एजेंडा श्रेणी सफलतापूर्वक अपडेट की गई", + "agendaCategoryDeleted": "एजेंडा श्रेणी सफलतापूर्वक हटा दी गई", + "deleteAgendaCategory": "एजेंडा श्रेणी हटाएं", + "deleteAgendaCategoryMsg": "क्या आप इस एजेंडा श्रेणी को हटाना चाहते हैं?" + }, + "agendaItems": { + "agendaItemDetails": "एजेंडा आइटम विवरण", + "updateAgendaItem": "एजेंडा आइटम अपडेट करें", + "title": "शीर्षक", + "enterTitle": "शीर्षक दर्ज करें", + "sequence": "क्रम", + "description": "विवरण", + "enterDescription": "विवरण दर्ज करें", + "category": "एजेंडा श्रेणी", + "attachments": "संलग्नक", + "attachmentLimit": "10MB तक कोई भी छवि फ़ाइल या वीडियो फ़ाइल जोड़ें", + "fileSizeExceedsLimit": "फ़ाइल का आकार सीमा 10MB से अधिक है", + "urls": "URL", + "url": "URL में लिंक जोड़ें", + "enterUrl": "https://example.com", + "invalidUrl": "कृपया एक वैध URL दर्ज करें", + "link": "लिंक", + "createdBy": "बनाया गया द्वारा", + "regular": "नियमित", + "note": "नोट", + "duration": "अवधि", + "enterDuration": "मिमी:से", + "options": "विकल्प", + "createAgendaItem": "एजेंडा आइटम बनाएं", + "noAgendaItems": "कोई एजेंडा आइटम नहीं", + "selectAgendaItemCategory": "एजेंडा आइटम श्रेणी चुनें", + "update": "अपडेट करें", + "delete": "हटाएं", + "agendaItemCreated": "एजेंडा आइटम सफलतापूर्वक बनाया गया", + "agendaItemUpdated": "एजेंडा आइटम सफलतापूर्वक अपडेट किया गया", + "agendaItemDeleted": "एजेंडा आइटम सफलतापूर्वक हटा दिया गया", + "deleteAgendaItem": "एजेंडा आइटम हटाएं", + "deleteAgendaItemMsg": "क्या आप इस एजेंडा आइटम को हटाना चाहते हैं?" + }, + "eventListCard": { + "deleteEvent": "ईवेंट हटाएँ", + "deleteEventMsg": "क्या आप इस ईवेंट को हटाना चाहते हैं?", + "editEvent": "इवेंट संपादित करें", + "eventTitle": "शीर्षक", + "alreadyRegistered": "पहले से ही पंजीकृत", + "startTime": "समय शुरू", + "endTime": "अंत समय", + "allDay": "पूरे दिन", + "recurringEvent": "पुनरावर्ती ईवेंट", + "isPublic": "सार्वजनिक है", + "isRegistrable": "पंजीकरण योग्य है", + "updatePost": "पोस्ट अपडेट करें", + "eventDetails": "घटना की जानकारी", + "eventDeleted": "ईवेंट सफलतापूर्वक हटा दिया गया.", + "eventUpdated": "इवेंट सफलतापूर्वक अपडेट किया गया.", + "thisInstance": "यह उदाहरण", + "thisAndFollowingInstances": "यह और निम्नलिखित उदाहरण", + "allInstances": "सभी उदाहरण", + "customRecurrence": "कस्टम पुनरावृत्ति", + "repeatsEvery": "प्रत्येक को दोहराता है", + "repeatsOn": "पर दोहराता है", + "ends": "समाप्त होता है", + "never": "कभी नहीं", + "on": "पर", + "after": "बाद", + "occurences": "घटनाओं", + "location": "स्थान", + "no": "नहीं", + "yes": "हाँ", + "description": "विवरण", + "startDate": "प्रारंभ तिथि", + "endDate": "समाप्ति तिथि", + "registerEvent": "कार्यक्रम के लिए पंजीकरण करें", + "close": "बंद करें", + "talawaApiUnavailable": "Talawa API अनुपलब्ध", + "done": "समाप्त" + }, + "funds": { + "title": "फंड", + "createFund": "फंड बनाएँ", + "fundName": "फंड का नाम", + "fundId": "फंड (संदर्भ) आईडी", + "taxDeductible": "कर ड्यूटी कटाई", + "default": "डिफ़ॉल्ट", + "archived": "आर्काइव", + "fundCreate": "फंड बनाएँ", + "fundUpdate": "फंड अपडेट करें", + "fundDelete": "फंड को हटाएँ", + "searchByName": "नाम से खोजें", + "noFundsFound": "कोई फंड नहीं मिला", + "createdBy": "द्वारा बनाया गया", + "createdOn": "पर बनाया गया", + "status": "स्थिति", + "fundCreated": "फंड सफलतापूर्वक बनाया गया", + "fundUpdated": "फंड सफलतापूर्वक अपडेट किया गया", + "fundDeleted": "फंड सफलतापूर्वक हटाया गया", + "deleteFundMsg": "क्या आप वाकई इस फंड को हटाना चाहते हैं?", + "createdLatest": "सबसे पहले बनाया", + "createdEarliest": "सबसे जल्दी बनाया", + "viewCampaigns": "कैम्पेंस देखें" + }, + "fundCampaign": { + "title": "फंडरेजिंग कैंपेन", + "campaignName": "कैंपेन का नाम", + "campaignOptions": "विकल्प", + "fundingGoal": "फंडिंग उद्देश्य", + "addCampaign": "कैंपेन जोड़ें", + "createdCampaign": "कैंपेन सफलतापूर्वक बनाई गई", + "updatedCampaign": "कैंपेन सफलतापूर्वक अपडेट की गई", + "deletedCampaign": "कैंपेन सफलतापूर्वक हटा दी गई", + "deleteCampaignMsg": "क्या आप वाकई इस कैंपेन को हटाना चाहते हैं?", + "noCampaigns": "कोई कैंपेन नहीं मिली", + "createCampaign": "कैंपेन बनाएँ", + "updateCampaign": "कैंपेन अपडेट करें", + "deleteCampaign": "कैंपेन को हटाएं", + "currency": "मुद्रा", + "selectCurrency": "मुद्रा का चयन करें", + "searchFullName": "नाम से खोजें", + "viewPledges": "प्लेज देखें", + "noCampaignsFound": "कोई कैंपेन नहीं मिली", + "latestEndDate": "अंतिम समाप्ति तिथि", + "earliestEndDate": "सबसे पहली समाप्ति तिथि", + "lowestGoal": "सबसे कम उद्देश्य", + "highestGoal": "सबसे ऊंचा उद्देश्य" + }, + "pledges": { + "title": "निधि अभियान प्रतिज्ञाएँ", + "pledgeAmount": "प्रतिज्ञा राशि", + "pledgeOptions": "विकल्प", + "pledgeCreated": "प्रतिज्ञा सफलतापूर्वक बनाई गई", + "pledgeUpdated": "प्रतिज्ञा सफलतापूर्वक अद्यतन की गई", + "pledgeDeleted": "प्रतिज्ञा सफलतापूर्वक हटा दी गई", + "addPledge": "प्रतिज्ञा जोड़ें", + "createPledge": "प्रतिज्ञा बनाएँ", + "currency": "मुद्रा", + "selectCurrency": "मुद्रा चुनें", + "updatePledge": "प्रतिज्ञा अद्यतन करें", + "deletePledge": "प्रतिज्ञा हटाएँ", + "amount": "मात्रा", + "editPledge": "प्रतिज्ञा संपादित करें", + "deletePledgeMsg": "क्या आप वाकई इस प्रतिज्ञा को हटाना चाहते हैं?", + "noPledges": "कोई प्रतिज्ञा नहीं मिली", + "searchPledger": "प्लेजर्स के द्वारा खोजें", + "highestAmount": "सबसे अधिक राशि", + "lowestAmount": "सबसे कम राशि", + "latestEndDate": "नवीनतम समाप्ति तिथि", + "earliestEndDate": "सबसे प्रारंभिक समाप्ति तिथि", + "campaigns": "अभियान", + "pledges": "प्रतिज्ञाएँ", + "endsOn": "पर समाप्त होता है", + "raisedAmount": "उठाया गया राशि", + "pledgedAmount": "प्रतिबद्ध राशि", + "startDate": "प्रारंभ तिथि", + "endDate": "समाप्ति तिथि" + }, + "orgPost": { + "title": "पदों", + "searchPost": "पोस्ट खोजें", + "posts": "पदों", + "createPost": "पोस्ट बनाएं", + "postDetails": "पोस्ट विवरण", + "postTitle1": "पोस्ट का शीर्षक लिखें", + "postTitle": "शीर्षक", + "addMedia": "मीडिया अपलोड करें", + "information": "जानकारी", + "information1": "पोस्ट की जानकारी लिखें", + "addPost": "पोस्ट जोड़ें", + "searchTitle": "शीर्षक से खोजें", + "searchText": "पाठ द्वारा खोजें", + "ptitle": "शीर्षक पोस्ट करें", + "postDes": "तुम्हें किस बारे में बात करनी है?", + "Title": "शीर्षक", + "Text": "मूलपाठ", + "searchBy": "खोज से", + "Oldest": "सबसे पुराना पहले", + "Latest": "नवीनतम प्रथम", + "sortPost": "पोस्ट क्रमबद्ध करें", + "tag": " आपका ब्राउज़र में वीडियो टैग समर्थित नहीं है", + "postCreatedSuccess": "बधाई हो! ", + "pinPost": "पिन पद", + "Next": "अगला पृष्ठ", + "Previous": "पिछला पृष्ठ", + "cancel": "रद्द करें" + }, + "postNotFound": { + "post": "डाक", + "not found!": "नहीं मिला!", + "organization": "संगठन", + "post not found!": "पोस्ट नहीं मिली!", + "organization not found!": "संगठन नहीं मिला!" + }, + "userNotFound": { + "not found!": "नहीं मिला!", + "roles": "भूमिकाएँ", + "user not found!": "उपयोगकर्ता नहीं मिला!", + "member not found!": "सदस्य अनुपस्थित!", + "admin not found!": "व्यवस्थापक नहीं मिला!", + "roles not found!": "भूमिकाएँ नहीं मिलीं!", + "user": "उपयोगकर्ता" + }, + "orgPostCard": { + "author": "लेखक", + "imageURL": "छवि यूआरएल", + "videoURL": "वीडियो यूआरएल", + "deletePost": "पोस्ट को हटाएं", + "deletePostMsg": "क्या आप इस पोस्ट को हटाना चाहते हैं?", + "editPost": "संपादित पोस्ट", + "postTitle": "शीर्षक", + "postTitle1": "पोस्ट का शीर्षक संपादित करें", + "information1": "पोस्ट की जानकारी संपादित करें", + "information": "जानकारी", + "image": "छवि", + "video": "वीडियो", + "updatePost": "पोस्ट अपडेट करें", + "postDeleted": "पोस्ट सफलतापूर्वक हटा दी गई.", + "postUpdated": "पोस्ट सफलतापूर्वक अपडेट किया गया.", + "tag": " आपका ब्राउज़र में वीडियो टैग समर्थित नहीं है", + "pin": "पिन पद", + "edit": "संपादित करें", + "no": "नहीं", + "yes": "हाँ", + "close": "बंद करें", + "talawaApiUnavailable": "Talawa API अनुपलब्ध" + }, + "blockUnblockUser": { + "title": "उपयोगकर्ता को ब्लॉक/अनब्लॉक करें", + "pageName": "ब्लॉक/अनब्लॉक करें", + "listOfUsers": "स्पैम भेजने वाले उपयोगकर्ताओं की सूची", + "block_unblock": "ब्लॉक/अनब्लॉक करें", + "unblock": "अनवरोधित", + "block": "अवरोध पैदा करना", + "orgName": "नाम दर्ज करें", + "blockedSuccessfully": "उपयोगकर्ता को सफलतापूर्वक अवरोधित किया गया", + "Un-BlockedSuccessfully": "उपयोगकर्ता को सफलतापूर्वक अन-अवरुद्ध किया गया", + "allMembers": "सभी सदस्य", + "blockedUsers": "रोके गए उपयोगकर्ता", + "searchByFirstName": "प्रथम नाम से खोजें", + "searchByLastName": "अंतिम नाम से खोजें", + "noSpammerFound": "कोई स्पैमर नहीं मिला", + "searchByName": "नाम से खोजें", + "name": "नाम", + "email": "ईमेल", + "talawaApiUnavailable": "Talawa API अनुपलब्ध", + "noResultsFoundFor": "के लिए कोई परिणाम नहीं मिला" + }, + "eventManagement": { + "title": "इवेंट मैनेजमेंट", + "dashboard": "डैशबोर्ड", + "registrants": "कुलसचिव", + "attendance": "उपस्थिति", + "actions": "कार्य", + "agendas": "एजेंडे", + "statistics": "आँकड़े", + "to": "को", + "volunteers": "स्वयंसेवक" + }, + "forgotPassword": { + "title": "तलावा पासवर्ड भूल गए", + "registeredEmail": "पंजीकृत ईमेल", + "getOtp": "ओटीपी प्राप्त करें", + "enterOtp": "ओटीपी दर्ज करें", + "enterNewPassword": "नया पासवर्ड दर्ज करें", + "cofirmNewPassword": "नए पासवर्ड की पुष्टि करें", + "changePassword": "पासवर्ड बदलें", + "backToLogin": "लॉगिन पर वापस जाएं", + "userOtp": "जैसे ", + "emailNotRegistered": "ईमेल पंजीकृत नहीं है.", + "errorSendingMail": "मेल भेजने में त्रुटि.", + "passwordMismatches": "पासवर्ड और पासवर्ड बेमेल होने की पुष्टि करें।", + "passwordChanges": "पासवर्ड सफलतापूर्वक बदल गया.", + "OTPsent": "ओटीपी आपके पंजीकृत ईमेल पर भेजा जाता है।", + "forgotPassword": "पासवर्ड भूल गए", + "password": "पासवर्ड", + "talawaApiUnavailable": "Talawa API अनुपलब्ध" + }, + "pageNotFound": { + "404": "404", + "title": "404 नहीं मिला", + "talawaAdmin": "तलावा प्रशासन", + "talawaUser": "तलावा उपयोगकर्ता", + "notFoundMsg": "उफ़! ", + "backToHome": "घर वापिस जा रहा हूँ" + }, + "orgContribution": { + "title": "तलावा योगदान", + "filterByName": "नाम से फ़िल्टर करें", + "filterByTransId": "ट्रांस द्वारा फ़िल्टर करें। ", + "recentStats": "हाल के आँकड़े", + "contribution": "योगदान", + "orgname": "नाम दर्ज करें", + "searchtransaction": "लेनदेन आईडी दर्ज करें" + }, + "contriStats": { + "recentContribution": "हालिया योगदान", + "highestContribution": "सर्वोच्च योगदान", + "totalContribution": "कुल योगदान" + }, + "orgContriCards": { + "date": "तारीख", + "transactionId": "लेन-देन आईडी", + "amount": "मात्रा" + }, + "orgSettings": { + "title": "सेटिंग्स", + "general": "सामान्य", + "actionItemCategories": "कार्य आइटम श्रेणियाँ", + "updateOrganization": "संगठन अपडेट करें", + "seeRequest": "अनुरोध देखें", + "noData": "कोई डेटा नहीं", + "otherSettings": "अन्य सेटिंग्स", + "changeLanguage": "भाषा बदलें", + "manageCustomFields": "कस्टम फ़ील्ड प्रबंधित करें", + "agendaItemCategories": "एजेंडा आइटम श्रेणियाँ" + }, + "deleteOrg": { + "deleteOrganization": "संगठन हटाएँ", + "deleteSampleOrganization": "नमूना संगठन हटाएँ", + "deleteMsg": "क्या आप इस संगठन को हटाना चाहते हैं?", + "confirmDelete": "हटाने की पुष्टि करें", + "longDelOrgMsg": "संगठन हटाएं बटन पर क्लिक करने से संगठन अपने ईवेंट, टैग और सभी संबंधित डेटा के साथ स्थायी रूप से हटा दिया जाएगा।", + "successfullyDeletedSampleOrganization": "नमूना संगठन सफलतापूर्वक हटा दिया गया", + "cancel": "रद्द करें" + }, + "userUpdate": { + "appLanguageCode": "डिफ़ॉल्ट भाषा", + "userType": "उपयोगकर्ता का प्रकार", + "firstName": "प्रथम नाम", + "lastName": "अंतिम नाम", + "email": "ईमेल", + "password": "पासवर्ड", + "admin": "प्रशासक", + "superAdmin": "सुपर प्रशासक", + "displayImage": "प्रदर्शन छवि", + "saveChanges": "परिवर्तन सहेजें", + "cancel": "रद्द करें" + }, + "userPasswordUpdate": { + "previousPassword": "पिछला पासवर्ड", + "newPassword": "नया पासवर्ड", + "confirmNewPassword": "नए पासवर्ड की पुष्टि करें", + "passCantBeEmpty": "पासवर्ड खाली नहीं हो सकता", + "passNoMatch": "नया पासवर्ड और पुष्टि पासवर्ड मेल नहीं खाते।", + "saveChanges": "परिवर्तन सहेजें", + "cancel": "रद्द करें" + }, + "orgDelete": { + "deleteOrg": "संगठन हटाएं" + }, + "membershipRequest": { + "accept": "स्वीकार करना", + "reject": "अस्वीकार करना", + "memberAdded": "यह स्वीकृत है", + "joined": "शामिल हुए", + "talawaApiUnavailable": "Talawa API अनुपलब्ध" + }, + "orgUpdate": { + "city": "शहर", + "countryCode": "कंट्री कोड", + "line1": "लाइन 1", + "line2": "लाइन 2", + "postalCode": "डाक कोड", + "dependentLocality": "आश्रित इलाका", + "sortingCode": "कोड क्रमबद्ध करना", + "state": "राज्य/प्रान्त", + "userRegistrationRequired": "उपयोगकर्ता पंजीकरण आवश्यक", + "isVisibleInSearch": "खोज में दृश्यमान", + "enterNameOrganization": "संगठन का नाम दर्ज करें", + "successfulUpdated": "संगठन सफलतापूर्वक अद्यतन किया गया", + "name": "नाम", + "description": "विवरण", + "location": "स्थान", + "address": "पता", + "displayImage": "प्रदर्शन छवि", + "saveChanges": "परिवर्तन सहेजें", + "cancel": "रद्द करें", + "talawaApiUnavailable": "Talawa API अनुपलब्ध" + }, + "addOnRegister": { + "addNew": "नया जोड़ो", + "addPlugin": "प्लगइन जोड़ें", + "pluginName": "प्लगइन नाम", + "creatorName": "निर्माता का नाम", + "pluginDesc": "प्लगइन विवरण", + "pName": "उदाहरणार्थ: दान", + "cName": "उदाहरण: जॉन डो", + "pDesc": "यह प्लगइन यूआई को सक्षम बनाता है", + "close": "बंद करें", + "register": "पंजीकरण करें" + }, + "addOnStore": { + "title": "स्टोर पर जोड़ें", + "searchName": "उदाहरणार्थ: दान", + "search": "खोजें", + "enable": "सक्रिय", + "disable": "अक्षम", + "pHeading": "प्लग-इन", + "install": "स्थापित", + "available": "उपलब्ध", + "pMessage": "प्लगइन मौजूद नहीं है", + "filter": "फ़िल्टर" + }, + "addOnEntry": { + "enable": "सक्रिय", + "install": "स्थापित करना", + "uninstall": "स्थापना रद्द करें", + "uninstallMsg": "यह सुविधा अब आपके संगठन से हटा दी गई है", + "installMsg": "यह सुविधा अब आपके संगठन में सक्षम है" + }, + "memberDetail": { + "title": "उपयोगकर्ता विवरण", + "addAdmin": "व्यवस्थापक जोड़ें", + "noeventsAttended": "कोई कार्यक्रम में भाग नहीं लिया", + "alreadyIsAdmin": "सदस्य पहले से ही एक व्यवस्थापक है", + "organizations": "संगठनों", + "events": "आयोजन", + "role": "भूमिका", + "createdOn": "पर बनाया", + "main": "मुख्य", + "firstName": "पहला नाम", + "lastName": "उपनाम", + "language": "भाषा", + "gender": "लिंग", + "birthDate": "जन्म तिथि", + "educationGrade": "शैक्षिक ग्रेड", + "employmentStatus": "रोज़गार की स्थिति", + "maritalStatus": "वैवाहिक स्थिति", + "phone": "फ़ोन", + "countryCode": "कंट्री कोड", + "state": "राज्य", + "city": "शहर", + "personalInfoHeading": "व्यक्तिगत जानकारी", + "eventsAttended": "ईवेंट्स जिन्हें भाग लिया गया है", + "viewAll": "सभी को देखें", + "contactInfoHeading": "संपर्क जानकारी", + "actionsHeading": "कार्रवाई", + "personalDetailsHeading": "प्रोफ़ाइल विवरण", + "appLanguageCode": "भाषा चुनें", + "deleteUser": "उपभोक्ता मिटायें", + "pluginCreationAllowed": "प्लगइन निर्माण की अनुमति दी गई", + "created": "बनाया था", + "adminForOrganizations": "संगठनों के लिए व्यवस्थापक", + "membershipRequests": "सदस्यता अनुरोध", + "adminForEvents": "घटनाओं के लिए व्यवस्थापक", + "addedAsAdmin": "उपयोगकर्ता को व्यवस्थापक के रूप में जोड़ा गया है.", + "userType": "उपयोगकर्ता का प्रकार", + "email": "ईमेल", + "displayImage": "प्रदर्शन छवि", + "address": "पता", + "delete": "हटाएं", + "saveChanges": "परिवर्तन सहेजें", + "joined": "शामिल हुए", + "talawaApiUnavailable": "Talawa API अनुपलब्ध", + "unassignUserTag": "टैग को हटाएं", + "unassignUserTagMessage": "क्या आप इस उपयोगकर्ता से टैग हटाना चाहते हैं?", + "successfullyUnassigned": "उपयोगकर्ता से टैग हटा दिया गया", + "tagsAssigned": "टैग सौंपे गए", + "noTagsAssigned": "कोई टैग सौंपा नहीं गया" + }, + "people": { + "title": "लोग", + "searchUsers": "उपयोगकर्ताओं को खोजें" + }, + "userLogin": { + "login": "लॉग इन करें", + "loginIntoYourAccount": "अपने खाते में लॉगिन करें", + "invalidDetailsMessage": "कृपया एक वैध ईमेल और पासवर्ड दर्ज करें।", + "notAuthorised": "क्षमा मांगना! ", + "invalidCredentials": "दर्ज किए गए क्रेडेंशियल ग़लत हैं. ", + "forgotPassword": "पासवर्ड भूल गए", + "emailAddress": "ईमेल पता", + "enterEmail": "ईमेल दर्ज करें", + "password": "पासवर्ड", + "enterPassword": "पासवर्ड दर्ज करें", + "register": "पंजीकरण करें", + "talawaApiUnavailable": "Talawa API अनुपलब्ध" + }, + "userRegister": { + "enterFirstName": "अपना पहला नाम दर्ज करें", + "enterLastName": "अपना अंतिम नाम दर्ज करें", + "enterConfirmPassword": "पुष्टि करने के लिए अपना पासवर्ड दर्ज करें", + "alreadyhaveAnAccount": "क्या आपके पास पहले से एक खाता मौजूद है?", + "login": "लॉग इन करें", + "afterRegister": "पंजीकरण सफलतापूर्वक हो गया है। ", + "passwordNotMatch": "पासवर्ड मेल नहीं खाता. ", + "invalidDetailsMessage": "कृपया वैध विवरण दर्ज करें.", + "register": "पंजीकरण करें", + "firstName": "प्रथम नाम", + "lastName": "अंतिम नाम", + "emailAddress": "ईमेल पता", + "enterEmail": "ईमेल दर्ज करें", + "password": "पासवर्ड", + "enterPassword": "पासवर्ड दर्ज करें", + "confirmPassword": "पासवर्ड की पुष्टि करें", + "talawaApiUnavailable": "Talawa API अनुपलब्ध" + }, + "userNavbar": { + "talawa": "तलावा", + "home": "घर", + "people": "लोग", + "events": "आयोजन", + "chat": "बात करना", + "donate": "दान करें", + "language": "भाषा", + "settings": "समायोजन", + "logout": "लॉग आउट करें", + "close": "बंद करें" + }, + "userOrganizations": { + "allOrganizations": "सभी संगठन", + "joinedOrganizations": "संगठनों से जुड़े", + "createdOrganizations": "संगठन बनाये", + "selectOrganization": "एक संगठन चुनें", + "searchUsers": "उपयोगकर्ता खोजें", + "nothingToShow": "यहां दिखाने के लिए कुछ भी नहीं है.", + "organizations": "संगठनों", + "search": "खोजें", + "filter": "फ़िल्टर", + "searchByName": "नाम से खोजें", + "searchOrganizations": "संगठनों खोजें" + }, + "userSidebarOrg": { + "yourOrganizations": "आपके संगठन", + "noOrganizations": "आप अभी तक किसी संगठन में शामिल नहीं हुए हैं.", + "viewAll": "सभी को देखें", + "talawaUserPortal": "तलावा उपयोगकर्ता पोर्टल", + "my organizations": "मेरे संगठन", + "communityProfile": "सामुदायिक प्रोफ़ाइल", + "users": "उपयोगकर्ता", + "requests": "अनुरोध", + "logout": "लॉग आउट", + "settings": "सेटिंग्स", + "chat": "चैट", + "menu": "मेनू" + }, + "organizationSidebar": { + "viewAll": "सभी को देखें", + "events": "आयोजन", + "noEvents": "दिखाने के लिए कोई ईवेंट नहीं", + "noMembers": "दिखाने के लिए कोई सदस्य नहीं", + "members": "सदस्य" + }, + "postCard": { + "likes": "पसंद है", + "comments": "टिप्पणियाँ", + "viewPost": "पोस्ट देखें", + "editPost": "पोस्ट संपादित करें", + "postedOn": "{{date}} को पोस्ट किया गया" + }, + "home": { + "title": "पदों", + "posts": "पदों", + "post": "डाक", + "textArea": "आपके मन में कुछ है?", + "feed": "खिलाना", + "loading": "लोड हो रहा है", + "pinnedPosts": "चिपके पत्र", + "yourFeed": "आपका फ़ीड", + "nothingToShowHere": "यहां दिखाने के लिए कुछ भी नहीं है", + "somethingOnYourMind": "आपके मन में कुछ है?", + "addPost": "पोस्ट जोड़ें", + "startPost": "एक पोस्ट प्रारंभ करें", + "media": "मिडिया", + "event": "आयोजन", + "article": "लेख", + "postNowVisibleInFeed": "पोस्ट अब फीड में दिखाई दे रहा है" + }, + "eventAttendance": { + "historical_statistics": "ऐतिहासिक आंकड़े", + "Search member": "सदस्य खोजें", + "Member Name": "सदस्य का नाम", + "Status": "स्थिति", + "Events Attended": "भाग लिए गए कार्यक्रम", + "Task Assigned": "सौंपा गया कार्य", + "Member": "सदस्य", + "Admin": "व्यवस्थापक", + "loading": "लोड हो रहा है", + "noAttendees": "कोई प्रतिभागी नहीं मिला" + }, + "onSpotAttendee": { + "title": "ऑन-स्पॉट प्रतिभागी", + "enterFirstName": "प्रथम नाम दर्ज करें", + "enterLastName": "अंतिम नाम दर्ज करें", + "enterEmail": "ईमेल दर्ज करें", + "enterPhoneNo": "फ़ोन नंबर दर्ज करें", + "selectGender": "लिंग चुनें", + "invalidDetailsMessage": "कृपया सभी आवश्यक फ़ील्ड भरें", + "orgIdMissing": "संगठन आईडी गायब है। कृपया पुनः प्रयास करें।", + "attendeeAddedSuccess": "प्रतिभागी सफलतापूर्वक जोड़ा गया!", + "addAttendee": "जोड़ें", + "phoneNumber": "फ़ोन नंबर", + "addingAttendee": "जोड़ा जा रहा है...", + "male": "पुरुष", + "female": "महिला", + "other": "अन्य" + }, + "settings": { + "noeventsAttended": "कोई कार्यक्रम में उपस्थित नहीं", + "eventAttended": "भाग लिए गए कार्यक्रम", + "profileSettings": "पार्श्वचित्र समायोजन", + "gender": "लिंग", + "phoneNumber": "फ़ोन नंबर", + "chooseFile": "फाइलें चुनें", + "birthDate": "जन्म तिथि", + "grade": "शैक्षिक ग्रेड", + "empStatus": "रोज़गार की स्थिति", + "maritalStatus": "वैवाहिक स्थिति", + "state": "शहरी स्थान", + "country": "देश", + "resetChanges": "परिवर्तन रीसेट करें", + "profileDetails": "प्रोफ़ाइल विवरण", + "deleteUserMessage": "डिलीट यूजर बटन पर क्लिक करने से आपका यूजर अपने इवेंट, टैग और सभी संबंधित डेटा के साथ स्थायी रूप से हटा दिया जाएगा।", + "copyLink": "प्रोफ़ाइल लिंक कॉपी करें", + "deleteUser": "उपभोक्ता मिटायें", + "otherSettings": "अन्य सेटिंग", + "changeLanguage": "भाषा बदलें", + "sgender": "लिंग चुनें", + "gradePlaceholder": "ग्रेड दर्ज करें", + "sEmpStatus": "रोजगार की स्थिति चुनें", + "female": "महिला", + "male": "पुरुष", + "employed": "कार्यरत", + "other": "अन्य", + "sMaritalStatus": "वैवाहिक स्थिति चुनें", + "unemployed": "बेरोज़गार", + "married": "विवाहित", + "single": "अकेला", + "widowed": "विधवा", + "divorced": "तलाकशुदा", + "engaged": "काम में लगा हुआ", + "separated": "विभाजित", + "grade1": "ग्रेड 1", + "grade2": "ग्रेड 2", + "grade3": "ग्रेड 3", + "grade4": "ग्रेड 4", + "grade5": "ग्रेड 5", + "grade6": "वर्ग 6", + "grade7": "श्रेणी 7", + "grade8": "कक्षा 8", + "grade9": "श्रेणी 9", + "grade10": "ग्रेड 10", + "grade11": "ग्रेड 11", + "grade12": "कक्षा 12", + "graduate": "स्नातक", + "kg": "किलोग्राम", + "preKg": "पूर्व केजी", + "noGrade": "कोई ग्रेड नहीं", + "fullTime": "पूरा समय", + "partTime": "पार्ट टाईम", + "selectCountry": "कोई देश चुनें", + "enterState": "शहर या राज्य दर्ज करें", + "settings": "समायोजन", + "firstName": "प्रथम नाम", + "lastName": "अंतिम नाम", + "emailAddress": "ईमेल पता", + "displayImage": "प्रदर्शन छवि", + "address": "पता", + "saveChanges": "परिवर्तन सहेजें", + "joined": "शामिल हुए" + }, + "donate": { + "title": "दान", + "donations": "दान", + "searchDonations": "दान खोजें", + "donateForThe": "के लिए दान करें", + "amount": "मात्रा", + "yourPreviousDonations": "आपका पिछला दान", + "donate": "दान करें", + "nothingToShow": "यहां दिखाने के लिए कुछ भी नहीं है.", + "success": "दान सफल", + "invalidAmount": "कृपया दान राशि के लिए संख्यात्मक मान दर्ज करें.", + "donationAmountDescription": "कृपया दान राशि के लिए संख्यात्मक मान दर्ज करें.", + "donationOutOfRange": "दान राशि {{min}} और {{max}} के बीच होनी चाहिए.", + "donateTo": "दान करें" + }, + "userEvents": { + "title": "ईवेंट", + "nothingToShow": "यहां दिखाने के लिए कुछ भी नहीं है.", + "createEvent": "कार्यक्रम बनाएँ", + "recurring": "पुनरावर्ती ईवेंट", + "startTime": "समय शुरू", + "endTime": "अंत समय", + "listView": "लिस्ट व्यू", + "calendarView": "कैलेंडर दृश्य", + "allDay": "पूरे दिन", + "eventCreated": "ईवेंट सफलतापूर्वक बनाया और पोस्ट किया गया.", + "eventDetails": "घटना की जानकारी", + "eventTitle": "शीर्षक", + "enterTitle": "शीर्षक दर्ज करें", + "enterDescription": "विवरण दर्ज करें", + "enterLocation": "स्थान दर्ज करें", + "publicEvent": "सार्वजनिक है", + "registerable": "पंजीकरण योग्य है", + "monthlyCalendarView": "मासिक कैलेंडर", + "yearlyCalendarView": "वार्षिक कैलेंडर", + "search": "खोजें", + "cancel": "रद्द करें", + "create": "बनाएं", + "eventDescription": "कार्यक्रम विवरण", + "eventLocation": "कार्यक्रम स्थान", + "startDate": "प्रारंभ तिथि", + "endDate": "समाप्ति तिथि" + }, + "userEventCard": { + "starts": "प्रारंभ होगा", + "ends": "समाप्त होता है", + "creator": "निर्माता", + "alreadyRegistered": "पहले से ही पंजीकृत", + "location": "स्थान", + "register": "पंजीकरण करें" + }, + "advertisement": { + "title": "विज्ञापनों", + "activeAds": "सक्रिय अभियान", + "archievedAds": "पूर्ण अभियान", + "pMessage": "इस अभियान के लिए विज्ञापन मौजूद नहीं हैं.", + "validLink": "लिंक मान्य है", + "invalidLink": "लिंक अमान्य है", + "Rname": "विज्ञापन का नाम दर्ज करें", + "Rtype": "विज्ञापन का प्रकार चुनें", + "Rmedia": "प्रदर्शित करने के लिए मीडिया सामग्री प्रदान करें", + "RstartDate": "आरंभ तिथि चुनें", + "RendDate": "अंतिम तिथि चुनें", + "RClose": "खिड़की बंद करो", + "addNew": "नया विज्ञापन बनाएं", + "EXname": "पूर्व। ", + "EXlink": "पूर्व। ", + "createAdvertisement": "विज्ञापन बनाएं", + "deleteAdvertisement": "विज्ञापन हटाएँ", + "deleteAdvertisementMsg": "क्या आप यह विज्ञापन हटाना चाहते हैं?", + "view": "देखना", + "editAdvertisement": "विज्ञापन संपादित करें", + "advertisementDeleted": "विज्ञापन सफलतापूर्वक हटाया गया।", + "endDateGreaterOrEqual": "समाप्ति तिथि प्रारंभ तिथि से अधिक या उसके बराबर होनी चाहिए", + "advertisementCreated": "विज्ञापन सफलतापूर्वक बनाया गया।", + "pHeading": "विज्ञापन शीर्षक", + "delete": "हटाएं", + "close": "बंद करें", + "no": "नहीं", + "yes": "हाँ", + "edit": "संपादित करें", + "saveChanges": "परिवर्तन सहेजें", + "endOfResults": "परिणाम समाप्त" + }, + "userChat": { + "chat": "बात करना", + "contacts": "संपर्क", + "search": "खोज", + "messages": "संदेश" + }, + "userChatRoom": { + "selectContact": "बातचीत शुरू करने के लिए एक संपर्क चुनें", + "sendMessage": "मेसेज भेजें" + }, + "orgProfileField": { + "loading": "लोड हो रहा है...", + "noCustomField": "कोई कस्टम फ़ील्ड उपलब्ध नहीं है", + "customFieldName": "कार्यक्षेत्र नाम", + "enterCustomFieldName": "फ़ील्ड नाम दर्ज करें", + "customFieldType": "क्षेत्र प्रकार", + "Remove Custom Field": "कस्टम फ़ील्ड हटाएँ", + "fieldSuccessMessage": "फ़ील्ड सफलतापूर्वक जोड़ा गया", + "fieldRemovalSuccess": "फ़ील्ड सफलतापूर्वक हटा दी गई", + "String": "स्ट्रिंग", + "Boolean": "बूलियन", + "Date": "तारीख", + "Number": "संख्या", + "saveChanges": "परिवर्तन सहेजें" + }, + "orgActionItemCategories": { + "enableButton": "सक्षम", + "disableButton": "अक्षम करना", + "updateActionItemCategory": "अद्यतन", + "actionItemCategoryName": "नाम", + "categoryDetails": "श्रेणी विवरण", + "enterName": "नाम दर्ज करें", + "successfulCreation": "कार्रवाई आइटम श्रेणी सफलतापूर्वक बनाई गई", + "successfulUpdation": "कार्रवाई आइटम श्रेणी सफलतापूर्वक अपडेट की गई", + "sameNameConflict": "अपडेट करने के लिए कृपया नाम बदलें", + "categoryEnabled": "कार्य आइटम श्रेणी सक्षम", + "categoryDisabled": "कार्रवाई आइटम श्रेणी अक्षम", + "noActionItemCategories": "कोई कार्रवाई आइटम श्रेणी नहीं", + "status": "स्थिति", + "categoryDeleted": "कार्रवाई आइटम श्रेणी सफलतापूर्वक हटा दी गई", + "deleteCategory": "श्रेणी हटाएं", + "deleteCategoryMsg": "क्या आप वाकई इस कार्रवाई आइटम श्रेणी को हटाना चाहते हैं?", + "createButton": "बटन बनाएं", + "editButton": "संपादित बटन" + }, + "organizationVenues": { + "title": "स्थानों", + "addVenue": "स्थान जोड़ें", + "venueDetails": "स्थल विवरण", + "venueName": "आयोजन स्थल का नाम", + "enterVenueName": "स्थान का नाम दर्ज करें", + "enterVenueDesc": "स्थान विवरण दर्ज करें", + "capacity": "क्षमता", + "enterVenueCapacity": "स्थान क्षमता दर्ज करें", + "image": "स्थल छवि", + "uploadVenueImage": "स्थल छवि अपलोड करें", + "createVenue": "स्थान बनाएँ", + "venueAdded": "स्थान सफलतापूर्वक जोड़ा गया", + "editVenue": "स्थान अद्यतन करें", + "venueUpdated": "स्थान विवरण सफलतापूर्वक अपडेट किया गया", + "sort": "क्रम से लगाना", + "highestCapacity": "उच्चतम क्षमता", + "lowestCapacity": "सबसे कम क्षमता", + "noVenues": "कोई स्थान नहीं मिला!", + "view": "देखना", + "venueTitleError": "स्थान का शीर्षक खाली नहीं हो सकता!", + "venueCapacityError": "क्षमता एक धनात्मक संख्या होनी चाहिए!", + "searchBy": "खोज से", + "description": "विवरण", + "edit": "संपादित करें", + "delete": "हटाएं", + "name": "नाम", + "desc": "विवरण" + }, + "addMember": { + "title": "सदस्य जोड़ें", + "addMembers": "सदस्य जोड़ें", + "existingUser": "मौजूदा उपयोगकर्ता", + "newUser": "नए उपयोगकर्ता", + "searchFullName": "पूरे नाम से खोजें", + "enterFirstName": "प्रथम नाम दर्ज करें", + "enterLastName": "अंतिम नाम दर्ज करो", + "enterConfirmPassword": "पासवर्ड की पुष्टि करें दर्ज करें", + "organization": "संगठन", + "invalidDetailsMessage": "कृपया सभी आवश्यक विवरण प्रदान करें।", + "passwordNotMatch": "सांकेतिक शब्द मेल नहीं खाते।", + "addMember": "सदस्य जोड़ें", + "firstName": "प्रथम नाम", + "lastName": "अंतिम नाम", + "emailAddress": "ईमेल पता", + "enterEmail": "ईमेल दर्ज करें", + "password": "पासवर्ड", + "enterPassword": "पासवर्ड दर्ज करें", + "confirmPassword": "पासवर्ड की पुष्टि करें", + "cancel": "रद्द करें", + "create": "बनाएं", + "user": "उपयोगकर्ता", + "profile": "प्रोफ़ाइल" + }, + "eventActionItems": { + "title": "एक्शन आइटम्स", + "createActionItem": "एक्शन आइटम बनाएं", + "actionItemCategory": "कार्य आइटम श्रेणी", + "selectActionItemCategory": "एक क्रिया आइटम श्रेणी का चयन करें", + "selectAssignee": "एक समनुदेशिती का चयन करें", + "preCompletionNotes": "टिप्पणियाँ", + "postCompletionNotes": "समापन नोट्स", + "actionItemDetails": "कार्रवाई मद विवरण", + "dueDate": "नियत तारीख", + "completionDate": "पूरा करने की तिथि", + "editActionItem": "क्रिया आइटम संपादित करें", + "deleteActionItem": "क्रिया आइटम हटाएँ", + "deleteActionItemMsg": "क्या आप इस क्रिया आइटम को हटाना चाहते हैं?", + "successfulDeletion": "कार्रवाई आइटम सफलतापूर्वक हटा दिया गया", + "successfulCreation": "कार्रवाई आइटम सफलतापूर्वक बनाया गया", + "successfulUpdation": "कार्रवाई आइटम सफलतापूर्वक अपडेट किया गया", + "notes": "टिप्पणियाँ", + "assignee": "संपत्ति-भागी", + "assigner": "असाइनर", + "assignmentDate": "असाइनमेंट तिथि", + "status": "स्थिति", + "actionItemActive": "सक्रिय", + "actionItemStatus": "कार्रवाई आइटम स्थिति", + "actionItemCompleted": "कार्रवाई आइटम पूर्ण हुआ", + "markCompletion": "पूर्णता को चिह्नित करें", + "save": "बचाना", + "yes": "हाँ", + "no": "नहीं" + }, + "checkIn": { + "errorCheckingIn": "चेक-इन में त्रुटि", + "checkedInSuccessfully": "सफलतापूर्वक चेक-इन किया गया" + }, + "eventRegistrantsModal": { + "errorAddingAttendee": "उपस्थित होने वाले को जोड़ने में त्रुटि", + "errorRemovingAttendee": "उपस्थित होने वाले को हटाने में त्रुटि" + }, + "userCampaigns": { + "title": "धन उगाहने के अभियान", + "searchByName": "नाम से खोजें...", + "searchBy": "द्वारा खोजें", + "pledgers": "प्रतिज्ञाकर्ता", + "campaigns": "अभियान", + "myPledges": "मेरी प्रतिज्ञाएँ", + "lowestAmount": "सबसे कम राशि", + "highestAmount": "सबसे अधिक राशि", + "lowestGoal": "सबसे कम लक्ष्य", + "highestGoal": "सबसे बड़ा लक्ष्य", + "latestEndDate": "सबसे अंतिम समाप्ति तिथि", + "earliestEndDate": "सबसे पहले समाप्ति तिथि", + "addPledge": "प्रतिज्ञा जोड़ें", + "viewPledges": "प्रतिज्ञाएँ देखें", + "noPledges": "कोई प्रतिज्ञा नहीं मिली", + "noCampaigns": "कोई अभियान नहीं मिला" + }, + "userPledges": { + "title": "मेरी प्रतिज्ञाएँ" + }, + "eventVolunteers": { + "volunteers": "स्वयंसेवक", + "volunteer": "स्वयंसेवक", + "volunteerGroups": "स्वयंसेवक समूह", + "individuals": "व्यक्ति", + "groups": "समूह", + "status": "स्थिति", + "noVolunteers": "कोई स्वयंसेवक नहीं", + "noVolunteerGroups": "कोई स्वयंसेवक समूह नहीं", + "add": "जोड़ें", + "mostHoursVolunteered": "सबसे अधिक घंटे स्वयंसेवा", + "leastHoursVolunteered": "सबसे कम घंटे स्वयंसेवा", + "accepted": "स्वीकृत", + "addVolunteer": "स्वयंसेवक जोड़ें", + "removeVolunteer": "स्वयंसेवक हटाएं", + "volunteerAdded": "स्वयंसेवक सफलतापूर्वक जोड़ा गया", + "volunteerRemoved": "स्वयंसेवक सफलतापूर्वक हटाया गया", + "volunteerGroupCreated": "स्वयंसेवक समूह सफलतापूर्वक बनाया गया", + "volunteerGroupUpdated": "स्वयंसेवक समूह सफलतापूर्वक अपडेट किया गया", + "volunteerGroupDeleted": "स्वयंसेवक समूह सफलतापूर्वक हटाया गया", + "removeVolunteerMsg": "क्या आप वाकई इस स्वयंसेवक को हटाना चाहते हैं?", + "deleteVolunteerGroupMsg": "क्या आप वाकई इस स्वयंसेवक समूह को हटाना चाहते हैं?", + "leader": "नेता", + "group": "समूह", + "createGroup": "समूह बनाएं", + "updateGroup": "समूह अपडेट करें", + "deleteGroup": "समूह हटाएं", + "volunteersRequired": "आवश्यक स्वयंसेवक", + "volunteerDetails": "स्वयंसेवक विवरण", + "hoursVolunteered": "स्वयंसेवा घंटे", + "groupDetails": "समूह विवरण", + "creator": "निर्माता", + "requests": "अनुरोध", + "noRequests": "कोई अनुरोध नहीं", + "latest": "नवीनतम", + "earliest": "प्रारंभिक", + "requestAccepted": "अनुरोध सफलतापूर्वक स्वीकृत", + "requestRejected": "अनुरोध सफलतापूर्वक अस्वीकृत", + "details": "विवरण", + "manageGroup": "समूह प्रबंधित करें", + "mostVolunteers": "सबसे अधिक स्वयंसेवक", + "leastVolunteers": "सबसे कम स्वयंसेवक" + }, + "userVolunteer": { + "title": "स्वयंसेवकता", + "name": "शीर्षक", + "upcomingEvents": "आगामी कार्यक्रम", + "requests": "अनुरोध", + "invitations": "निमंत्रण", + "groups": "स्वयंसेवक समूह", + "actions": "क्रियाएँ", + "searchByName": "नाम से खोजें", + "latestEndDate": "नवीनतम समाप्ति तिथि", + "earliestEndDate": "प्रारंभिक समाप्ति तिथि", + "noEvents": "कोई आगामी कार्यक्रम नहीं", + "volunteer": "स्वयंसेवक", + "volunteered": "स्वयंसेवित", + "join": "शामिल हों", + "joined": "शामिल हुआ", + "searchByEventName": "कार्यक्रम शीर्षक से खोजें", + "filter": "फ़िल्टर", + "groupInvite": "समूह निमंत्रण", + "individualInvite": "व्यक्तिगत निमंत्रण", + "noInvitations": "कोई निमंत्रण नहीं", + "accept": "स्वीकारें", + "reject": "अस्वीकार करें", + "receivedLatest": "हाल में प्राप्त", + "receivedEarliest": "सबसे पहले प्राप्त", + "invitationAccepted": "निमंत्रण सफलतापूर्वक स्वीकार किया गया", + "invitationRejected": "निमंत्रण सफलतापूर्वक अस्वीकृत", + "volunteerSuccess": "स्वयंसेवक के रूप में अनुरोध सफलतापूर्वक किया गया", + "recurring": "पुनरावृत्ति", + "groupInvitationSubject": "स्वयंसेवक समूह में शामिल होने के लिए निमंत्रण", + "eventInvitationSubject": "कार्यक्रम के लिए स्वयंसेवक बनने का निमंत्रण" + } +} diff --git a/public/locales/sp/common.json b/public/locales/sp/common.json new file mode 100644 index 0000000000..2c06daac5e --- /dev/null +++ b/public/locales/sp/common.json @@ -0,0 +1,98 @@ +{ + "firstName": "First Name", + "lastName": "Last Name", + "searchByName": "Search By Name", + "loading": "Loading...", + "endOfResults": "End of results", + "noResultsFoundFor": "No results found for ", + "edit": "Edit", + "admins": "Admins", + "admin": "ADMIN", + "user": "USER", + "superAdmin": "SUPERADMIN", + "members": "Members", + "logout": "Logout", + "login": "Login", + "register": "Register", + "menu": "Menu", + "settings": "Settings", + "gender": "Género", + "users": "Users", + "requests": "Requests", + "OR": "OR", + "cancel": "Cancel", + "close": "Close", + "create": "Create", + "delete": "Delete", + "done": "Done", + "yes": "Yes", + "no": "No", + "filter": "Filter", + "search": "Search", + "description": "Description", + "saveChanges": "Save Changes", + "resetChanges": "Reset Changes", + "displayImage": "Display Image", + "enterEmail": "Enter Email", + "emailAddress": "Email Address", + "email": "Email", + "name": "Name", + "desc": "Description", + "enterPassword": "Enter Password", + "password": "Password", + "confirmPassword": "Confirm Password", + "forgotPassword": "Forgot Password ?", + "talawaAdminPortal": "Talawa Admin Portal", + "address": "Address", + "location": "Location", + "enterLocation": "Enter Location", + "joined": "Joined", + "startDate": "Start Date", + "endDate": "End Date", + "startTime": "Start Time", + "endTime": "End Time", + "My Organizations": "Mis Organizaciones", + "Dashboard": "Tablero", + "People": "Gente", + "Events": "Eventos", + "Venues": "Lugares", + "Action Items": "Elementos de Acción", + "Posts": "Publicaciones", + "Block/Unblock": "Bloquear/Desbloquear", + "Advertisement": "Publicidad", + "Funds": "Fondos", + "Membership Requests": "Solicitudes de Membresía", + "Plugins": "Complementos", + "Plugin Store": "Tienda de Complementos", + "Settings": "Configuraciones", + "createdOn": "Creado En", + "createdBy": "Creado Por", + "usersRole": "Rol del Usuario", + "changeRole": "Cambiar Rol", + "action": "Acción", + "removeUser": "Eliminar Usuario", + "remove": "Eliminar", + "viewProfile": "Ver Perfil", + "profile": "Perfil", + "noFiltersApplied": "No se aplicaron filtros", + "manage": "Administrar", + "searchResultsFor": "Resultados de búsqueda para {{text}}", + "none": "Ninguno", + "sort": "Ordenar", + "Donate": "Donar", + "addedSuccessfully": "{{item}} agregado con éxito", + "updatedSuccessfully": "{{item}} actualizado con éxito", + "removedSuccessfully": "{{item}} eliminado con éxito", + "successfullyUpdated": "Actualizado con éxito", + "sessionWarning": "Su sesión expirará pronto debido a la inactividad. Por favor, interactúe con la página para extender su sesión.", + "sessionLogOut": "Su sesión ha expirado debido a la inactividad. Por favor, inicie sesión nuevamente para continuar.", + "all": "Todos", + "active": "Activo", + "disabled": "Deshabilitado", + "pending": "Pendiente", + "completed": "Completado", + "late": "Tarde", + "createdLatest": "Creado más reciente", + "createdEarliest": "Creado más temprano", + "searchBy": "Buscar por {{item}}" +} diff --git a/public/locales/sp/errors.json b/public/locales/sp/errors.json new file mode 100644 index 0000000000..39b579abac --- /dev/null +++ b/public/locales/sp/errors.json @@ -0,0 +1,11 @@ +{ + "talawaApiUnavailable": "Talawa-API service is unavailable!. Is it running? Check your network connectivity too.", + "notFound": "Not found", + "unknownError": "An unknown error occurred. Please try again later. {{msg}}", + "notAuthorised": "Sorry! you are not Authorised!", + "errorSendingMail": "Error sending mail", + "emailNotRegistered": "Email not registered", + "notFoundMsg": "Oops! The Page you requested was not found!", + "errorOccurredCouldntCreate": "Ocurrió un error. No se pudo crear {{entity}}", + "errorLoading": "Ocurrió un error al cargar los datos de {{entity}}" +} diff --git a/public/locales/sp/translation.json b/public/locales/sp/translation.json new file mode 100644 index 0000000000..7aa1d6ffc0 --- /dev/null +++ b/public/locales/sp/translation.json @@ -0,0 +1,1485 @@ +{ + "leaderboard": { + "title": "Tabla de Clasificación", + "searchByVolunteer": "Buscar por Voluntario", + "mostHours": "Más Horas", + "leastHours": "Menos Horas", + "timeFrame": "Período", + "allTime": "Todo el Tiempo", + "weekly": "Esta Semana", + "monthly": "Este Mes", + "yearly": "Este Año", + "noVolunteers": "¡No Se Encontraron Voluntarios!" + }, + "loginPage": { + "title": "Administrador Talawa", + "fromPalisadoes": "Una aplicación de código abierto de los voluntarios de la Fundación palisados", + "talawa_portal": "Portal De Administración Talawa", + "login": "Acceso", + "userLogin": "Inicio de sesión de usuario", + "register": "Registro", + "firstName": "Primer nombre", + "lastName": "Apellido", + "email": "Correo electrónico", + "password": "Clave", + "atleast_8_char_long": "Al menos 8 caracteres de largo", + "atleast_6_char_long": "Al menos 6 caracteres de largo", + "firstName_invalid": "El nombre debe contener solo letras minúsculas y mayúsculas.", + "lastName_invalid": "El apellido debe contener solo letras minúsculas y mayúsculas.", + "password_invalid": "La contraseña debe contener al menos una letra minúscula, una letra mayúscula, un valor numérico y un carácter especial.", + "email_invalid": "El correo electrónico debe tener al menos 8 caracteres.", + "Password_and_Confirm_password_mismatches.": "Contraseña y Confirmar contraseña no coinciden.", + "confirmPassword": "Confirmar contraseña", + "forgotPassword": "Has olvidado tu contraseña ?", + "enterEmail": "ingrese correo electrónico", + "enterPassword": "introducir la contraseña", + "doNotOwnAnAccount": "¿No tienes una cuenta?", + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. ¿Está funcionando? Verifica también la conectividad de tu red.", + "captchaError": "¡Error de captcha!", + "Please_check_the_captcha": "Por favor, revisa el captcha.", + "Something_went_wrong": "Algo salió mal. Inténtalo después de un tiempo", + "passwordMismatches": "Contraseña y Confirmar contraseña no coinciden.", + "fillCorrectly": "Complete todos los detalles correctamente.", + "notAuthorised": "¡Lo siento! ¡No estás autorizado!", + "notFound": "¡Usuario no encontrado!", + "successfullyRegistered": "Registrado con éxito. Espere hasta que sea aprobado", + "OR": "O", + "admin": "ADMINISTRACIÓN", + "user": "USUARIO", + "lowercase_check": "Al menos una letra mayuscula", + "uppercase_check": "Al menos una letra minúscula", + "numeric_value_check": "Al menos un valor numérico", + "special_char_check": "Al menos un carácter especial", + "loading": "Cargando...", + "selectOrg": "Seleccione una organización", + "afterRegister": "Registro exitoso. Por favor, espere a que el administrador apruebe su solicitud." + }, + "userLoginPage": { + "title": "Administrador Talawa", + "fromPalisadoes": "Una aplicación de código abierto de los voluntarios de la Fundación palisados", + "talawa_portal": "Portal De Administración Talawa", + "login": "Acceso", + "register": "Registro", + "firstName": "Primer nombre", + "lastName": "Apellido", + "email": "Correo electrónico", + "password": "Clave", + "atleast_8_char_long": "Al menos 8 caracteres de largo", + "Password_and_Confirm_password_mismatches.": "Contraseña y Confirmar contraseña no coinciden.", + "confirmPassword": "Confirmar contraseña", + "forgotPassword": "Has olvidado tu contraseña ?", + "enterEmail": "ingrese correo electrónico", + "enterPassword": "introducir la contraseña", + "doNotOwnAnAccount": "¿No tienes una cuenta?", + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. ¿Está funcionando? Verifica también la conectividad de tu red.", + "captchaError": "¡Error de captcha!", + "Please_check_the_captcha": "Por favor, revisa el captcha.", + "Something_went_wrong": "Algo salió mal. Inténtalo después de un tiempo", + "passwordMismatches": "Contraseña y Confirmar contraseña no coinciden.", + "fillCorrectly": "Complete todos los detalles correctamente.", + "notAuthorised": "¡Lo siento! ¡No estás autorizado!", + "notFound": "¡Usuario no encontrado!", + "successfullyRegistered": "Registrado con éxito. Espere hasta que sea aprobado", + "userLogin": "Inicio de sesión de usuario", + "afterRegister": "Registrado exitosamente. Espere a que el administrador apruebe su solicitud.", + "OR": "O", + "loading": "Cargando...", + "selectOrg": "Seleccione una organización" + }, + "latestEvents": { + "eventCardTitle": "Próximos Eventos", + "eventCardSeeAll": "Ver Todos", + "noEvents": "No Hay Eventos Próximos" + }, + "latestPosts": { + "latestPostsTitle": "Últimas Publicaciones", + "seeAllLink": "Ver Todo", + "noPostsCreated": "No se han creado publicaciones" + }, + "listNavbar": { + "talawa_portal": "Portal De Administración Talawa", + "roles": "Roles", + "requests": "Peticiones", + "logout": "Cerrar sesión" + }, + "leftDrawer": { + "talawaAdminPortal": "Portal de administración de Talawa", + "menu": "Menú", + "my organizations": "Mis Organizaciones", + "users": "Usuarios", + "requests": "Solicitudes", + "communityProfile": "Perfil de la comunidad", + "logout": "Cerrar sesión" + }, + "leftDrawerOrg": { + "talawaAdminPortal": "Portal de administración de Talawa", + "menu": "Menú", + "talawa_portal": "Portal De Administración Talawa", + "Dashboard": "Tablero", + "People": "Gente", + "Events": "Eventos", + "Contributions": "Contribuciones", + "Posts": "Publicaciones", + "Block/Unblock": "Bloquear/Desbloquear", + "Plugins": "Complementos", + "Plugin Store": "Tienda de complementos", + "allOrganizations": "Todas las organizaciones", + "yourOrganization": "Tu organización", + "notification": "Notificación", + "settings": "Ajustes", + "language": "Idioma", + "logout": "Cerrar sesión", + "notifications": "Notificaciones", + "spamsThe": "envía correo no deseado", + "group": "grupo", + "noNotifications": "No Notificaciones", + "close": "Cerca", + "Advertisement": "Publicidad" + }, + "orgList": { + "title": "Organizaciones Talawa", + "you": "Tú", + "name": "Nombre", + "designation": "Designacion", + "email": "Correo electrónico", + "searchByName": "Buscar por nombre", + "my organizations": "Mis Organizaciones.", + "createOrganization": "Crear organización", + "createSampleOrganization": "Crear organización de muestra", + "description": "Descripción", + "location": "Ubicación", + "address": "Dirección", + "city": "Ciudad", + "countryCode": "Código de País", + "line1": "Línea 1", + "line2": "Línea 2", + "postalCode": "Código Postal", + "dependentLocality": "Localidad Dependiente", + "sortingCode": "Código de Ordenamiento", + "state": "Estado / Provincia", + "userRegistrationRequired": "Registro de usuario requerido", + "visibleInSearch": "Visible en la búsqueda", + "displayImage": "Mostrar imagen", + "enterName": "Ingrese su nombre", + "sort": "Ordenar", + "Earliest": "Más Temprano", + "Latest": "El último", + "filter": "Filtrar", + "cancel": "Cancelar", + "endOfResults": "Fin de los resultados", + "noOrgErrorTitle": "Organizaciones no encontradas", + "sampleOrgDuplicate": "Solo se permite una organización de muestra", + "noOrgErrorDescription": "Por favor, crea una organización a través del panel de control", + "noResultsFoundFor": "No se encontraron resultados para ", + "OR": "O", + "sampleOrgSuccess": "Organización de ejemplo creada exitosamente", + "manageFeatures": "Gestionar funciones", + "manageFeaturesInfo": "Información de gestión de funciones", + "goToStore": "Ir a la tienda", + "enableEverything": "Habilitar todo" + }, + "orgListCard": { + "admins": "Administradores", + "members": "Miembros", + "manage": "Administrar", + "sampleOrganization": "Organización de muestra" + }, + "paginationList": { + "rowsPerPage": "filas por página", + "all": "Todos" + }, + "requests": { + "title": "Solicitudes", + "sl_no": "Núm.", + "name": "Nombre", + "email": "Correo electrónico", + "accept": "Aceptar", + "reject": "Rechazar", + "searchRequests": "Buscar solicitudes", + "endOfResults": "Fin de los resultados", + "noOrgError": "Organizaciones no encontradas, por favor crea una organización a través del panel", + "noResultsFoundFor": "No se encontraron resultados para ", + "noRequestsFound": "No se encontraron solicitudes", + "acceptedSuccessfully": "Solicitud aceptada exitosamente", + "rejectedSuccessfully": "Solicitud rechazada exitosamente", + "noOrgErrorTitle": "Organizaciones no encontradas", + "noOrgErrorDescription": "Por favor, crea una organización a través del panel de control" + }, + "users": { + "title": "Roles Talawa", + "searchByName": "Buscar por nombre", + "users": "Usuarios", + "name": "Nombre", + "email": "Correo electrónico", + "joined_organizations": "Organizaciones unidas", + "blocked_organizations": "Organizaciones bloqueadas", + "endOfResults": "Fin de los resultados", + "orgJoinedBy": "Organizaciones unidas por", + "orgThatBlocked": "Organizaciones bloqueadas por", + "hasNotJoinedAnyOrg": "No se ha unido a ninguna organización.", + "isNotBlockedByAnyOrg": "No está bloqueado por ninguna organización.", + "searchByOrgName": "Buscar por nombre de organización", + "view": "Ver", + "admin": "ADMINISTRACIÓN", + "superAdmin": "SUPERADMIN", + "user": "USUARIO", + "enterName": "Ingrese su nombre", + "loadingUsers": "Cargando usuarios ...", + "noUserFound": "No se encontró ningún usuario.", + "sort": "Ordenar", + "Oldest": "Más Antiguas Primero", + "Newest": "Más Recientes Primero", + "filter": "Filtrar", + "roleUpdated": "Rol actualizado.", + "noResultsFoundFor": "No se encontraron resultados para ", + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. ¿Está funcionando? Compruebe también la conectividad de su red.", + "cancel": "Cancelar", + "admins": "Administradores", + "members": "Miembros", + "joinNow": "Únete ahora", + "visit": "visita", + "withdraw": "retirar", + "orgJoined": "Unido a la organización exitosamente", + "MembershipRequestSent": "Solicitud de membresía enviada exitosamente", + "AlreadyJoined": "Ya eres miembro de esta organización.", + "errorOccured": "Se produjo un error. Por favor, inténtalo de nuevo más tarde.", + "removeUserFrom": "Eliminar Usuario de {{org}}", + "removeConfirmation": "¿Está seguro de que desea eliminar a '{{name}}' de la organización '{{org}}'?", + "noOrgError": "Error sin organización" + }, + "communityProfile": { + "title": "Perfil de la comunidad", + "editProfile": "Editar perfil", + "communityProfileInfo": "Estos detalles aparecerán en la pantalla de inicio de sesión/registro para usted y los miembros de su comunidad.", + "communityName": "Nombre de la comunidad", + "wesiteLink": "Enlace de página web", + "logo": "Logo", + "social": "Enlaces de redes sociales", + "url": "Introducir URL", + "profileChangedMsg": "Se actualizaron correctamente los detalles del perfil.", + "resetData": "Restablezca correctamente los detalles del perfil." + }, + "dashboard": { + "title": "Panel de", + "location": "Ubicación", + "about": "Sobre", + "deleteThisOrganization": "Eliminar esta organización", + "statistics": "Estadísticas", + "members": "Miembros", + "admins": "Administradores", + "posts": "Publicaciones", + "events": "Eventos", + "blockedUsers": "Usuarios bloqueados", + "requests": "Solicitudes", + "viewAll": "Ver Todo", + "upcomingEvents": "Próximos Eventos", + "noUpcomingEvents": "No Hay Próximos Eventos", + "latestPosts": "Últimas Publicaciones", + "noPostsPresent": "No Hay Publicaciones Presentes", + "membershipRequests": "Solicitudes de Membresía", + "noMembershipRequests": "No Hay Solicitudes de Membresía", + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. ¿Está funcionando? Compruebe también la conectividad de su red.", + "volunteerRankings": "Clasificación de Voluntarios", + "noVolunteers": "¡No Se Encontraron Voluntarios!" + }, + "organizationPeople": { + "title": "Miembros Talawa", + "filterByName": "Filtrar por nombre", + "filterByLocation": "Filtrar por Ubicación", + "filterByEvent": "Filtrar por Evento", + "members": "Miembros", + "admins": "Administradores", + "users": "Usuarios", + "searchName": "Ingrese su nombre", + "searchevent": "Ingresar evento", + "searchFirstName": "Ingrese el nombre", + "searchLastName": "Introduzca el apellido", + "people": "Personas", + "sort": "Ordenar por Rol", + "actions": "Acciones", + "existingUser": "Usuario existente", + "newUser": "Nuevo usuario", + "firstName": "Nombre de pila", + "enterFirstName": "Ponga su primer nombre", + "lastName": "Apellido", + "enterLastName": "Ingresa tu apellido", + "emailAddress": "correo electrónico", + "enterEmail": "Ingrese su dirección de correo electrónico", + "password": "Contraseña", + "enterPassword": "Ingresa tu contraseña", + "confirmPassword": "confirmar Contraseña", + "enterConfirmPassword": "Ingrese su contraseña para confirmar", + "organization": "Organización", + "create": "Crear", + "cancel": "Cancelar", + "invalidDetailsMessage": "Ingrese detalles válidos.", + "addMembers": "Agregar miembros", + "searchFullName": "Ingrese el nombre completo", + "user": "Usuario", + "profile": "Perfil" + }, + "organizationTags": { + "title": "Etiquetas de Organización", + "createTag": "Crear una nueva etiqueta", + "manageTag": "Gestionar", + "editTag": "Editar", + "removeTag": "Eliminar", + "tagDetails": "Detalles de la Etiqueta", + "tagName": "Nombre", + "tagType": "Tipo", + "tagNamePlaceholder": "Escribe el nombre de la etiqueta", + "tagCreationSuccess": "Nueva etiqueta creada con éxito", + "tagUpdationSuccess": "Etiqueta actualizada con éxito", + "tagRemovalSuccess": "Etiqueta eliminada con éxito", + "noTagsFound": "No se encontraron etiquetas", + "removeUserTag": "Eliminar Etiqueta", + "removeUserTagMessage": "¿Desea eliminar esta etiqueta?", + "addChildTag": "Agregar una Sub Etiqueta", + "enterTagName": "Ingrese el nombre de la etiqueta" + }, + "manageTag": { + "title": "Detalles de la Etiqueta", + "addPeopleToTag": "Agregar Personas a la etiqueta", + "viewProfile": "Ver", + "noAssignedMembersFound": "Ningún miembro asignado", + "unassignUserTag": "Desasignar Etiqueta", + "unassignUserTagMessage": "¿Desea eliminar la etiqueta de este usuario?", + "successfullyUnassigned": "Etiqueta desasignada del usuario", + "addPeople": "Agregar Personas", + "add": "Agregar", + "subTags": "Subetiquetas", + "successfullyAssignedToPeople": "Etiqueta asignada con éxito", + "errorOccurredWhileLoadingMembers": "Error al cargar los miembros", + "userName": "Nombre de usuario", + "actions": "Acciones", + "noOneSelected": "Nadie seleccionado", + "assignToTags": "Asignar a etiquetas", + "removeFromTags": "Eliminar de etiquetas", + "assign": "Asignar", + "remove": "Eliminar", + "successfullyAssignedToTags": "Asignado a etiquetas con éxito", + "successfullyRemovedFromTags": "Eliminado de etiquetas con éxito", + "errorOccurredWhileLoadingOrganizationUserTags": "Error al cargar las etiquetas de la organización", + "errorOccurredWhileLoadingSubTags": "Ocurrió un error al cargar las subetiquetas", + "removeUserTag": "Eliminar etiqueta", + "removeUserTagMessage": "¿Desea eliminar esta etiqueta? Esto eliminará todas las subetiquetas y todas las asociaciones.", + "tagDetails": "Detalles de la etiqueta", + "tagName": "Nombre", + "tagUpdationSuccess": "Etiqueta actualizada con éxito", + "tagRemovalSuccess": "Etiqueta eliminada con éxito", + "noTagSelected": "Ninguna etiqueta seleccionada", + "changeNameToEdit": "Cambia el nombre para hacer una actualización", + "selectTag": "Seleccionar etiqueta", + "collapse": "Colapsar", + "expand": "Expandir", + "tagNamePlaceholder": "Escribe el nombre de la etiqueta", + "allTags": "Todas las etiquetas", + "noMoreMembersFound": "No se encontraron más miembros" + }, + "userListCard": { + "joined": "Unido", + "addAdmin": "Agregar administrador", + "addedAsAdmin": "El usuario se agrega como administrador.", + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. ¿Está funcionando? Compruebe también la conectividad de su red." + }, + "orgAdminListCard": { + "joined": "Unido", + "remove": "Remover", + "removeAdmin": "Eliminar administrador", + "removeAdminMsg": "¿Quieres eliminar a este administrador?", + "no": "No", + "yes": "Sí", + "adminRemoved": "Se elimina el administrador.", + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. ¿Está funcionando? Compruebe también la conectividad de su red." + }, + "orgPeopleListCard": { + "joined": "Unido", + "remove": "Remover", + "removeMember": "Eliminar miembro", + "removeMemberMsg": "¿Quieres eliminar a este miembro?", + "no": "No", + "yes": "Sí", + "memberRemoved": "El miembro es eliminado", + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. ¿Está funcionando? Compruebe también la conectividad de su red." + }, + "organizationEvents": { + "title": "Eventos", + "filterByTitle": "Filtrar por Título", + "filterByLocation": "Filtrar por Ubicación", + "filterByDescription": "Filtrar por descripción", + "events": "Eventos", + "addEvent": "Añadir evento", + "eventDetails": "Detalles del evento", + "eventTitle": "Título", + "description": "Descripción", + "location": "Ubicación", + "startDate": "Fecha de inicio", + "searchMemberName": "Buscar nombre de miembro", + "endDate": "Fecha final", + "startTime": "Hora de inicio", + "endTime": "Hora de finalización", + "allDay": "Todo el dia", + "recurringEvent": "Evento recurrente", + "isPublic": "Es público", + "isRegistrable": "Es registrable", + "createEvent": "Crear evento", + "enterFilter": "Introducir filtro", + "enterTitle": "Ingrese el título", + "enterDescrip": "Introduce la descripción", + "eventLocation": "Introducir ubicación", + "eventCreated": "¡Felicidades! Se crea el Evento.", + "eventType": "Tipo de evento", + "searchEventName": "Buscar nombre del evento", + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. ¿Está funcionando? Compruebe también la conectividad de su red.", + "customRecurrence": "Recurrencia personalizada", + "repeatsEvery": "Se repite cada", + "repeatsOn": "Se repite en", + "ends": "Finaliza", + "never": "Nunca", + "on": "En", + "after": "Después de", + "occurences": "ocurrencias", + "done": "Hecho" + }, + "organizationActionItems": { + "actionItemCategory": "Categoría de Acción", + "actionItemDetails": "Detalles de la Acción", + "actionItemCompleted": "Acción Completada", + "assignee": "Asignado", + "assigner": "Asignador", + "assignmentDate": "Fecha de Asignación", + "active": "Activo", + "clearFilters": "Limpiar Filtros", + "completionDate": "Fecha de Finalización", + "createActionItem": "Crear Acción", + "deleteActionItem": "Eliminar Acción", + "deleteActionItemMsg": "¿Desea eliminar esta acción?", + "details": "Detalles", + "dueDate": "Fecha de Vencimiento", + "earliest": "El Más Antiguo", + "editActionItem": "Editar Acción", + "isCompleted": "Completado", + "latest": "El Más Reciente", + "makeActive": "Hacer Activo", + "noActionItems": "No Hay Acciones", + "options": "Opciones", + "preCompletionNotes": "Notas Pre-Compleción", + "actionItemActive": "Acción Activa", + "markCompletion": "Marcar como Completado", + "actionItemStatus": "Estado de la Acción", + "postCompletionNotes": "Notas de Finalización", + "selectActionItemCategory": "Seleccionar una Categoría de Acción", + "selectAssignee": "Seleccionar Asignado", + "status": "Estado", + "successfulCreation": "Acción creada con éxito", + "successfulUpdation": "Acción actualizada con éxito", + "successfulDeletion": "Acción eliminada con éxito", + "title": "Acciones", + "category": "Categoría", + "allottedHours": "Horas Asignadas", + "latestDueDate": "Fecha de Vencimiento Más Reciente", + "earliestDueDate": "Fecha de Vencimiento Más Antigua", + "updateActionItem": "Actualizar Acción", + "noneUpdated": "Ningún campo fue actualizado", + "updateStatusMsg": "¿Está seguro de que desea marcar esta acción como pendiente?", + "close": "Cerrar", + "eventActionItems": "Acciones del Evento", + "no": "No", + "yes": "Sí", + "individuals": "Individuos", + "groups": "Grupos", + "assignTo": "Asignar a", + "volunteers": "Voluntarios", + "volunteerGroups": "Grupos de Voluntarios" + }, + "organizationAgendaCategory": { + "agendaCategoryDetails": "Detalles de la categoría de la agenda", + "updateAgendaCategory": "Actualizar categoría de la agenda", + "title": "Categorías de la agenda", + "name": "Categoría", + "description": "Descripción", + "createdBy": "Creado por", + "options": "Opciones", + "createAgendaCategory": "Crear categoría de la agenda", + "noAgendaCategories": "No hay categorías de la agenda", + "update": "Actualizar", + "agendaCategoryCreated": "Categoría de la agenda creada exitosamente", + "agendaCategoryUpdated": "Categoría de la agenda actualizada exitosamente", + "agendaCategoryDeleted": "Categoría de la agenda eliminada exitosamente", + "deleteAgendaCategory": "Eliminar categoría de la agenda", + "deleteAgendaCategoryMsg": "¿Desea eliminar esta categoría de la agenda?" + }, + "agendaItems": { + "agendaItemDetails": "Detalles del punto del orden del día", + "updateAgendaItem": "Actualizar punto del orden del día", + "title": "Título", + "enterTitle": "Ingresar título", + "sequence": "Secuencia", + "description": "Descripción", + "enterDescription": "Ingresar descripción", + "category": "Categoría del orden del día", + "attachments": "Archivos adjuntos", + "attachmentLimit": "Agregar cualquier archivo de imagen o video hasta 10MB", + "fileSizeExceedsLimit": "El tamaño del archivo excede el límite de 10MB", + "urls": "URLs", + "url": "Agregar enlace a URL", + "enterUrl": "https://example.com", + "invalidUrl": "Ingrese una URL válida", + "link": "Enlace", + "createdBy": "Creado por", + "regular": "Regular", + "note": "Nota", + "duration": "Duración", + "enterDuration": "mm:ss", + "options": "Opciones", + "createAgendaItem": "Crear punto del orden del día", + "noAgendaItems": "No hay puntos del orden del día", + "selectAgendaItemCategory": "Seleccionar una categoría de punto del orden del día", + "update": "Actualizar", + "delete": "Eliminar", + "agendaItemCreated": "Punto del orden del día creado exitosamente", + "agendaItemUpdated": "Punto del orden del día actualizado exitosamente", + "agendaItemDeleted": "Punto del orden del día eliminado exitosamente", + "deleteAgendaItem": "Eliminar punto del orden del día", + "deleteAgendaItemMsg": "¿Desea eliminar este punto del orden del día?" + }, + "eventListCard": { + "location": "Lugar del evento", + "deleteEvent": "Eliminar evento", + "deleteEventMsg": "¿Quieres eliminar este evento?", + "no": "No", + "yes": "Sí", + "editEvent": "Editar evento", + "eventTitle": "Título", + "description": "Descripción", + "startDate": "Fecha de inicio", + "endDate": "Fecha final", + "registerEvent": "Registro", + "alreadyRegistered": "Ya registrado", + "startTime": "Hora de inicio", + "endTime": "Hora de finalización", + "allDay": "Todo el dia", + "recurringEvent": "Evento recurrente", + "isPublic": "Es público", + "isRegistrable": "Es registrable", + "close": "Cerca", + "updatePost": "Actualizar publicación", + "eventDetails": "Detalles del evento", + "eventDeleted": "Evento eliminado con éxito.", + "eventUpdated": "Evento actualizado con éxito.", + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. ¿Está funcionando? Compruebe también la conectividad de su red.", + "thisInstance": "Esta Instancia", + "thisAndFollowingInstances": "Esta y las Siguientes Instancias", + "allInstances": "Todas las Instancias", + "customRecurrence": "Recurrencia personalizada", + "repeatsEvery": "Se repite cada", + "repeatsOn": "Se repite en", + "ends": "Finaliza", + "never": "Nunca", + "on": "En", + "after": "Después de", + "occurences": "ocurrencias", + "done": "Hecho" + }, + "funds": { + "title": "Fondos", + "createFund": "Crear fondo", + "fundName": "Nombre del fondo", + "fundId": "ID de referencia del fondo", + "taxDeductible": "Deducible de impuestos", + "default": "Predeterminado", + "archived": "Archivado", + "fundCreate": "Crear fondo", + "fundUpdate": "Actualizar fondo", + "fundDelete": "Eliminar fondo", + "searchByName": "Buscar por nombre", + "noFundsFound": "No se encontraron fondos", + "createdBy": "Creado por", + "createdOn": "Creado en", + "status": "Estado", + "fundCreated": "Fondo creado correctamente", + "fundUpdated": "Fondo actualizado correctamente", + "fundDeleted": "Fondo eliminado correctamente", + "deleteFundMsg": "¿Está seguro de que desea eliminar este fondo?", + "createdLatest": "Creado más reciente", + "createdEarliest": "Creado más temprano", + "viewCampaigns": "Ver campañas" + }, + "fundCampaign": { + "title": "Campañas de recaudación de fondos", + "campaignName": "Nombre de la campaña", + "campaignOptions": "Opciones", + "fundingGoal": "Meta de financiación", + "addCampaign": "Agregar campaña", + "createdCampaign": "Campaña creada correctamente", + "updatedCampaign": "Campaña actualizada correctamente", + "deletedCampaign": "Campaña eliminada correctamente", + "deleteCampaignMsg": "¿Está seguro de que desea eliminar esta campaña?", + "noCampaigns": "No se encontraron campañas", + "createCampaign": "Crear campaña", + "updateCampaign": "Actualizar campaña", + "deleteCampaign": "Eliminar campaña", + "currency": "Moneda", + "selectCurrency": "Seleccionar moneda", + "searchFullName": "Buscar por nombre", + "viewPledges": "Ver compromisos", + "noCampaignsFound": "No se encontraron campañas", + "latestEndDate": "Fecha de finalización más reciente", + "earliestEndDate": "Fecha de finalización más temprana", + "lowestGoal": "Meta más baja", + "highestGoal": "Meta más alta" + }, + "pledges": { + "title": "Compromisos de Campaña de Financiamiento", + "startDate": "Fecha de Inicio", + "endDate": "Fecha de Finalización", + "pledgeAmount": "Monto del Compromiso", + "pledgeOptions": "Opciones", + "pledgeCreated": "Compromiso creado exitosamente", + "pledgeUpdated": "Compromiso actualizado exitosamente", + "pledgeDeleted": "Compromiso eliminado exitosamente", + "addPledge": "Agregar Compromiso", + "createPledge": "Crear Compromiso", + "currency": "Moneda", + "selectCurrency": "Seleccionar Moneda", + "updatePledge": "Actualizar Compromiso", + "deletePledge": "Eliminar Compromiso", + "amount": "Monto", + "editPledge": "Editar Compromiso", + "deletePledgeMsg": "¿Estás seguro de que quieres eliminar este compromiso?", + "noPledges": "No se encontraron compromisos", + "searchPledger": "Buscar por compromisos", + "highestAmount": "Cantidad más alta", + "lowestAmount": "Cantidad más baja", + "latestEndDate": "Fecha de finalización más reciente", + "earliestEndDate": "Fecha de finalización más cercana", + "campaigns": "Campañas", + "pledges": "Compromisos", + "endsOn": "Finaliza el", + "raisedAmount": "Monto recaudado", + "pledgedAmount": "Monto comprometido" + }, + + "orgPost": { + "title": "Publicaciones de Talawa", + "searchPost": "Buscar Publicación", + "posts": "Publicaciones", + "createPost": "Crear Publicación", + "postDetails": "Detalles de la Publicación", + "postTitle1": "Escribir título de la publicación", + "postTitle": "Título", + "addMedia": "Subir foto o video", + "information": "Información", + "information1": "Escribir información de la publicación", + "addPost": "Agregar Publicación", + "searchTitle": "Buscar por Título", + "searchText": "Buscar por Texto", + "ptitle": "Título de la Publicación", + "postDes": "¿De qué quieres hablar?", + "Title": "Título", + "Text": "Texto", + "cancel": "Cancelar", + "searchBy": "Buscar por", + "Oldest": "Más Antiguas Primero", + "Latest": "Más Recientes Primero", + "sortPost": "Ordenar Publicaciones", + "tag": "Su navegador no admite la etiqueta de video", + "postCreatedSuccess": "¡Felicidades! Has publicado algo.", + "pinPost": "Fijar publicación", + "Next": "Siguiente página", + "Previous": "Página anterior" + }, + "postNotFound": { + "post": "Publicaciones", + "not found!": "Extraviado!", + "organization": "Organización", + "post not found!": "Publicaciones Extraviado!", + "organization not found!": "Organización Extraviado!" + }, + "userNotFound": { + "user": "usuari(a/o)", + "not found!": "extraviado!", + "roles": "papeles", + "user not found!": "usuario no encontrado!", + "member not found!": "Miembro no encontrado!", + "admin not found!": "Administrador no encontrado!", + "roles not found!": "roles no encontrados!" + }, + "orgPostCard": { + "author": "Autor", + "imageURL": "URL de la Imagen", + "videoURL": "URL del Video", + "edit": "Editar Publicación", + "deletePost": "Eliminar Publicación", + "deletePostMsg": "¿Desea eliminar esta publicación?", + "no": "No", + "yes": "Sí", + "editPost": "Editar Publicación", + "postTitle": "Título", + "postTitle1": "Editar título de la publicación", + "information1": "Editar información de la publicación", + "information": "Información", + "image": "Imagen", + "video": "Video", + "close": "Cerrar", + "updatePost": "Actualizar Publicación", + "postDeleted": "Publicación eliminada exitosamente.", + "postUpdated": "Publicación actualizada exitosamente.", + "tag": "Su navegador no admite la etiqueta de video", + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. ¿Está en funcionamiento? Compruebe también su conectividad de red.", + "pin": "Fijar" + }, + "blockUnblockUser": { + "title": "Usuario de bloqueo/desbloqueo de Talawa", + "pageName": "Bloqueo/desbloqueo", + "searchByName": "Buscar por nombre", + "listOfUsers": "Lista de Usuarios que enviaron spam", + "name": "Nombre", + "email": "Correo electrónico", + "block_unblock": "Bloquear/Desbloquear", + "unblock": "Desatascar", + "block": "Bloquear", + "orgName": "Ingrese su nombre", + "blockedSuccessfully": "Usuario bloqueado con éxito", + "Un-BlockedSuccessfully": "Usuario desbloqueado con éxito", + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. ¿Está funcionando? Compruebe también la conectividad de su red.", + "allMembers": "Todos los miembros", + "blockedUsers": "Usuarios bloqueados", + "searchByFirstName": "Buscar por nombre de pila", + "searchByLastName": "Buscar por apellido", + "noResultsFoundFor": "No se encontraron resultados para ", + "noSpammerFound": "No se encontró ningún spammer" + }, + "eventManagement": { + "title": "Gestión de eventos", + "dashboard": "Tablero", + "registrants": "Inscritos", + "attendance": "Asistencia", + "actions": "Acciones", + "agendas": "Agendas", + "statistics": "Estadísticas", + "to": "A", + "volunteers": "Voluntarios" + }, + "forgotPassword": { + "title": "Talawa olvidó su contraseña", + "forgotPassword": "Has olvidado tu contraseña", + "registeredEmail": "Email registrado", + "getOtp": "Obtener OTP", + "enterOtp": "Ingresar OTP", + "enterNewPassword": "Ingrese nueva clave", + "cofirmNewPassword": "Confirmar nueva contraseña", + "changePassword": "Cambia la contraseña", + "backToLogin": "Volver al inicio de sesión", + "userOtp": "por ejemplo 12345", + "password": "Contraseña", + "emailNotRegistered": "El correo electrónico no está registrado.", + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. ¿Está funcionando? Verifica también la conectividad de tu red.", + "errorSendingMail": "Error al enviar correo.", + "passwordMismatches": "Contraseña y Confirmar contraseña no coinciden.", + "passwordChanges": "La contraseña cambia correctamente.", + "OTPsent": "OTP se envía a su correo electrónico registrado" + }, + "pageNotFound": { + "title": "404 No encontrado", + "talawaAdmin": "Administrador de Talawa", + "talawaUser": "Usuario de Talawa", + "404": "404", + "notFoundMsg": "¡Ups! ¡No se encontró la página que solicitaste!", + "backToHome": "De vuelta a casa" + }, + "orgContribution": { + "title": "Contribuciones Talawa", + "filterByName": "Filtrar por nombre", + "filterByTransId": "Filtrar por ID de transacción", + "recentStats": "Estadísticas recientes", + "contribution": "Contribución", + "orgname": "Ingrese su nombre", + "searchtransaction": "Ingrese la identificación de la transacción" + }, + "contriStats": { + "recentContribution": "Contribución reciente", + "highestContribution": "Contribución más alta", + "totalContribution": "Contribución total" + }, + "orgContriCards": { + "date": "Fecha", + "transactionId": "ID de transacción", + "amount": "Monto" + }, + "orgSettings": { + "title": "Configuración", + "general": "General", + "actionItemCategories": "Categorías de elementos de acción", + "updateOrganization": "Actualizar organización", + "seeRequest": "Ver solicitud", + "noData": "Sin datos", + "otherSettings": "Otras configuraciones", + "changeLanguage": "Cambiar idioma", + "manageCustomFields": "Administrar campos personalizados", + "agendaItemCategories": "Categorías de elementos de agenda" + }, + "deleteOrg": { + "deleteOrganization": "Eliminar organización", + "deleteSampleOrganization": "Eliminar organización de muestra", + "deleteMsg": "¿Desea eliminar esta organización?", + "cancel": "Cancelar", + "confirmDelete": "Confirmar eliminación", + "longDelOrgMsg": "Al hacer clic en el botón Eliminar organización, la organización se eliminará permanentemente junto con sus eventos, etiquetas y todos los datos relacionados.", + "successfullyDeletedSampleOrganization": "Organización de muestra eliminada correctamente" + }, + "userUpdate": { + "firstName": "Primer nombre", + "lastName": "Apellido", + "email": "Correo electrónico", + "password": "Clave", + "appLanguageCode": "Idioma predeterminado", + "userType": "Tipo de usuario", + "admin": "Administración", + "superAdmin": "Superadministrador", + "displayImage": "Mostrar imagen", + "saveChanges": "Guardar cambios", + "cancel": "Cancelar" + }, + "userPasswordUpdate": { + "previousPassword": "Contraseña anterior", + "newPassword": "Nueva contraseña", + "confirmNewPassword": "Confirmar nueva contraseña", + "saveChanges": "Guardar cambios", + "cancel": "Cancelar", + "passCantBeEmpty": "La contraseña no puede estar vacía", + "passNoMatch": "La nueva contraseña y la confirmación no coinciden." + }, + "orgDelete": { + "deleteOrg": "Eliminar organización" + }, + "membershipRequest": { + "joined": "Unido", + "accept": "Aceptar", + "reject": "Rechazar", + "memberAdded": "es aceptado", + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. ¿Está funcionando? Compruebe también la conectividad de su red." + }, + "orgUpdate": { + "name": "Nombre", + "description": "Descripción", + "location": "ubicación", + "address": "Dirección", + "city": "Ciudad", + "countryCode": "Código de País", + "line1": "Línea 1", + "line2": "Línea 2", + "postalCode": "Código Postal", + "dependentLocality": "Localidad Dependiente", + "sortingCode": "Código de Ordenamiento", + "state": "Estado / Provincia", + "displayImage": "Mostrar imagen", + "userRegistrationRequired": "Registro de usuario requerido", + "isVisibleInSearch": "Visible en la búsqueda", + "saveChanges": "Guardar cambios", + "cancel": "Cancelar", + "enterNameOrganization": "Ingrese el nombre de la organización", + "successfulUpdated": "Exitoso actualizado", + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. ¿Está funcionando? Compruebe también la conectividad de su red." + }, + "addOnRegister": { + "addNew": "Agregar nueva", + "addPlugin": "Agregar complemento", + "pluginName": "Nombre del complemento", + "creatorName": "Nombre del creadora", + "pluginDesc": "Descripción del complemento", + "close": "Cerca", + "register": "Registro", + "pName": "Ej: Donaciones", + "cName": "Ej: John Doe", + "pDesc": "Este complemento habilita la interfaz de usuario para" + }, + "addOnStore": { + "title": "Tienda de complementos", + "searchName": "Ej: Donaciones", + "search": "Buscar", + "enable": "Activada", + "disable": "Desactivada", + "pHeading": "Complementos", + "install": "Instalada", + "available": "Disponible", + "pMessage": "El complemento no existe", + "filter": "filtros" + }, + "addOnEntry": { + "enable": "Activada", + "install": "Instalar", + "uninstall": "Desinstalar", + "uninstallMsg": "Mensaje de desinstalación", + "installMsg": "Mensaje de instalación" + }, + "memberDetail": { + "title": "Detalles del usuario", + "addAdmin": "Agregar administrador", + "noeventsAttended": "No hay eventos asistidos", + "alreadyIsAdmin": "El Miembro ya es Administrador", + "organizations": "Organizaciones", + "events": "Eventos", + "role": "Rol", + "email": "Correo electrónico", + "createdOn": "Creado en", + "main": "Principal", + "firstName": "Nombre", + "lastName": "Apellido", + "language": "Idioma", + "gender": "Género", + "birthDate": "Fecha de Nacimiento", + "educationGrade": "Nivel Educativo", + "employmentStatus": "Estado Laboral", + "maritalStatus": "Estado Civil", + "displayImage": "Imagen de Perfil", + "phone": "Teléfono", + "address": "Dirección", + "countryCode": "Código de País", + "state": "Estado", + "city": "Ciudad", + "personalInfoHeading": "Información Personal", + "viewAll": "Ver todo", + "eventsAttended": "Eventos Asistidos", + "contactInfoHeading": "Información de Contacto", + "actionsHeading": "Acciones", + "personalDetailsHeading": "Detalles del perfil", + "appLanguageCode": "Elegir Idioma", + "delete": "Eliminar Usuario", + "saveChanges": "Guardar Cambios", + "pluginCreationAllowed": "Permitir creación de complementos", + "joined": "Unido", + "created": "Creado", + "adminForOrganizations": "Administrador de organizaciones", + "membershipRequests": "Solicitudes de membresía", + "adminForEvents": "Administrador de eventos", + "addedAsAdmin": "El usuario se agrega como administrador.", + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. Compruebe amablemente su conexión de red y espere un momento.", + "deleteUser": "Eliminar usuario", + "userType": "Tipo de usuario", + "unassignUserTag": "Desasignar Etiqueta", + "unassignUserTagMessage": "¿Desea eliminar la etiqueta de este usuario?", + "successfullyUnassigned": "Etiqueta desasignada del usuario", + "tagsAssigned": "Etiquetas asignadas", + "noTagsAssigned": "No se asignaron etiquetas" + }, + "userLogin": { + "login": "Acceso", + "forgotPassword": "Has olvidado tu contraseña ?", + "loginIntoYourAccount": "Inicie sesión en su cuenta", + "emailAddress": "correo electrónico", + "enterEmail": "Ingrese su dirección de correo electrónico", + "password": "Contraseña", + "enterPassword": "Ingresa tu contraseña", + "register": "Registro", + "invalidDetailsMessage": "Por favor, introduzca un correo electrónico y una contraseña válidos.", + "notAuthorised": "¡Lo siento! usted no está autorizado!", + "invalidCredentials": "Las credenciales ingresadas son incorrectas. Ingrese credenciales válidas.", + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. Compruebe amablemente su conexión de red y espere un momento." + }, + "people": { + "title": "Gente", + "searchUsers": "Buscar usuarios" + }, + "userRegister": { + "register": "Registro", + "firstName": "Nombre de pila", + "enterFirstName": "Ponga su primer nombre", + "lastName": "Apellido", + "enterLastName": "Ingresa tu apellido", + "emailAddress": "correo electrónico", + "enterEmail": "Ingrese su dirección de correo electrónico", + "password": "Contraseña", + "enterPassword": "Ingresa tu contraseña", + "confirmPassword": "confirmar Contraseña", + "enterConfirmPassword": "Ingrese su contraseña para confirmar", + "alreadyhaveAnAccount": "¿Ya tienes una cuenta?", + "login": "Acceso", + "afterRegister": "Registrado exitosamente. Espere a que el administrador apruebe su solicitud.", + "passwordNotMatch": "La contraseña no coincide. Confirme la contraseña y vuelva a intentarlo.", + "invalidDetailsMessage": "Ingrese detalles válidos.", + "talawaApiUnavailable": "El servicio Talawa-API no está disponible. Compruebe amablemente su conexión de red y espere un momento." + }, + "userNavbar": { + "talawa": "Talawa", + "home": "Hogar", + "people": "Gente", + "events": "Eventos", + "chat": "Charlar", + "donate": "Donar", + "settings": "Ajustes", + "language": "Idioma", + "logout": "Cerrar sesión", + "close": "Cerca" + }, + "userOrganizations": { + "allOrganizations": "Todas las organizaciones", + "joinedOrganizations": "Organizaciones unidas", + "createdOrganizations": "Organizaciones creadas", + "search": "Buscar usuarios", + "nothingToShow": "Nada que mostrar aquí.", + "selectOrganization": "Seleccionar organización", + "filter": "Filtrar", + "organizations": "Organizaciones", + "searchByName": "Buscar por nombre", + "searchUsers": "Buscar usuarios", + "searchOrganizations": "Buscar Organizaciones" + }, + "userSidebarOrg": { + "yourOrganizations": "Tus Organizaciones", + "noOrganizations": "Aún no te has unido a ninguna organización.", + "viewAll": "Ver todo", + "talawaUserPortal": "Talawa portal de usuario", + "menu": "Menú", + "my organizations": "Mis Organizaciones.", + "users": "Usuarios", + "requests": "Solicitudes", + "communityProfile": "Perfil de la comunidad", + "logout": "Cerrar sesión", + "settings": "Ajustes", + "chat": "Chat" + }, + "organizationSidebar": { + "viewAll": "Ver todo", + "events": "Eventos", + "members": "Miembros", + "noEvents": "No hay eventos para mostrar", + "noMembers": "No hay miembros para mostrar" + }, + "postCard": { + "likes": "Gustos", + "comments": "Comentarios", + "viewPost": "Ver publicación", + "editPost": "Editar publicación", + "postedOn": "Publicado el {{date}}" + }, + "home": { + "posts": "Publicaciones", + "post": "Publicación", + "title": "Publicaciones", + "textArea": "¿Tienes algo en mente?", + "feed": "Feed", + "loading": "Cargando", + "pinnedPosts": "Publicaciones fijadas", + "yourFeed": "Tu feed", + "nothingToShowHere": "No hay nada que mostrar aquí", + "somethingOnYourMind": "¿Tienes algo en mente?", + "addPost": "Añadir publicación", + "startPost": "Comenzar una publicación", + "media": "Medios", + "event": "Evento", + "article": "Artículo", + "postNowVisibleInFeed": "Publicar ahora visible en el feed" + }, + + "eventAttendance": { + "historical_statistics": "Estadísticas históricas", + "Search member": "Buscar miembro", + "Member Name": "Nombre del miembro", + "Status": "Estado", + "Events Attended": "Eventos asistidos", + "Task Assigned": "Tarea asignada", + "Member": "Miembro", + "Admin": "Administrador", + "loading": "Cargando...", + "noAttendees": "No se encontraron asistentes" + }, + "onSpotAttendee": { + "title": "Asistente en el lugar", + "enterFirstName": "Ingrese el nombre", + "enterLastName": "Ingrese el apellido", + "enterEmail": "Ingrese el correo electrónico", + "enterPhoneNo": "Ingrese el número de teléfono", + "selectGender": "Seleccione el género", + "invalidDetailsMessage": "Por favor complete todos los campos requeridos", + "orgIdMissing": "Falta el ID de la organización. Por favor, inténtelo de nuevo.", + "attendeeAddedSuccess": "¡Asistente agregado exitosamente!", + "addAttendee": "Agregar", + "phoneNumber": "Número de teléfono", + "addingAttendee": "Agregando...", + "male": "Masculino", + "female": "Femenino", + "other": "Otro" + }, + "settings": { + "settings": "Ajustes", + "eventAttended": "Événements Assistés", + "noeventsAttended": "No hay eventos asistidos", + "profileSettings": "Configuración de perfil", + "firstName": "Nombre de pila", + "lastName": "Apellido", + "gender": "Género", + "emailAddress": "Dirección de correo electrónico", + "phoneNumber": "Número de teléfono", + "displayImage": "Imagen de perfil", + "chooseFile": "Elegir archivo", + "birthDate": "Fecha de nacimiento", + "grade": "Nivel educativo", + "empStatus": "Situación laboral", + "maritalStatus": "Estado civil", + "address": "Dirección", + "state": "Ciudad/Estado", + "country": "País", + "resetChanges": "Restablecer cambios", + "saveChanges": "Guardar cambios", + "profileDetails": "Detalles del perfil", + "deleteUserMessage": "Al hacer clic en el botón Eliminar usuario, su usuario se eliminará permanentemente junto con sus eventos, etiquetas y todos los datos relacionados.", + "copyLink": "Copiar enlace del perfil", + "deleteUser": "Eliminar usuario", + "otherSettings": "Otras configuraciones", + "changeLanguage": "Cambiar idioma", + "sgender": "Seleccionar género", + "gradePlaceholder": "Ingresar grado", + "sEmpStatus": "Seleccionar estado de empleo", + "male": "Masculino", + "female": "Femenino", + "other": "Otro", + "employed": "Empleado", + "unemployed": "Desempleado", + "sMaritalStatus": "Seleccionar estado civil", + "single": "Soltero", + "married": "Casado", + "divorced": "Divorciado", + "widowed": "Viudo", + "engaged": "Comprometido", + "separated": "Separado", + "grade1": "1er Grado", + "grade2": "2do Grado", + "grade3": "3er Grado", + "grade4": "4to Grado", + "grade5": "5to Grado", + "grade6": "6to Grado", + "grade7": "7mo Grado", + "grade8": "8vo Grado", + "grade9": "9no Grado", + "grade10": "10mo Grado", + "grade11": "11vo Grado", + "grade12": "12vo Grado", + "graduate": "Graduado", + "kg": "KG", + "preKg": "Pre-KG", + "noGrade": "Sin Grado", + "fullTime": "Tiempo Completo", + "partTime": "Medio Tiempo", + "enterState": "Ingresar ciudad o estado", + "selectCountry": "Seleccionar un país", + "joined": "Unido" + }, + "donate": { + "title": "Donaciones", + "donations": "Donaciones", + "searchDonations": "Buscar donaciones", + "donateForThe": "Donar para el", + "donateTo": "Donar a", + "amount": "Cantidad", + "yourPreviousDonations": "Tus donaciones anteriores", + "donate": "Donar", + "nothingToShow": "Nada que mostrar aquí.", + "success": "Donación exitosa", + "invalidAmount": "Ingrese un valor numérico para el monto de la donación.", + "donationAmountDescription": "Ingrese el valor numérico del monto de la donación.", + "donationOutOfRange": "El monto de la donación debe estar entre {{min}} y {{max}}." + }, + "userEvents": { + "title": "Eventos", + "nothingToShow": "No hay nada que mostrar aquí.", + "search": "Buscar", + "createEvent": "Crear evento", + "recurring": "Periódica", + "startTime": "Hora de inicio", + "endTime": "Hora de finalización", + "cancel": "Cancelar", + "create": "Crear", + "listView": "Vista de la lista", + "calendarView": "Vista de calendario", + "allDay": "Todo el dia", + "eventCreated": "Evento creado y publicado exitosamente.", + "eventDetails": "Detalles del evento", + "eventTitle": "Título", + "enterTitle": "Ingrese el título", + "eventDescription": "Descripción", + "enterDescription": "Ingresar descripción", + "eventLocation": "Ubicación", + "enterLocation": "Ingresar Ubicación", + "startDate": "Fecha de inicio", + "endDate": "Fecha de finalización", + "publicEvent": "Es público", + "registerable": "Es registrable", + "monthlyCalendarView": "Calendario mensual", + "yearlyCalendarView": "Calendario anual" + }, + "userEventCard": { + "location": "Ubicación", + "starts": "Empieza", + "ends": "Termina", + "creator": "Creadora", + "alreadyRegistered": "Ya registrado", + "register": "Registro" + }, + "advertisement": { + "title": "Anuncios", + "pHeading": "Gestionar anuncios", + "activeAds": "Campañas activas", + "archievedAds": "Campañas completadas", + "pMessage": "No hay anuncios disponibles para esta campaña.", + "delete": "Eliminar", + "validLink": "El enlace es válido.", + "invalidLink": "El enlace no es válido.", + "close": "Cerrar", + "deleteAdvertisement": "Eliminar anuncio", + "deleteAdvertisementMsg": "¿Desea eliminar este anuncio?", + "no": "No", + "yes": "Sí", + "Rmedia": "Proporcionar contenido multimedia para mostrar", + "view": "Ver", + "edit": "Editar", + "editAdvertisement": "Editar Anuncio", + "advertisementDeleted": "Anuncio eliminado con éxito.", + "endDateGreaterOrEqual": "La fecha de finalización debe ser mayor o igual a la fecha de inicio", + "advertisementCreated": "Anuncio creado con éxito.", + "saveChanges": "Guardar Cambios", + "endOfResults": "Fin de los resultados", + "Rname": "Nombre", + "Rtype": "Tipo", + "RstartDate": "Fecha de inicio", + "RendDate": "Fecha de finalización", + "RClose": "Cerrar", + "addNew": "Agregar nuevo", + "EXname": "Nombre", + "EXlink": "Enlace", + "createAdvertisement": "Crear publicidad" + }, + "userChat": { + "chat": "Charlar", + "search": "Buscar", + "contacts": "Contactos", + "messages": "Mensajes" + }, + "userChatRoom": { + "selectContact": "Seleccione un contacto para iniciar una conversación", + "sendMessage": "Enviar mensaje" + }, + "orgProfileField": { + "loading": "Cargando..", + "noCustomField": "No hay campos personalizados disponibles", + "customFieldName": "Nombre del Campo", + "enterCustomFieldName": "Ingrese el Nombre del Campo", + "customFieldType": "Tipo de Campo", + "saveChanges": "Guardar Cambios", + "Remove Custom Field": "Eliminar Campo Personalizado", + "fieldSuccessMessage": "Campo añadido exitosamente", + "fieldRemovalSuccess": "Campo eliminado exitosamente", + "String": "Cadena", + "Boolean": "Booleano", + "Date": "Fecha", + "Number": "Número" + }, + "orgActionItemCategories": { + "createButton": "Crear", + "editButton": "Editar", + "enableButton": "Habilitar", + "disableButton": "Inhabilitar", + "updateActionItemCategory": "Actualizar", + "actionItemCategoryName": "Nombre", + "categoryDetails": "Detalles de la categoría", + "enterName": "Introduzca el nombre", + "successfulCreation": "Categoría de elemento de acción creada correctamente", + "successfulUpdation": "Categoría de elemento de acción actualizada correctamente", + "sameNameConflict": "Cambie el nombre para realizar una actualización", + "categoryEnabled": "Categoría de elemento de acción habilitada", + "categoryDisabled": "Categoría de elemento de acción deshabilitada", + "noActionItemCategories": "No hay categorías de elementos de acción", + "status": "Estado", + "categoryDeleted": "Categoría de elemento de acción eliminada con éxito", + "deleteCategory": "Eliminar categoría", + "deleteCategoryMsg": "¿Está seguro de que desea eliminar esta categoría de elemento de acción?" + }, + "organizationVenues": { + "title": "Lugares", + "addVenue": "Agregar lugar", + "venueDetails": "Detalles del lugar", + "venueName": "Nombre del lugar", + "enterVenueName": "Ingrese el nombre del lugar", + "description": "Descripción del lugar", + "enterVenueDesc": "Ingrese la descripción del lugar", + "capacity": "Capacidad", + "enterVenueCapacity": "Ingrese la capacidad del lugar", + "image": "Imagen del lugar", + "uploadVenueImage": "Subir imagen del lugar", + "createVenue": "Crear lugar", + "venueAdded": "Lugar agregado correctamente", + "editVenue": "Actualizar lugar", + "venueUpdated": "Detalles del lugar actualizados correctamente", + "sort": "Ordenar", + "highestCapacity": "Mayor capacidad", + "lowestCapacity": "Menor capacidad", + "noVenues": "¡No se encontraron lugares!", + "edit": "Editar", + "view": "Ver", + "delete": "Eliminar", + "venueTitleError": "¡El título del lugar no puede estar vacío!", + "venueCapacityError": "¡La capacidad debe ser un número positivo!", + "searchBy": "Buscar por", + "name": "Nombre", + "desc": "Descripción" + }, + "addMember": { + "title": "Agregar miembro", + "addMembers": "Agregar miembros", + "existingUser": "Usuario existente", + "newUser": "Usuario nuevo", + "searchFullName": "Buscar por nombre completo", + "firstName": "Nombre", + "enterFirstName": "Ingrese el nombre", + "lastName": "Apellido", + "enterLastName": "Ingrese el apellido", + "emailAddress": "Dirección de correo electrónico", + "enterEmail": "Ingrese el correo electrónico", + "password": "Contraseña", + "enterPassword": "Ingrese la contraseña", + "confirmPassword": "Confirmar contraseña", + "enterConfirmPassword": "Ingrese la contraseña de confirmación", + "organization": "Organización", + "cancel": "Cancelar", + "create": "Crear", + "invalidDetailsMessage": "Por favor proporcione todos los detalles requeridos.", + "passwordNotMatch": "Las contraseñas no coinciden.", + "user": "Usuario", + "profile": "Perfil", + "addMember": "Agregar miembro" + }, + "eventActionItems": { + "title": "Elementos de acción", + "createActionItem": "Crear elementos de acción", + "actionItemCategory": "Categoría de elemento de acción", + "selectActionItemCategory": "Seleccione una categoría de elemento de acción", + "selectAssignee": "Seleccione un asignado", + "preCompletionNotes": "Notas", + "postCompletionNotes": "Notas finales", + "actionItemDetails": "Detalles del elemento de acción", + "dueDate": "Fecha de vencimiento", + "completionDate": "Fecha de finalización", + "editActionItem": "Editar elemento de acción", + "deleteActionItem": "Eliminar elemento de acción", + "deleteActionItemMsg": "¿Quieres eliminar este elemento de acción?", + "yes": "Sí", + "no": "no", + "successfulDeletion": "Elemento de acción eliminado exitosamente", + "successfulCreation": "Elemento de acción creado exitosamente", + "successfulUpdation": "Elemento de acción actualizado correctamente", + "notes": "Notas", + "assignee": "Cesionario", + "assigner": "Asignador", + "assignmentDate": "Fecha de asignación", + "status": "Estado", + "actionItemActive": "Activo", + "actionItemStatus": "Estado del elemento de acción", + "actionItemCompleted": "Elemento de acción completado", + "markCompletion": "Marcar finalización", + "save": "Guardar" + }, + "checkIn": { + "errorCheckingIn": "Error al registrarse", + "checkedInSuccessfully": "Registrado con éxito" + }, + "eventRegistrantsModal": { + "errorAddingAttendee": "Error al agregar asistente", + "errorRemovingAttendee": "Error al eliminar asistente" + }, + "userCampaigns": { + "title": "Campañas de recaudación de fondos", + "searchByName": "Buscar por nombre...", + "searchBy": "Buscar por", + "pledgers": "Contribuyentes", + "campaigns": "Campañas", + "myPledges": "Mis Promesas", + "lowestAmount": "Monto más bajo", + "highestAmount": "Monto más alto", + "lowestGoal": "Meta más baja", + "highestGoal": "Meta más alta", + "latestEndDate": "Fecha de finalización más tardía", + "earliestEndDate": "Fecha de finalización más temprana", + "addPledge": "Añadir Promesa", + "viewPledges": "Ver Promesas", + "noPledges": "No se encontraron promesas", + "noCampaigns": "No se encontraron campañas" + }, + "userPledges": { + "title": "Mis Promesas" + }, + "eventVolunteers": { + "volunteers": "Voluntarios", + "volunteer": "Voluntario", + "volunteerGroups": "Grupos de Voluntarios", + "individuals": "Individuos", + "groups": "Grupos", + "status": "Estado", + "noVolunteers": "No Hay Voluntarios", + "noVolunteerGroups": "No Hay Grupos de Voluntarios", + "add": "Agregar", + "mostHoursVolunteered": "Más Horas de Voluntariado", + "leastHoursVolunteered": "Menos Horas de Voluntariado", + "accepted": "Aceptado", + "addVolunteer": "Agregar Voluntario", + "removeVolunteer": "Eliminar Voluntario", + "volunteerAdded": "Voluntario agregado con éxito", + "volunteerRemoved": "Voluntario eliminado con éxito", + "volunteerGroupCreated": "Grupo de voluntarios creado con éxito", + "volunteerGroupUpdated": "Grupo de voluntarios actualizado con éxito", + "volunteerGroupDeleted": "Grupo de voluntarios eliminado con éxito", + "removeVolunteerMsg": "¿Está seguro de que desea eliminar a este Voluntario?", + "deleteVolunteerGroupMsg": "¿Está seguro de que desea eliminar este Grupo de Voluntarios?", + "leader": "Líder", + "group": "Grupo", + "createGroup": "Crear Grupo", + "updateGroup": "Actualizar Grupo", + "deleteGroup": "Eliminar Grupo", + "volunteersRequired": "Voluntarios Necesarios", + "volunteerDetails": "Detalles del Voluntario", + "hoursVolunteered": "Horas de Voluntariado", + "groupDetails": "Detalles del Grupo", + "creator": "Creador", + "requests": "Solicitudes", + "noRequests": "No Hay Solicitudes", + "latest": "Más Reciente", + "earliest": "Más Antiguo", + "requestAccepted": "Solicitud aceptada con éxito", + "requestRejected": "Solicitud rechazada con éxito", + "details": "Detalles", + "manageGroup": "Gestionar Grupo", + "mostVolunteers": "La mayoría de voluntarios", + "leastVolunteers": "Menos voluntarios" + }, + "userVolunteer": { + "title": "Voluntariado", + "name": "Título", + "upcomingEvents": "Próximos Eventos", + "requests": "Solicitudes", + "invitations": "Invitaciones", + "groups": "Grupos de Voluntarios", + "actions": "Acciones", + "searchByName": "Buscar por Nombre", + "latestEndDate": "Fecha de Finalización Más Reciente", + "earliestEndDate": "Fecha de Finalización Más Antigua", + "noEvents": "No Hay Próximos Eventos", + "volunteer": "Voluntario", + "volunteered": "Voluntariado", + "join": "Unirse", + "joined": "Unido", + "searchByEventName": "Buscar por Título del Evento", + "filter": "Filtrar", + "groupInvite": "Invitación de Grupo", + "individualInvite": "Invitación Individual", + "noInvitations": "No Hay Invitaciones", + "accept": "Aceptar", + "reject": "Rechazar", + "receivedLatest": "Recibido Recientemente", + "receivedEarliest": "Recibido en Primer Lugar", + "invitationAccepted": "Invitación aceptada con éxito", + "invitationRejected": "Invitación rechazada con éxito", + "volunteerSuccess": "Solicitud de voluntariado realizada con éxito", + "recurring": "Recurrente", + "groupInvitationSubject": "Invitación a unirse al grupo de voluntarios", + "eventInvitationSubject": "Invitación a ser voluntario para el evento" + } +} diff --git a/public/locales/zh/common.json b/public/locales/zh/common.json new file mode 100644 index 0000000000..e3da0bc32f --- /dev/null +++ b/public/locales/zh/common.json @@ -0,0 +1,98 @@ +{ + "firstName": "名", + "lastName": "姓", + "searchByName": "按名称搜索", + "loading": "加载中...", + "endOfResults": "结果结束", + "noResultsFoundFor": "没有找到结果 ", + "edit": "编辑", + "admins": "管理员", + "admin": "行政", + "user": "用户", + "superAdmin": "超级管理员", + "members": "会员", + "logout": "登出", + "login": "登录", + "register": "登记", + "menu": "菜单", + "gender": "性别", + "settings": "设置", + "users": "用户", + "requests": "要求", + "OR": "或者", + "cancel": "取消", + "close": "关闭", + "create": "创造", + "delete": "删除", + "done": "完毕", + "yes": "是的", + "no": "不", + "filter": "筛选", + "search": "搜索", + "description": "描述", + "saveChanges": "保存更改", + "resetChanges": "重置更改", + "displayImage": "显示图像", + "enterEmail": "输入电子邮件", + "emailAddress": "电子邮件地址", + "email": "电子邮件", + "name": "姓名", + "desc": "描述", + "enterPassword": "输入密码", + "password": "密码", + "confirmPassword": "确认密码", + "forgotPassword": "忘记密码 ?", + "talawaAdminPortal": "塔拉瓦管理门户", + "address": "地址", + "location": "地点", + "enterLocation": "输入位置", + "joined": "已加入", + "startDate": "开始日期", + "endDate": "结束日期", + "startTime": "开始时间", + "endTime": "时间结束", + "My Organizations": "我的组织", + "Dashboard": "仪表板", + "People": "人们", + "Events": "事件", + "Venues": "场地", + "Action Items": "行动项目", + "Posts": "帖子", + "Block/Unblock": "封锁/解除封锁", + "Advertisement": "广告", + "Funds": "资金", + "Membership Requests": "会员请求", + "Plugins": "插件", + "Plugin Store": "插件商店", + "Settings": "设置", + "createdOn": "创建于", + "createdBy": "创建者", + "usersRole": "用户角色", + "changeRole": "更改角色", + "action": "操作", + "removeUser": "删除用户", + "remove": "删除", + "viewProfile": "查看个人资料", + "profile": "轮廓", + "noFiltersApplied": "未应用筛选器", + "manage": "管理", + "searchResultsFor": "搜索结果", + "none": "没有", + "sort": "种类", + "Donate": "捐赠", + "addedSuccessfully": "{{item}} 添加成功", + "updatedSuccessfully": "{{item}} 更新成功", + "removedSuccessfully": "{{item}} 删除成功", + "successfullyUpdated": "更新成功", + "sessionWarning": "由于不活动,您的会话即将过期。请与页面互动以延长您的会话。", + "sessionLogOut": "由于不活动,您的会话已过期。请重新登录以继续。", + "all": "全部", + "active": "活跃", + "disabled": "禁用", + "pending": "待处理", + "completed": "已完成", + "late": "迟到", + "createdLatest": "最近创建", + "createdEarliest": "最早创建", + "searchBy": "搜索依据 {{item}}" +} diff --git a/public/locales/zh/errors.json b/public/locales/zh/errors.json new file mode 100644 index 0000000000..c872f367a5 --- /dev/null +++ b/public/locales/zh/errors.json @@ -0,0 +1,11 @@ +{ + "talawaApiUnavailable": "Talawa-API 服务不可用!", + "notFound": "未找到", + "unknownError": "出现未知错误。 {{msg}}", + "notAuthorised": "对不起!", + "errorSendingMail": "发送邮件时出错", + "emailNotRegistered": "邮箱未注册", + "notFoundMsg": "哎呀!", + "errorOccurredCouldntCreate": "发生错误。 无法创建{{entity}}", + "errorLoading": "加载{{entity}}数据时出错" +} diff --git a/public/locales/zh/translation.json b/public/locales/zh/translation.json new file mode 100644 index 0000000000..adafbdbe45 --- /dev/null +++ b/public/locales/zh/translation.json @@ -0,0 +1,1483 @@ +{ + "leaderboard": { + "title": "排行榜", + "searchByVolunteer": "按志愿者搜索", + "mostHours": "最多时数", + "leastHours": "最少时数", + "timeFrame": "时间范围", + "allTime": "全部时间", + "weekly": "本周", + "monthly": "本月", + "yearly": "今年", + "noVolunteers": "未找到志愿者!" + }, + "loginPage": { + "title": "塔拉瓦管理员", + "fromPalisadoes": "Palisadoes 基金会志愿者开发的开源应用程序", + "userLogin": "用户登录", + "atleast_8_char_long": "至少 8 个字符长", + "atleast_6_char_long": "至少 6 个字符长", + "firstName_invalid": "名字只能包含小写和大写字母", + "lastName_invalid": "姓氏只能包含小写和大写字母", + "password_invalid": "密码应至少包含1个小写字母、1个大写字母、1个数字和1个特殊字符", + "email_invalid": "电子邮件应至少包含 8 个字符", + "Password_and_Confirm_password_mismatches.": "密码和确认密码不匹配。", + "doNotOwnAnAccount": "没有帐户?", + "captchaError": "验证码错误!", + "Please_check_the_captcha": "请检查验证码。", + "Something_went_wrong": "出了点问题,请稍后再试。", + "passwordMismatches": "密码和确认密码不匹配。", + "fillCorrectly": "正确填写所有详细信息。", + "successfullyRegistered": "注册成功。", + "lowercase_check": "至少一个小写字母", + "uppercase_check": "至少有一个大写字母", + "numeric_value_check": "至少设定一个数值", + "special_char_check": "至少一个特殊字符", + "selectOrg": "选择一个组织", + "afterRegister": "注册成功。", + "talawa_portal": "塔拉瓦门户", + "login": "登录", + "register": "注册", + "firstName": "名字", + "lastName": "姓氏", + "email": "电子邮件", + "password": "密码", + "confirmPassword": "确认密码", + "forgotPassword": "忘记密码", + "enterEmail": "输入电子邮件", + "enterPassword": "输入密码", + "talawaApiUnavailable": "塔拉瓦 API 不可用", + "notAuthorised": "未授权", + "notFound": "未找到", + "OR": "或", + "admin": "管理员", + "user": "用户", + "loading": "加载中" + }, + "userLoginPage": { + "title": "塔拉瓦管理员", + "fromPalisadoes": "Palisadoes 基金会志愿者开发的开源应用程序", + "atleast_8_char_long": "至少 8 个字符长", + "Password_and_Confirm_password_mismatches.": "密码和确认密码不匹配。", + "doNotOwnAnAccount": "没有帐户?", + "captchaError": "验证码错误!", + "Please_check_the_captcha": "请检查验证码。", + "Something_went_wrong": "出了点问题,请稍后再试。", + "passwordMismatches": "密码和确认密码不匹配。", + "fillCorrectly": "正确填写所有详细信息。", + "successfullyRegistered": "注册成功。", + "userLogin": "用户登录", + "afterRegister": "注册成功。", + "selectOrg": "选择一个组织", + "talawa_portal": "塔拉瓦门户", + "login": "登录", + "register": "注册", + "firstName": "名字", + "lastName": "姓氏", + "email": "电子邮件", + "password": "密码", + "confirmPassword": "确认密码", + "forgotPassword": "忘记密码", + "enterEmail": "输入电子邮件", + "enterPassword": "输入密码", + "talawaApiUnavailable": "塔拉瓦 API 不可用", + "notAuthorised": "未授权", + "notFound": "未找到", + "OR": "或", + "loading": "加载中" + }, + "latestEvents": { + "eventCardTitle": "即将举行的活动", + "eventCardSeeAll": "查看全部", + "noEvents": "没有即将举行的活动" + }, + "latestPosts": { + "latestPostsTitle": "最新帖子", + "seeAllLink": "查看全部", + "noPostsCreated": "没有创建帖子" + }, + "listNavbar": { + "roles": "角色", + "talawa_portal": "塔拉瓦门户", + "requests": "请求", + "logout": "退出登录" + }, + "leftDrawer": { + "my organizations": "我的组织", + "requests": "会员申请", + "communityProfile": "社区简介", + "talawaAdminPortal": "塔拉瓦管理员门户", + "menu": "菜单", + "users": "用户", + "logout": "退出登录" + }, + "leftDrawerOrg": { + "Dashboard": "仪表板", + "People": "人们", + "Events": "活动", + "Contributions": "贡献", + "Posts": "帖子", + "Block/Unblock": "阻止/解除阻止", + "Plugins": "插件", + "Plugin Store": "插件商店", + "Advertisement": "广告", + "allOrganizations": "所有组织", + "yourOrganization": "您的组织", + "notification": "通知", + "language": "语言", + "notifications": "通知", + "spamsThe": "垃圾邮件", + "group": "团体", + "noNotifications": "无通知", + "talawaAdminPortal": "塔拉瓦管理员门户", + "menu": "菜单", + "talawa_portal": "塔拉瓦门户", + "settings": "设置", + "logout": "退出登录", + "close": "关闭" + }, + "orgList": { + "title": "塔拉瓦组织", + "you": "你", + "designation": "指定", + "my organizations": "我的组织", + "createOrganization": "创建组织", + "createSampleOrganization": "创建样本组织", + "city": "城市", + "countryCode": "国家代码", + "dependentLocality": "附属地点", + "line1": "1号线", + "line2": "2号线", + "postalCode": "邮政编码", + "sortingCode": "排序代码", + "state": "州/省", + "userRegistrationRequired": "需要用户注册", + "visibleInSearch": "在搜索中可见", + "enterName": "输入名字", + "sort": "种类", + "Latest": "最新的", + "Earliest": "最早", + "noOrgErrorTitle": "未找到组织", + "sampleOrgDuplicate": "只允许一个样本组织", + "noOrgErrorDescription": "请通过仪表板创建组织", + "manageFeatures": "管理功能", + "manageFeaturesInfo": "创建成功!", + "goToStore": "前往插件商店", + "enableEverything": "启用一切", + "sampleOrgSuccess": "样本组织已成功创建", + "name": "名称", + "email": "电子邮件", + "searchByName": "按名称搜索", + "description": "描述", + "location": "位置", + "address": "地址", + "displayImage": "显示图像", + "filter": "筛选", + "cancel": "取消", + "endOfResults": "结果结束", + "noResultsFoundFor": "未找到结果", + "OR": "或" + }, + "orgListCard": { + "manage": "管理", + "sampleOrganization": "组织样本", + "admins": "管理员", + "members": "成员" + }, + "paginationList": { + "rowsPerPage": "每页行数", + "all": "全部" + }, + "requests": { + "title": "会员申请", + "sl_no": "SL。", + "accept": "接受", + "reject": "拒绝", + "searchRequests": "搜索会员请求", + "noOrgError": "未找到组织,请通过仪表板创建组织", + "noRequestsFound": "未找到会员申请", + "acceptedSuccessfully": "请求已成功接受", + "rejectedSuccessfully": "请求被成功拒绝", + "noOrgErrorTitle": "未找到组织", + "noOrgErrorDescription": "请通过仪表板创建组织", + "name": "名称", + "email": "电子邮件", + "endOfResults": "结果结束", + "noResultsFoundFor": "未找到结果" + }, + "users": { + "title": "塔拉瓦角色", + "joined_organizations": "加入组织", + "blocked_organizations": "被阻止的组织", + "orgJoinedBy": "加入组织", + "orgThatBlocked": "阻止的组织", + "hasNotJoinedAnyOrg": "没有加入任何组织", + "isNotBlockedByAnyOrg": "没有被任何组织封锁", + "searchByOrgName": "按组织名称搜索", + "view": "看法", + "enterName": "输入名字", + "loadingUsers": "正在加载用户...", + "noUserFound": "未找到用户", + "sort": "种类", + "Newest": "新的先来", + "Oldest": "最旧的在前", + "noOrgError": "未找到组织,请通过仪表板创建组织", + "roleUpdated": "角色已更新。", + "joinNow": "立即加入", + "visit": "访问", + "withdraw": "拉幅", + "removeUserFrom": "从{{org}}中删除用户", + "removeConfirmation": "您确定要将'{{name}}'从组织'{{org}}'中删除吗?", + "searchByName": "按名称搜索", + "users": "用户", + "name": "名称", + "email": "电子邮件", + "endOfResults": "结果结束", + "admin": "管理员", + "superAdmin": "超级管理员", + "user": "用户", + "filter": "筛选", + "noResultsFoundFor": "未找到结果", + "talawaApiUnavailable": "塔拉瓦 API 不可用", + "cancel": "取消", + "admins": "管理员", + "members": "成员", + "orgJoined": "已加入组织", + "MembershipRequestSent": "会员请求已发送", + "AlreadyJoined": "已加入", + "errorOccured": "发生错误" + }, + "communityProfile": { + "title": "社区简介", + "editProfile": "编辑个人资料", + "communityProfileInfo": "这些详细信息将显示在您和您的社区成员的登录/注册屏幕上", + "communityName": "社区名字", + "wesiteLink": "网站链接", + "logo": "标识", + "social": "社交媒体链接", + "url": "输入网址", + "profileChangedMsg": "已成功更新个人资料详细信息。", + "resetData": "成功重置个人资料详细信息。" + }, + "dashboard": { + "title": "仪表板", + "about": "关于", + "deleteThisOrganization": "删除该组织", + "statistics": "统计数据", + "posts": "帖子", + "events": "活动", + "blockedUsers": "被阻止的用户", + "viewAll": "查看全部", + "upcomingEvents": "即将举行的活动", + "noUpcomingEvents": "没有即将举行的活动", + "latestPosts": "最新帖子", + "noPostsPresent": "没有帖子", + "membershipRequests": "会员请求", + "noMembershipRequests": "没有会员请求", + "location": "位置", + "members": "成员", + "admins": "管理员", + "requests": "请求", + "talawaApiUnavailable": "塔拉瓦 API 不可用", + "volunteerRankings": "志愿者排名", + "noVolunteers": "未找到志愿者!" + }, + "organizationPeople": { + "title": "塔拉瓦会员", + "filterByName": "按名称过滤", + "filterByLocation": "按地点过滤", + "filterByEvent": "按事件过滤", + "searchName": "输入名字", + "searchevent": "输入事件", + "searchFullName": "输入全名", + "people": "人们", + "sort": "按角色搜索", + "actions": "行动", + "addMembers": "添加会员", + "existingUser": "现有用户", + "newUser": "新用户", + "enterFirstName": "输入您的名字", + "enterLastName": "输入您的姓氏", + "enterConfirmPassword": "输入您的密码进行确认", + "organization": "组织", + "invalidDetailsMessage": "请输入有效的详细信息。", + "members": "成员", + "admins": "管理员", + "users": "用户", + "searchFirstName": "按名字搜索", + "searchLastName": "按姓氏搜索", + "firstName": "名字", + "lastName": "姓氏", + "emailAddress": "电子邮件地址", + "enterEmail": "输入你的电子邮件地址", + "password": "密码", + "enterPassword": "输入你的密码", + "confirmPassword": "确认密码", + "cancel": "取消", + "create": "创建", + "user": "用户", + "profile": "个人资料" + }, + "organizationTags": { + "title": "组织标签", + "createTag": "创建新标签", + "manageTag": "管理", + "editTag": "编辑", + "removeTag": "删除", + "tagDetails": "标签详情", + "tagName": "名称", + "tagType": "类型", + "tagNamePlaceholder": "输入标签名称", + "tagCreationSuccess": "新标签创建成功", + "tagUpdationSuccess": "标签更新成功", + "tagRemovalSuccess": "标签删除成功", + "noTagsFound": "未找到标签", + "removeUserTag": "删除标签", + "removeUserTagMessage": "您确定要删除此标签吗?", + "addChildTag": "添加子标签", + "enterTagName": "输入标签名称" + }, + "manageTag": { + "title": "标签详情", + "addPeopleToTag": "将人员添加到标签", + "viewProfile": "查看", + "noAssignedMembersFound": "没有分配成员", + "unassignUserTag": "取消分配标签", + "unassignUserTagMessage": "您想从此用户中删除标签吗?", + "successfullyUnassigned": "标签已从用户中取消分配", + "addPeople": "添加人员", + "add": "添加", + "subTags": "子标签", + "successfullyAssignedToPeople": "标签分配成功", + "errorOccurredWhileLoadingMembers": "加载成员时出错", + "userName": "用户名", + "actions": "操作", + "noOneSelected": "未选择任何人", + "assignToTags": "分配到标签", + "removeFromTags": "从标签中移除", + "assign": "分配", + "remove": "移除", + "successfullyAssignedToTags": "成功分配到标签", + "successfullyRemovedFromTags": "成功从标签中移除", + "errorOccurredWhileLoadingOrganizationUserTags": "加载组织标签时出错", + "errorOccurredWhileLoadingSubTags": "加载子标签时发生错误", + "removeUserTag": "删除标签", + "removeUserTagMessage": "您要删除此标签吗?这将删除所有子标签和所有关联。", + "tagDetails": "标签详情", + "tagName": "名称", + "tagUpdationSuccess": "标签更新成功", + "tagRemovalSuccess": "标签删除成功", + "noTagSelected": "未选择标签", + "changeNameToEdit": "更改名称以进行更新", + "selectTag": "选择标签", + "collapse": "收起", + "expand": "展开", + "tagNamePlaceholder": "输入标签名称", + "allTags": "所有标签", + "noMoreMembersFound": "未找到更多成员" + }, + "userListCard": { + "addAdmin": "添加管理员", + "addedAsAdmin": "用户被添加为管理员。", + "joined": "已加入", + "talawaApiUnavailable": "塔拉瓦 API 不可用" + }, + "orgAdminListCard": { + "remove": "消除", + "removeAdmin": "删除管理员", + "removeAdminMsg": "您想删除该管理员吗?", + "adminRemoved": "管理员被删除。", + "joined": "已加入", + "no": "否", + "yes": "是", + "talawaApiUnavailable": "塔拉瓦 API 不可用" + }, + "orgPeopleListCard": { + "remove": "消除", + "removeMember": "删除会员", + "removeMemberMsg": "您想删除该成员吗?", + "memberRemoved": "该会员已被删除", + "joined": "已加入", + "no": "否", + "yes": "是", + "talawaApiUnavailable": "塔拉瓦 API 不可用" + }, + "organizationEvents": { + "title": "活动", + "filterByTitle": "按标题过滤", + "filterByLocation": "按地点过滤", + "filterByDescription": "按描述过滤", + "searchMemberName": "搜索成员名称", + "addEvent": "添加事件", + "eventDetails": "活动详情", + "eventTitle": "标题", + "startTime": "开始时间", + "endTime": "时间结束", + "allDay": "一整天", + "recurringEvent": "重复事件", + "isPublic": "是公开的", + "isRegistrable": "可注册", + "createEvent": "创建事件", + "enterFilter": "输入过滤器", + "enterTitle": "输入标题", + "enterDescrip": "输入描述", + "eventLocation": "输入位置", + "searchEventName": "搜索活动名称", + "eventType": "事件类型", + "eventCreated": "恭喜!", + "customRecurrence": "自定义重复", + "repeatsEvery": "重复每个", + "repeatsOn": "重复开启", + "ends": "结束", + "never": "绝不", + "on": "在", + "after": "后", + "occurences": "事件", + "events": "活动", + "description": "描述", + "location": "位置", + "startDate": "开始日期", + "endDate": "结束日期", + "talawaApiUnavailable": "塔拉瓦 API 不可用", + "done": "完成" + }, + "organizationActionItems": { + "actionItemCategory": "行动项类别", + "actionItemDetails": "行动项详情", + "actionItemCompleted": "行动项已完成", + "assignee": "受托人", + "assigner": "分配人", + "assignmentDate": "分配日期", + "active": "活跃", + "clearFilters": "清除过滤器", + "completionDate": "完成日期", + "createActionItem": "创建行动项", + "deleteActionItem": "删除行动项", + "deleteActionItemMsg": "是否要删除此行动项?", + "details": "详情", + "dueDate": "截止日期", + "earliest": "最早", + "editActionItem": "编辑行动项", + "isCompleted": "已完成", + "latest": "最新", + "makeActive": "激活", + "noActionItems": "无行动项", + "options": "选项", + "preCompletionNotes": "预完成备注", + "actionItemActive": "活跃的行动项", + "markCompletion": "标记为完成", + "actionItemStatus": "行动项状态", + "postCompletionNotes": "完成后备注", + "selectActionItemCategory": "选择行动项类别", + "selectAssignee": "选择受托人", + "status": "状态", + "successfulCreation": "行动项创建成功", + "successfulUpdation": "行动项更新成功", + "successfulDeletion": "行动项删除成功", + "title": "行动项", + "category": "类别", + "allottedHours": "分配的小时", + "latestDueDate": "最新截止日期", + "earliestDueDate": "最早截止日期", + "updateActionItem": "更新行动项", + "noneUpdated": "没有字段被更新", + "updateStatusMsg": "您确定要将此行动项标记为待处理吗?", + "close": "关闭", + "eventActionItems": "事件行动项", + "no": "否", + "yes": "是", + "individuals": "个人", + "groups": "小组", + "assignTo": "分配给", + "volunteers": "志愿者", + "volunteerGroups": "志愿者小组" + }, + "organizationAgendaCategory": { + "agendaCategoryDetails": "议程类别详情", + "updateAgendaCategory": "更新议程类别", + "title": "议程类别", + "name": "类别", + "description": "描述", + "createdBy": "创建人", + "options": "选项", + "createAgendaCategory": "创建议程类别", + "noAgendaCategories": "没有议程类别", + "update": "更新", + "agendaCategoryCreated": "议程类别创建成功", + "agendaCategoryUpdated": "议程类别更新成功", + "agendaCategoryDeleted": "议程类别删除成功", + "deleteAgendaCategory": "删除议程类别", + "deleteAgendaCategoryMsg": "是否要删除此议程类别?" + }, + "agendaItems": { + "agendaItemDetails": "议程项目详细信息", + "updateAgendaItem": "更新议程项目", + "title": "标题", + "enterTitle": "输入标题", + "sequence": "顺序", + "description": "描述", + "enterDescription": "输入描述", + "category": "议程类别", + "attachments": "附件", + "attachmentLimit": "添加任何图像文件或视频文件,最大 10MB", + "fileSizeExceedsLimit": "文件大小超过 10MB 的限制", + "urls": "网址", + "url": "添加链接到网址", + "enterUrl": "https://example.com", + "invalidUrl": "请输入有效的网址", + "link": "链接", + "createdBy": "创建人", + "regular": "常规", + "note": "注意", + "duration": "持续时间", + "enterDuration": "分:秒", + "options": "选项", + "createAgendaItem": "创建议程项目", + "noAgendaItems": "没有议程项目", + "selectAgendaItemCategory": "选择议程项目类别", + "update": "更新", + "delete": "删除", + "agendaItemCreated": "议程项目已成功创建", + "agendaItemUpdated": "议程项目已成功更新", + "agendaItemDeleted": "议程项目已成功删除", + "deleteAgendaItem": "删除议程项目", + "deleteAgendaItemMsg": "您要删除此议程项目吗?" + }, + "eventListCard": { + "deleteEvent": "删除事件", + "deleteEventMsg": "您想删除此事件吗?", + "editEvent": "编辑事件", + "eventTitle": "标题", + "alreadyRegistered": "已经注册", + "startTime": "开始时间", + "endTime": "时间结束", + "allDay": "一整天", + "recurringEvent": "重复事件", + "isPublic": "是公开的", + "isRegistrable": "可注册", + "updatePost": "更新帖子", + "eventDetails": "活动详情", + "eventDeleted": "活动删除成功。", + "eventUpdated": "活动更新成功。", + "thisInstance": "本实例", + "thisAndFollowingInstances": "本实例及后续实例", + "allInstances": "所有实例", + "customRecurrence": "自定义重复", + "repeatsEvery": "重复每个", + "repeatsOn": "重复开启", + "ends": "结束", + "never": "绝不", + "on": "在", + "after": "后", + "occurences": "事件", + "location": "位置", + "no": "否", + "yes": "是", + "description": "描述", + "startDate": "开始日期", + "endDate": "结束日期", + "registerEvent": "注册活动", + "close": "关闭", + "talawaApiUnavailable": "塔拉瓦 API 不可用", + "done": "完成" + }, + "funds": { + "title": "基金", + "createFund": "创建基金", + "fundName": "基金名称", + "fundId": "基金(参考)ID", + "taxDeductible": "税前扣除", + "default": "默认", + "archived": "已归档", + "fundCreate": "创建基金", + "fundUpdate": "更新基金", + "fundDelete": "删除基金", + "searchByName": "按名称搜索", + "noFundsFound": "未找到基金", + "createdBy": "由...创建", + "createdOn": "创建于", + "status": "状态", + "fundCreated": "基金创建成功", + "fundUpdated": "基金更新成功", + "fundDeleted": "基金删除成功", + "deleteFundMsg": "您确定要删除此基金吗?", + "createdLatest": "最近创建", + "createdEarliest": "最早创建", + "viewCampaigns": "查看活动" + }, + "fundCampaign": { + "title": "募捐活动", + "campaignName": "活动名称", + "campaignOptions": "选项", + "fundingGoal": "资金目标", + "addCampaign": "添加活动", + "createdCampaign": "活动创建成功", + "updatedCampaign": "活动更新成功", + "deletedCampaign": "活动删除成功", + "deleteCampaignMsg": "您确定要删除此活动吗?", + "noCampaigns": "未找到活动", + "createCampaign": "创建活动", + "updateCampaign": "更新活动", + "deleteCampaign": "删除活动", + "currency": "货币", + "selectCurrency": "选择货币", + "searchFullName": "按名称搜索", + "viewPledges": "查看承诺", + "noCampaignsFound": "未找到活动", + "latestEndDate": "最新结束日期", + "earliestEndDate": "最早结束日期", + "lowestGoal": "最低目标", + "highestGoal": "最高目标" + }, + "pledges": { + "title": "基金活动承诺", + "pledgeAmount": "质押金额", + "pledgeOptions": "选项", + "pledgeCreated": "质押创建成功", + "pledgeUpdated": "承诺更新成功", + "pledgeDeleted": "承诺删除成功", + "addPledge": "添加承诺", + "createPledge": "创建承诺", + "currency": "货币", + "selectCurrency": "选择货币", + "updatePledge": "更新承诺", + "deletePledge": "删除承诺", + "amount": "数量", + "editPledge": "编辑承诺", + "deletePledgeMsg": "您确定要删除此承诺吗?", + "noPledges": "未找到承诺", + "searchPledger": "按承诺搜索", + "highestAmount": "最高金额", + "lowestAmount": "最低金额", + "latestEndDate": "最新结束日期", + "earliestEndDate": "最早结束日期", + "campaigns": "活动", + "pledges": "承诺", + "endsOn": "结束于", + "raisedAmount": "募集金額", + "pledgedAmount": "承诺金額", + "startDate": "开始日期", + "endDate": "结束日期" + }, + "orgPost": { + "title": "帖子", + "searchPost": "搜索帖子", + "posts": "帖子", + "createPost": "创建帖子", + "postDetails": "帖子详情", + "postTitle1": "写下帖子的标题", + "postTitle": "标题", + "addMedia": "上传媒体", + "information": "信息", + "information1": "填写帖子信息", + "addPost": "添加帖子", + "searchTitle": "按标题搜索", + "searchText": "按文本搜索", + "ptitle": "帖子标题", + "postDes": "你要聊什么?", + "Title": "标题", + "Text": "文本", + "searchBy": "搜索依据", + "Oldest": "最旧的在前", + "Latest": "最新第一", + "sortPost": "排序帖子", + "tag": " 您的浏览器不支持video标签", + "postCreatedSuccess": "恭喜!", + "pinPost": "针柱", + "Next": "下一页", + "Previous": "上一页", + "cancel": "取消" + }, + "postNotFound": { + "post": "邮政", + "not found!": "未找到!", + "organization": "组织", + "post not found!": "帖子未找到!", + "organization not found!": "未找到组织!" + }, + "userNotFound": { + "not found!": "未找到!", + "roles": "角色", + "user not found!": "未找到用户!", + "member not found!": "未找到会员!", + "admin not found!": "找不到管理员!", + "roles not found!": "未找到角色!", + "user": "用户" + }, + "orgPostCard": { + "author": "作者", + "imageURL": "图片网址", + "videoURL": "视频网址", + "deletePost": "删除帖子", + "deletePostMsg": "您想删除此帖子吗?", + "editPost": "编辑帖子", + "postTitle": "标题", + "postTitle1": "编辑帖子标题", + "information1": "编辑帖子信息", + "information": "信息", + "image": "图像", + "video": "视频", + "updatePost": "更新帖子", + "postDeleted": "帖子删除成功。", + "postUpdated": "帖子更新成功。", + "tag": " 您的浏览器不支持video标签", + "pin": "针柱", + "edit": "编辑", + "no": "否", + "yes": "是", + "close": "关闭", + "talawaApiUnavailable": "塔拉瓦 API 不可用" + }, + "blockUnblockUser": { + "title": "阻止/取消阻止用户", + "pageName": "阻止/解除阻止", + "listOfUsers": "发送垃圾邮件的用户列表", + "block_unblock": "阻止/解除阻止", + "unblock": "解锁", + "block": "堵塞", + "orgName": "输入名字", + "blockedSuccessfully": "用户被成功屏蔽", + "Un-BlockedSuccessfully": "用户解封成功", + "allMembers": "所有会员", + "blockedUsers": "被阻止的用户", + "searchByFirstName": "按名字搜索", + "searchByLastName": "按姓氏搜索", + "noSpammerFound": "未发现垃圾邮件发送者", + "searchByName": "按名称搜索", + "name": "名称", + "email": "电子邮件", + "talawaApiUnavailable": "塔拉瓦 API 不可用", + "noResultsFoundFor": "未找到结果" + }, + "eventManagement": { + "title": "事件管理", + "dashboard": "仪表板", + "registrants": "注册者", + "attendance": "出席", + "actions": "操作", + "agendas": "议程", + "statistics": "统计数据", + "to": "到", + "volunteers": "志愿者" + }, + "forgotPassword": { + "title": "塔拉瓦 忘记密码", + "registeredEmail": "注册的电子邮件", + "getOtp": "获取一次性密码", + "enterOtp": "输入一次性密码", + "enterNewPassword": "输入新密码", + "cofirmNewPassword": "确认新密码", + "changePassword": "更改密码", + "backToLogin": "回到登入", + "userOtp": "例如", + "emailNotRegistered": "电子邮件未注册。", + "errorSendingMail": "发送邮件时出错。", + "passwordMismatches": "密码和确认密码不匹配。", + "passwordChanges": "密码修改成功。", + "OTPsent": "OTP 已发送至您的注册邮箱。", + "forgotPassword": "忘记密码", + "password": "密码", + "talawaApiUnavailable": "塔拉瓦 API 不可用" + }, + "pageNotFound": { + "404": "404", + "title": "404 未找到", + "talawaAdmin": "塔拉瓦管理员", + "talawaUser": "塔拉瓦用户", + "notFoundMsg": "哎呀!", + "backToHome": "返回首页" + }, + "orgContribution": { + "title": "塔拉瓦 贡献", + "filterByName": "按名称过滤", + "filterByTransId": "按反式过滤。 ", + "recentStats": "最近的统计数据", + "contribution": "贡献", + "orgname": "输入名字", + "searchtransaction": "输入交易ID" + }, + "contriStats": { + "recentContribution": "最近的贡献", + "highestContribution": "最高贡献", + "totalContribution": "总贡献" + }, + "orgContriCards": { + "date": "日期", + "transactionId": "交易ID", + "amount": "数量" + }, + "orgSettings": { + "title": "设置", + "general": "一般的", + "actionItemCategories": "行动项目类别", + "updateOrganization": "更新组织", + "seeRequest": "查看请求", + "noData": "没有数据", + "otherSettings": "其他设置", + "changeLanguage": "改变语言", + "manageCustomFields": "管理自定义字段", + "agendaItemCategories": "议程项目类别" + }, + "deleteOrg": { + "deleteOrganization": "删除组织", + "deleteSampleOrganization": "删除样本组织", + "deleteMsg": "您想删除该组织吗?", + "confirmDelete": "确认删除", + "longDelOrgMsg": "通过单击“删除组织”按钮,该组织及其事件、标签和所有相关数据将被永久删除。", + "successfullyDeletedSampleOrganization": "已成功删除样本组织", + "cancel": "取消" + }, + "userUpdate": { + "appLanguageCode": "默认语言", + "userType": "用户类型", + "firstName": "名字", + "lastName": "姓氏", + "email": "电子邮件", + "password": "密码", + "admin": "管理员", + "superAdmin": "超级管理员", + "displayImage": "显示图像", + "saveChanges": "保存更改", + "cancel": "取消" + }, + "userPasswordUpdate": { + "previousPassword": "旧密码", + "newPassword": "新密码", + "confirmNewPassword": "确认新密码", + "passCantBeEmpty": "密码不能为空", + "passNoMatch": "新密码和确认密码不匹配。", + "saveChanges": "保存更改", + "cancel": "取消" + }, + "orgDelete": { + "deleteOrg": "删除组织" + }, + "membershipRequest": { + "accept": "接受", + "reject": "拒绝", + "memberAdded": "它被接受", + "joined": "已加入", + "talawaApiUnavailable": "塔拉瓦 API 不可用" + }, + "orgUpdate": { + "city": "城市", + "countryCode": "国家代码", + "line1": "1号线", + "line2": "2号线", + "postalCode": "邮政编码", + "dependentLocality": "附属地点", + "sortingCode": "排序代码", + "state": "州/省", + "userRegistrationRequired": "需要用户注册", + "isVisibleInSearch": "在搜索中可见", + "enterNameOrganization": "输入组织名称", + "successfulUpdated": "组织更新成功", + "name": "名称", + "description": "描述", + "location": "位置", + "address": "地址", + "displayImage": "显示图像", + "saveChanges": "保存更改", + "cancel": "取消", + "talawaApiUnavailable": "塔拉瓦 API 不可用" + }, + "addOnRegister": { + "addNew": "添新", + "addPlugin": "添加插件", + "pluginName": "插件名称", + "creatorName": "创建者姓名", + "pluginDesc": "插件说明", + "pName": "例如:捐款", + "cName": "例如:约翰·多伊", + "pDesc": "该插件启用 UI", + "close": "关闭", + "register": "注册" + }, + "addOnStore": { + "title": "添加商店", + "searchName": "例如:捐款", + "search": "搜索", + "enable": "启用", + "disable": "残疾人", + "pHeading": "插件", + "install": "已安装", + "available": "可用的", + "pMessage": "插件不存在", + "filter": "筛选" + }, + "addOnEntry": { + "enable": "启用", + "install": "安装", + "uninstall": "卸载", + "uninstallMsg": "此功能现已从您的组织中删除", + "installMsg": "您的组织现已启用此功能" + }, + "memberDetail": { + "title": "用户详细信息", + "addAdmin": "添加管理员", + "noeventsAttended": "未参加任何活动", + "alreadyIsAdmin": "会员已经是管理员", + "organizations": "组织机构", + "events": "活动", + "role": "角色", + "createdOn": "创建于", + "main": "主要的", + "firstName": "名", + "lastName": "姓", + "language": "语言", + "gender": "性别", + "birthDate": "出生日期", + "educationGrade": "教育等级", + "employmentStatus": "就业状况", + "maritalStatus": "婚姻状况", + "phone": "电话", + "countryCode": "国家代码", + "state": "状态", + "city": "城市", + "personalInfoHeading": "个人信息", + "viewAll": "查看全部", + "eventsAttended": "活动参与", + "contactInfoHeading": "联系信息", + "actionsHeading": "行动", + "personalDetailsHeading": "个人资料详情", + "appLanguageCode": "选择语言", + "deleteUser": "删除用户", + "pluginCreationAllowed": "允许创建插件", + "created": "已创建", + "adminForOrganizations": "组织管理员", + "membershipRequests": "会员请求", + "adminForEvents": "活动管理员", + "addedAsAdmin": "用户被添加为管理员。", + "userType": "用户类型", + "email": "电子邮件", + "displayImage": "显示图像", + "address": "地址", + "delete": "删除", + "saveChanges": "保存更改", + "joined": "已加入", + "talawaApiUnavailable": "塔拉瓦 API 不可用", + "unassignUserTag": "取消分配标签", + "unassignUserTagMessage": "您想从此用户中删除标签吗?", + "successfullyUnassigned": "标签已从用户中取消分配", + "tagsAssigned": "已分配标签", + "noTagsAssigned": "未分配标签" + }, + "userLogin": { + "login": "登录", + "loginIntoYourAccount": "登录您的帐户", + "invalidDetailsMessage": "请输入有效的电子邮件和密码。", + "notAuthorised": "对不起!", + "invalidCredentials": "输入的凭据不正确。", + "forgotPassword": "忘记密码", + "emailAddress": "电子邮件地址", + "enterEmail": "输入电子邮件", + "password": "密码", + "enterPassword": "输入密码", + "register": "注册", + "talawaApiUnavailable": "塔拉瓦 API 不可用" + }, + "people": { + "title": "人们", + "searchUsers": "搜索用户" + }, + "userRegister": { + "enterFirstName": "输入您的名字", + "enterLastName": "输入您的姓氏", + "enterConfirmPassword": "输入您的密码进行确认", + "alreadyhaveAnAccount": "已经有帐户?", + "login": "登录", + "afterRegister": "注册成功。", + "passwordNotMatch": "密码不匹配。", + "invalidDetailsMessage": "请输入有效的详细信息。", + "register": "注册", + "firstName": "名字", + "lastName": "姓氏", + "emailAddress": "电子邮件地址", + "enterEmail": "输入电子邮件", + "password": "密码", + "enterPassword": "输入密码", + "confirmPassword": "确认密码", + "talawaApiUnavailable": "塔拉瓦 API 不可用" + }, + "userNavbar": { + "talawa": "塔拉瓦", + "home": "家", + "people": "人们", + "events": "活动", + "chat": "聊天", + "donate": "捐", + "language": "语言", + "settings": "设置", + "logout": "退出登录", + "close": "关闭" + }, + "userOrganizations": { + "allOrganizations": "所有组织", + "joinedOrganizations": "加入组织", + "createdOrganizations": "创建组织", + "selectOrganization": "选择一个组织", + "searchUsers": "搜索用户", + "nothingToShow": "这里没有什么可显示的。", + "organizations": "组织机构", + "search": "搜索", + "filter": "筛选", + "searchByName": "按名称搜索", + "searchOrganizations": "搜索组织" + }, + "userSidebarOrg": { + "yourOrganizations": "您的组织", + "noOrganizations": "您还没有加入任何组织。", + "viewAll": "查看全部", + "talawaUserPortal": "塔拉瓦用户门户", + "my organizations": "我的组织", + "communityProfile": "社区简介", + "users": "用户", + "requests": "请求", + "logout": "登出", + "settings": "设置", + "chat": "聊天", + "menu": "菜单" + }, + "organizationSidebar": { + "viewAll": "查看全部", + "events": "活动", + "noEvents": "没有可显示的活动", + "noMembers": "没有可显示的会员", + "members": "成员" + }, + "postCard": { + "likes": "喜欢", + "comments": "评论", + "viewPost": "查看帖子", + "editPost": "编辑帖子", + "postedOn": "发布于 {{date}}" + }, + "home": { + "title": "帖子", + "posts": "帖子", + "post": "邮政", + "textArea": "你有什么心事吗?", + "feed": "喂养", + "loading": "加载中", + "pinnedPosts": "已标记的帖子", + "yourFeed": "您的动态", + "nothingToShowHere": "这里没有可显示的内容", + "somethingOnYourMind": "你有什么心事吗?", + "addPost": "添加帖子", + "startPost": "开始发帖", + "media": "媒体", + "event": "事件", + "article": "文章", + "postNowVisibleInFeed": "帖子现在在动态中可见" + }, + "eventAttendance": { + "historical_statistics": "历史统计", + "Search member": "搜索成员", + "Member Name": "成员姓名", + "Status": "状态", + "Events Attended": "参加的活动", + "Task Assigned": "分配的任务", + "Member": "成员", + "Admin": "管理员", + "loading": "加载中...", + "noAttendees": "未找到参与者" + }, + "onSpotAttendee": { + "title": "现场参与者", + "enterFirstName": "输入名字", + "enterLastName": "输入姓氏", + "enterEmail": "输入电子邮件", + "enterPhoneNo": "输入电话号码", + "selectGender": "选择性别", + "invalidDetailsMessage": "请填写所有必填字段", + "orgIdMissing": "组织ID缺失。请重试。", + "attendeeAddedSuccess": "参与者添加成功!", + "addAttendee": "添加", + "phoneNumber": "电话号码", + "addingAttendee": "添加中...", + "male": "男性", + "female": "女性", + "other": "其他" + }, + "settings": { + "noeventsAttended": "未参加任何活动", + "eventAttended": "参加的活动", + "profileSettings": "配置文件设置", + "gender": "性别", + "phoneNumber": "电话号码", + "chooseFile": "选择文件", + "birthDate": "出生日期", + "grade": "教育等级", + "empStatus": "就业状况", + "maritalStatus": "婚姻状况", + "state": "市,州", + "country": "国家", + "resetChanges": "重置更改", + "profileDetails": "个人资料详情", + "deleteUserMessage": "通过单击“删除用户”按钮,您的用户及其事件、标签和所有相关数据将被永久删除。", + "copyLink": "复制个人资料链接", + "deleteUser": "删除用户", + "otherSettings": "其他设置", + "changeLanguage": "改变语言", + "sgender": "选择性别", + "gradePlaceholder": "输入年级", + "sEmpStatus": "选择就业状况", + "female": "女性", + "male": "男性", + "employed": "就业", + "other": "其他", + "sMaritalStatus": "选择婚姻状况", + "unemployed": "失业", + "married": "已婚", + "single": "单身的", + "widowed": "寡", + "divorced": "离婚", + "engaged": "已订婚的", + "separated": "分离的", + "grade1": "1级", + "grade2": "二年级", + "grade3": "三年级", + "grade4": "四年级", + "grade5": "五年级", + "grade6": "6年级", + "grade7": "7年级", + "grade8": "8级", + "grade9": "9年级", + "grade10": "10年级", + "grade11": "11年级", + "grade12": "12年级", + "graduate": "毕业", + "kg": "公斤", + "preKg": "预幼稚园", + "noGrade": "无等级", + "fullTime": "全职", + "partTime": "兼职", + "selectCountry": "选择一个国家", + "enterState": "输入城市或州", + "settings": "设置", + "firstName": "名字", + "lastName": "姓氏", + "emailAddress": "电子邮件地址", + "displayImage": "显示图像", + "address": "地址", + "saveChanges": "保存更改", + "joined": "已加入" + }, + "donate": { + "title": "捐款", + "donations": "捐款", + "searchDonations": "搜索捐款", + "donateForThe": "为", + "amount": "数量", + "yourPreviousDonations": "您之前的捐款", + "donate": "捐", + "nothingToShow": "这里没有什么可显示的。", + "success": "捐赠成功", + "invalidAmount": "请输入捐赠金额的数值。", + "donationAmountDescription": "请输入捐款金额的数值。", + "donationOutOfRange": "捐款金额必须在 {{min}} 和 {{max}} 之间。", + "donateTo": "捐赠给" + }, + "userEvents": { + "title": "活动", + "nothingToShow": "这里没有什么可显示的。", + "createEvent": "创建事件", + "recurring": "重复事件", + "startTime": "开始时间", + "endTime": "时间结束", + "listView": "列表显示", + "calendarView": "日历视图", + "allDay": "一整天", + "eventCreated": "活动已成功创建并发布。", + "eventDetails": "活动详情", + "eventTitle": "标题", + "enterTitle": "输入标题", + "enterDescription": "输入描述", + "enterLocation": "输入位置", + "publicEvent": "是公开的", + "registerable": "可注册", + "monthlyCalendarView": "月历", + "yearlyCalendarView": "年历", + "search": "搜索", + "cancel": "取消", + "create": "创建", + "eventDescription": "活动描述", + "eventLocation": "活动位置", + "startDate": "开始日期", + "endDate": "结束日期" + }, + "userEventCard": { + "starts": "开始", + "ends": "结束", + "creator": "创作者", + "alreadyRegistered": "已经注册", + "location": "位置", + "register": "注册" + }, + "advertisement": { + "title": "广告", + "activeAds": "活跃活动", + "archievedAds": "已完成的活动", + "pMessage": "此活动中不存在广告。", + "validLink": "链接有效", + "invalidLink": "链接无效", + "Rname": "输入广告名称", + "Rtype": "选择广告类型", + "Rmedia": "提供要显示的媒体内容", + "RstartDate": "选择开始日期", + "RendDate": "选择结束日期", + "RClose": "关上窗户", + "addNew": "制作新广告", + "EXname": "前任。", + "EXlink": "前任。 ", + "createAdvertisement": "创建广告", + "deleteAdvertisement": "删除广告", + "deleteAdvertisementMsg": "您想删除该广告吗?", + "view": "看法", + "editAdvertisement": "编辑广告", + "advertisementDeleted": "广告已成功删除。", + "endDateGreaterOrEqual": "结束日期应大于或等于开始日期", + "advertisementCreated": "广告已成功创建。", + "pHeading": "广告标题", + "delete": "删除", + "close": "关闭", + "no": "否", + "yes": "是", + "edit": "编辑", + "saveChanges": "保存更改", + "endOfResults": "结果结束" + }, + "userChat": { + "chat": "聊天", + "contacts": "联系方式", + "search": "搜索", + "messages": "消息" + }, + "userChatRoom": { + "selectContact": "选择联系人开始对话", + "sendMessage": "发信息" + }, + "orgProfileField": { + "loading": "加载中...", + "noCustomField": "没有可用的自定义字段", + "customFieldName": "字段名称", + "enterCustomFieldName": "输入字段名称", + "customFieldType": "字段类型", + "Remove Custom Field": "删除自定义字段", + "fieldSuccessMessage": "字段添加成功", + "fieldRemovalSuccess": "字段删除成功", + "String": "字符串", + "Boolean": "布尔值", + "Date": "日期", + "Number": "数字", + "saveChanges": "保存更改" + }, + "orgActionItemCategories": { + "enableButton": "使能够", + "disableButton": "禁用", + "updateActionItemCategory": "更新", + "actionItemCategoryName": "姓名", + "categoryDetails": "类别详情", + "enterName": "输入名字", + "successfulCreation": "操作项类别创建成功", + "successfulUpdation": "行动项目类别已成功更新", + "sameNameConflict": "请更改名称以进行更新", + "categoryEnabled": "已启用操作项类别", + "categoryDisabled": "操作项类别已禁用", + "noActionItemCategories": "没有操作项目类别", + "status": "地位", + "categoryDeleted": "操作项目类别已成功删除", + "deleteCategory": "删除类别", + "deleteCategoryMsg": "您确定要删除此操作项目类别吗?", + "createButton": "创建按钮", + "editButton": "编辑按钮" + }, + "organizationVenues": { + "title": "场地", + "addVenue": "添加场地", + "venueDetails": "场地详情", + "venueName": "场地名称", + "enterVenueName": "输入场地名称", + "enterVenueDesc": "输入场地描述", + "capacity": "容量", + "enterVenueCapacity": "输入场地容量", + "image": "场地图片", + "uploadVenueImage": "上传场地图片", + "createVenue": "创建场地", + "venueAdded": "场地添加成功", + "editVenue": "更新地点", + "venueUpdated": "场地详情更新成功", + "sort": "种类", + "highestCapacity": "最高容量", + "lowestCapacity": "最低容量", + "noVenues": "未找到场地!", + "view": "看法", + "venueTitleError": "场地名称不能为空!", + "venueCapacityError": "容量必须是正数!", + "searchBy": "搜索依据", + "description": "描述", + "edit": "编辑", + "delete": "删除", + "name": "名称", + "desc": "描述" + }, + "addMember": { + "title": "添加会员", + "addMembers": "添加会员", + "existingUser": "现有用户", + "newUser": "新用户", + "searchFullName": "按全名搜索", + "enterFirstName": "输入名字", + "enterLastName": "输入姓氏", + "enterConfirmPassword": "输入确认密码", + "organization": "组织", + "invalidDetailsMessage": "请提供所有必需的详细信息。", + "passwordNotMatch": "密码不匹配。", + "firstName": "名字", + "lastName": "姓氏", + "emailAddress": "电子邮件地址", + "enterEmail": "输入电子邮件", + "password": "密码", + "enterPassword": "输入密码", + "confirmPassword": "确认密码", + "cancel": "取消", + "create": "创建", + "addMember": "添加会员", + "user": "用户", + "profile": "个人资料" + }, + "eventActionItems": { + "title": "行动项目", + "createActionItem": "创建行动项目", + "actionItemCategory": "行动项目类别", + "selectActionItemCategory": "选择操作项类别", + "selectAssignee": "选择受托人", + "preCompletionNotes": "笔记", + "postCompletionNotes": "完成说明", + "actionItemDetails": "行动项目详情", + "dueDate": "到期日", + "completionDate": "完成日期", + "editActionItem": "编辑操作项", + "deleteActionItem": "删除操作项", + "deleteActionItemMsg": "您想删除此操作项吗?", + "successfulDeletion": "操作项已成功删除", + "successfulCreation": "操作项创建成功", + "successfulUpdation": "操作项已成功更新", + "notes": "笔记", + "assignee": "受让人", + "assigner": "分配者", + "assignmentDate": "任务分配日期", + "status": "地位", + "actionItemActive": "积极的", + "actionItemStatus": "行动项目状态", + "actionItemCompleted": "行动项目已完成", + "markCompletion": "标记完成", + "save": "节省", + "yes": "是", + "no": "否" + }, + "checkIn": { + "errorCheckingIn": "签到错误", + "checkedInSuccessfully": "成功签到" + }, + "eventRegistrantsModal": { + "errorAddingAttendee": "添加与会者时出错", + "errorRemovingAttendee": "删除与会者时出错" + }, + "userCampaigns": { + "title": "筹款活动", + "searchByName": "按名字搜索...", + "searchBy": "按...搜索", + "pledgers": "承诺者", + "campaigns": "活动", + "myPledges": "我的承诺", + "lowestAmount": "最低金额", + "highestAmount": "最高金额", + "lowestGoal": "最低目标", + "highestGoal": "最高目标", + "latestEndDate": "最晚结束日期", + "earliestEndDate": "最早结束日期", + "addPledge": "添加承诺", + "viewPledges": "查看承诺", + "noPledges": "未找到承诺", + "noCampaigns": "未找到活动" + }, + "userPledges": { + "title": "我的承诺" + }, + "eventVolunteers": { + "volunteers": "志愿者", + "volunteer": "志愿者", + "volunteerGroups": "志愿者小组", + "individuals": "个人", + "groups": "小组", + "status": "状态", + "noVolunteers": "无志愿者", + "noVolunteerGroups": "无志愿者小组", + "add": "添加", + "mostHoursVolunteered": "最多志愿时数", + "leastHoursVolunteered": "最少志愿时数", + "accepted": "已接受", + "addVolunteer": "添加志愿者", + "removeVolunteer": "移除志愿者", + "volunteerAdded": "志愿者已成功添加", + "volunteerRemoved": "志愿者已成功移除", + "volunteerGroupCreated": "志愿者小组已成功创建", + "volunteerGroupUpdated": "志愿者小组已成功更新", + "volunteerGroupDeleted": "志愿者小组已成功删除", + "removeVolunteerMsg": "您确定要移除此志愿者吗?", + "deleteVolunteerGroupMsg": "您确定要删除此志愿者小组吗?", + "leader": "领导", + "group": "小组", + "createGroup": "创建小组", + "updateGroup": "更新小组", + "deleteGroup": "删除小组", + "volunteersRequired": "需要志愿者", + "volunteerDetails": "志愿者详情", + "hoursVolunteered": "志愿时数", + "groupDetails": "小组详情", + "creator": "创建者", + "requests": "请求", + "noRequests": "无请求", + "latest": "最新", + "earliest": "最早", + "requestAccepted": "请求已成功接受", + "requestRejected": "请求已成功拒绝", + "details": "详情", + "manageGroup": "管理小组", + "mostVolunteers": "最多的志愿者", + "leastVolunteers": "最少的志愿者" + }, + "userVolunteer": { + "title": "志愿服务", + "name": "标题", + "upcomingEvents": "即将举行的活动", + "requests": "请求", + "invitations": "邀请", + "groups": "志愿者小组", + "actions": "操作", + "searchByName": "按名称搜索", + "latestEndDate": "最晚结束日期", + "earliestEndDate": "最早结束日期", + "noEvents": "无即将举行的活动", + "volunteer": "志愿者", + "volunteered": "已志愿", + "join": "加入", + "joined": "已加入", + "searchByEventName": "按活动标题搜索", + "filter": "筛选", + "groupInvite": "小组邀请", + "individualInvite": "个人邀请", + "noInvitations": "无邀请", + "accept": "接受", + "reject": "拒绝", + "receivedLatest": "最近收到", + "receivedEarliest": "最早收到", + "invitationAccepted": "邀请已成功接受", + "invitationRejected": "邀请已成功拒绝", + "volunteerSuccess": "志愿请求成功", + "recurring": "重复", + "groupInvitationSubject": "邀请加入志愿者小组", + "eventInvitationSubject": "邀请参加活动志愿服务" + } +} diff --git a/public/logo192.png b/public/logo192.png deleted file mode 100644 index fc44b0a379..0000000000 Binary files a/public/logo192.png and /dev/null differ diff --git a/public/manifest.json b/public/manifest.json index 080d6c77ac..23e6e6efe4 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -10,10 +10,10 @@ { "src": "logo192.png", "type": "image/png", - "sizes": "192x192" + "sizes": "16x16" }, { - "src": "logo512.png", + "src": "images/logo512.png", "type": "image/png", "sizes": "512x512" } diff --git a/public/markdown/images/install1.png b/public/markdown/images/install1.png new file mode 100644 index 0000000000..632cb4f7a5 Binary files /dev/null and b/public/markdown/images/install1.png differ diff --git a/public/markdown/images/install2.png b/public/markdown/images/install2.png new file mode 100644 index 0000000000..308cfdbd56 Binary files /dev/null and b/public/markdown/images/install2.png differ diff --git a/schema.graphql b/schema.graphql new file mode 100644 index 0000000000..a0ff7bde8b --- /dev/null +++ b/schema.graphql @@ -0,0 +1,1537 @@ +directive @auth on FIELD_DEFINITION + +directive @role(requires: UserType) on FIELD_DEFINITION + +type ActionItem { + _id: ID! + actionItemCategory: ActionItemCategory + assignee: User + assigner: User + assignmentDate: Date! + completionDate: Date! + createdAt: Date! + creator: User + dueDate: Date! + event: Event + isCompleted: Boolean! + postCompletionNotes: String + preCompletionNotes: String + updatedAt: Date! +} + +type ActionItemCategory { + _id: ID! + createdAt: Date! + creator: User + isDisabled: Boolean! + name: String! + organization: Organization + updatedAt: Date! +} + +type Address { + city: String + countryCode: String + dependentLocality: String + line1: String + line2: String + postalCode: String + sortingCode: String + state: String +} + +input AddressInput { + city: String + countryCode: String + dependentLocality: String + line1: String + line2: String + postalCode: String + sortingCode: String + state: String +} + +type Advertisement { + _id: ID! + createdAt: DateTime! + creator: User + endDate: Date! + mediaUrl: URL! + name: String! + orgId: ID! + startDate: Date! + type: AdvertisementType! + updatedAt: DateTime! +} + +enum AdvertisementType { + BANNER + MENU + POPUP +} + +type AdvertisementEdge { + cursor: String + node: Advertisement +} + +type AdvertisementsConnection { + edges: [AdvertisementEdge] + pageInfo: DefaultConnectionPageInfo + totalCount: Int +} + +type AgendaCategory { + _id: ID! + createdAt: Date! + createdBy: User! + description: String + name: String! + organization: Organization! + updatedAt: Date + updatedBy: User +} + +type AggregatePost { + count: Int! +} + +type AggregateUser { + count: Int! +} + +scalar Any + +type AppUserProfile { + _id: ID! + adminFor: [Organization] + appLanguageCode: String! + createdEvents: [Event] + createdOrganizations: [Organization] + eventAdmin: [Event] + isSuperAdmin: Boolean! + pluginCreationAllowed: Boolean! + userId: User! +} + +type AuthData { + accessToken: String! + appUserProfile: AppUserProfile! + refreshToken: String! + user: User! +} + +type CheckIn { + _id: ID! + allotedRoom: String + allotedSeat: String + createdAt: DateTime! + event: Event! + feedbackSubmitted: Boolean! + time: DateTime! + updatedAt: DateTime! + user: User! +} + +input CheckInInput { + allotedRoom: String + allotedSeat: String + eventId: ID! + userId: ID! +} + +type CheckInStatus { + _id: ID! + checkIn: CheckIn + user: User! +} + +type Comment { + _id: ID! + createdAt: DateTime! + creator: User + likeCount: Int + likedBy: [User] + post: Post! + text: String! + updatedAt: DateTime! +} + +input CommentInput { + text: String! +} + +type Community { + _id: ID! + logoUrl: String + name: String! + socialMediaUrls: SocialMediaUrls + timeout: Int + websiteLink: String +} + +union ConnectionError = InvalidCursor | MaximumValueError + +type ConnectionPageInfo { + endCursor: String + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String +} + +scalar CountryCode + +input CreateActionItemInput { + assigneeId: ID! + dueDate: Date + eventId: ID + preCompletionNotes: String +} + +input CreateAgendaCategoryInput { + description: String + name: String! + organizationId: ID! +} + +input CreateUserTagInput { + name: String! + organizationId: ID! + parentTagId: ID +} + +input CursorPaginationInput { + cursor: String + direction: PaginationDirection! + limit: PositiveInt! +} + +scalar Date + +scalar DateTime + +type DeletePayload { + success: Boolean! +} + +type Donation { + _id: ID! + amount: Float! + createdAt: DateTime! + nameOfOrg: String! + nameOfUser: String! + orgId: ID! + payPalId: String! + updatedAt: DateTime! + userId: ID! +} + +input DonationWhereInput { + id: ID + id_contains: ID + id_in: [ID!] + id_not: ID + id_not_in: [ID!] + id_starts_with: ID + name_of_user: String + name_of_user_contains: String + name_of_user_in: [String!] + name_of_user_not: String + name_of_user_not_in: [String!] + name_of_user_starts_with: String +} + +enum EducationGrade { + GRADE_1 + GRADE_2 + GRADE_3 + GRADE_4 + GRADE_5 + GRADE_6 + GRADE_7 + GRADE_8 + GRADE_9 + GRADE_10 + GRADE_11 + GRADE_12 + GRADUATE + KG + NO_GRADE + PRE_KG +} + +scalar EmailAddress + +enum EmploymentStatus { + FULL_TIME + PART_TIME + UNEMPLOYED +} + +interface Error { + message: String! +} + +type Event { + _id: ID! + actionItems: [ActionItem] + admins(adminId: ID): [User!] + allDay: Boolean! + attendees: [User] + attendeesCheckInStatus: [CheckInStatus!]! + averageFeedbackScore: Float + createdAt: DateTime! + creator: User + description: String! + endDate: Date + endTime: Time + feedback: [Feedback!]! + isPublic: Boolean! + isRegisterable: Boolean! + latitude: Latitude + location: String + longitude: Longitude + organization: Organization + recurrance: Recurrance + recurring: Boolean! + startDate: Date! + startTime: Time + status: Status! + title: String! + updatedAt: DateTime! +} + +input EventAttendeeInput { + eventId: ID! + userId: ID! +} + +input EventInput { + allDay: Boolean! + description: String! + endDate: Date + endTime: Time + isPublic: Boolean! + isRegisterable: Boolean! + latitude: Latitude + location: String + longitude: Longitude + organizationId: ID! + recurrance: Recurrance + recurring: Boolean! + startDate: Date! + startTime: Time + title: String! +} + +enum EventOrderByInput { + allDay_ASC + allDay_DESC + description_ASC + description_DESC + endDate_ASC + endDate_DESC + endTime_ASC + endTime_DESC + id_ASC + id_DESC + location_ASC + location_DESC + recurrance_ASC + recurrance_DESC + startDate_ASC + startDate_DESC + startTime_ASC + startTime_DESC + title_ASC + title_DESC +} + +type EventVolunteer { + _id: ID! + createdAt: DateTime! + creator: User + event: Event + isAssigned: Boolean + isInvited: Boolean + response: String + updatedAt: DateTime! + user: User! +} + +input EventVolunteerInput { + eventId: ID! + userId: ID! +} + +enum EventVolunteerResponse { + NO + YES +} + +input EventWhereInput { + description: String + description_contains: String + description_in: [String!] + description_not: String + description_not_in: [String!] + description_starts_with: String + id: ID + id_contains: ID + id_in: [ID!] + id_not: ID + id_not_in: [ID!] + id_starts_with: ID + location: String + location_contains: String + location_in: [String!] + location_not: String + location_not_in: [String!] + location_starts_with: String + organization_id: ID + title: String + title_contains: String + title_in: [String!] + title_not: String + title_not_in: [String!] + title_starts_with: String +} + +type ExtendSession { + accessToken: String! + refreshToken: String! +} + +type Feedback { + _id: ID! + createdAt: DateTime! + event: Event! + rating: Int! + review: String + updatedAt: DateTime! +} + +input FeedbackInput { + eventId: ID! + rating: Int! + review: String +} + +interface FieldError { + message: String! + path: [String!]! +} + +input ForgotPasswordData { + newPassword: String! + otpToken: String! + userOtp: String! +} + +enum Frequency { + DAILY + MONTHLY + WEEKLY + YEARLY +} + +enum Gender { + FEMALE + MALE + OTHER +} + +type Group { + _id: ID! + admins: [User!]! + createdAt: DateTime! + description: String + organization: Organization! + title: String! + updatedAt: DateTime! +} + +type InvalidCursor implements FieldError { + message: String! + path: [String!]! +} + +scalar JSON + +type Language { + _id: ID! + createdAt: String! + en: String! + translation: [LanguageModel] +} + +input LanguageInput { + en_value: String! + translation_lang_code: String! + translation_value: String! +} + +type LanguageModel { + _id: ID! + createdAt: DateTime! + lang_code: String! + value: String! + verified: Boolean! +} + +scalar Latitude + +input LoginInput { + email: EmailAddress! + password: String! +} + +scalar Longitude + +enum MaritalStatus { + DIVORCED + ENGAGED + MARRIED + SEPERATED + SINGLE + WIDOWED +} + +type Chat { + _id: ID! + isGroup: Boolean! + name: String + createdAt: DateTime! + creator: User + messages: [ChatMessage] + organization: Organization + updatedAt: DateTime! + users: [User!]! + admins: [User] + lastMessageId: String +} + +type ChatMessage { + _id: ID! + createdAt: DateTime! + chatMessageBelongsTo: Chat! + messageContent: String! + type: String! + replyTo: ChatMessage + sender: User! + deletedBy: [User] + updatedAt: DateTime! +} + +type MaximumLengthError implements FieldError { + message: String! + path: [String!]! +} + +type MaximumValueError implements FieldError { + limit: Int! + message: String! + path: [String!]! +} + +type MembershipRequest { + _id: ID! + organization: Organization! + user: User! +} + +type MinimumLengthError implements FieldError { + limit: Int! + message: String! + path: [String!]! +} + +type MinimumValueError implements FieldError { + message: String! + path: [String!]! +} + +enum AdvertisementType { + BANNER + MENU + POPUP +} + +input CreateAdvertisementInput { + endDate: Date! + name: String! + organizationId: ID! + startDate: Date! + type: AdvertisementType! + mediaFile: String! +} + +type CreateAdvertisementPayload { + advertisement: Advertisement +} + +input EditVenueInput { + capacity: Int + description: String + file: String + id: ID! + name: String +} + +type Mutation { + acceptMembershipRequest(membershipRequestId: ID!): MembershipRequest! + addEventAttendee(data: EventAttendeeInput!): User! + addFeedback(data: FeedbackInput!): Feedback! + addLanguageTranslation(data: LanguageInput!): Language! + addOrganizationCustomField( + name: String! + organizationId: ID! + type: String! + ): OrganizationCustomField! + addOrganizationImage(file: String!, organizationId: String!): Organization! + addUserCustomData( + dataName: String! + dataValue: Any! + organizationId: ID! + ): UserCustomData! + addUserImage(file: String!): User! + addUserToUserFamily(familyId: ID!, userId: ID!): UserFamily! + adminRemoveEvent(eventId: ID!): Event! + assignUserTag(input: ToggleUserTagAssignInput!): User + blockPluginCreationBySuperadmin( + blockUser: Boolean! + userId: ID! + ): AppUserProfile! + blockUser(organizationId: ID!, userId: ID!): User! + cancelMembershipRequest(membershipRequestId: ID!): MembershipRequest! + checkIn(data: CheckInInput!): CheckIn! + createActionItem( + actionItemCategoryId: ID! + data: CreateActionItemInput! + ): ActionItem! + createActionItemCategory( + isDisabled: Boolean! + name: String! + organizationId: ID! + ): ActionItemCategory! + createAdmin(data: UserAndOrganizationInput!): AppUserProfile! + createAdvertisement( + endDate: Date! + link: String! + name: String! + orgId: ID! + startDate: Date! + type: String! + ): Advertisement! + createAgendaCategory(input: CreateAgendaCategoryInput!): AgendaCategory! + createComment(data: CommentInput!, postId: ID!): Comment + createChat(data: chatInput!): Chat! + createDonation( + amount: Float! + nameOfOrg: String! + nameOfUser: String! + orgId: ID! + payPalId: ID! + userId: ID! + ): Donation! + createEvent( + data: EventInput! + recurrenceRuleData: RecurrenceRuleInput + ): Event! + createEventVolunteer(data: EventVolunteerInput!): EventVolunteer! + createMember(input: UserAndOrganizationInput!): Organization! + createOrganization(data: OrganizationInput, file: String): Organization! + createPlugin( + pluginCreatedBy: String! + pluginDesc: String! + pluginName: String! + uninstalledOrgs: [ID!] + ): Plugin! + createPost(data: PostInput!, file: String): Post + createSampleOrganization: Boolean! + createUserFamily(data: createUserFamilyInput!): UserFamily! + createUserTag(input: CreateUserTagInput!): UserTag + createVenue(data: VenueInput!): Venue + deleteAdvertisement(id: ID!): DeletePayload! + deleteAdvertisementById(id: ID!): DeletePayload! + deleteAgendaCategory(id: ID!): ID! + deleteDonationById(id: ID!): DeletePayload! + deleteVenue(id: ID!): Venue + editVenue(data: EditVenueInput!): Venue + forgotPassword(data: ForgotPasswordData!): Boolean! + joinPublicOrganization(organizationId: ID!): User! + leaveOrganization(organizationId: ID!): User! + likeComment(id: ID!): Comment + likePost(id: ID!): Post + login(data: LoginInput!): AuthData! + logout: Boolean! + otp(data: OTPInput!): OtpData! + recaptcha(data: RecaptchaVerification!): Boolean! + refreshToken(refreshToken: String!): ExtendSession! + registerForEvent(id: ID!): Event! + rejectMembershipRequest(membershipRequestId: ID!): MembershipRequest! + removeActionItem(id: ID!): ActionItem! + removeAdmin(data: UserAndOrganizationInput!): AppUserProfile! + removeAdvertisement(id: ID!): Advertisement + removeComment(id: ID!): Comment + removeEvent(id: ID!): Event! + removeEventAttendee(data: EventAttendeeInput!): User! + removeEventVolunteer(id: ID!): EventVolunteer! + removeMember(data: UserAndOrganizationInput!): Organization! + removeOrganization(id: ID!): UserData! + removeOrganizationCustomField( + customFieldId: ID! + organizationId: ID! + ): OrganizationCustomField! + removeOrganizationImage(organizationId: String!): Organization! + removePost(id: ID!): Post + removeSampleOrganization: Boolean! + removeUserCustomData(organizationId: ID!): UserCustomData! + removeUserFamily(familyId: ID!): UserFamily! + removeUserFromUserFamily(familyId: ID!, userId: ID!): UserFamily! + removeUserImage: User! + removeUserTag(id: ID!): UserTag + revokeRefreshTokenForUser: Boolean! + saveFcmToken(token: String): Boolean! + sendMembershipRequest(organizationId: ID!): MembershipRequest! + sendMessageToChat(chatId: ID!, messageContent: String!, type: String!, replyTo: ID): ChatMessage! + signUp(data: UserInput!, file: String): AuthData! + togglePostPin(id: ID!, title: String): Post! + unassignUserTag(input: ToggleUserTagAssignInput!): User + unblockUser(organizationId: ID!, userId: ID!): User! + unlikeComment(id: ID!): Comment + unlikePost(id: ID!): Post + unregisterForEventByUser(id: ID!): Event! + updateActionItem(data: UpdateActionItemInput!, id: ID!): ActionItem + updateActionItemCategory( + data: UpdateActionItemCategoryInput! + id: ID! + ): ActionItemCategory + updateAdvertisement( + input: UpdateAdvertisementInput! + ): UpdateAdvertisementPayload + updateAgendaCategory( + id: ID! + input: UpdateAgendaCategoryInput! + ): AgendaCategory + updateEvent(data: UpdateEventInput, id: ID!): Event! + updateEventVolunteer( + data: UpdateEventVolunteerInput + id: ID! + ): EventVolunteer! + updateLanguage(languageCode: String!): User! + updateOrganization( + data: UpdateOrganizationInput + file: String + id: ID! + ): Organization! + updatePluginStatus(id: ID!, orgId: ID!): Plugin! + updatePost(data: PostUpdateInput, id: ID!): Post! + updateSessionTimeout(timeout: Int!): Boolean! + updateUserPassword(data: UpdateUserPasswordInput!): UserData! + updateUserProfile(data: UpdateUserInput, file: String): User! + updateUserRoleInOrganization( + organizationId: ID! + role: String! + userId: ID! + ): Organization! + updateUserTag(input: UpdateUserTagInput!): UserTag + updateUserType(data: UpdateUserTypeInput!): Boolean! + venues: [Venue] +} + +input OTPInput { + email: EmailAddress! +} + +type Organization { + _id: ID! + actionItemCategories: [ActionItemCategory] + address: Address + admins(adminId: ID): [User!] + agendaCategories: [AgendaCategory] + apiUrl: URL! + blockedUsers: [User] + createdAt: DateTime! + creator: User + customFields: [OrganizationCustomField!]! + description: String! + image: String + members: [User] + membershipRequests: [MembershipRequest] + name: String! + pinnedPosts: [Post] + updatedAt: DateTime! + userRegistrationRequired: Boolean! + userTags( + after: String + before: String + first: PositiveInt + last: PositiveInt + ): UserTagsConnection + visibleInSearch: Boolean! + venues: [Venue] +} + +type OrganizationCustomField { + _id: ID! + name: String! + organizationId: String! + type: String! +} + +type OrganizationCustomField { + _id: ID! + name: String! + organizationId: String! + type: String! +} + +type OrganizationInfoNode { + _id: ID! + apiUrl: URL! + creator: User + description: String! + image: String + name: String! + userRegistrationRequired: Boolean! + visibleInSearch: Boolean! +} + +input OrganizationInput { + address: AddressInput! + apiUrl: URL + attendees: String + description: String! + image: String + name: String! + userRegistrationRequired: Boolean + visibleInSearch: Boolean +} + +enum OrganizationOrderByInput { + apiUrl_ASC + apiUrl_DESC + createdAt_ASC + createdAt_DESC + description_ASC + description_DESC + id_ASC + id_DESC + name_ASC + name_DESC +} + +input OrganizationWhereInput { + apiUrl: URL + apiUrl_contains: URL + apiUrl_in: [URL!] + apiUrl_not: URL + apiUrl_not_in: [URL!] + apiUrl_starts_with: URL + description: String + description_contains: String + description_in: [String!] + description_not: String + description_not_in: [String!] + description_starts_with: String + id: ID + id_contains: ID + id_in: [ID!] + id_not: ID + id_not_in: [ID!] + id_starts_with: ID + name: String + name_contains: String + name_in: [String!] + name_not: String + name_not_in: [String!] + name_starts_with: String + userRegistrationRequired: Boolean + visibleInSearch: Boolean +} + +type OtpData { + otpToken: String! +} + +""" +Information about pagination in a connection. +""" +type PageInfo { + currPageNo: Int + + """ + When paginating forwards, are there more items? + """ + hasNextPage: Boolean! + + """ + When paginating backwards, are there more items? + """ + hasPreviousPage: Boolean! + nextPageNo: Int + prevPageNo: Int + totalPages: Int +} + +enum PaginationDirection { + BACKWARD + FORWARD +} + +scalar PhoneNumber + +type Plugin { + _id: ID! + pluginCreatedBy: String! + pluginDesc: String! + pluginName: String! + uninstalledOrgs: [ID!] +} + +type PluginField { + createdAt: DateTime! + key: String! + status: Status! + value: String! +} + +input PluginFieldInput { + key: String! + value: String! +} + +input PluginInput { + fields: [PluginFieldInput] + orgId: ID! + pluginKey: String + pluginName: String! + pluginType: Type +} + +scalar PositiveInt + +type Post { + _id: ID + commentCount: Int + comments: [Comment] + createdAt: DateTime! + creator: User + imageUrl: URL + likeCount: Int + likedBy: [User] + organization: Organization! + pinned: Boolean + text: String! + title: String + updatedAt: DateTime! + videoUrl: URL +} + +""" +A connection to a list of items. +""" +type PostConnection { + aggregate: AggregatePost! + + """ + A list of edges. + """ + edges: [Post]! + + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! +} + +input PostInput { + _id: ID + imageUrl: URL + organizationId: ID! + pinned: Boolean + text: String! + title: String + videoUrl: URL +} + +enum PostOrderByInput { + commentCount_ASC + commentCount_DESC + createdAt_ASC + createdAt_DESC + id_ASC + id_DESC + imageUrl_ASC + imageUrl_DESC + likeCount_ASC + likeCount_DESC + text_ASC + text_DESC + title_ASC + title_DESC + videoUrl_ASC + videoUrl_DESC +} + +input PostUpdateInput { + imageUrl: String + text: String + title: String + videoUrl: String +} + +input PostWhereInput { + id: ID + id_contains: ID + id_in: [ID!] + id_not: ID + id_not_in: [ID!] + id_starts_with: ID + text: String + text_contains: String + text_in: [String!] + text_not: String + text_not_in: [String!] + text_starts_with: String + title: String + title_contains: String + title_in: [String!] + title_not: String + title_not_in: [String!] + title_starts_with: String +} + +type Query { + actionItem(id: ID!): ActionItem + actionItemCategoriesByOrganization(organizationId: ID!): [ActionItemCategory] + actionItemCategory(id: ID!): ActionItemCategory + actionItemsByEvent(eventId: ID!): [ActionItem] + actionItemsByOrganization(organizationId: ID!): [ActionItem] + adminPlugin(orgId: ID!): [Plugin] + agendaCategory(id: ID!): AgendaCategory! + checkAuth: User! + customDataByOrganization(organizationId: ID!): [UserCustomData!]! + customFieldsByOrganization(id: ID!): [OrganizationCustomField] + chatById(id: ID!): Chat! + chatsByUserId(id: ID!): [Chat] + event(id: ID!): Event + eventsAttendedByUser(id: ID, orderBy: EventOrderByInput): [Event] + eventVolunteersByEvent(id: ID!): [EventVolunteer] + eventsByOrganization(id: ID, orderBy: EventOrderByInput): [Event] + eventsByOrganizationConnection( + first: Int + orderBy: EventOrderByInput + skip: Int + where: EventWhereInput + ): [Event!]! + advertisementsConnection(after: String, before: String, first: PositiveInt, last: PositiveInt): AdvertisementsConnection + getDonationById(id: ID!): Donation! + getDonationByOrgId(orgId: ID!): [Donation] + getDonationByOrgIdConnection( + first: Int + orgId: ID! + skip: Int + where: DonationWhereInput + ): [Donation!]! + getPlugins: [Plugin] + getlanguage(lang_code: String!): [Translation] + hasSubmittedFeedback(eventId: ID!, userId: ID!): Boolean + isSampleOrganization(id: ID!): Boolean! + joinedOrganizations(id: ID): [Organization] + me: UserData! + myLanguage: String + fundsByOrganization(organizationId: ID!, where: FundWhereInput): [Fund] + organizations(id: ID, orderBy: OrganizationOrderByInput): [Organization] + organizationsConnection( + first: Int + orderBy: OrganizationOrderByInput + skip: Int + where: OrganizationWhereInput + ): [Organization]! + organizationsMemberConnection( + first: Int + orderBy: UserOrderByInput + orgId: ID! + skip: Int + where: UserWhereInput + ): UserConnection! + plugin(orgId: ID!): [Plugin] + getRecurringEvents(baseRecurringEventId: ID!): [Event] + post(id: ID!): Post + postsByOrganization(id: ID!, orderBy: PostOrderByInput): [Post] + postsByOrganizationConnection( + first: Int + id: ID! + orderBy: PostOrderByInput + skip: Int + where: PostWhereInput + ): PostConnection + registeredEventsByUser(id: ID, orderBy: EventOrderByInput): [Event] + registrantsByEvent(id: ID!): [User] + user(id: ID!): UserData! + userLanguage(userId: ID!): String + users( + first: Int + orderBy: UserOrderByInput + skip: Int + where: UserWhereInput + ): [UserData] + usersConnection( + first: Int + orderBy: UserOrderByInput + skip: Int + where: UserWhereInput + ): [UserData]! + venue(id:ID!):[Venue] +} + +input RecaptchaVerification { + recaptchaToken: String! +} + +enum Recurrance { + DAILY + MONTHLY + ONCE + WEEKLY + YEARLY +} + +input RecurrenceRuleInput { + count: Int + frequency: Frequency + weekDays: [WeekDays] +} + +enum Status { + ACTIVE + BLOCKED + DELETED +} + +type Subscription { + messageSentToChat(userId: ID!): ChatMessage + onPluginUpdate: Plugin +} + +scalar Time + +input ToggleUserTagAssignInput { + tagId: ID! + userId: ID! +} + +type Translation { + en_value: String + lang_code: String + translation: String + verified: Boolean +} + +enum Type { + PRIVATE + UNIVERSAL +} + +scalar URL + +type UnauthenticatedError implements Error { + message: String! +} + +type UnauthorizedError implements Error { + message: String! +} + +input UpdateActionItemCategoryInput { + isDisabled: Boolean + name: String +} + +input UpdateActionItemInput { + assigneeId: ID + completionDate: Date + dueDate: Date + isCompleted: Boolean + postCompletionNotes: String + preCompletionNotes: String +} + +input UpdateAdvertisementInput { + _id: ID! + endDate: Date + link: String + name: String + startDate: Date + type: AdvertisementType +} + +type UpdateAdvertisementPayload { + advertisement: Advertisement +} + +input UpdateAgendaCategoryInput { + description: String + name: String +} + +input UpdateEventInput { + allDay: Boolean + description: String + endDate: Date + endTime: Time + isPublic: Boolean + isRegisterable: Boolean + latitude: Latitude + location: String + longitude: Longitude + recurrance: Recurrance + recurring: Boolean + startDate: Date + startTime: Time + title: String +} + +input UpdateEventVolunteerInput { + eventId: ID + isAssigned: Boolean + isInvited: Boolean + response: EventVolunteerResponse +} + +input UpdateOrganizationInput { + address: AddressInput + description: String + name: String + userRegistrationRequired: Boolean + visibleInSearch: Boolean +} + +input AddressInput { + city: String + countryCode: String + dependentLocality: String + line1: String + line2: String + postalCode: String + sortingCode: String + state: String +} + +enum EducationGrade { + GRADE_1 + GRADE_2 + GRADE_3 + GRADE_4 + GRADE_5 + GRADE_6 + GRADE_7 + GRADE_8 + GRADE_9 + GRADE_10 + GRADE_11 + GRADE_12 + GRADUATE + KG + NO_GRADE + PRE_KG +} + +enum EmploymentStatus { + FULL_TIME + PART_TIME + UNEMPLOYED +} + +enum Gender { + FEMALE + MALE + OTHER +} + +enum MaritalStatus { + DIVORCED + ENGAGED + MARRIED + SEPERATED + SINGLE + WIDOWED +} + +input UpdateUserInput { + address: AddressInput + birthDate: Date + educationGrade: EducationGrade + email: EmailAddress + employmentStatus: EmploymentStatus + firstName: String + gender: Gender + lastName: String + maritalStatus: MaritalStatus + phone: UserPhoneInput +} + +input UpdateUserPasswordInput { + confirmNewPassword: String! + newPassword: String! + previousPassword: String! +} + +input UpdateUserTagInput { + _id: ID! + name: String! +} + +input UpdateUserTypeInput { + id: ID + userType: String +} + +scalar Upload + +type User { + _id: ID! + address: Address + appUserProfileId: AppUserProfile + birthDate: Date + createdAt: DateTime! + educationGrade: EducationGrade + email: EmailAddress! + employmentStatus: EmploymentStatus + firstName: String! + gender: Gender + image: String + joinedOrganizations: [Organization] + lastName: String! + maritalStatus: MaritalStatus + membershipRequests: [MembershipRequest] + organizationsBlockedBy: [Organization] + phone: UserPhone + pluginCreationAllowed: Boolean! + registeredEvents: [Event] + eventsAttended: [Event] + tagsAssignedWith( + after: String + before: String + first: PositiveInt + last: PositiveInt + organizationId: ID + ): UserTagsConnection + updatedAt: DateTime! +} + +type Fund { + _id: ID! + campaigns: [FundraisingCampaign!] + createdAt: DateTime! + isArchived: Boolean! + isDefault: Boolean! + name: String! + creator: User + organizationId: ID! + refrenceNumber: String + taxDeductible: Boolean! + updatedAt: DateTime! +} + +input FundWhereInput { + name_contains: String +} + +input UserAndOrganizationInput { + organizationId: ID! + userId: ID! +} + +type UserPhone { + home: PhoneNumber + mobile: PhoneNumber + work: PhoneNumber +} + +type UserConnection { + aggregate: AggregateUser! + edges: [User]! + pageInfo: PageInfo! +} + +type UserCustomData { + _id: ID! + organizationId: ID! + userId: ID! + values: JSON! +} + +type UserData { + appUserProfile: AppUserProfile! + user: User! +} + +type UserEdge { + cursor: String! + node: User! +} + +type UserFamily { + _id: ID! + admins: [User!]! + creator: User! + title: String + users: [User!]! +} + +input UserInput { + appLanguageCode: String + email: EmailAddress! + firstName: String! + lastName: String! + password: String! + selectedOrganization : ID! +} + +enum UserOrderByInput { + email_ASC + email_DESC + firstName_ASC + firstName_DESC + id_ASC + id_DESC + lastName_ASC + lastName_DESC + createdAt_ASC + createdAt_DESC +} + +type UserPhone { + home: PhoneNumber + mobile: PhoneNumber + work: PhoneNumber +} + +input UserPhoneInput { + home: PhoneNumber + mobile: PhoneNumber + work: PhoneNumber +} + +type UserTag { + _id: ID! + childTags(input: UserTagsConnectionInput!): UserTagsConnectionResult! + name: String! + organization: Organization + parentTag: UserTag + usersAssignedTo(input: UsersConnectionInput!): UsersConnectionResult! +} + +type UserTagEdge { + cursor: String! + node: UserTag! +} + +type UserTagsConnection { + edges: [UserTagEdge!]! + pageInfo: ConnectionPageInfo! +} + +input UserTagsConnectionInput { + cursor: String + direction: PaginationDirection! + limit: PositiveInt! +} + +type UserTagsConnectionResult { + data: UserTagsConnection + errors: [ConnectionError!]! +} + +enum UserType { + ADMIN + NON_USER + SUPERADMIN + USER +} + +input UserWhereInput { + email: EmailAddress + email_contains: EmailAddress + email_in: [EmailAddress!] + email_not: EmailAddress + email_not_in: [EmailAddress!] + email_starts_with: EmailAddress + event_title_contains: String + firstName: String + firstName_contains: String + firstName_in: [String!] + firstName_not: String + firstName_not_in: [String!] + firstName_starts_with: String + id: ID + id_contains: ID + id_in: [ID!] + id_not: ID + id_not_in: [ID!] + id_starts_with: ID + lastName: String + lastName_contains: String + lastName_in: [String!] + lastName_not: String + lastName_not_in: [String!] + lastName_starts_with: String +} + +type UsersConnection { + edges: [UserEdge!]! + pageInfo: ConnectionPageInfo! +} + +input UsersConnectionInput { + cursor: String + direction: PaginationDirection! + limit: PositiveInt! +} + +type UsersConnectionResult { + data: UsersConnection + errors: [ConnectionError!]! +} + +enum WeekDays { + FR + MO + SA + SU + TH + TU + WE +} + +type Venue { + _id: ID! + capacity: Int! + description: String + imageUrl: URL + name: String! + organization: Organization! +} + +input VenueInput { + capacity: Int! + description: String + file: String + name: String! + organizationId: ID! +} + +input createUserFamilyInput { + title: String! + userIds: [ID!]! +} + +input chatInput { + isGroup: Boolean! + organizationId: ID + userIds: [ID!]! + name: String +} \ No newline at end of file diff --git a/scripts/__mocks__/@dicebear/collection.ts b/scripts/__mocks__/@dicebear/collection.ts new file mode 100644 index 0000000000..9a7e1e9a57 --- /dev/null +++ b/scripts/__mocks__/@dicebear/collection.ts @@ -0,0 +1 @@ +export const initials = jest.fn(); diff --git a/scripts/__mocks__/@dicebear/core.ts b/scripts/__mocks__/@dicebear/core.ts new file mode 100644 index 0000000000..41811e2f8d --- /dev/null +++ b/scripts/__mocks__/@dicebear/core.ts @@ -0,0 +1,5 @@ +export const createAvatar = jest.fn(() => { + return { + toDataUri: jest.fn(() => 'mocked-data-uri'), + }; +}); diff --git a/scripts/__mocks__/@pdfme/generator.test.ts b/scripts/__mocks__/@pdfme/generator.test.ts new file mode 100644 index 0000000000..592c193a71 --- /dev/null +++ b/scripts/__mocks__/@pdfme/generator.test.ts @@ -0,0 +1,47 @@ +import { generate } from './generator'; +import type { Template } from '@pdfme/common'; + +describe('Testing mock generate util', () => { + test('should return a Promise', async () => { + const result = generate({ + template: { schemas: [] } as Template, + inputs: [], + }); + expect(result).toBeInstanceOf(Promise); + }); + + test('should resolve to a Uint8Array', async () => { + const result = generate({ + template: { schemas: [] } as Template, + inputs: [], + }); + expect(result).toBeInstanceOf(Uint8Array); + }); + + it('should throw an error when template is empty', async () => { + const emptyTemplate = { schemas: [] } as Template; + const validInputs = [{ field1: 'value1' }]; + + await expect( + generate({ template: emptyTemplate, inputs: validInputs }), + ).rejects.toThrow('Template or inputs cannot be empty.'); + }); + + it('should throw an error when inputs are empty', async () => { + const validTemplate = { schemas: [{ name: 'field1' }] } as Template; + const emptyInputs: Record[] = []; + + await expect( + generate({ template: validTemplate, inputs: emptyInputs }), + ).rejects.toThrow('Template or inputs cannot be empty.'); + }); + + it('should throw an error when both template and inputs are empty', async () => { + const emptyTemplate = { schemas: [] } as Template; + const emptyInputs: Record[] = []; + + await expect( + generate({ template: emptyTemplate, inputs: emptyInputs }), + ).rejects.toThrow('Template or inputs cannot be empty.'); + }); +}); diff --git a/scripts/__mocks__/@pdfme/generator.ts b/scripts/__mocks__/@pdfme/generator.ts new file mode 100644 index 0000000000..ac03ae6210 --- /dev/null +++ b/scripts/__mocks__/@pdfme/generator.ts @@ -0,0 +1,22 @@ +import type { Template } from '@pdfme/common'; + +export const generate = async ({ + template, + inputs, +}: { + template: Template; + inputs: Record[]; +}): Promise => { + if (template.schemas.length === 0 || inputs.length === 0) { + // console.log('pdf error: length : ', template, inputs, inputs.length); + throw new Error('Template or inputs cannot be empty.'); + } + // Generate mock PDF-like header bytes + const pdfHeader = [0x25, 0x50, 0x44, 0x46]; // %PDF + // Add some random content based on input size + const contentSize = Math.min(template.schemas.length, inputs.length) * 10; + const mockContent = Array.from({ length: contentSize }, () => + Math.floor(Math.random() * 256), + ); + return Promise.resolve(new Uint8Array([...pdfHeader, ...mockContent])); +}; diff --git a/scripts/__mocks__/fileMock.js b/scripts/__mocks__/fileMock.js new file mode 100644 index 0000000000..06ad689c8b --- /dev/null +++ b/scripts/__mocks__/fileMock.js @@ -0,0 +1,2 @@ +// __mocks__/fileMock.js +module.exports = 'test-file-stub'; diff --git a/scripts/custom-test-env.js b/scripts/custom-test-env.js new file mode 100644 index 0000000000..6174d8cf11 --- /dev/null +++ b/scripts/custom-test-env.js @@ -0,0 +1,16 @@ +import Environment from 'jest-environment-jsdom'; +import { TextEncoder, TextDecoder } from 'util'; + +/** + * A custom environment to set the TextEncoder and TextDecoder variables, that is required by @pdfme during testing. + * Providing a polyfill to the environment for the same + */ +export default class CustomTestEnvironment extends Environment { + async setup() { + await super.setup(); + if (typeof this.global.TextEncoder === 'undefined') { + this.global.TextEncoder = TextEncoder; + this.global.TextDecoder = TextDecoder; + } + } +} diff --git a/scripts/githooks/check-localstorage-usage.js b/scripts/githooks/check-localstorage-usage.js new file mode 100755 index 0000000000..0a811df307 --- /dev/null +++ b/scripts/githooks/check-localstorage-usage.js @@ -0,0 +1,96 @@ +#!/usr/bin/env node + +import { readFileSync, existsSync } from 'fs'; +import path from 'path'; +import { execSync } from 'child_process'; + +const args = process.argv.slice(2); +const scanEntireRepo = args.includes('--scan-entire-repo'); + +const containsSkipComment = (file) => { + try { + const content = readFileSync(file, 'utf-8'); + return content.includes('// SKIP_LOCALSTORAGE_CHECK'); + } catch (error) { + console.error(`Error reading file ${file}:`, error.message); + return false; + } +}; + +const getModifiedFiles = () => { + try { + if (scanEntireRepo) { + const result = execSync('git ls-files | grep ".tsx\\?$"', { + encoding: 'utf-8', + }); + return result.trim().split('\n'); + } + + const result = execSync('git diff --cached --name-only', { + encoding: 'utf-8', + }); + return result.trim().split('\n'); + } catch (error) { + console.error('Error fetching modified files:', error.message); + process.exit(1); + } +}; + +const files = getModifiedFiles(); + +const filesWithLocalStorage = []; + +const checkLocalStorageUsage = (file) => { + if (!file) { + return; + } + + const fileName = path.basename(file); + + // Skip files with specific names or containing a skip comment + if ( + fileName === 'check-localstorage-usage.js' || + fileName === 'useLocalstorage.test.ts' || + fileName === 'useLocalstorage.ts' || + containsSkipComment(file) + ) { + console.log(`Skipping file: ${file}`); + return; + } + + try { + if (existsSync(file)) { + const content = readFileSync(file, 'utf-8'); + + if ( + content.includes('localStorage.getItem') || + content.includes('localStorage.setItem') || + content.includes('localStorage.removeItem') + ) { + filesWithLocalStorage.push(file); + } + } else { + console.log(`File ${file} does not exist.`); + } + } catch (error) { + console.error(`Error reading file ${file}:`, error.message); + } +}; + +files.forEach(checkLocalStorageUsage); + +if (filesWithLocalStorage.length > 0) { + console.error('\x1b[31m%s\x1b[0m', '\nError: Found usage of localStorage'); + console.error('\nFiles with localStorage usage:'); + filesWithLocalStorage.forEach((file) => console.error(file)); + + console.info( + '\x1b[34m%s\x1b[0m', + '\nInfo: Consider using custom hook functions.' + ); + console.info( + 'Please use the getItem, setItem, and removeItem functions provided by the custom hook useLocalStorage.\n' + ); + + process.exit(1); +} diff --git a/scripts/githooks/update-toc.js b/scripts/githooks/update-toc.js new file mode 100644 index 0000000000..268becfd13 --- /dev/null +++ b/scripts/githooks/update-toc.js @@ -0,0 +1,14 @@ +import fs from 'fs'; +import { execSync } from 'child_process'; + +const markdownFiles = fs + .readdirSync('./') + .filter((file) => file.endsWith('.md')); + +markdownFiles.forEach((file) => { + const command = `markdown-toc -i "${file}" --bullets "-"`; + execSync(command, { stdio: 'inherit' }); + +}); + +console.log('Table of contents updated successfully.'); diff --git a/setup.ts b/setup.ts new file mode 100644 index 0000000000..2a6c437fa3 --- /dev/null +++ b/setup.ts @@ -0,0 +1,185 @@ +import dotenv from 'dotenv'; +import fs from 'fs'; +import inquirer from 'inquirer'; +import { checkConnection } from './src/setup/checkConnection/checkConnection'; +import { askForTalawaApiUrl } from './src/setup/askForTalawaApiUrl/askForTalawaApiUrl'; +import { checkEnvFile } from './src/setup/checkEnvFile/checkEnvFile'; +import { validateRecaptcha } from './src/setup/validateRecaptcha/validateRecaptcha'; +import { askForCustomPort } from './src/setup/askForCustomPort/askForCustomPort'; + +export async function main(): Promise { + console.log('Welcome to the Talawa Admin setup! 🚀'); + + if (!fs.existsSync('.env')) { + fs.openSync('.env', 'w'); + const config = dotenv.parse(fs.readFileSync('.env.example')); + for (const key in config) { + fs.appendFileSync('.env', `${key}=${config[key]}\n`); + } + } else { + checkEnvFile(); + } + + let shouldSetCustomPort: boolean; + + if (process.env.PORT) { + console.log( + `\nCustom port for development server already exists with the value:\n${process.env.PORT}`, + ); + shouldSetCustomPort = true; + } else { + const { shouldSetCustomPortResponse } = await inquirer.prompt({ + type: 'confirm', + name: 'shouldSetCustomPortResponse', + message: 'Would you like to set up a custom port?', + default: true, + }); + shouldSetCustomPort = shouldSetCustomPortResponse; + } + + if (shouldSetCustomPort) { + const customPort = await askForCustomPort(); + + const port = dotenv.parse(fs.readFileSync('.env')).PORT; + + fs.readFile('.env', 'utf8', (err, data) => { + const result = data.replace(`PORT=${port}`, `PORT=${customPort}`); + fs.writeFileSync('.env', result, 'utf8'); + }); + } + + let shouldSetTalawaApiUrl: boolean; + + if (process.env.REACT_APP_TALAWA_URL) { + console.log( + `\nEndpoint for accessing talawa-api graphql service already exists with the value:\n${process.env.REACT_APP_TALAWA_URL}`, + ); + shouldSetTalawaApiUrl = true; + } else { + const { shouldSetTalawaApiUrlResponse } = await inquirer.prompt({ + type: 'confirm', + name: 'shouldSetTalawaApiUrlResponse', + message: 'Would you like to set up talawa-api endpoint?', + default: true, + }); + shouldSetTalawaApiUrl = shouldSetTalawaApiUrlResponse; + } + + if (shouldSetTalawaApiUrl) { + let isConnected = false, + endpoint = ''; + + while (!isConnected) { + endpoint = await askForTalawaApiUrl(); + const url = new URL(endpoint); + isConnected = await checkConnection(url.origin); + } + const envPath = '.env'; + const currentEnvContent = fs.readFileSync(envPath, 'utf8'); + const talawaApiUrl = dotenv.parse(currentEnvContent).REACT_APP_TALAWA_URL; + + const updatedEnvContent = currentEnvContent.replace( + `REACT_APP_TALAWA_URL=${talawaApiUrl}`, + `REACT_APP_TALAWA_URL=${endpoint}`, + ); + + fs.writeFileSync(envPath, updatedEnvContent, 'utf8'); + const websocketUrl = endpoint.replace(/^http(s)?:\/\//, 'ws$1://'); + const currentWebSocketUrl = + dotenv.parse(updatedEnvContent).REACT_APP_BACKEND_WEBSOCKET_URL; + + const finalEnvContent = updatedEnvContent.replace( + `REACT_APP_BACKEND_WEBSOCKET_URL=${currentWebSocketUrl}`, + `REACT_APP_BACKEND_WEBSOCKET_URL=${websocketUrl}`, + ); + + fs.writeFileSync(envPath, finalEnvContent, 'utf8'); + } + + const { shouldUseRecaptcha } = await inquirer.prompt({ + type: 'confirm', + name: 'shouldUseRecaptcha', + message: 'Would you like to set up ReCAPTCHA?', + default: true, + }); + + if (shouldUseRecaptcha) { + const useRecaptcha = dotenv.parse( + fs.readFileSync('.env'), + ).REACT_APP_USE_RECAPTCHA; + + fs.readFile('.env', 'utf8', (err, data) => { + const result = data.replace( + `REACT_APP_USE_RECAPTCHA=${useRecaptcha}`, + `REACT_APP_USE_RECAPTCHA=yes`, + ); + fs.writeFileSync('.env', result, 'utf8'); + }); + let shouldSetRecaptchaSiteKey: boolean; + if (process.env.REACT_APP_RECAPTCHA_SITE_KEY) { + console.log( + `\nreCAPTCHA site key already exists with the value ${process.env.REACT_APP_RECAPTCHA_SITE_KEY}`, + ); + shouldSetRecaptchaSiteKey = true; + } else { + const { shouldSetRecaptchaSiteKeyResponse } = await inquirer.prompt({ + type: 'confirm', + name: 'shouldSetRecaptchaSiteKeyResponse', + message: 'Would you like to set up a reCAPTCHA site key?', + default: true, + }); + shouldSetRecaptchaSiteKey = shouldSetRecaptchaSiteKeyResponse; + } + + if (shouldSetRecaptchaSiteKey) { + const { recaptchaSiteKeyInput } = await inquirer.prompt([ + { + type: 'input', + name: 'recaptchaSiteKeyInput', + message: 'Enter your reCAPTCHA site key:', + validate: async (input: string): Promise => { + if (validateRecaptcha(input)) { + return true; + } + return 'Invalid reCAPTCHA site key. Please try again.'; + }, + }, + ]); + + const recaptchaSiteKey = dotenv.parse( + fs.readFileSync('.env'), + ).REACT_APP_RECAPTCHA_SITE_KEY; + + fs.readFile('.env', 'utf8', (err, data) => { + const result = data.replace( + `REACT_APP_RECAPTCHA_SITE_KEY=${recaptchaSiteKey}`, + `REACT_APP_RECAPTCHA_SITE_KEY=${recaptchaSiteKeyInput}`, + ); + fs.writeFileSync('.env', result, 'utf8'); + }); + } + } + + const { shouldLogErrors } = await inquirer.prompt({ + type: 'confirm', + name: 'shouldLogErrors', + message: + 'Would you like to log Compiletime and Runtime errors in the console?', + default: true, + }); + + if (shouldLogErrors) { + const logErrors = dotenv.parse(fs.readFileSync('.env')).ALLOW_LOGS; + + fs.readFile('.env', 'utf8', (err, data) => { + const result = data.replace(`ALLOW_LOGS=${logErrors}`, 'ALLOW_LOGS=YES'); + fs.writeFileSync('.env', result, 'utf8'); + }); + } + + console.log( + '\nCongratulations! Talawa Admin has been successfully setup! 🥂🎉', + ); +} + +main(); diff --git a/src/App.test.tsx b/src/App.test.tsx index 2a68616d98..f4fba2ebf8 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,9 +1,111 @@ -import React from 'react'; +import React, { act } from 'react'; import { render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MockedProvider } from '@apollo/react-testing'; +import { BrowserRouter } from 'react-router-dom'; +import { I18nextProvider } from 'react-i18next'; +import 'jest-location-mock'; import App from './App'; +import { store } from 'state/store'; +import { CHECK_AUTH } from 'GraphQl/Queries/Queries'; +import i18nForTest from './utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import useLocalStorage from 'utils/useLocalstorage'; -test('renders learn react link', () => { - render(); - const linkElement = screen.getByText(/learn react/i); - expect(linkElement).toBeInTheDocument(); +const { setItem } = useLocalStorage(); + +// Mock the modules for PieChart rendering as they require a trasformer being used (which is not done by Jest) +// These modules are used by the Feedback components +jest.mock('@mui/x-charts/PieChart', () => ({ + pieArcLabelClasses: jest.fn(), + PieChart: jest.fn().mockImplementation(() => <>Test), + pieArcClasses: jest.fn(), +})); + +const MOCKS = [ + { + request: { + query: CHECK_AUTH, + }, + result: { + data: { + checkAuth: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + createdAt: '2023-04-13T04:53:17.742+00:00', + image: 'john.jpg', + email: 'johndoe@gmail.com', + birthDate: '1990-01-01', + educationGrade: 'NO_GRADE', + employmentStatus: 'EMPLOYED', + gender: 'MALE', + maritalStatus: 'SINGLE', + address: { + line1: 'line1', + state: 'state', + countryCode: 'IND', + }, + phone: { + mobile: '+8912313112', + }, + }, + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink([], true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Testing the App Component', () => { + test('Component should be rendered properly and user is loggedin', async () => { + setItem('AdminFor', [{ name: 'adi', _id: '1234', image: '' }]); + render( + + + + + + + + + , + ); + + await wait(); + + window.location.assign('/orglist'); + await wait(); + expect(window.location).toBeAt('/orglist'); + expect( + screen.getByText( + 'An open source application by Palisadoes Foundation volunteers', + ), + ).toBeTruthy(); + }); + + test('Component should be rendered properly and user is loggedout', async () => { + render( + + + + + + + + + , + ); + + await wait(); + }); }); diff --git a/src/App.tsx b/src/App.tsx index c31b4f754d..37f3bc301e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,27 +1,220 @@ -import React from 'react'; -import './App.css'; +import AddOnStore from 'components/AddOn/core/AddOnStore/AddOnStore'; +import OrganizationScreen from 'components/OrganizationScreen/OrganizationScreen'; +import SecuredRoute from 'components/SecuredRoute/SecuredRoute'; +import SuperAdminScreen from 'components/SuperAdminScreen/SuperAdminScreen'; +import * as installedPlugins from 'components/plugins/index'; +import { Route, Routes } from 'react-router-dom'; +import BlockUser from 'screens/BlockUser/BlockUser'; +import EventManagement from 'screens/EventManagement/EventManagement'; +import ForgotPassword from 'screens/ForgotPassword/ForgotPassword'; +import LoginPage from 'screens/LoginPage/LoginPage'; +import MemberDetail from 'screens/MemberDetail/MemberDetail'; +import OrgContribution from 'screens/OrgContribution/OrgContribution'; +import OrgList from 'screens/OrgList/OrgList'; +import OrgPost from 'screens/OrgPost/OrgPost'; +import OrgSettings from 'screens/OrgSettings/OrgSettings'; +import OrganizationActionItems from 'screens/OrganizationActionItems/OrganizationActionItems'; +import OrganizationDashboard from 'screens/OrganizationDashboard/OrganizationDashboard'; +import OrganizationEvents from 'screens/OrganizationEvents/OrganizationEvents'; +import OrganizaitionFundCampiagn from 'screens/OrganizationFundCampaign/OrganizationFundCampagins'; +import OrganizationFunds from 'screens/OrganizationFunds/OrganizationFunds'; +import FundCampaignPledge from 'screens/FundCampaignPledge/FundCampaignPledge'; +import OrganizationPeople from 'screens/OrganizationPeople/OrganizationPeople'; +import OrganizationTags from 'screens/OrganizationTags/OrganizationTags'; +import ManageTag from 'screens/ManageTag/ManageTag'; +import SubTags from 'screens/SubTags/SubTags'; +import PageNotFound from 'screens/PageNotFound/PageNotFound'; +import Requests from 'screens/Requests/Requests'; +import Users from 'screens/Users/Users'; +import CommunityProfile from 'screens/CommunityProfile/CommunityProfile'; +import OrganizationVenues from 'screens/OrganizationVenues/OrganizationVenues'; +import Leaderboard from 'screens/Leaderboard/Leaderboard'; -function App(): JSX.Element { - return ( -
-
-
- -

- Edit src/App.js and save to reload. -

- - Learn React - -
-
+import React, { useEffect } from 'react'; +// User Portal Components +import Donate from 'screens/UserPortal/Donate/Donate'; +import Events from 'screens/UserPortal/Events/Events'; +import Posts from 'screens/UserPortal/Posts/Posts'; +import Organizations from 'screens/UserPortal/Organizations/Organizations'; +import People from 'screens/UserPortal/People/People'; +import Settings from 'screens/UserPortal/Settings/Settings'; +import Chat from 'screens/UserPortal/Chat/Chat'; +import { useQuery } from '@apollo/client'; +import { CHECK_AUTH } from 'GraphQl/Queries/Queries'; +import Advertisements from 'components/Advertisements/Advertisements'; +import SecuredRouteForUser from 'components/UserPortal/SecuredRouteForUser/SecuredRouteForUser'; + +import useLocalStorage from 'utils/useLocalstorage'; +import UserScreen from 'screens/UserPortal/UserScreen/UserScreen'; +import EventDashboardScreen from 'components/EventDashboardScreen/EventDashboardScreen'; +import Campaigns from 'screens/UserPortal/Campaigns/Campaigns'; +import Pledges from 'screens/UserPortal/Pledges/Pledges'; +import VolunteerManagement from 'screens/UserPortal/Volunteer/VolunteerManagement'; + +const { setItem } = useLocalStorage(); + +/** + * This is the main function for our application. It sets up all the routes and components, + * defining how the user can navigate through the app. The function uses React Router's `Routes` + * and `Route` components to map different URL paths to corresponding screens and components. + * + * ## Important Details + * - **UseEffect Hook**: This hook checks user authentication status using the `CHECK_AUTH` GraphQL query. + * - **Plugins**: It dynamically loads additional routes for any installed plugins. + * - **Routes**: + * - The root route ("/") takes the user to the `LoginPage`. + * - Protected routes are wrapped with the `SecuredRoute` component to ensure they are only accessible to authenticated users. + * - Admin and Super Admin routes allow access to organization and user management screens. + * - User portal routes allow end-users to interact with organizations, settings, chat, events, etc. + * + * @returns The rendered routes and components of the application. + */ + +function app(): JSX.Element { + /*const { updatePluginLinks, updateInstalled } = bindActionCreators( + actionCreators, + dispatch + ); + + const getInstalledPlugins = async () => { + const plugins = await fetchInstalled(); + updateInstalled(plugins); + updatePluginLinks(new PluginHelper().generateLinks(plugins)); + }; + + const fetchInstalled = async () => { + const result = await fetch(`http://localhost:3005/installed`); + return await result.json(); + }; + + useEffect(() => { + getInstalledPlugins(); + }, []);*/ + + // const appRoutes = useSelector((state: RootState) => state.appRoutes); + // const { components } = appRoutes; + + // TODO: Fetch Installed plugin extras and store for use within MainContent and Side Panel Components. + + const { data, loading } = useQuery(CHECK_AUTH); + + useEffect(() => { + if (data) { + setItem('name', `${data.checkAuth.firstName} ${data.checkAuth.lastName}`); + setItem('id', data.checkAuth._id); + setItem('email', data.checkAuth.email); + setItem('IsLoggedIn', 'TRUE'); + setItem('FirstName', data.checkAuth.firstName); + setItem('LastName', data.checkAuth.lastName); + setItem('UserImage', data.checkAuth.image); + setItem('Email', data.checkAuth.email); + } + }, [data, loading]); + + const extraRoutes = Object.entries(installedPlugins).map( + ( + plugin: [ + string, + ( + | typeof installedPlugins.DummyPlugin + | typeof installedPlugins.DummyPlugin2 + ), + ], + index: number, + ) => { + const ExtraComponent = plugin[1]; + return ( + } + /> ); -
+ }, + ); + + return ( + <> + + } /> + }> + }> + } /> + } /> + } /> + } /> + + }> + } /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + } + /> + } + /> + } /> + } + /> + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {extraRoutes} + + + } /> + {/* User Portal Routes */} + }> + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + }> + } + /> + + + + {/* */} + } /> + + ); } -export default App; +export default app; diff --git a/src/Constant/constant.spec.ts b/src/Constant/constant.spec.ts new file mode 100644 index 0000000000..c2e57d29df --- /dev/null +++ b/src/Constant/constant.spec.ts @@ -0,0 +1,29 @@ +import { + AUTH_TOKEN, + BACKEND_URL, + RECAPTCHA_SITE_KEY, + REACT_APP_USE_RECAPTCHA, +} from './constant'; + +describe('constants', () => { + it('AUTH_TOKEN should be an empty string', () => { + expect(typeof AUTH_TOKEN).toEqual('string'); + expect(AUTH_TOKEN).toEqual(''); + }); + + it('BACKEND_URL should be equal to REACT_APP_TALAWA_URL environment variable', () => { + expect(BACKEND_URL).toEqual(process.env.REACT_APP_TALAWA_URL); + }); + + it('RECAPTCHA_SITE_KEY should be equal to REACT_APP_RECAPTCHA_SITE_KEY environment variable', () => { + expect(RECAPTCHA_SITE_KEY).toEqual( + process.env.REACT_APP_RECAPTCHA_SITE_KEY, + ); + }); + + it('REACT_APP_USE_RECAPTCHA should be equal to REACT_APP_USE_RECAPTCHA environment variable', () => { + expect(REACT_APP_USE_RECAPTCHA).toEqual( + process.env.REACT_APP_USE_RECAPTCHA, + ); + }); +}); diff --git a/src/Constant/constant.ts b/src/Constant/constant.ts new file mode 100644 index 0000000000..d3b0efe1c1 --- /dev/null +++ b/src/Constant/constant.ts @@ -0,0 +1,7 @@ +export const AUTH_TOKEN = ''; +export const BACKEND_URL = process.env.REACT_APP_TALAWA_URL; +export const RECAPTCHA_SITE_KEY = process.env.REACT_APP_RECAPTCHA_SITE_KEY; +export const REACT_APP_USE_RECAPTCHA = process.env.REACT_APP_USE_RECAPTCHA; +export const REACT_APP_CUSTOM_PORT = process.env.PORT; +export const REACT_APP_BACKEND_WEBSOCKET_URL: string = + process.env.REACT_APP_BACKEND_WEBSOCKET_URL || ''; diff --git a/src/GraphQl/Mutations/ActionItemCategoryMutations.ts b/src/GraphQl/Mutations/ActionItemCategoryMutations.ts new file mode 100644 index 0000000000..92e7b0968c --- /dev/null +++ b/src/GraphQl/Mutations/ActionItemCategoryMutations.ts @@ -0,0 +1,48 @@ +import gql from 'graphql-tag'; + +/** + * GraphQL mutation to create an action item category. + * + * @param name - Name of the ActionItemCategory. + * @param isDisabled - Disabled status of the ActionItemCategory. + * @param organizationId - Organization to which the ActionItemCategory belongs. + */ + +export const CREATE_ACTION_ITEM_CATEGORY_MUTATION = gql` + mutation CreateActionItemCategory( + $name: String! + $isDisabled: Boolean! + $organizationId: ID! + ) { + createActionItemCategory( + name: $name + isDisabled: $isDisabled + organizationId: $organizationId + ) { + _id + } + } +`; + +/** + * GraphQL mutation to update an action item category. + * + * @param id - The id of the ActionItemCategory to be updated. + * @param name - Updated name of the ActionItemCategory. + * @param isDisabled - Updated disabled status of the ActionItemCategory. + */ + +export const UPDATE_ACTION_ITEM_CATEGORY_MUTATION = gql` + mutation UpdateActionItemCategory( + $actionItemCategoryId: ID! + $name: String + $isDisabled: Boolean + ) { + updateActionItemCategory( + id: $actionItemCategoryId + data: { name: $name, isDisabled: $isDisabled } + ) { + _id + } + } +`; diff --git a/src/GraphQl/Mutations/ActionItemMutations.ts b/src/GraphQl/Mutations/ActionItemMutations.ts new file mode 100644 index 0000000000..03ff50907f --- /dev/null +++ b/src/GraphQl/Mutations/ActionItemMutations.ts @@ -0,0 +1,94 @@ +import gql from 'graphql-tag'; + +/** + * GraphQL mutation to create an action item. + * + * @param actionItemCategoryId - ActionItemCategory to which the ActionItem is related. + * @param assigneeId - User to whom the ActionItem is assigned. + * @param preCompletionNotes - Notes prior to completion. + * @param dueDate - Due date. + * @param eventId - Event to which the ActionItem is related. + * @param allottedHours - Hours allotted for the ActionItem. + */ + +export const CREATE_ACTION_ITEM_MUTATION = gql` + mutation CreateActionItem( + $actionItemCategoryId: ID! + $assigneeId: ID! + $assigneeType: String! + $preCompletionNotes: String + $dDate: Date + $eventId: ID + $allottedHours: Float + ) { + createActionItem( + actionItemCategoryId: $actionItemCategoryId + data: { + assigneeId: $assigneeId + assigneeType: $assigneeType + preCompletionNotes: $preCompletionNotes + dueDate: $dDate + eventId: $eventId + allottedHours: $allottedHours + } + ) { + _id + } + } +`; + +/** + * GraphQL mutation to update an action item. + * + * @param id - Id of the ActionItem to be updated. + * @param assigneeId - User to whom the ActionItem is assigned. + * @param preCompletionNotes - Notes prior to completion. + * @param postCompletionNotes - Notes on completion. + * @param dueDate - Due date. + * @param completionDate - Completion date. + * @param isCompleted - Whether the ActionItem has been completed. + */ + +export const UPDATE_ACTION_ITEM_MUTATION = gql` + mutation UpdateActionItem( + $actionItemId: ID! + $assigneeId: ID! + $assigneeType: String! + $preCompletionNotes: String + $postCompletionNotes: String + $dueDate: Date + $completionDate: Date + $isCompleted: Boolean + $allottedHours: Float + ) { + updateActionItem( + id: $actionItemId + data: { + assigneeId: $assigneeId + assigneeType: $assigneeType + preCompletionNotes: $preCompletionNotes + postCompletionNotes: $postCompletionNotes + dueDate: $dueDate + completionDate: $completionDate + allottedHours: $allottedHours + isCompleted: $isCompleted + } + ) { + _id + } + } +`; + +/** + * GraphQL mutation to delete an action item. + * + * @param id - Id of the ActionItem to be updated. + */ + +export const DELETE_ACTION_ITEM_MUTATION = gql` + mutation RemoveActionItem($actionItemId: ID!) { + removeActionItem(id: $actionItemId) { + _id + } + } +`; diff --git a/src/GraphQl/Mutations/AgendaCategoryMutations.ts b/src/GraphQl/Mutations/AgendaCategoryMutations.ts new file mode 100644 index 0000000000..c344eca7e2 --- /dev/null +++ b/src/GraphQl/Mutations/AgendaCategoryMutations.ts @@ -0,0 +1,45 @@ +import gql from 'graphql-tag'; + +/** + * GraphQL mutation to create an agenda category. + * + * @param input - Name, Description, OrganizationID of the AgendaCategory. + */ + +export const CREATE_AGENDA_ITEM_CATEGORY_MUTATION = gql` + mutation CreateAgendaCategory($input: CreateAgendaCategoryInput!) { + createAgendaCategory(input: $input) { + _id + } + } +`; + +/** + * GraphQL mutation to delete an agenda category. + * + * @param deleteAgendaCategoryId - The ID of the AgendaCategory to be deleted. + */ + +export const DELETE_AGENDA_ITEM_CATEGORY_MUTATION = gql` + mutation DeleteAgendaCategory($deleteAgendaCategoryId: ID!) { + deleteAgendaCategory(id: $deleteAgendaCategoryId) + } +`; + +/** + * GraphQL mutation to update an agenda category. + * + * @param updateAgendaCategoryId - The ID of the AgendaCategory to be updated. + * @param input - Updated Name, Description, OrganizationID of the AgendaCategory. + */ + +export const UPDATE_AGENDA_ITEM_CATEGORY_MUTATION = gql` + mutation UpdateAgendaCategory( + $updateAgendaCategoryId: ID! + $input: UpdateAgendaCategoryInput! + ) { + updateAgendaCategory(id: $updateAgendaCategoryId, input: $input) { + _id + } + } +`; diff --git a/src/GraphQl/Mutations/AgendaItemMutations.ts b/src/GraphQl/Mutations/AgendaItemMutations.ts new file mode 100644 index 0000000000..20191b1b7c --- /dev/null +++ b/src/GraphQl/Mutations/AgendaItemMutations.ts @@ -0,0 +1,31 @@ +import gql from 'graphql-tag'; + +export const CREATE_AGENDA_ITEM_MUTATION = gql` + mutation CreateAgendaItem($input: CreateAgendaItemInput!) { + createAgendaItem(input: $input) { + _id + title + } + } +`; + +export const DELETE_AGENDA_ITEM_MUTATION = gql` + mutation RemoveAgendaItem($removeAgendaItemId: ID!) { + removeAgendaItem(id: $removeAgendaItemId) { + _id + } + } +`; + +export const UPDATE_AGENDA_ITEM_MUTATION = gql` + mutation UpdateAgendaItem( + $updateAgendaItemId: ID! + $input: UpdateAgendaItemInput! + ) { + updateAgendaItem(id: $updateAgendaItemId, input: $input) { + _id + description + title + } + } +`; diff --git a/src/GraphQl/Mutations/CampaignMutation.ts b/src/GraphQl/Mutations/CampaignMutation.ts new file mode 100644 index 0000000000..d14d318af6 --- /dev/null +++ b/src/GraphQl/Mutations/CampaignMutation.ts @@ -0,0 +1,75 @@ +import gql from 'graphql-tag'; + +/** + * GraphQL mutation to create a new fund Campaign. + * + * @param name - The name of the fund. + * @param fundId - The fund ID the campaign is associated with. + * @param fundingGoal - The funding goal of the campaign. + * @param startDate - The start date of the campaign. + * @param endDate - The end date of the campaign. + * @param currency - The currency of the campaign. + * @returns The ID of the created campaign. + */ + +export const CREATE_CAMPAIGN_MUTATION = gql` + mutation createFundraisingCampaign( + $fundId: ID! + $organizationId: ID! + $name: String! + $fundingGoal: Float! + $startDate: Date! + $endDate: Date! + $currency: Currency! + ) { + createFundraisingCampaign( + data: { + fundId: $fundId + organizationId: $organizationId + name: $name + fundingGoal: $fundingGoal + startDate: $startDate + endDate: $endDate + currency: $currency + } + ) { + _id + } + } +`; + +/** + * GraphQL mutation to update a fund Campaign. + * + * @param id - The ID of the campaign being updated. + * @param name - The name of the campaign. + * @param fundingGoal - The funding goal of the campaign. + * @param startDate - The start date of the campaign. + * @param endDate - The end date of the campaign. + * @param currency - The currency of the campaign. + * @returns The ID of the updated campaign. + */ + +export const UPDATE_CAMPAIGN_MUTATION = gql` + mutation updateFundraisingCampaign( + $id: ID! + $name: String + $fundingGoal: Float + $startDate: Date + $endDate: Date + $currency: Currency + ) { + updateFundraisingCampaign( + id: $id + data: { + name: $name + fundingGoal: $fundingGoal + startDate: $startDate + endDate: $endDate + currency: $currency + } + ) { + _id + } + } +`; diff --git a/src/GraphQl/Mutations/CommentMutations.ts b/src/GraphQl/Mutations/CommentMutations.ts new file mode 100644 index 0000000000..7a2ca00c83 --- /dev/null +++ b/src/GraphQl/Mutations/CommentMutations.ts @@ -0,0 +1,58 @@ +import gql from 'graphql-tag'; + +/** + * GraphQL mutation to create a new comment on a post. + * + * @param comment - The text content of the comment. + * @param postId - The ID of the post to which the comment is being added. + * @returns The created comment object. + */ + +export const CREATE_COMMENT_POST = gql` + mutation createComment($comment: String!, $postId: ID!) { + createComment(data: { text: $comment }, postId: $postId) { + _id + creator { + _id + firstName + lastName + email + } + likeCount + likedBy { + _id + } + text + } + } +`; + +/** + * GraphQL mutation to like a comment. + * + * @param commentId - The ID of the comment to be liked. + * @returns The liked comment object. + */ + +export const LIKE_COMMENT = gql` + mutation likeComment($commentId: ID!) { + likeComment(id: $commentId) { + _id + } + } +`; + +/** + * GraphQL mutation to unlike a comment. + * + * @param commentId - The ID of the comment to be unliked. + * @returns The unliked comment object. + */ + +export const UNLIKE_COMMENT = gql` + mutation unlikeComment($commentId: ID!) { + unlikeComment(id: $commentId) { + _id + } + } +`; diff --git a/src/GraphQl/Mutations/EventAttendeeMutations.ts b/src/GraphQl/Mutations/EventAttendeeMutations.ts new file mode 100644 index 0000000000..94d7d97705 --- /dev/null +++ b/src/GraphQl/Mutations/EventAttendeeMutations.ts @@ -0,0 +1,49 @@ +import gql from 'graphql-tag'; + +/** + * GraphQL mutation to add an attendee to an event. + * + * @param userId - The ID of the user being added as an attendee. + * @param eventId - The ID of the event to which the user is being added as an attendee. + * @returns The updated event object with the added attendee. + */ + +export const ADD_EVENT_ATTENDEE = gql` + mutation addEventAttendee($userId: ID!, $eventId: ID!) { + addEventAttendee(data: { userId: $userId, eventId: $eventId }) { + _id + } + } +`; + +/** + * GraphQL mutation to remove an attendee from an event. + * + * @param userId - The ID of the user being removed as an attendee. + * @param eventId - The ID of the event from which the user is being removed as an attendee. + * @returns The updated event object without the removed attendee. + */ + +export const REMOVE_EVENT_ATTENDEE = gql` + mutation removeEventAttendee($userId: ID!, $eventId: ID!) { + removeEventAttendee(data: { userId: $userId, eventId: $eventId }) { + _id + } + } +`; + +/** + * GraphQL mutation to mark a user's check-in at an event. + * + * @param userId - The ID of the user checking in. + * @param eventId - The ID of the event at which the user is checking in. + * @returns The updated event object with the user's check-in information. + */ + +export const MARK_CHECKIN = gql` + mutation checkIn($userId: ID!, $eventId: ID!) { + checkIn(data: { userId: $userId, eventId: $eventId }) { + _id + } + } +`; diff --git a/src/GraphQl/Mutations/EventVolunteerMutation.ts b/src/GraphQl/Mutations/EventVolunteerMutation.ts new file mode 100644 index 0000000000..eb611361c0 --- /dev/null +++ b/src/GraphQl/Mutations/EventVolunteerMutation.ts @@ -0,0 +1,112 @@ +import gql from 'graphql-tag'; + +/** + * GraphQL mutation to create an event volunteer. + * + * @param data - The data required to create an event volunteer. + * @returns The ID of the created event volunteer. + * + */ + +export const ADD_VOLUNTEER = gql` + mutation CreateEventVolunteer($data: EventVolunteerInput!) { + createEventVolunteer(data: $data) { + _id + } + } +`; + +/** + * GraphQL mutation to delete an event volunteer. + * + * @param id - The ID of the event volunteer being deleted. + * @returns The ID of the deleted event volunteer. + * + */ +export const DELETE_VOLUNTEER = gql` + mutation RemoveEventVolunteer($id: ID!) { + removeEventVolunteer(id: $id) { + _id + } + } +`; + +/** + * GraphQL mutation to create an event volunteer group. + * + * @param data - The data required to create an event volunteer group. + * - data contains following fileds: + * - eventId: string + * - leaderId: string + * - name: string + * - description?: string + * - volunteers: [string] + * - volunteersRequired?: number + * @returns The ID of the created event volunteer group. + * + */ +export const CREATE_VOLUNTEER_GROUP = gql` + mutation CreateEventVolunteerGroup($data: EventVolunteerGroupInput!) { + createEventVolunteerGroup(data: $data) { + _id + } + } +`; + +/** + * GraphQL mutation to update an event volunteer group. + * @param id - The ID of the event volunteer group being updated. + * @param data - The data required to update an event volunteer group. + * @returns The ID of the updated event volunteer group. + * + */ + +export const UPDATE_VOLUNTEER_GROUP = gql` + mutation UpdateEventVolunteerGroup( + $id: ID! + $data: UpdateEventVolunteerGroupInput! + ) { + updateEventVolunteerGroup(id: $id, data: $data) { + _id + } + } +`; + +/** + * GraphQL mutation to delete an event volunteer group. + * + * @param id - The ID of the event volunteer group being deleted. + * @returns The ID of the deleted event volunteer group. + * + */ +export const DELETE_VOLUNTEER_GROUP = gql` + mutation RemoveEventVolunteerGroup($id: ID!) { + removeEventVolunteerGroup(id: $id) { + _id + } + } +`; + +export const CREATE_VOLUNTEER_MEMBERSHIP = gql` + mutation CreateVolunteerMembership($data: VolunteerMembershipInput!) { + createVolunteerMembership(data: $data) { + _id + } + } +`; + +/** + * GraphQL mutation to update an event volunteer group. + * + * @param id - The ID of the event volunteer group being updated. + * @param data - The data required to update an event volunteer group. + * @returns The ID of the updated event volunteer group. + * + */ +export const UPDATE_VOLUNTEER_MEMBERSHIP = gql` + mutation UpdateVolunteerMembership($id: ID!, $status: String!) { + updateVolunteerMembership(id: $id, status: $status) { + _id + } + } +`; diff --git a/src/GraphQl/Mutations/FundMutation.ts b/src/GraphQl/Mutations/FundMutation.ts new file mode 100644 index 0000000000..dd7bac8e5f --- /dev/null +++ b/src/GraphQl/Mutations/FundMutation.ts @@ -0,0 +1,71 @@ +import gql from 'graphql-tag'; + +/** + * GraphQL mutation to create a new fund. + * + * @param name - The name of the fund. + * @param organizationId - The organization ID the fund is associated with. + * @param refrenceNumber - The reference number of the fund. + * @param taxDeductible - Whether the fund is tax deductible. + * @param isArchived - Whether the fund is archived. + * @param isDefault - Whether the fund is the default. + * @returns The ID of the created fund. + */ +export const CREATE_FUND_MUTATION = gql` + mutation CreateFund( + $name: String! + $organizationId: ID! + $refrenceNumber: String + $taxDeductible: Boolean! + $isArchived: Boolean! + $isDefault: Boolean! + ) { + createFund( + data: { + name: $name + organizationId: $organizationId + refrenceNumber: $refrenceNumber + taxDeductible: $taxDeductible + isArchived: $isArchived + isDefault: $isDefault + } + ) { + _id + } + } +`; + +/** + * GraphQL mutation to update a fund. + * + * @param id - The ID of the fund being updated. + * @param name - The name of the fund. + * @param refrenceNumber - The reference number of the fund. + * @param taxDeductible - Whether the fund is tax deductible. + * @param isArchived - Whether the fund is archived. + * @param isDefault - Whether the fund is the default. + * @returns The ID of the updated fund. + */ +export const UPDATE_FUND_MUTATION = gql` + mutation UpdateFund( + $id: ID! + $name: String + $refrenceNumber: String + $taxDeductible: Boolean + $isArchived: Boolean + $isDefault: Boolean + ) { + updateFund( + id: $id + data: { + name: $name + refrenceNumber: $refrenceNumber + taxDeductible: $taxDeductible + isArchived: $isArchived + isDefault: $isDefault + } + ) { + _id + } + } +`; diff --git a/src/GraphQl/Mutations/OrganizationMutations.ts b/src/GraphQl/Mutations/OrganizationMutations.ts new file mode 100644 index 0000000000..68a0c9026c --- /dev/null +++ b/src/GraphQl/Mutations/OrganizationMutations.ts @@ -0,0 +1,252 @@ +import gql from 'graphql-tag'; + +// Changes the role of a user in an organization +/** + * GraphQL mutation to update the role of a user in an organization. + * + * @param organizationId - The ID of the organization in which the user's role is being updated. + * @param userId - The ID of the user whose role is being updated. + * @param role - The new role to be assigned to the user in the organization. + * @returns The updated user object with the new role in the organization. + */ +export const UPDATE_USER_ROLE_IN_ORG_MUTATION = gql` + mutation updateUserRoleInOrganization( + $organizationId: ID! + $userId: ID! + $role: String! + ) { + updateUserRoleInOrganization( + organizationId: $organizationId + userId: $userId + role: $role + ) { + _id + } + } +`; + +/** + * GraphQL mutation to create a sample organization. + * + * @returns The created sample organization object. + */ + +export const CREATE_SAMPLE_ORGANIZATION_MUTATION = gql` + mutation { + createSampleOrganization + } +`; + +/** + * GraphQL mutation to remove a sample organization. + * + * @returns The removed sample organization object. + */ + +export const REMOVE_SAMPLE_ORGANIZATION_MUTATION = gql` + mutation { + removeSampleOrganization + } +`; + +/** + * GraphQL mutation to create a chat between users in an organization. + * + * @param userIds - An array of user IDs participating in the direct chat. + * @param organizationId - The ID of the organization where the direct chat is created. + * @returns The created direct chat object. + */ + +export const CREATE_CHAT = gql` + mutation createChat( + $userIds: [ID!]! + $organizationId: ID + $isGroup: Boolean! + $name: String + ) { + createChat( + data: { + userIds: $userIds + organizationId: $organizationId + isGroup: $isGroup + name: $name + } + ) { + _id + } + } +`; + +export const SEND_MESSAGE_TO_CHAT = gql` + mutation sendMessageToChat( + $chatId: ID! + $replyTo: ID + $messageContent: String! + ) { + sendMessageToChat( + chatId: $chatId + replyTo: $replyTo + messageContent: $messageContent + ) { + _id + createdAt + messageContent + replyTo { + _id + createdAt + messageContent + sender { + _id + firstName + lastName + } + updatedAt + } + sender { + _id + firstName + lastName + } + updatedAt + } + } +`; + +export const MESSAGE_SENT_TO_CHAT = gql` + subscription messageSentToChat($userId: ID!) { + messageSentToChat(userId: $userId) { + _id + createdAt + chatMessageBelongsTo { + _id + } + messageContent + replyTo { + _id + createdAt + messageContent + sender { + _id + firstName + lastName + } + updatedAt + } + sender { + _id + firstName + lastName + } + updatedAt + } + } +`; + +//Plugin WebSocket listner + +/** + * GraphQL subscription to listen for updates on plugins. + * + * @returns An object containing information about the updated plugin. + */ + +export const PLUGIN_SUBSCRIPTION = gql` + subscription onPluginUpdate { + onPluginUpdate { + pluginName + _id + pluginDesc + uninstalledOrgs + } + } +`; + +/** + * GraphQL mutation to toggle the pinned status of a post. + * + * @param id - The ID of the post to be toggled. + * @returns The updated post object with the new pinned status. + */ + +export const TOGGLE_PINNED_POST = gql` + mutation TogglePostPin($id: ID!) { + togglePostPin(id: $id) { + _id + } + } +`; + +/** + * GraphQL mutation to add a custom field to an organization. + * + * @param organizationId - The ID of the organization where the custom field is being added. + * @param type - The type of the custom field (e.g., String, Number). + * @param name - The name of the custom field. + * @returns The added organization custom field object. + */ + +export const ADD_CUSTOM_FIELD = gql` + mutation ($organizationId: ID!, $type: String!, $name: String!) { + addOrganizationCustomField( + organizationId: $organizationId + type: $type + name: $name + ) { + name + type + } + } +`; + +// Handles custom organization fields + +/** + * GraphQL mutation to remove a custom field from an organization. + * + * @param organizationId - The ID of the organization from which the custom field is being removed. + * @param customFieldId - The ID of the custom field to be removed. + * @returns The removed organization custom field object. + */ + +export const REMOVE_CUSTOM_FIELD = gql` + mutation ($organizationId: ID!, $customFieldId: ID!) { + removeOrganizationCustomField( + organizationId: $organizationId + customFieldId: $customFieldId + ) { + type + name + } + } +`; + +export const SEND_MEMBERSHIP_REQUEST = gql` + mutation ($organizationId: ID!) { + sendMembershipRequest(organizationId: $organizationId) { + _id + organization { + _id + name + } + user { + _id + } + } + } +`; + +export const JOIN_PUBLIC_ORGANIZATION = gql` + mutation ($organizationId: ID!) { + joinPublicOrganization(organizationId: $organizationId) { + _id + } + } +`; + +export const CANCEL_MEMBERSHIP_REQUEST = gql` + mutation ($membershipRequestId: ID!) { + cancelMembershipRequest(membershipRequestId: $membershipRequestId) { + _id + } + } +`; diff --git a/src/GraphQl/Mutations/PledgeMutation.ts b/src/GraphQl/Mutations/PledgeMutation.ts new file mode 100644 index 0000000000..321f7aa697 --- /dev/null +++ b/src/GraphQl/Mutations/PledgeMutation.ts @@ -0,0 +1,84 @@ +import gql from 'graphql-tag'; + +/** + * GraphQL mutation to create a pledge. + * + * @param campaignId - The ID of the campaign the pledge is associated with. + * @param amount - The amount of the pledge. + * @param currency - The currency of the pledge. + * @param startDate - The start date of the pledge. + * @param endDate - The end date of the pledge. + * @param userIds - The IDs of the users associated with the pledge. + * @returns The ID of the created pledge. + */ +export const CREATE_PlEDGE = gql` + mutation CreateFundraisingCampaignPledge( + $campaignId: ID! + $amount: Float! + $currency: Currency! + $startDate: Date! + $endDate: Date! + $userIds: [ID!]! + ) { + createFundraisingCampaignPledge( + data: { + campaignId: $campaignId + amount: $amount + currency: $currency + startDate: $startDate + endDate: $endDate + userIds: $userIds + } + ) { + _id + } + } +`; + +/** + * GraphQL mutation to update a pledge. + * + * @param id - The ID of the pledge being updated. + * @param amount - The amount of the pledge. + * @param currency - The currency of the pledge. + * @param startDate - The start date of the pledge. + * @param endDate - The end date of the pledge. + * @returns The ID of the updated pledge. + */ +export const UPDATE_PLEDGE = gql` + mutation UpdateFundraisingCampaignPledge( + $id: ID! + $amount: Float + $currency: Currency + $startDate: Date + $endDate: Date + $users: [ID!] + ) { + updateFundraisingCampaignPledge( + id: $id + data: { + users: $users + amount: $amount + currency: $currency + startDate: $startDate + endDate: $endDate + } + ) { + _id + } + } +`; + +/** + * GraphQL mutation to delete a pledge. + * + * @param id - The ID of the pledge being deleted. + * @returns Whether the pledge was successfully deleted. + */ +export const DELETE_PLEDGE = gql` + mutation DeleteFundraisingCampaignPledge($id: ID!) { + removeFundraisingCampaignPledge(id: $id) { + _id + } + } +`; diff --git a/src/GraphQl/Mutations/TagMutations.ts b/src/GraphQl/Mutations/TagMutations.ts new file mode 100644 index 0000000000..9f8ed1ec61 --- /dev/null +++ b/src/GraphQl/Mutations/TagMutations.ts @@ -0,0 +1,123 @@ +import gql from 'graphql-tag'; + +/** + * GraphQL mutation to create a user tag. + * + * @param name - Name of the tag. + * @param tagColor - Color of the tag. + * @param parentTagId - Id of the parent tag. + * @param organizationId - Organization to which the tag belongs. + */ + +export const CREATE_USER_TAG = gql` + mutation CreateUserTag( + $name: String! + $tagColor: String + $parentTagId: ID + $organizationId: ID! + ) { + createUserTag( + input: { + name: $name + organizationId: $organizationId + parentTagId: $parentTagId + tagColor: $tagColor + } + ) { + _id + } + } +`; + +/** + * GraphQL mutation to unsssign a user tag from a user. + * + * @param tagId - Id the tag. + * @param userId - Id of the user to be unassigned. + */ + +export const UNASSIGN_USER_TAG = gql` + mutation UnassignUserTag($tagId: ID!, $userId: ID!) { + unassignUserTag(input: { tagId: $tagId, userId: $userId }) { + _id + } + } +`; + +/** + * GraphQL mutation to update a user tag. + * + * @param tagId - Id the tag. + * @param name - Updated name of the tag. + */ + +export const UPDATE_USER_TAG = gql` + mutation UpdateUserTag($tagId: ID!, $name: String!) { + updateUserTag(input: { tagId: $tagId, name: $name }) { + _id + } + } +`; + +/** + * GraphQL mutation to remove a user tag. + * + * @param id - Id of the tag to be removed . + */ + +export const REMOVE_USER_TAG = gql` + mutation RemoveUserTag($id: ID!) { + removeUserTag(id: $id) { + _id + } + } +`; + +/** + * GraphQL mutation to add people to tag. + * + * @param tagId - Id of the tag to be assigned. + * @param userIds - Ids of the users to assign to. + */ + +export const ADD_PEOPLE_TO_TAG = gql` + mutation AddPeopleToUserTag($tagId: ID!, $userIds: [ID!]!) { + addPeopleToUserTag(input: { tagId: $tagId, userIds: $userIds }) { + _id + } + } +`; + +/** + * GraphQL mutation to assign people to multiple tags. + * + * @param currentTagId - Id of the current tag. + * @param selectedTagIds - Ids of the selected tags to be assined. + */ + +export const ASSIGN_TO_TAGS = gql` + mutation AssignToUserTags($currentTagId: ID!, $selectedTagIds: [ID!]!) { + assignToUserTags( + input: { currentTagId: $currentTagId, selectedTagIds: $selectedTagIds } + ) { + _id + } + } +`; + +/** + * GraphQL mutation to remove people from multiple tags. + * + * @param currentTagId - Id of the current tag. + * @param selectedTagIds - Ids of the selected tags to be removed from. + */ + +export const REMOVE_FROM_TAGS = gql` + mutation RemoveFromUserTags($currentTagId: ID!, $selectedTagIds: [ID!]!) { + removeFromUserTags( + input: { currentTagId: $currentTagId, selectedTagIds: $selectedTagIds } + ) { + _id + } + } +`; diff --git a/src/GraphQl/Mutations/VenueMutations.ts b/src/GraphQl/Mutations/VenueMutations.ts new file mode 100644 index 0000000000..44ccc1f63e --- /dev/null +++ b/src/GraphQl/Mutations/VenueMutations.ts @@ -0,0 +1,79 @@ +import gql from 'graphql-tag'; + +/** + * GraphQL mutation to create a venue. + * + * @param name - Name of the venue. + * @param capacity - Ineteger representing capacity of venue. + * @param description - Description of the venue. + * @param file - Image file for the venue. + * @param organizationId - Organization to which the ActionItemCategory belongs. + */ + +export const CREATE_VENUE_MUTATION = gql` + mutation createVenue( + $capacity: Int! + $description: String + $file: String + $name: String! + $organizationId: ID! + ) { + createVenue( + data: { + capacity: $capacity + description: $description + file: $file + name: $name + organizationId: $organizationId + } + ) { + _id + } + } +`; + +/** + * GraphQL mutation to update a venue. + * + * @param id - The id of the Venue to be updated. + * @param capacity - Ineteger representing capacity of venue. + * @param description - Description of the venue. + * @param file - Image file for the venue. + * @param name - Name of the venue. + */ + +export const UPDATE_VENUE_MUTATION = gql` + mutation editVenue( + $capacity: Int + $description: String + $file: String + $id: ID! + $name: String + ) { + editVenue( + data: { + capacity: $capacity + description: $description + file: $file + id: $id + name: $name + } + ) { + _id + } + } +`; + +/** + * GraphQL mutation to delete a venue. + * + * @param id - The id of the Venue to be deleted. + */ + +export const DELETE_VENUE_MUTATION = gql` + mutation DeleteVenue($id: ID!) { + deleteVenue(id: $id) { + _id + } + } +`; diff --git a/src/GraphQl/Mutations/mutations.ts b/src/GraphQl/Mutations/mutations.ts new file mode 100644 index 0000000000..628328987e --- /dev/null +++ b/src/GraphQl/Mutations/mutations.ts @@ -0,0 +1,742 @@ +import gql from 'graphql-tag'; + +export const UNBLOCK_USER_MUTATION = gql` + mutation UnblockUser($userId: ID!, $orgId: ID!) { + unblockUser(organizationId: $orgId, userId: $userId) { + _id + } + } +`; + +// to block the user + +export const BLOCK_USER_MUTATION = gql` + mutation BlockUser($userId: ID!, $orgId: ID!) { + blockUser(organizationId: $orgId, userId: $userId) { + _id + } + } +`; + +// to reject the organization request + +export const REJECT_ORGANIZATION_REQUEST_MUTATION = gql` + mutation RejectMembershipRequest($id: ID!) { + rejectMembershipRequest(membershipRequestId: $id) { + _id + } + } +`; + +// to accept the organization request + +export const ACCEPT_ORGANIZATION_REQUEST_MUTATION = gql` + mutation AcceptMembershipRequest($id: ID!) { + acceptMembershipRequest(membershipRequestId: $id) { + _id + } + } +`; + +// to update the organization details + +export const UPDATE_ORGANIZATION_MUTATION = gql` + mutation UpdateOrganization( + $id: ID! + $name: String + $description: String + $address: AddressInput + $userRegistrationRequired: Boolean + $visibleInSearch: Boolean + $file: String + ) { + updateOrganization( + id: $id + data: { + name: $name + description: $description + userRegistrationRequired: $userRegistrationRequired + visibleInSearch: $visibleInSearch + address: $address + } + file: $file + ) { + _id + } + } +`; + +// fragment for defining the Address input type. +export const ADDRESS_DETAILS_FRAGMENT = gql` + fragment AddressDetails on AddressInput { + city: String + countryCode: String + dependentLocality: String + line1: String + line2: String + postalCode: String + sortingCode: String + state: String + } +`; + +// to update the details of the user + +export const UPDATE_USER_MUTATION = gql` + mutation UpdateUserProfile( + $firstName: String + $lastName: String + $gender: Gender + $email: EmailAddress + $phoneNumber: PhoneNumber + $birthDate: Date + $grade: EducationGrade + $empStatus: EmploymentStatus + $maritalStatus: MaritalStatus + $address: String + $state: String + $country: String + $image: String + $appLanguageCode: String + ) { + updateUserProfile( + data: { + firstName: $firstName + lastName: $lastName + gender: $gender + email: $email + phone: { mobile: $phoneNumber } + birthDate: $birthDate + educationGrade: $grade + employmentStatus: $empStatus + maritalStatus: $maritalStatus + address: { line1: $address, state: $state, countryCode: $country } + appLanguageCode: $appLanguageCode + } + file: $image + ) { + _id + } + } +`; + +// to update the password of user + +export const UPDATE_USER_PASSWORD_MUTATION = gql` + mutation UpdateUserPassword( + $previousPassword: String! + $newPassword: String! + $confirmNewPassword: String! + ) { + updateUserPassword( + data: { + previousPassword: $previousPassword + newPassword: $newPassword + confirmNewPassword: $confirmNewPassword + } + ) { + user { + _id + } + } + } +`; + +// to sign up in the talawa admin + +export const SIGNUP_MUTATION = gql` + mutation SignUp( + $firstName: String! + $lastName: String! + $email: EmailAddress! + $password: String! + $orgId: ID! + ) { + signUp( + data: { + firstName: $firstName + lastName: $lastName + email: $email + password: $password + selectedOrganization: $orgId + } + ) { + user { + _id + } + accessToken + refreshToken + } + } +`; + +// to login in the talawa admin + +export const LOGIN_MUTATION = gql` + mutation Login($email: EmailAddress!, $password: String!) { + login(data: { email: $email, password: $password }) { + user { + _id + firstName + lastName + image + email + } + appUserProfile { + adminFor { + _id + } + isSuperAdmin + appLanguageCode + } + accessToken + refreshToken + } + } +`; + +// to get the refresh token + +export const REFRESH_TOKEN_MUTATION = gql` + mutation RefreshToken($refreshToken: String!) { + refreshToken(refreshToken: $refreshToken) { + refreshToken + accessToken + } + } +`; + +// to revoke a refresh token + +export const REVOKE_REFRESH_TOKEN = gql` + mutation RevokeRefreshTokenForUser { + revokeRefreshTokenForUser + } +`; + +// To verify the google recaptcha + +export const RECAPTCHA_MUTATION = gql` + mutation Recaptcha($recaptchaToken: String!) { + recaptcha(data: { recaptchaToken: $recaptchaToken }) + } +`; + +// to create the organization + +export const CREATE_ORGANIZATION_MUTATION = gql` + mutation CreateOrganization( + $description: String! + $address: AddressInput! + $name: String! + $visibleInSearch: Boolean! + $userRegistrationRequired: Boolean! + $image: String + ) { + createOrganization( + data: { + description: $description + address: $address + name: $name + visibleInSearch: $visibleInSearch + userRegistrationRequired: $userRegistrationRequired + } + file: $image + ) { + _id + } + } +`; + +// to delete the organization + +export const DELETE_ORGANIZATION_MUTATION = gql` + mutation RemoveOrganization($id: ID!) { + removeOrganization(id: $id) { + user { + _id + } + } + } +`; + +// to create the event by any organization + +export const CREATE_EVENT_MUTATION = gql` + mutation CreateEvent( + $title: String! + $description: String! + $recurring: Boolean! + $isPublic: Boolean! + $isRegisterable: Boolean! + $organizationId: ID! + $startDate: Date! + $endDate: Date! + $allDay: Boolean! + $startTime: Time + $endTime: Time + $location: String + $recurrenceStartDate: Date + $recurrenceEndDate: Date + $frequency: Frequency + $weekDays: [WeekDays] + $count: PositiveInt + $interval: PositiveInt + $weekDayOccurenceInMonth: Int + ) { + createEvent( + data: { + title: $title + description: $description + recurring: $recurring + isPublic: $isPublic + isRegisterable: $isRegisterable + organizationId: $organizationId + startDate: $startDate + endDate: $endDate + allDay: $allDay + startTime: $startTime + endTime: $endTime + location: $location + } + recurrenceRuleData: { + recurrenceStartDate: $recurrenceStartDate + recurrenceEndDate: $recurrenceEndDate + frequency: $frequency + weekDays: $weekDays + interval: $interval + count: $count + weekDayOccurenceInMonth: $weekDayOccurenceInMonth + } + ) { + _id + } + } +`; + +// to delete any event by any organization + +export const DELETE_EVENT_MUTATION = gql` + mutation RemoveEvent( + $id: ID! + $recurringEventDeleteType: RecurringEventMutationType + ) { + removeEvent(id: $id, recurringEventDeleteType: $recurringEventDeleteType) { + _id + } + } +`; + +// to remove an admin from an organization +export const REMOVE_ADMIN_MUTATION = gql` + mutation RemoveAdmin($orgid: ID!, $userid: ID!) { + removeAdmin(data: { organizationId: $orgid, userId: $userid }) { + _id + } + } +`; + +// to Remove member from an organization +export const REMOVE_MEMBER_MUTATION = gql` + mutation RemoveMember($orgid: ID!, $userid: ID!) { + removeMember(data: { organizationId: $orgid, userId: $userid }) { + _id + } + } +`; + +// to add the admin +export const ADD_ADMIN_MUTATION = gql` + mutation CreateAdmin($orgid: ID!, $userid: ID!) { + createAdmin(data: { organizationId: $orgid, userId: $userid }) { + user { + _id + } + } + } +`; + +export const ADD_MEMBER_MUTATION = gql` + mutation CreateMember($orgid: ID!, $userid: ID!) { + createMember(input: { organizationId: $orgid, userId: $userid }) { + organization { + _id + } + } + } +`; + +export const CREATE_POST_MUTATION = gql` + mutation CreatePost( + $text: String! + $title: String! + $imageUrl: URL + $videoUrl: URL + $organizationId: ID! + $file: String + $pinned: Boolean + ) { + createPost( + data: { + text: $text + title: $title + imageUrl: $imageUrl + videoUrl: $videoUrl + organizationId: $organizationId + pinned: $pinned + } + file: $file + ) { + _id + } + } +`; + +export const DELETE_POST_MUTATION = gql` + mutation RemovePost($id: ID!) { + removePost(id: $id) { + _id + } + } +`; + +export const GENERATE_OTP_MUTATION = gql` + mutation Otp($email: EmailAddress!) { + otp(data: { email: $email }) { + otpToken + } + } +`; + +export const FORGOT_PASSWORD_MUTATION = gql` + mutation ForgotPassword( + $userOtp: String! + $newPassword: String! + $otpToken: String! + ) { + forgotPassword( + data: { + userOtp: $userOtp + newPassword: $newPassword + otpToken: $otpToken + } + ) + } +`; + +/** + * {@label UPDATE_INSTALL_STATUS_PLUGIN_MUTATION} + * @remarks + * used to toggle `installStatus` (boolean value) of a Plugin + */ +export const UPDATE_INSTALL_STATUS_PLUGIN_MUTATION = gql` + mutation ($id: ID!, $orgId: ID!) { + updatePluginStatus(id: $id, orgId: $orgId) { + _id + pluginName + pluginCreatedBy + pluginDesc + uninstalledOrgs + } + } +`; + +/** + * {@label UPDATE_ORG_STATUS_PLUGIN_MUTATION} + * @remarks + * used `updatePluginStatus`to add or remove the current Organization the in the plugin list `uninstalledOrgs` + */ +export const UPDATE_ORG_STATUS_PLUGIN_MUTATION = gql` + mutation update_install_status_plugin_mutation($id: ID!, $orgId: ID!) { + updatePluginStatus(id: $id, orgId: $orgId) { + _id + pluginName + pluginCreatedBy + pluginDesc + uninstalledOrgs + } + } +`; + +/** + * {@label ADD_PLUGIN_MUTATION} + * @remarks + * used `createPlugin` to add new Plugin in database + */ +export const ADD_PLUGIN_MUTATION = gql` + mutation add_plugin_mutation( + $pluginName: String! + $pluginCreatedBy: String! + $pluginDesc: String! + ) { + createPlugin( + pluginName: $pluginName + pluginCreatedBy: $pluginCreatedBy + pluginDesc: $pluginDesc + ) { + _id + pluginName + pluginCreatedBy + pluginDesc + } + } +`; +export const ADD_ADVERTISEMENT_MUTATION = gql` + mutation ( + $organizationId: ID! + $name: String! + $type: AdvertisementType! + $startDate: Date! + $endDate: Date! + $file: String! + ) { + createAdvertisement( + input: { + organizationId: $organizationId + name: $name + type: $type + startDate: $startDate + endDate: $endDate + mediaFile: $file + } + ) { + advertisement { + _id + } + } + } +`; +export const UPDATE_ADVERTISEMENT_MUTATION = gql` + mutation UpdateAdvertisement( + $id: ID! + $name: String + $file: String + $type: AdvertisementType + $startDate: Date + $endDate: Date + ) { + updateAdvertisement( + input: { + _id: $id + name: $name + mediaFile: $file + type: $type + startDate: $startDate + endDate: $endDate + } + ) { + advertisement { + _id + } + } + } +`; +export const DELETE_ADVERTISEMENT_BY_ID = gql` + mutation ($id: ID!) { + deleteAdvertisement(id: $id) { + advertisement { + _id + } + } + } +`; +export const UPDATE_POST_MUTATION = gql` + mutation UpdatePost( + $id: ID! + $title: String + $text: String + $imageUrl: String + $videoUrl: String + ) { + updatePost( + id: $id + data: { + title: $title + text: $text + imageUrl: $imageUrl + videoUrl: $videoUrl + } + ) { + _id + } + } +`; + +export const UPDATE_EVENT_MUTATION = gql` + mutation UpdateEvent( + $id: ID! + $title: String + $description: String + $recurring: Boolean + $recurringEventUpdateType: RecurringEventMutationType + $isPublic: Boolean + $isRegisterable: Boolean + $allDay: Boolean + $startDate: Date + $endDate: Date + $startTime: Time + $endTime: Time + $location: String + $recurrenceStartDate: Date + $recurrenceEndDate: Date + $frequency: Frequency + $weekDays: [WeekDays] + $count: PositiveInt + $interval: PositiveInt + $weekDayOccurenceInMonth: Int + ) { + updateEvent( + id: $id + data: { + title: $title + description: $description + recurring: $recurring + isPublic: $isPublic + isRegisterable: $isRegisterable + allDay: $allDay + startDate: $startDate + endDate: $endDate + startTime: $startTime + endTime: $endTime + location: $location + } + recurrenceRuleData: { + recurrenceStartDate: $recurrenceStartDate + recurrenceEndDate: $recurrenceEndDate + frequency: $frequency + weekDays: $weekDays + interval: $interval + count: $count + weekDayOccurenceInMonth: $weekDayOccurenceInMonth + } + recurringEventUpdateType: $recurringEventUpdateType + ) { + _id + } + } +`; + +export const LIKE_POST = gql` + mutation likePost($postId: ID!) { + likePost(id: $postId) { + _id + } + } +`; + +export const UNLIKE_POST = gql` + mutation unlikePost($postId: ID!) { + unlikePost(id: $postId) { + _id + } + } +`; + +export const REGISTER_EVENT = gql` + mutation registerForEvent($eventId: ID!) { + registerForEvent(id: $eventId) { + _id + } + } +`; + +export const UPDATE_COMMUNITY = gql` + mutation updateCommunity($data: UpdateCommunityInput!) { + updateCommunity(data: $data) + } +`; + +export const UPDATE_SESSION_TIMEOUT = gql` + mutation updateSessionTimeout($timeout: Int!) { + updateSessionTimeout(timeout: $timeout) + } +`; + +export const RESET_COMMUNITY = gql` + mutation resetCommunity { + resetCommunity + } +`; + +export const DONATE_TO_ORGANIZATION = gql` + mutation donate( + $userId: ID! + $createDonationOrgId2: ID! + $payPalId: ID! + $nameOfUser: String! + $amount: Float! + $nameOfOrg: String! + ) { + createDonation( + userId: $userId + orgId: $createDonationOrgId2 + payPalId: $payPalId + nameOfUser: $nameOfUser + amount: $amount + nameOfOrg: $nameOfOrg + ) { + _id + amount + nameOfUser + nameOfOrg + } + } +`; + +// Create and Update Action Item Categories +export { + CREATE_ACTION_ITEM_CATEGORY_MUTATION, + UPDATE_ACTION_ITEM_CATEGORY_MUTATION, +} from './ActionItemCategoryMutations'; + +// Create, Update and Delete Action Items +export { + CREATE_ACTION_ITEM_MUTATION, + DELETE_ACTION_ITEM_MUTATION, + UPDATE_ACTION_ITEM_MUTATION, +} from './ActionItemMutations'; + +export { + CREATE_AGENDA_ITEM_CATEGORY_MUTATION, + DELETE_AGENDA_ITEM_CATEGORY_MUTATION, + UPDATE_AGENDA_ITEM_CATEGORY_MUTATION, +} from './AgendaCategoryMutations'; + +export { + CREATE_AGENDA_ITEM_MUTATION, + DELETE_AGENDA_ITEM_MUTATION, + UPDATE_AGENDA_ITEM_MUTATION, +} from './AgendaItemMutations'; + +// Changes the role of a event in an organization and add and remove the event from the organization +export { + ADD_EVENT_ATTENDEE, + MARK_CHECKIN, + REMOVE_EVENT_ATTENDEE, +} from './EventAttendeeMutations'; + +// Create the new comment on a post and Like and Unlike the comment +export { + CREATE_COMMENT_POST, + LIKE_COMMENT, + UNLIKE_COMMENT, +} from './CommentMutations'; + +// Changes the role of a user in an organization +export { + ADD_CUSTOM_FIELD, + CREATE_SAMPLE_ORGANIZATION_MUTATION, + JOIN_PUBLIC_ORGANIZATION, + PLUGIN_SUBSCRIPTION, + REMOVE_CUSTOM_FIELD, + REMOVE_SAMPLE_ORGANIZATION_MUTATION, + SEND_MEMBERSHIP_REQUEST, + TOGGLE_PINNED_POST, + UPDATE_USER_ROLE_IN_ORG_MUTATION, +} from './OrganizationMutations'; + +export { + CREATE_VENUE_MUTATION, + DELETE_VENUE_MUTATION, + UPDATE_VENUE_MUTATION, +} from './VenueMutations'; diff --git a/src/GraphQl/Queries/ActionItemCategoryQueries.ts b/src/GraphQl/Queries/ActionItemCategoryQueries.ts new file mode 100644 index 0000000000..db0db119e3 --- /dev/null +++ b/src/GraphQl/Queries/ActionItemCategoryQueries.ts @@ -0,0 +1,32 @@ +import gql from 'graphql-tag'; + +/** + * GraphQL query to retrieve action item categories by organization. + * + * @param organizationId - The ID of the organization for which action item categories are being retrieved. + * @returns The list of action item categories associated with the organization. + */ + +export const ACTION_ITEM_CATEGORY_LIST = gql` + query ActionItemCategoriesByOrganization( + $organizationId: ID! + $where: ActionItemCategoryWhereInput + $orderBy: ActionItemsOrderByInput + ) { + actionItemCategoriesByOrganization( + organizationId: $organizationId + where: $where + orderBy: $orderBy + ) { + _id + name + isDisabled + createdAt + creator { + _id + firstName + lastName + } + } + } +`; diff --git a/src/GraphQl/Queries/ActionItemQueries.ts b/src/GraphQl/Queries/ActionItemQueries.ts new file mode 100644 index 0000000000..7b24dd6138 --- /dev/null +++ b/src/GraphQl/Queries/ActionItemQueries.ts @@ -0,0 +1,128 @@ +import gql from 'graphql-tag'; + +/** + * GraphQL query to retrieve action item categories by organization. + * + * @param organizationId - The ID of the organization for which action item categories are being retrieved. + * @param orderBy - Sort action items Latest/Earliest first. + * @param actionItemCategory_id - Filter action items belonging to an action item category. + * @param event_id - Filter action items belonging to an event. + * @param is_completed - Filter all the completed action items. + * @returns The list of action item categories associated with the organization. + */ + +export const ACTION_ITEM_LIST = gql` + query ActionItemsByOrganization( + $organizationId: ID! + $eventId: ID + $where: ActionItemWhereInput + $orderBy: ActionItemsOrderByInput + ) { + actionItemsByOrganization( + organizationId: $organizationId + eventId: $eventId + orderBy: $orderBy + where: $where + ) { + _id + assignee { + _id + user { + _id + firstName + lastName + image + } + } + assigneeGroup { + _id + name + } + assigneeUser { + _id + firstName + lastName + image + } + assigneeType + assigner { + _id + firstName + lastName + image + } + actionItemCategory { + _id + name + } + preCompletionNotes + postCompletionNotes + assignmentDate + dueDate + completionDate + isCompleted + event { + _id + title + } + creator { + _id + firstName + lastName + } + allottedHours + } + } +`; + +export const ACTION_ITEMS_BY_USER = gql` + query ActionItemsByUser( + $userId: ID! + $where: ActionItemWhereInput + $orderBy: ActionItemsOrderByInput + ) { + actionItemsByUser(userId: $userId, where: $where, orderBy: $orderBy) { + _id + assignee { + _id + user { + _id + firstName + lastName + image + } + } + assigneeGroup { + _id + name + } + assigneeType + assigner { + _id + firstName + lastName + image + } + actionItemCategory { + _id + name + } + preCompletionNotes + postCompletionNotes + assignmentDate + dueDate + completionDate + isCompleted + event { + _id + title + } + creator { + _id + firstName + lastName + } + allottedHours + } + } +`; diff --git a/src/GraphQl/Queries/AgendaCategoryQueries.ts b/src/GraphQl/Queries/AgendaCategoryQueries.ts new file mode 100644 index 0000000000..f766337c22 --- /dev/null +++ b/src/GraphQl/Queries/AgendaCategoryQueries.ts @@ -0,0 +1,23 @@ +import gql from 'graphql-tag'; + +/** + * GraphQL query to retrieve agenda category by id. + * + * @param agendaCategoryId - The ID of the category which is being retrieved. + * @returns Agenda category associated with the id. + */ + +export const AGENDA_ITEM_CATEGORY_LIST = gql` + query AgendaItemCategoriesByOrganization($organizationId: ID!) { + agendaItemCategoriesByOrganization(organizationId: $organizationId) { + _id + name + description + createdBy { + _id + firstName + lastName + } + } + } +`; diff --git a/src/GraphQl/Queries/AgendaItemQueries.ts b/src/GraphQl/Queries/AgendaItemQueries.ts new file mode 100644 index 0000000000..92957983c8 --- /dev/null +++ b/src/GraphQl/Queries/AgendaItemQueries.ts @@ -0,0 +1,73 @@ +import gql from 'graphql-tag'; + +export const AgendaItemByOrganization = gql` + query AgendaItemByOrganization($organizationId: ID!) { + agendaItemByOrganization(organizationId: $organizationId) { + _id + title + description + duration + attachments + createdBy { + _id + firstName + lastName + } + urls + users { + _id + firstName + lastName + } + categories { + _id + name + } + sequence + organization { + _id + name + } + relatedEvent { + _id + title + } + } + } +`; + +export const AgendaItemByEvent = gql` + query AgendaItemByEvent($relatedEventId: ID!) { + agendaItemByEvent(relatedEventId: $relatedEventId) { + _id + title + description + duration + attachments + createdBy { + _id + firstName + lastName + } + urls + users { + _id + firstName + lastName + } + sequence + categories { + _id + name + } + organization { + _id + name + } + relatedEvent { + _id + title + } + } + } +`; diff --git a/src/GraphQl/Queries/EventVolunteerQueries.ts b/src/GraphQl/Queries/EventVolunteerQueries.ts new file mode 100644 index 0000000000..f4a3a02564 --- /dev/null +++ b/src/GraphQl/Queries/EventVolunteerQueries.ts @@ -0,0 +1,134 @@ +import gql from 'graphql-tag'; + +/** + * GraphQL query to retrieve event volunteers. + * + * @param where - The filter to apply to the query. + * @param orderBy - The order in which to return the results. + * @returns The list of event volunteers. + * + **/ + +export const EVENT_VOLUNTEER_LIST = gql` + query GetEventVolunteers( + $where: EventVolunteerWhereInput! + $orderBy: EventVolunteersOrderByInput + ) { + getEventVolunteers(where: $where, orderBy: $orderBy) { + _id + hasAccepted + hoursVolunteered + user { + _id + firstName + lastName + image + } + assignments { + _id + } + groups { + _id + name + volunteers { + _id + } + } + } + } +`; + +export const EVENT_VOLUNTEER_GROUP_LIST = gql` + query GetEventVolunteerGroups( + $where: EventVolunteerGroupWhereInput! + $orderBy: EventVolunteerGroupOrderByInput + ) { + getEventVolunteerGroups(where: $where, orderBy: $orderBy) { + _id + name + description + volunteersRequired + createdAt + creator { + _id + firstName + lastName + image + } + leader { + _id + firstName + lastName + image + } + volunteers { + _id + user { + _id + firstName + lastName + image + } + } + assignments { + _id + actionItemCategory { + _id + name + } + allottedHours + isCompleted + } + event { + _id + } + } + } +`; + +export const USER_VOLUNTEER_MEMBERSHIP = gql` + query GetVolunteerMembership( + $where: VolunteerMembershipWhereInput! + $orderBy: VolunteerMembershipOrderByInput + ) { + getVolunteerMembership(where: $where, orderBy: $orderBy) { + _id + status + createdAt + event { + _id + title + startDate + } + volunteer { + _id + user { + _id + firstName + lastName + image + } + } + group { + _id + name + } + } + } +`; + +export const VOLUNTEER_RANKING = gql` + query GetVolunteerRanks($orgId: ID!, $where: VolunteerRankWhereInput!) { + getVolunteerRanks(orgId: $orgId, where: $where) { + rank + hoursVolunteered + user { + _id + lastName + firstName + image + email + } + } + } +`; diff --git a/src/GraphQl/Queries/OrganizationQueries.ts b/src/GraphQl/Queries/OrganizationQueries.ts new file mode 100644 index 0000000000..3329390783 --- /dev/null +++ b/src/GraphQl/Queries/OrganizationQueries.ts @@ -0,0 +1,380 @@ +// OrganizationQueries.js +import gql from 'graphql-tag'; + +// display posts + +/** + * GraphQL query to retrieve the list of organizations. + * + * @param first - Optional. Number of organizations to retrieve in the first batch. + * @param skip - Optional. Number of organizations to skip before starting to collect the result set. + * @param filter - Optional. Filter organizations by a specified string. + * @param id - Optional. The ID of a specific organization to retrieve. + * @returns The list of organizations based on the applied filters. + */ +export const ORGANIZATION_POST_LIST = gql` + query Organizations( + $id: ID! + $after: String + $before: String + $first: PositiveInt + $last: PositiveInt + ) { + organizations(id: $id) { + posts(after: $after, before: $before, first: $first, last: $last) { + edges { + node { + _id + title + text + creator { + _id + firstName + lastName + email + } + createdAt + likeCount + likedBy { + _id + firstName + lastName + } + commentCount + comments { + _id + text + creator { + _id + } + createdAt + likeCount + likedBy { + _id + } + } + pinned + } + cursor + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + totalCount + } + } + } +`; + +/** + * GraphQL query to retrieve the list of user tags belonging to an organization. + * + * @param id - ID of the organization. + * @param first - Number of tags to retrieve "after" (if provided) a certain tag. + * @param after - Id of the last tag on the current page. + * @param last - Number of tags to retrieve "before" (if provided) a certain tag. + * @param before - Id of the first tag on the current page. + * @returns The list of organizations based on the applied filters. + */ + +export const ORGANIZATION_USER_TAGS_LIST = gql` + query Organizations( + $id: ID! + $after: String + $before: String + $first: PositiveInt + $last: PositiveInt + $where: UserTagWhereInput + $sortedBy: UserTagSortedByInput + ) { + organizations(id: $id) { + userTags( + after: $after + before: $before + first: $first + last: $last + where: $where + sortedBy: $sortedBy + ) { + edges { + node { + _id + name + parentTag { + _id + } + usersAssignedTo(first: $first, last: $last) { + totalCount + } + childTags(first: $first, last: $last) { + totalCount + } + ancestorTags { + _id + name + } + } + cursor + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + totalCount + } + } + } +`; + +export const ORGANIZATION_ADVERTISEMENT_LIST = gql` + query Organizations( + $id: ID! + $after: String + $before: String + $first: Int + $last: Int + ) { + organizations(id: $id) { + _id + advertisements( + after: $after + before: $before + first: $first + last: $last + ) { + edges { + node { + _id + name + startDate + endDate + mediaUrl + } + cursor + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + totalCount + } + } + } +`; + +/** + * GraphQL query to retrieve organizations based on user connection. + * + * @param first - Optional. Number of organizations to retrieve in the first batch. + * @param skip - Optional. Number of organizations to skip before starting to collect the result set. + * @param filter - Optional. Filter organizations by a specified string. + * @param id - Optional. The ID of a specific organization to retrieve. + * @returns The list of organizations based on the applied filters. + */ + +export const USER_ORGANIZATION_CONNECTION = gql` + query organizationsConnection( + $first: Int + $skip: Int + $filter: String + $id: ID + ) { + organizationsConnection( + first: $first + skip: $skip + where: { name_contains: $filter, id: $id } + orderBy: name_ASC + ) { + _id + name + image + description + userRegistrationRequired + creator { + firstName + lastName + } + members { + _id + } + admins { + _id + } + createdAt + address { + city + countryCode + dependentLocality + line1 + line2 + postalCode + sortingCode + state + } + membershipRequests { + _id + user { + _id + } + } + } + } +`; + +/** + * GraphQL query to retrieve organizations joined by a user. + * + * @param id - The ID of the user for which joined organizations are being retrieved. + * @returns The list of organizations joined by the user. + */ + +export const USER_JOINED_ORGANIZATIONS = gql` + query UserJoinedOrganizations($id: ID!) { + users(where: { id: $id }) { + user { + joinedOrganizations { + _id + name + description + image + members { + _id + } + address { + city + countryCode + dependentLocality + line1 + line2 + postalCode + sortingCode + state + } + admins { + _id + } + } + } + } + } +`; + +/** + * GraphQL query to retrieve organizations created by a user. + * + * @param id - The ID of the user for which created organizations are being retrieved. + * @returns The list of organizations created by the user. + */ + +export const USER_CREATED_ORGANIZATIONS = gql` + query UserCreatedOrganizations($id: ID!) { + users(where: { id: $id }) { + appUserProfile { + createdOrganizations { + _id + name + description + image + members { + _id + } + address { + city + countryCode + dependentLocality + line1 + line2 + postalCode + sortingCode + state + } + admins { + _id + } + } + } + } + } +`; + +/** + * GraphQL query to retrieve the list of admins for a specific organization. + * + * @param id - The ID of the organization for which admins are being retrieved. + * @returns The list of admins associated with the organization. + */ + +export const ORGANIZATION_ADMINS_LIST = gql` + query Organizations($id: ID!) { + organizations(id: $id) { + _id + admins { + _id + image + firstName + lastName + email + } + } + } +`; + +/** + * GraphQL query to retrieve the list of members for a specific organization. + * + * @param id - The ID of the organization for which members are being retrieved. + * @returns The list of members associated with the organization. + */ +export const ORGANIZATION_FUNDS = gql` + query Organizations($id: ID!) { + organizations(id: $id) { + funds { + _id + name + refrenceNumber + taxDeductible + isArchived + isDefault + createdAt + } + } + } +`; + +/** + * GraphQL query to retrieve the list of venues for a specific organization. + * + * @param id - The ID of the organization for which venues are being retrieved. + * @returns The list of venues associated with the organization. + */ +export const VENUE_LIST = gql` + query GetVenueByOrgId( + $orgId: ID! + $first: Int + $orderBy: VenueOrderByInput + $where: VenueWhereInput + ) { + getVenueByOrgId( + orgId: $orgId + first: $first + orderBy: $orderBy + where: $where + ) { + _id + capacity + name + description + imageUrl + organization { + _id + } + } + } +`; diff --git a/src/GraphQl/Queries/PlugInQueries.ts b/src/GraphQl/Queries/PlugInQueries.ts new file mode 100644 index 0000000000..508da522e9 --- /dev/null +++ b/src/GraphQl/Queries/PlugInQueries.ts @@ -0,0 +1,267 @@ +import gql from 'graphql-tag'; + +/** + * GraphQL query to retrieve a list of plugins. + * + * @returns The list of plugins with details such as ID, name, creator, description, and uninstalled organizations. + */ + +export const PLUGIN_GET = gql` + query getPluginList { + getPlugins { + _id + pluginName + pluginCreatedBy + pluginDesc + uninstalledOrgs + } + } +`; + +/** + * GraphQL query to retrieve a list of advertisements. + * + * @returns The list of advertisements with details such as ID, name, type, organization ID, link, start date, and end date. + */ + +export const ADVERTISEMENTS_GET = gql` + query getAdvertisements { + advertisementsConnection { + edges { + node { + _id + name + type + organization { + _id + } + mediaUrl + endDate + startDate + } + } + } + } +`; + +/** + * GraphQL query to retrieve a list of events based on organization connection. + * + * @param organization_id - The ID of the organization for which events are being retrieved. + * @param title_contains - Optional. Filter events by title containing a specified string. + * @param description_contains - Optional. Filter events by description containing a specified string. + * @param location_contains - Optional. Filter events by location containing a specified string. + * @param first - Optional. Number of events to retrieve in the first batch. + * @param skip - Optional. Number of events to skip before starting to collect the result set. + * @returns The list of events associated with the organization based on the applied filters. + */ + +export const ORGANIZATION_EVENTS_CONNECTION = gql` + query EventsByOrganizationConnection( + $organization_id: ID! + $title_contains: String + $description_contains: String + $location_contains: String + $first: Int + $skip: Int + ) { + eventsByOrganizationConnection( + where: { + organization_id: $organization_id + title_contains: $title_contains + description_contains: $description_contains + location_contains: $location_contains + } + first: $first + skip: $skip + ) { + _id + title + description + startDate + endDate + location + startTime + endTime + allDay + recurring + isPublic + isRegisterable + creator { + _id + firstName + lastName + } + attendees { + _id + } + } + } +`; + +export const USER_EVENTS_VOLUNTEER = gql` + query UserEventsVolunteer( + $organization_id: ID! + $title_contains: String + $location_contains: String + $first: Int + $skip: Int + $upcomingOnly: Boolean + ) { + eventsByOrganizationConnection( + where: { + organization_id: $organization_id + title_contains: $title_contains + location_contains: $location_contains + } + first: $first + skip: $skip + upcomingOnly: $upcomingOnly + ) { + _id + title + startDate + endDate + location + startTime + endTime + allDay + recurring + volunteerGroups { + _id + name + volunteersRequired + description + volunteers { + _id + } + } + volunteers { + _id + user { + _id + } + } + } + } +`; + +/** + * GraphQL query to retrieve a list of chats based on user ID. + * + * @param id - The ID of the user for which chats are being retrieved. + * @returns The list of chats associated with the user, including details such as ID, creator, messages, organization, and participating users. + */ + +export const CHAT_BY_ID = gql` + query chatById($id: ID!) { + chatById(id: $id) { + _id + isGroup + name + organization { + _id + } + createdAt + messages { + _id + createdAt + messageContent + replyTo { + _id + createdAt + messageContent + sender { + _id + firstName + lastName + email + image + } + } + sender { + _id + firstName + lastName + email + image + } + } + users { + _id + firstName + lastName + email + } + } + } +`; + +export const CHATS_LIST = gql` + query ChatsByUserId($id: ID!) { + chatsByUserId(id: $id) { + _id + isGroup + name + + creator { + _id + firstName + lastName + email + } + messages { + _id + createdAt + messageContent + sender { + _id + firstName + lastName + email + } + } + organization { + _id + name + } + users { + _id + firstName + lastName + email + image + } + } + } +`; + +/** + * GraphQL query to check if an organization is a sample organization. + * + * @param isSampleOrganizationId - The ID of the organization being checked. + * @returns A boolean indicating whether the organization is a sample organization. + */ + +export const IS_SAMPLE_ORGANIZATION_QUERY = gql` + query ($isSampleOrganizationId: ID!) { + isSampleOrganization(id: $isSampleOrganizationId) + } +`; + +/** + * GraphQL query to retrieve custom fields for a specific organization. + * + * @param customFieldsByOrganizationId - The ID of the organization for which custom fields are being retrieved. + * @returns The list of custom fields associated with the organization, including details such as ID, type, and name. + */ + +export const ORGANIZATION_CUSTOM_FIELDS = gql` + query ($customFieldsByOrganizationId: ID!) { + customFieldsByOrganization(id: $customFieldsByOrganizationId) { + _id + type + name + } + } +`; diff --git a/src/GraphQl/Queries/Queries.ts b/src/GraphQl/Queries/Queries.ts new file mode 100644 index 0000000000..81442cbad2 --- /dev/null +++ b/src/GraphQl/Queries/Queries.ts @@ -0,0 +1,890 @@ +import gql from 'graphql-tag'; + +//Query List +// Check Auth +export const CHECK_AUTH = gql` + query { + checkAuth { + _id + firstName + lastName + createdAt + image + email + birthDate + educationGrade + employmentStatus + gender + maritalStatus + phone { + mobile + } + address { + line1 + state + countryCode + } + eventsAttended { + _id + } + } + } +`; + +// Query to take the Organization list +export const ORGANIZATION_LIST = gql` + query { + organizations { + _id + image + creator { + firstName + lastName + } + name + members { + _id + } + admins { + _id + } + createdAt + address { + city + countryCode + dependentLocality + line1 + line2 + postalCode + sortingCode + state + } + } + } +`; + +// Query to take the Organization list with filter and sort option +export const ORGANIZATION_CONNECTION_LIST = gql` + query OrganizationsConnection( + $filter: String + $first: Int + $skip: Int + $orderBy: OrganizationOrderByInput + ) { + organizationsConnection( + where: { name_contains: $filter } + first: $first + skip: $skip + orderBy: $orderBy + ) { + _id + image + creator { + firstName + lastName + } + name + members { + _id + } + admins { + _id + } + createdAt + address { + city + countryCode + dependentLocality + line1 + line2 + postalCode + sortingCode + state + } + } + } +`; + +// Query to take the User list +export const USER_LIST = gql` + query Users( + $firstName_contains: String + $lastName_contains: String + $skip: Int + $first: Int + $order: UserOrderByInput + ) { + users( + where: { + firstName_contains: $firstName_contains + lastName_contains: $lastName_contains + } + skip: $skip + first: $first + orderBy: $order + ) { + user { + _id + joinedOrganizations { + _id + name + image + createdAt + address { + city + countryCode + dependentLocality + line1 + line2 + postalCode + sortingCode + state + } + creator { + _id + firstName + lastName + image + email + } + } + firstName + lastName + email + image + createdAt + registeredEvents { + _id + } + organizationsBlockedBy { + _id + name + image + address { + city + countryCode + dependentLocality + line1 + line2 + postalCode + sortingCode + state + } + creator { + _id + firstName + lastName + image + email + } + createdAt + } + membershipRequests { + _id + } + } + appUserProfile { + _id + adminFor { + _id + } + isSuperAdmin + createdOrganizations { + _id + } + createdEvents { + _id + } + eventAdmin { + _id + } + } + } + } +`; +export const USER_LIST_FOR_TABLE = gql` + query Users($firstName_contains: String, $lastName_contains: String) { + users( + where: { + firstName_contains: $firstName_contains + lastName_contains: $lastName_contains + } + ) { + user { + _id + firstName + lastName + email + image + createdAt + } + } + } +`; + +export const USER_LIST_REQUEST = gql` + query Users( + $firstName_contains: String + $lastName_contains: String + $first: Int + $skip: Int + ) { + users( + where: { + firstName_contains: $firstName_contains + lastName_contains: $lastName_contains + } + skip: $skip + first: $first + ) { + user { + firstName + lastName + image + _id + email + createdAt + } + appUserProfile { + _id + adminFor { + _id + } + isSuperAdmin + createdOrganizations { + _id + } + createdEvents { + _id + } + eventAdmin { + _id + } + } + } + } +`; + +export const EVENT_DETAILS = gql` + query Event($id: ID!) { + event(id: $id) { + _id + title + description + startDate + endDate + startTime + endTime + allDay + location + recurring + baseRecurringEvent { + _id + } + organization { + _id + members { + _id + firstName + lastName + } + } + attendees { + _id + } + } + } +`; + +export const RECURRING_EVENTS = gql` + query RecurringEvents($baseRecurringEventId: ID!) { + getRecurringEvents(baseRecurringEventId: $baseRecurringEventId) { + _id + startDate + title + attendees { + _id + gender + } + } + } +`; + +export const EVENT_ATTENDEES = gql` + query Event($id: ID!) { + event(id: $id) { + attendees { + _id + firstName + lastName + createdAt + gender + birthDate + eventsAttended { + _id + } + } + } + } +`; + +export const EVENT_CHECKINS = gql` + query eventCheckIns($id: ID!) { + event(id: $id) { + _id + attendeesCheckInStatus { + _id + user { + _id + firstName + lastName + } + checkIn { + _id + time + } + } + } + } +`; + +export const EVENT_FEEDBACKS = gql` + query eventFeedback($id: ID!) { + event(id: $id) { + _id + feedback { + _id + rating + review + } + averageFeedbackScore + } + } +`; + +// Query to take the Organization with data +export const ORGANIZATIONS_LIST = gql` + query Organizations($id: ID!) { + organizations(id: $id) { + _id + image + creator { + firstName + lastName + email + } + name + description + address { + city + countryCode + dependentLocality + line1 + line2 + postalCode + sortingCode + state + } + userRegistrationRequired + visibleInSearch + members { + _id + firstName + lastName + email + } + admins { + _id + firstName + lastName + email + createdAt + } + membershipRequests { + _id + user { + firstName + lastName + email + } + } + blockedUsers { + _id + firstName + lastName + email + } + } + } +`; + +// Query to take the Members of a particular organization +export const MEMBERS_LIST = gql` + query Organizations($id: ID!) { + organizations(id: $id) { + _id + members { + _id + firstName + lastName + image + email + createdAt + organizationsBlockedBy { + _id + } + } + } + } +`; + +export const BLOCK_PAGE_MEMBER_LIST = gql` + query Organizations( + $orgId: ID! + $firstName_contains: String + $lastName_contains: String + ) { + organizationsMemberConnection( + orgId: $orgId + where: { + firstName_contains: $firstName_contains + lastName_contains: $lastName_contains + } + ) { + edges { + _id + firstName + lastName + email + organizationsBlockedBy { + _id + } + } + } + } +`; + +// Query to filter out all the members with the macthing query and a particular OrgId +export const ORGANIZATIONS_MEMBER_CONNECTION_LIST = gql` + query Organizations( + $orgId: ID! + $firstName_contains: String + $lastName_contains: String + $first: Int + $skip: Int + ) { + organizationsMemberConnection( + orgId: $orgId + first: $first + skip: $skip + where: { + firstName_contains: $firstName_contains + lastName_contains: $lastName_contains + } + ) { + edges { + _id + firstName + lastName + image + email + createdAt + } + } + } +`; + +// To take the list of the oranization joined by a user +export const USER_ORGANIZATION_LIST = gql` + query User($userId: ID!) { + user(id: $userId) { + user { + firstName + email + image + lastName + } + } + } +`; + +// To take the details of a user +export const USER_DETAILS = gql` + query User( + $id: ID! + $after: String + $before: String + $first: PositiveInt + $last: PositiveInt + ) { + user(id: $id) { + user { + _id + eventsAttended { + _id + } + joinedOrganizations { + _id + } + firstName + lastName + email + image + createdAt + birthDate + educationGrade + employmentStatus + gender + maritalStatus + phone { + mobile + } + address { + line1 + countryCode + city + state + } + tagsAssignedWith( + after: $after + before: $before + first: $first + last: $last + ) { + edges { + node { + _id + name + parentTag { + _id + } + } + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + totalCount + } + registeredEvents { + _id + } + membershipRequests { + _id + } + } + appUserProfile { + _id + adminFor { + _id + } + isSuperAdmin + appLanguageCode + pluginCreationAllowed + createdOrganizations { + _id + } + createdEvents { + _id + } + eventAdmin { + _id + } + } + } + } +`; + +// to take the organization event list +export const ORGANIZATION_EVENT_LIST = gql` + query EventsByOrganization($id: ID!) { + eventsByOrganization(id: $id) { + _id + title + description + startDate + endDate + location + startTime + endTime + allDay + recurring + isPublic + isRegisterable + } + } +`; + +export const ORGANIZATION_EVENT_CONNECTION_LIST = gql` + query EventsByOrganizationConnection( + $organization_id: ID! + $title_contains: String + $description_contains: String + $location_contains: String + $first: Int + $skip: Int + ) { + eventsByOrganizationConnection( + where: { + organization_id: $organization_id + title_contains: $title_contains + description_contains: $description_contains + location_contains: $location_contains + } + first: $first + skip: $skip + ) { + _id + title + description + startDate + endDate + location + startTime + endTime + allDay + recurring + attendees { + _id + createdAt + firstName + lastName + gender + eventsAttended { + _id + endDate + } + } + recurrenceRule { + recurrenceStartDate + recurrenceEndDate + frequency + weekDays + interval + count + weekDayOccurenceInMonth + } + isRecurringEventException + isPublic + isRegisterable + } + } +`; + +export const ORGANIZATION_DONATION_CONNECTION_LIST = gql` + query GetDonationByOrgIdConnection( + $orgId: ID! + $id: ID + $name_of_user_contains: String + ) { + getDonationByOrgIdConnection( + orgId: $orgId + where: { id: $id, name_of_user_contains: $name_of_user_contains } + ) { + _id + nameOfUser + amount + userId + payPalId + updatedAt + } + } +`; + +// to take the list of the admins of a particular +export const ADMIN_LIST = gql` + query Organizations($id: ID!) { + organizations(id: $id) { + _id + admins { + _id + firstName + lastName + image + email + createdAt + } + } + } +`; + +// to take the membership request +export const MEMBERSHIP_REQUEST = gql` + query Organizations( + $id: ID! + $skip: Int + $first: Int + $firstName_contains: String + ) { + organizations(id: $id) { + _id + membershipRequests( + skip: $skip + first: $first + where: { user: { firstName_contains: $firstName_contains } } + ) { + _id + user { + _id + firstName + lastName + email + } + } + } + } +`; + +export const USERS_CONNECTION_LIST = gql` + query usersConnection( + $id_not_in: [ID!] + $firstName_contains: String + $lastName_contains: String + ) { + users( + where: { + id_not_in: $id_not_in + firstName_contains: $firstName_contains + lastName_contains: $lastName_contains + } + ) { + user { + firstName + lastName + image + _id + email + createdAt + organizationsBlockedBy { + _id + name + image + address { + city + countryCode + dependentLocality + line1 + line2 + postalCode + sortingCode + state + } + createdAt + creator { + _id + firstName + lastName + image + email + createdAt + } + } + joinedOrganizations { + _id + name + image + address { + city + countryCode + dependentLocality + line1 + line2 + postalCode + sortingCode + state + } + createdAt + creator { + _id + firstName + lastName + image + email + createdAt + } + } + } + appUserProfile { + _id + adminFor { + _id + } + isSuperAdmin + createdOrganizations { + _id + } + createdEvents { + _id + } + eventAdmin { + _id + } + } + } + } +`; + +export const GET_COMMUNITY_DATA = gql` + query getCommunityData { + getCommunityData { + _id + websiteLink + name + logoUrl + socialMediaUrls { + facebook + gitHub + instagram + X + linkedIn + youTube + reddit + slack + } + } + } +`; + +export const GET_COMMUNITY_SESSION_TIMEOUT_DATA = gql` + query getCommunityData { + getCommunityData { + timeout + } + } +`; + +// get the list of Action Item Categories +export { ACTION_ITEM_CATEGORY_LIST } from './ActionItemCategoryQueries'; + +// get the list of Action Items +export { ACTION_ITEM_LIST } from './ActionItemQueries'; + +export { + AgendaItemByEvent, + AgendaItemByOrganization, +} from './AgendaItemQueries'; + +export { AGENDA_ITEM_CATEGORY_LIST } from './AgendaCategoryQueries'; +// to take the list of the blocked users +export { + ADVERTISEMENTS_GET, + IS_SAMPLE_ORGANIZATION_QUERY, + ORGANIZATION_CUSTOM_FIELDS, + ORGANIZATION_EVENTS_CONNECTION, + PLUGIN_GET, +} from './PlugInQueries'; + +// display posts +export { + ORGANIZATION_POST_LIST, + ORGANIZATION_ADVERTISEMENT_LIST, +} from './OrganizationQueries'; + +export { + ORGANIZATION_ADMINS_LIST, + USER_CREATED_ORGANIZATIONS, + USER_JOINED_ORGANIZATIONS, + USER_ORGANIZATION_CONNECTION, +} from './OrganizationQueries'; diff --git a/src/GraphQl/Queries/fundQueries.ts b/src/GraphQl/Queries/fundQueries.ts new file mode 100644 index 0000000000..f705b87797 --- /dev/null +++ b/src/GraphQl/Queries/fundQueries.ts @@ -0,0 +1,132 @@ +/*eslint-disable*/ +import gql from 'graphql-tag'; + +/** + * GraphQL query to retrieve the list of members for a specific organization. + * + * @param id - The ID of the organization for which members are being retrieved. + * @param filter - The filter to search for a specific member. + * @returns The list of members associated with the organization. + */ +export const FUND_LIST = gql` + query FundsByOrganization( + $organizationId: ID! + $filter: String + $orderBy: FundOrderByInput + ) { + fundsByOrganization( + organizationId: $organizationId + where: { name_contains: $filter } + orderBy: $orderBy + ) { + _id + name + refrenceNumber + taxDeductible + isDefault + isArchived + createdAt + organizationId + creator { + _id + firstName + lastName + } + } + } +`; + +export const FUND_CAMPAIGN = gql` + query GetFundById( + $id: ID! + $where: CampaignWhereInput + $orderBy: CampaignOrderByInput + ) { + getFundById(id: $id, where: $where, orderBy: $orderBy) { + name + isArchived + campaigns { + _id + endDate + fundingGoal + name + startDate + currency + } + } + } +`; + +export const FUND_CAMPAIGN_PLEDGE = gql` + query GetFundraisingCampaigns( + $where: CampaignWhereInput + $pledgeOrderBy: PledgeOrderByInput + ) { + getFundraisingCampaigns(where: $where, pledgeOrderBy: $pledgeOrderBy) { + fundId { + name + } + name + fundingGoal + currency + startDate + endDate + pledges { + _id + amount + currency + endDate + startDate + users { + _id + firstName + lastName + image + } + } + } + } +`; + +export const USER_FUND_CAMPAIGNS = gql` + query GetFundraisingCampaigns( + $where: CampaignWhereInput + $campaignOrderBy: CampaignOrderByInput + ) { + getFundraisingCampaigns(where: $where, campaignOrderby: $campaignOrderBy) { + _id + startDate + endDate + name + fundingGoal + currency + } + } +`; + +export const USER_PLEDGES = gql` + query GetPledgesByUserId( + $userId: ID! + $where: PledgeWhereInput + $orderBy: PledgeOrderByInput + ) { + getPledgesByUserId(userId: $userId, where: $where, orderBy: $orderBy) { + _id + amount + startDate + endDate + campaign { + _id + name + endDate + } + currency + users { + _id + firstName + lastName + image + } + } + } +`; diff --git a/src/GraphQl/Queries/userTagQueries.ts b/src/GraphQl/Queries/userTagQueries.ts new file mode 100644 index 0000000000..d58da19e1b --- /dev/null +++ b/src/GraphQl/Queries/userTagQueries.ts @@ -0,0 +1,154 @@ +import gql from 'graphql-tag'; + +/** + * GraphQL query to retrieve organization members assigned a certain tag. + * + * @param id - The ID of the tag that is assigned. + * @returns The list of organization members. + */ + +export const USER_TAGS_ASSIGNED_MEMBERS = gql` + query UserTagDetails( + $id: ID! + $after: String + $before: String + $first: PositiveInt + $last: PositiveInt + $where: UserTagUsersAssignedToWhereInput + $sortedBy: UserTagUsersAssignedToSortedByInput + ) { + getAssignedUsers: getUserTag(id: $id) { + name + usersAssignedTo( + after: $after + before: $before + first: $first + last: $last + where: $where + sortedBy: $sortedBy + ) { + edges { + node { + _id + firstName + lastName + } + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + totalCount + } + ancestorTags { + _id + name + } + } + } +`; + +/** + * GraphQL query to retrieve the sub tags of a certain tag. + * + * @param id - The ID of the parent tag. + * @returns The list of sub tags. + */ + +export const USER_TAG_SUB_TAGS = gql` + query GetChildTags( + $id: ID! + $after: String + $before: String + $first: PositiveInt + $last: PositiveInt + $where: UserTagWhereInput + $sortedBy: UserTagSortedByInput + ) { + getChildTags: getUserTag(id: $id) { + name + childTags( + after: $after + before: $before + first: $first + last: $last + where: $where + sortedBy: $sortedBy + ) { + edges { + node { + _id + name + usersAssignedTo(first: $first, last: $last) { + totalCount + } + childTags(first: $first, last: $last) { + totalCount + } + ancestorTags { + _id + name + } + } + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + totalCount + } + ancestorTags { + _id + name + } + } + } +`; + +/** + * GraphQL query to retrieve organization members that aren't assigned a certain tag. + * + * @param id - The ID of the tag. + * @returns The list of organization members. + */ + +export const USER_TAGS_MEMBERS_TO_ASSIGN_TO = gql` + query GetMembersToAssignTo( + $id: ID! + $after: String + $before: String + $first: PositiveInt + $last: PositiveInt + $where: UserTagUsersToAssignToWhereInput + ) { + getUsersToAssignTo: getUserTag(id: $id) { + name + usersToAssignTo( + after: $after + before: $before + first: $first + last: $last + where: $where + ) { + edges { + node { + _id + firstName + lastName + } + } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + totalCount + } + } + } +`; diff --git a/src/assets/css/app.css b/src/assets/css/app.css new file mode 100644 index 0000000000..0d23ea0e13 --- /dev/null +++ b/src/assets/css/app.css @@ -0,0 +1,14195 @@ +@charset "UTF-8"; +@import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&display=swap'); + +/*! + * Bootstrap v5.3.0 (https://getbootstrap.com/) + * Copyright 2011-2023 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +:root, +[data-bs-theme='light'] { + --bs-blue: #0d6efd; + --bs-indigo: #6610f2; + --bs-purple: #6f42c1; + --bs-pink: #d63384; + --bs-red: #dc3545; + --bs-orange: #fd7e14; + --bs-yellow: #ffc107; + --bs-green: #198754; + --bs-teal: #20c997; + --bs-cyan: #0dcaf0; + --bs-black: #000; + --bs-white: #fff; + --bs-gray: #6c757d; + --bs-gray-dark: #343a40; + --bs-greyish-black: #555555; + --bs-gray-100: #f8f9fa; + --bs-gray-200: #e9ecef; + --bs-gray-300: #dee2e6; + --bs-gray-400: #ced4da; + --bs-gray-500: #adb5bd; + --bs-gray-600: #6c757d; + --bs-gray-700: #495057; + --bs-gray-800: #343a40; + --bs-gray-900: #212529; + --bs-primary: #eaebef; + --bs-secondary: #707070; + --bs-success: #31bb6b; + --bs-info: #0dcaf0; + --bs-warning: #febc59; + --bs-danger: #dc3545; + --bs-light: #f8f9fa; + --bs-dark: #212529; + --bs-primary-rgb: 49, 187, 107; + --bs-secondary-rgb: 112, 112, 112; + --bs-success-rgb: 49, 187, 107; + --bs-info-rgb: 13, 202, 240; + --bs-warning-rgb: 254, 188, 89; + --bs-danger-rgb: 220, 53, 69; + --bs-light-rgb: 248, 249, 250; + --bs-dark-rgb: 33, 37, 41; + --bs-primary-text-emphasis: #144b2b; + --bs-secondary-text-emphasis: #2d2d2d; + --bs-success-text-emphasis: #144b2b; + --bs-info-text-emphasis: #055160; + --bs-warning-text-emphasis: #664b24; + --bs-danger-text-emphasis: #58151c; + --bs-light-text-emphasis: #495057; + --bs-dark-text-emphasis: #495057; + --bs-primary-bg-subtle: #d6f1e1; + --bs-secondary-bg-subtle: #e2e2e2; + --bs-success-bg-subtle: #d6f1e1; + --bs-info-bg-subtle: #cff4fc; + --bs-warning-bg-subtle: #fff2de; + --bs-danger-bg-subtle: #f8d7da; + --bs-light-bg-subtle: #fcfcfd; + --bs-dark-bg-subtle: #ced4da; + --bs-primary-border-subtle: #ade4c4; + --bs-secondary-border-subtle: #c6c6c6; + --bs-success-border-subtle: #ade4c4; + --bs-info-border-subtle: #9eeaf9; + --bs-warning-border-subtle: #ffe4bd; + --bs-danger-border-subtle: #f1aeb5; + --bs-light-border-subtle: #e9ecef; + --bs-dark-border-subtle: #adb5bd; + --bs-white-rgb: 255, 255, 255; + --bs-black-rgb: 0, 0, 0; + --bs-font-sans-serif: system-ui, -apple-system, 'Segoe UI', Roboto, + 'Helvetica Neue', 'Noto Sans', 'Liberation Sans', Arial, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; + --bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, + 'Liberation Mono', 'Courier New', monospace; + --bs-font-lato: 'Lato'; + --bs-gradient: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.15), + rgba(255, 255, 255, 0) + ); + --bs-body-font-family: var(--bs-font-sans-serif); + --bs-leftDrawer-font-family: var(--bs-font-sans-serif); + --bs-body-font-size: 1rem; + --bs-body-font-weight: 400; + --bs-body-line-height: 1.5; + --bs-body-color: #212529; + --bs-body-color-rgb: 33, 37, 41; + --bs-body-bg: #fff; + --bs-body-bg-rgb: 255, 255, 255; + --bs-emphasis-color: #000; + --bs-emphasis-color-rgb: 0, 0, 0; + --bs-secondary-color: rgba(33, 37, 41, 0.75); + --bs-secondary-color-rgb: 33, 37, 41; + --bs-secondary-bg: #e9ecef; + --bs-secondary-bg-rgb: 233, 236, 239; + --bs-tertiary-color: rgba(33, 37, 41, 0.5); + --bs-tertiary-color-rgb: 33, 37, 41; + --bs-tertiary-bg: #f8f9fa; + --bs-tertiary-bg-rgb: 248, 249, 250; + --bs-heading-color: inherit; + --bs-link-color: #0d6efd; + --bs-link-color-rgb: 13, 110, 253; + --bs-link-decoration: none; + --bs-link-hover-color: #0a58ca; + --bs-link-hover-color-rgb: 10, 88, 202; + --bs-code-color: #d63384; + --bs-highlight-bg: #fff3cd; + --bs-border-width: 1px; + --bs-border-style: solid; + --bs-border-color: #dee2e6; + --bs-border-color-translucent: rgba(0, 0, 0, 0.175); + --bs-border-radius: 0.375rem; + --bs-border-radius-sm: 0.25rem; + --bs-border-radius-lg: 0.5rem; + --bs-border-radius-xl: 1rem; + --bs-border-radius-xxl: 2rem; + --bs-border-radius-2xl: var(--bs-border-radius-xxl); + --bs-border-radius-pill: 50rem; + --bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + --bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + --bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175); + --bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075); + --bs-focus-ring-width: 0.25rem; + --bs-focus-ring-opacity: 0.25; + --bs-focus-ring-color: rgba(49, 187, 107, 0.25); + --bs-form-valid-color: #31bb6b; + --bs-form-valid-border-color: #31bb6b; + --bs-form-invalid-color: #dc3545; + --bs-form-invalid-border-color: #dc3545; +} + +[data-bs-theme='dark'] { + color-scheme: dark; + --bs-body-color: #f6f8fc; + --bs-body-color-rgb: 173, 181, 189; + --bs-body-bg: #212529; + --bs-body-bg-rgb: 33, 37, 41; + --bs-emphasis-color: #fff; + --bs-emphasis-color-rgb: 255, 255, 255; + --bs-secondary-color: rgba(173, 181, 189, 0.75); + --bs-secondary-color-rgb: 173, 181, 189; + --bs-secondary-bg: #343a40; + --bs-secondary-bg-rgb: 52, 58, 64; + --bs-tertiary-color: rgba(173, 181, 189, 0.5); + --bs-tertiary-color-rgb: 173, 181, 189; + --bs-tertiary-bg: #2b3035; + --bs-tertiary-bg-rgb: 43, 48, 53; + --bs-primary-text-emphasis: #83d6a6; + --bs-secondary-text-emphasis: darkgray; + --bs-success-text-emphasis: #83d6a6; + --bs-info-text-emphasis: #6edff6; + --bs-warning-text-emphasis: #fed79b; + --bs-danger-text-emphasis: #ea868f; + --bs-light-text-emphasis: #f8f9fa; + --bs-dark-text-emphasis: #dee2e6; + --bs-primary-bg-subtle: #0a2515; + --bs-secondary-bg-subtle: #161616; + --bs-success-bg-subtle: #0a2515; + --bs-info-bg-subtle: #032830; + --bs-warning-bg-subtle: #332612; + --bs-danger-bg-subtle: #2c0b0e; + --bs-light-bg-subtle: #343a40; + --bs-dark-bg-subtle: #1a1d20; + --bs-primary-border-subtle: #1d7040; + --bs-secondary-border-subtle: #434343; + --bs-success-border-subtle: #1d7040; + --bs-info-border-subtle: #087990; + --bs-warning-border-subtle: #987135; + --bs-danger-border-subtle: #842029; + --bs-light-border-subtle: #495057; + --bs-dark-border-subtle: #343a40; + --bs-heading-color: inherit; + --bs-link-color: #83d6a6; + --bs-link-hover-color: #9cdeb8; + --bs-link-color-rgb: 131, 214, 166; + --bs-link-hover-color-rgb: 156, 222, 184; + --bs-code-color: #e685b5; + --bs-border-color: #495057; + --bs-border-color-translucent: rgba(255, 255, 255, 0.15); + --bs-form-valid-color: #75b798; + --bs-form-valid-border-color: #75b798; + --bs-form-invalid-color: #ea868f; + --bs-form-invalid-border-color: #ea868f; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +@media (prefers-reduced-motion: no-preference) { + :root { + scroll-behavior: smooth; + } +} + +body { + margin: 0; + font-family: var(--bs-body-font-family); + font-size: var(--bs-body-font-size); + font-weight: var(--bs-body-font-weight); + line-height: var(--bs-body-line-height); + color: var(--bs-body-color); + text-align: var(--bs-body-text-align); + background-color: var(--bs-body-bg); + -webkit-text-size-adjust: 100%; + -webkit-tap-highlight-color: rgba(0, 0, 0, 0); +} + +hr { + margin: 1rem 0; + color: inherit; + border: 0; + border-top: var(--bs-border-width) solid; + opacity: 0.25; +} + +h6, +.h6, +h5, +.h5, +h4, +.h4, +h3, +.h3, +h2, +.h2, +h1, +.h1 { + margin-top: 0; + margin-bottom: 0.5rem; + font-weight: 500; + line-height: 1.2; + color: var(--bs-heading-color); +} + +h1, +.h1 { + font-size: calc(1.375rem + 1.5vw); +} + +@media (min-width: 1200px) { + h1, + .h1 { + font-size: 2.5rem; + } +} + +h2, +.h2 { + font-size: calc(1.325rem + 0.9vw); +} + +@media (min-width: 1200px) { + h2, + .h2 { + font-size: 2rem; + } +} + +h3, +.h3 { + font-size: calc(1.3rem + 0.6vw); +} + +@media (min-width: 1200px) { + h3, + .h3 { + font-size: 1.75rem; + } +} + +h4, +.h4 { + font-size: calc(1.275rem + 0.3vw); +} + +@media (min-width: 1200px) { + h4, + .h4 { + font-size: 1.5rem; + } +} + +h5, +.h5 { + font-size: 1.25rem; +} + +h6, +.h6 { + font-size: 1rem; +} + +p { + margin-top: 0; + margin-bottom: 1rem; +} + +abbr[title] { + text-decoration: underline dotted; + cursor: help; + text-decoration-skip-ink: none; +} + +address { + margin-bottom: 1rem; + font-style: normal; + line-height: inherit; +} + +ol, +ul { + padding-left: 2rem; +} + +ol, +ul, +dl { + margin-top: 0; + margin-bottom: 1rem; +} + +ol ol, +ul ul, +ol ul, +ul ol { + margin-bottom: 0; +} + +dt { + font-weight: 700; +} + +dd { + margin-bottom: 0.5rem; + margin-left: 0; +} + +blockquote { + margin: 0 0 1rem; +} + +b, +strong { + font-weight: bolder; +} + +small, +.small { + font-size: 0.875em; +} + +mark, +.mark { + padding: 0.1875em; + background-color: var(--bs-highlight-bg); +} + +sub, +sup { + position: relative; + font-size: 0.75em; + line-height: 0; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +a { + color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1)); + text-decoration: none; +} + +a:hover { + --bs-link-color-rgb: var(--bs-link-hover-color-rgb); +} + +a:not([href]):not([class]), +a:not([href]):not([class]):hover { + color: inherit; + text-decoration: none; +} + +pre, +code, +kbd, +samp { + font-family: var(--bs-font-monospace); + font-size: 1em; +} + +pre { + display: block; + margin-top: 0; + margin-bottom: 1rem; + overflow: auto; + font-size: 0.875em; +} + +pre code { + font-size: inherit; + color: inherit; + word-break: normal; +} + +code { + font-size: 0.875em; + color: var(--bs-code-color); + word-wrap: break-word; +} + +a > code { + color: inherit; +} + +kbd { + padding: 0.1875rem 0.375rem; + font-size: 0.875em; + color: var(--bs-body-bg); + background-color: var(--bs-body-color); + border-radius: 0.25rem; +} + +kbd kbd { + padding: 0; + font-size: 1em; +} + +figure { + margin: 0 0 1rem; +} + +img, +svg { + vertical-align: middle; +} + +table { + caption-side: bottom; + border-collapse: collapse; +} + +caption { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + color: var(--bs-secondary-color); + text-align: left; +} + +th { + text-align: inherit; + text-align: -webkit-match-parent; +} + +thead, +tbody, +tfoot, +tr, +td, +th { + border-color: inherit; + border-style: solid; + border-width: 0; +} + +label { + display: inline-block; +} + +button { + border-radius: 0; +} + +button:focus:not(:focus-visible) { + outline: 0; +} + +input, +button, +select, +optgroup, +textarea { + margin: 0; + font-family: inherit; + font-size: inherit; + line-height: inherit; +} + +button, +select { + text-transform: none; +} + +[role='button'] { + cursor: pointer; +} + +select { + word-wrap: normal; +} + +select:disabled { + opacity: 1; +} + +[list]:not([type='date']):not([type='datetime-local']):not([type='month']):not( + [type='week'] + ):not([type='time'])::-webkit-calendar-picker-indicator { + display: none !important; +} + +button, +[type='button'], +[type='reset'], +[type='submit'] { + -webkit-appearance: button; +} + +button:not(:disabled), +[type='button']:not(:disabled), +[type='reset']:not(:disabled), +[type='submit']:not(:disabled) { + cursor: pointer; +} + +::-moz-focus-inner { + padding: 0; + border-style: none; +} + +textarea { + resize: vertical; +} + +fieldset { + min-width: 0; + padding: 0; + margin: 0; + border: 0; +} + +legend { + float: left; + width: 100%; + padding: 0; + margin-bottom: 0.5rem; + font-size: calc(1.275rem + 0.3vw); + line-height: inherit; +} + +@media (min-width: 1200px) { + legend { + font-size: 1.5rem; + } +} + +legend + * { + clear: left; +} + +::-webkit-datetime-edit-fields-wrapper, +::-webkit-datetime-edit-text, +::-webkit-datetime-edit-minute, +::-webkit-datetime-edit-hour-field, +::-webkit-datetime-edit-day-field, +::-webkit-datetime-edit-month-field, +::-webkit-datetime-edit-year-field { + padding: 0; +} + +::-webkit-inner-spin-button { + height: auto; +} + +[type='search'] { + outline-offset: -2px; + -webkit-appearance: textfield; +} + +/* rtl:raw: + [type="tel"], + [type="url"], + [type="email"], + [type="number"] { + direction: ltr; + } + */ +::-webkit-search-decoration { + -webkit-appearance: none; +} + +::-webkit-color-swatch-wrapper { + padding: 0; +} + +::file-selector-button { + font: inherit; + -webkit-appearance: button; +} + +output { + display: inline-block; +} + +iframe { + border: 0; +} + +summary { + display: list-item; + cursor: pointer; +} + +progress { + vertical-align: baseline; +} + +[hidden] { + display: none !important; +} + +.lead { + font-size: 1.25rem; + font-weight: 300; +} + +.display-1 { + font-size: calc(1.625rem + 4.5vw); + font-weight: 300; + line-height: 1.2; +} + +@media (min-width: 1200px) { + .display-1 { + font-size: 5rem; + } +} + +.display-2 { + font-size: calc(1.575rem + 3.9vw); + font-weight: 300; + line-height: 1.2; +} + +@media (min-width: 1200px) { + .display-2 { + font-size: 4.5rem; + } +} + +.display-3 { + font-size: calc(1.525rem + 3.3vw); + font-weight: 300; + line-height: 1.2; +} + +@media (min-width: 1200px) { + .display-3 { + font-size: 4rem; + } +} + +.display-4 { + font-size: calc(1.475rem + 2.7vw); + font-weight: 300; + line-height: 1.2; +} + +@media (min-width: 1200px) { + .display-4 { + font-size: 3.5rem; + } +} + +.display-5 { + font-size: calc(1.425rem + 2.1vw); + font-weight: 300; + line-height: 1.2; +} + +@media (min-width: 1200px) { + .display-5 { + font-size: 3rem; + } +} + +.display-6 { + font-size: calc(1.375rem + 1.5vw); + font-weight: 300; + line-height: 1.2; +} + +@media (min-width: 1200px) { + .display-6 { + font-size: 2.5rem; + } +} + +.list-unstyled { + padding-left: 0; + list-style: none; +} + +.list-inline { + padding-left: 0; + list-style: none; +} + +.list-inline-item { + display: inline-block; +} + +.list-inline-item:not(:last-child) { + margin-right: 0.5rem; +} + +.initialism { + font-size: 0.875em; + text-transform: uppercase; +} + +.blockquote { + margin-bottom: 1rem; + font-size: 1.25rem; +} + +.blockquote > :last-child { + margin-bottom: 0; +} + +.blockquote-footer { + margin-top: -1rem; + margin-bottom: 1rem; + font-size: 0.875em; + color: #6c757d; +} + +.blockquote-footer::before { + content: '— '; +} + +.img-fluid { + max-width: 100%; + height: auto; +} + +.img-thumbnail { + padding: 0.25rem; + background-color: var(--bs-body-bg); + border: var(--bs-border-width) solid var(--bs-border-color); + border-radius: var(--bs-border-radius); + max-width: 100%; + height: auto; +} + +.figure { + display: inline-block; +} + +.figure-img { + margin-bottom: 0.5rem; + line-height: 1; +} + +.figure-caption { + font-size: 0.875em; + color: var(--bs-secondary-color); +} + +.container, +.container-fluid, +.container-xxl, +.container-xl, +.container-lg, +.container-md, +.container-sm { + --bs-gutter-x: 1.5rem; + --bs-gutter-y: 0; + width: 100%; + padding-right: calc(var(--bs-gutter-x) * 0.5); + padding-left: calc(var(--bs-gutter-x) * 0.5); + margin-right: auto; + margin-left: auto; +} + +@media (min-width: 576px) { + .container-sm, + .container { + max-width: 540px; + } +} + +@media (min-width: 768px) { + .container-md, + .container-sm, + .container { + max-width: 720px; + } +} + +@media (min-width: 992px) { + .container-lg, + .container-md, + .container-sm, + .container { + max-width: 960px; + } +} + +@media (min-width: 1200px) { + .container-xl, + .container-lg, + .container-md, + .container-sm, + .container { + max-width: 1140px; + } +} + +@media (min-width: 1400px) { + .container-xxl, + .container-xl, + .container-lg, + .container-md, + .container-sm, + .container { + max-width: 1320px; + } +} + +:root { + --bs-breakpoint-xs: 0; + --bs-breakpoint-sm: 576px; + --bs-breakpoint-md: 768px; + --bs-breakpoint-lg: 992px; + --bs-breakpoint-xl: 1200px; + --bs-breakpoint-xxl: 1400px; +} + +.row { + --bs-gutter-x: 1.5rem; + --bs-gutter-y: 0; + display: flex; + flex-wrap: wrap; + margin-top: calc(-1 * var(--bs-gutter-y)); + margin-right: calc(-0.5 * var(--bs-gutter-x)); + margin-left: calc(-0.5 * var(--bs-gutter-x)); +} + +.row > * { + flex-shrink: 0; + width: 100%; + max-width: 100%; + padding-right: calc(var(--bs-gutter-x) * 0.5); + padding-left: calc(var(--bs-gutter-x) * 0.5); + margin-top: var(--bs-gutter-y); +} + +.col { + flex: 1 0 0%; +} + +.row-cols-auto > * { + flex: 0 0 auto; + width: auto; +} + +.row-cols-1 > * { + flex: 0 0 auto; + width: 100%; +} + +.row-cols-2 > * { + flex: 0 0 auto; + width: 50%; +} + +.row-cols-3 > * { + flex: 0 0 auto; + width: 33.3333333333%; +} + +.row-cols-4 > * { + flex: 0 0 auto; + width: 25%; +} + +.row-cols-5 > * { + flex: 0 0 auto; + width: 20%; +} + +.row-cols-6 > * { + flex: 0 0 auto; + width: 16.6666666667%; +} + +.col-auto { + flex: 0 0 auto; + width: auto; +} + +.col-1 { + flex: 0 0 auto; + width: 8.33333333%; +} + +.col-2 { + flex: 0 0 auto; + width: 16.66666667%; +} + +.col-3 { + flex: 0 0 auto; + width: 25%; +} + +.col-4 { + flex: 0 0 auto; + width: 33.33333333%; +} + +.col-5 { + flex: 0 0 auto; + width: 41.66666667%; +} + +.col-6 { + flex: 0 0 auto; + width: 50%; +} + +.col-7 { + flex: 0 0 auto; + width: 58.33333333%; +} + +.col-8 { + flex: 0 0 auto; + width: 66.66666667%; +} + +.col-9 { + flex: 0 0 auto; + width: 75%; +} + +.col-10 { + flex: 0 0 auto; + width: 83.33333333%; +} + +.col-11 { + flex: 0 0 auto; + width: 91.66666667%; +} + +.col-12 { + flex: 0 0 auto; + width: 100%; +} + +.offset-1 { + margin-left: 8.33333333%; +} + +.offset-2 { + margin-left: 16.66666667%; +} + +.offset-3 { + margin-left: 25%; +} + +.offset-4 { + margin-left: 33.33333333%; +} + +.offset-5 { + margin-left: 41.66666667%; +} + +.offset-6 { + margin-left: 50%; +} + +.offset-7 { + margin-left: 58.33333333%; +} + +.offset-8 { + margin-left: 66.66666667%; +} + +.offset-9 { + margin-left: 75%; +} + +.offset-10 { + margin-left: 83.33333333%; +} + +.offset-11 { + margin-left: 91.66666667%; +} + +.g-0, +.gx-0 { + --bs-gutter-x: 0; +} + +.g-0, +.gy-0 { + --bs-gutter-y: 0; +} + +.g-1, +.gx-1 { + --bs-gutter-x: 0.25rem; +} + +.g-1, +.gy-1 { + --bs-gutter-y: 0.25rem; +} + +.g-2, +.gx-2 { + --bs-gutter-x: 0.5rem; +} + +.g-2, +.gy-2 { + --bs-gutter-y: 0.5rem; +} + +.g-3, +.gx-3 { + --bs-gutter-x: 1rem; +} + +.g-3, +.gy-3 { + --bs-gutter-y: 1rem; +} + +.g-4, +.gx-4 { + --bs-gutter-x: 1.5rem; +} + +.g-4, +.gy-4 { + --bs-gutter-y: 1.5rem; +} + +.g-5, +.gx-5 { + --bs-gutter-x: 3rem; +} + +.g-5, +.gy-5 { + --bs-gutter-y: 3rem; +} + +@media (min-width: 576px) { + .col-sm { + flex: 1 0 0%; + } + + .row-cols-sm-auto > * { + flex: 0 0 auto; + width: auto; + } + + .row-cols-sm-1 > * { + flex: 0 0 auto; + width: 100%; + } + + .row-cols-sm-2 > * { + flex: 0 0 auto; + width: 50%; + } + + .row-cols-sm-3 > * { + flex: 0 0 auto; + width: 33.3333333333%; + } + + .row-cols-sm-4 > * { + flex: 0 0 auto; + width: 25%; + } + + .row-cols-sm-5 > * { + flex: 0 0 auto; + width: 20%; + } + + .row-cols-sm-6 > * { + flex: 0 0 auto; + width: 16.6666666667%; + } + + .col-sm-auto { + flex: 0 0 auto; + width: auto; + } + + .col-sm-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + + .col-sm-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + + .col-sm-3 { + flex: 0 0 auto; + width: 25%; + } + + .col-sm-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + + .col-sm-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + + .col-sm-6 { + flex: 0 0 auto; + width: 50%; + } + + .col-sm-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + + .col-sm-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + + .col-sm-9 { + flex: 0 0 auto; + width: 75%; + } + + .col-sm-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + + .col-sm-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + + .col-sm-12 { + flex: 0 0 auto; + width: 100%; + } + + .offset-sm-0 { + margin-left: 0; + } + + .offset-sm-1 { + margin-left: 8.33333333%; + } + + .offset-sm-2 { + margin-left: 16.66666667%; + } + + .offset-sm-3 { + margin-left: 25%; + } + + .offset-sm-4 { + margin-left: 33.33333333%; + } + + .offset-sm-5 { + margin-left: 41.66666667%; + } + + .offset-sm-6 { + margin-left: 50%; + } + + .offset-sm-7 { + margin-left: 58.33333333%; + } + + .offset-sm-8 { + margin-left: 66.66666667%; + } + + .offset-sm-9 { + margin-left: 75%; + } + + .offset-sm-10 { + margin-left: 83.33333333%; + } + + .offset-sm-11 { + margin-left: 91.66666667%; + } + + .g-sm-0, + .gx-sm-0 { + --bs-gutter-x: 0; + } + + .g-sm-0, + .gy-sm-0 { + --bs-gutter-y: 0; + } + + .g-sm-1, + .gx-sm-1 { + --bs-gutter-x: 0.25rem; + } + + .g-sm-1, + .gy-sm-1 { + --bs-gutter-y: 0.25rem; + } + + .g-sm-2, + .gx-sm-2 { + --bs-gutter-x: 0.5rem; + } + + .g-sm-2, + .gy-sm-2 { + --bs-gutter-y: 0.5rem; + } + + .g-sm-3, + .gx-sm-3 { + --bs-gutter-x: 1rem; + } + + .g-sm-3, + .gy-sm-3 { + --bs-gutter-y: 1rem; + } + + .g-sm-4, + .gx-sm-4 { + --bs-gutter-x: 1.5rem; + } + + .g-sm-4, + .gy-sm-4 { + --bs-gutter-y: 1.5rem; + } + + .g-sm-5, + .gx-sm-5 { + --bs-gutter-x: 3rem; + } + + .g-sm-5, + .gy-sm-5 { + --bs-gutter-y: 3rem; + } +} + +@media (min-width: 768px) { + .col-md { + flex: 1 0 0%; + } + + .row-cols-md-auto > * { + flex: 0 0 auto; + width: auto; + } + + .row-cols-md-1 > * { + flex: 0 0 auto; + width: 100%; + } + + .row-cols-md-2 > * { + flex: 0 0 auto; + width: 50%; + } + + .row-cols-md-3 > * { + flex: 0 0 auto; + width: 33.3333333333%; + } + + .row-cols-md-4 > * { + flex: 0 0 auto; + width: 25%; + } + + .row-cols-md-5 > * { + flex: 0 0 auto; + width: 20%; + } + + .row-cols-md-6 > * { + flex: 0 0 auto; + width: 16.6666666667%; + } + + .col-md-auto { + flex: 0 0 auto; + width: auto; + } + + .col-md-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + + .col-md-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + + .col-md-3 { + flex: 0 0 auto; + width: 25%; + } + + .col-md-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + + .col-md-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + + .col-md-6 { + flex: 0 0 auto; + width: 50%; + } + + .col-md-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + + .col-md-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + + .col-md-9 { + flex: 0 0 auto; + width: 75%; + } + + .col-md-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + + .col-md-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + + .col-md-12 { + flex: 0 0 auto; + width: 100%; + } + + .offset-md-0 { + margin-left: 0; + } + + .offset-md-1 { + margin-left: 8.33333333%; + } + + .offset-md-2 { + margin-left: 16.66666667%; + } + + .offset-md-3 { + margin-left: 25%; + } + + .offset-md-4 { + margin-left: 33.33333333%; + } + + .offset-md-5 { + margin-left: 41.66666667%; + } + + .offset-md-6 { + margin-left: 50%; + } + + .offset-md-7 { + margin-left: 58.33333333%; + } + + .offset-md-8 { + margin-left: 66.66666667%; + } + + .offset-md-9 { + margin-left: 75%; + } + + .offset-md-10 { + margin-left: 83.33333333%; + } + + .offset-md-11 { + margin-left: 91.66666667%; + } + + .g-md-0, + .gx-md-0 { + --bs-gutter-x: 0; + } + + .g-md-0, + .gy-md-0 { + --bs-gutter-y: 0; + } + + .g-md-1, + .gx-md-1 { + --bs-gutter-x: 0.25rem; + } + + .g-md-1, + .gy-md-1 { + --bs-gutter-y: 0.25rem; + } + + .g-md-2, + .gx-md-2 { + --bs-gutter-x: 0.5rem; + } + + .g-md-2, + .gy-md-2 { + --bs-gutter-y: 0.5rem; + } + + .g-md-3, + .gx-md-3 { + --bs-gutter-x: 1rem; + } + + .g-md-3, + .gy-md-3 { + --bs-gutter-y: 1rem; + } + + .g-md-4, + .gx-md-4 { + --bs-gutter-x: 1.5rem; + } + + .g-md-4, + .gy-md-4 { + --bs-gutter-y: 1.5rem; + } + + .g-md-5, + .gx-md-5 { + --bs-gutter-x: 3rem; + } + + .g-md-5, + .gy-md-5 { + --bs-gutter-y: 3rem; + } +} + +@media (min-width: 992px) { + .col-lg { + flex: 1 0 0%; + } + + .row-cols-lg-auto > * { + flex: 0 0 auto; + width: auto; + } + + .row-cols-lg-1 > * { + flex: 0 0 auto; + width: 100%; + } + + .row-cols-lg-2 > * { + flex: 0 0 auto; + width: 50%; + } + + .row-cols-lg-3 > * { + flex: 0 0 auto; + width: 33.3333333333%; + } + + .row-cols-lg-4 > * { + flex: 0 0 auto; + width: 25%; + } + + .row-cols-lg-5 > * { + flex: 0 0 auto; + width: 20%; + } + + .row-cols-lg-6 > * { + flex: 0 0 auto; + width: 16.6666666667%; + } + + .col-lg-auto { + flex: 0 0 auto; + width: auto; + } + + .col-lg-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + + .col-lg-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + + .col-lg-3 { + flex: 0 0 auto; + width: 25%; + } + + .col-lg-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + + .col-lg-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + + .col-lg-6 { + flex: 0 0 auto; + width: 50%; + } + + .col-lg-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + + .col-lg-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + + .col-lg-9 { + flex: 0 0 auto; + width: 75%; + } + + .col-lg-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + + .col-lg-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + + .col-lg-12 { + flex: 0 0 auto; + width: 100%; + } + + .offset-lg-0 { + margin-left: 0; + } + + .offset-lg-1 { + margin-left: 8.33333333%; + } + + .offset-lg-2 { + margin-left: 16.66666667%; + } + + .offset-lg-3 { + margin-left: 25%; + } + + .offset-lg-4 { + margin-left: 33.33333333%; + } + + .offset-lg-5 { + margin-left: 41.66666667%; + } + + .offset-lg-6 { + margin-left: 50%; + } + + .offset-lg-7 { + margin-left: 58.33333333%; + } + + .offset-lg-8 { + margin-left: 66.66666667%; + } + + .offset-lg-9 { + margin-left: 75%; + } + + .offset-lg-10 { + margin-left: 83.33333333%; + } + + .offset-lg-11 { + margin-left: 91.66666667%; + } + + .g-lg-0, + .gx-lg-0 { + --bs-gutter-x: 0; + } + + .g-lg-0, + .gy-lg-0 { + --bs-gutter-y: 0; + } + + .g-lg-1, + .gx-lg-1 { + --bs-gutter-x: 0.25rem; + } + + .g-lg-1, + .gy-lg-1 { + --bs-gutter-y: 0.25rem; + } + + .g-lg-2, + .gx-lg-2 { + --bs-gutter-x: 0.5rem; + } + + .g-lg-2, + .gy-lg-2 { + --bs-gutter-y: 0.5rem; + } + + .g-lg-3, + .gx-lg-3 { + --bs-gutter-x: 1rem; + } + + .g-lg-3, + .gy-lg-3 { + --bs-gutter-y: 1rem; + } + + .g-lg-4, + .gx-lg-4 { + --bs-gutter-x: 1.5rem; + } + + .g-lg-4, + .gy-lg-4 { + --bs-gutter-y: 1.5rem; + } + + .g-lg-5, + .gx-lg-5 { + --bs-gutter-x: 3rem; + } + + .g-lg-5, + .gy-lg-5 { + --bs-gutter-y: 3rem; + } +} + +@media (min-width: 1200px) { + .col-xl { + flex: 1 0 0%; + } + + .row-cols-xl-auto > * { + flex: 0 0 auto; + width: auto; + } + + .row-cols-xl-1 > * { + flex: 0 0 auto; + width: 100%; + } + + .row-cols-xl-2 > * { + flex: 0 0 auto; + width: 50%; + } + + .row-cols-xl-3 > * { + flex: 0 0 auto; + width: 33.3333333333%; + } + + .row-cols-xl-4 > * { + flex: 0 0 auto; + width: 25%; + } + + .row-cols-xl-5 > * { + flex: 0 0 auto; + width: 20%; + } + + .row-cols-xl-6 > * { + flex: 0 0 auto; + width: 16.6666666667%; + } + + .col-xl-auto { + flex: 0 0 auto; + width: auto; + } + + .col-xl-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + + .col-xl-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + + .col-xl-3 { + flex: 0 0 auto; + width: 25%; + } + + .col-xl-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + + .col-xl-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + + .col-xl-6 { + flex: 0 0 auto; + width: 50%; + } + + .col-xl-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + + .col-xl-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + + .col-xl-9 { + flex: 0 0 auto; + width: 75%; + } + + .col-xl-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + + .col-xl-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + + .col-xl-12 { + flex: 0 0 auto; + width: 100%; + } + + .offset-xl-0 { + margin-left: 0; + } + + .offset-xl-1 { + margin-left: 8.33333333%; + } + + .offset-xl-2 { + margin-left: 16.66666667%; + } + + .offset-xl-3 { + margin-left: 25%; + } + + .offset-xl-4 { + margin-left: 33.33333333%; + } + + .offset-xl-5 { + margin-left: 41.66666667%; + } + + .offset-xl-6 { + margin-left: 50%; + } + + .offset-xl-7 { + margin-left: 58.33333333%; + } + + .offset-xl-8 { + margin-left: 66.66666667%; + } + + .offset-xl-9 { + margin-left: 75%; + } + + .offset-xl-10 { + margin-left: 83.33333333%; + } + + .offset-xl-11 { + margin-left: 91.66666667%; + } + + .g-xl-0, + .gx-xl-0 { + --bs-gutter-x: 0; + } + + .g-xl-0, + .gy-xl-0 { + --bs-gutter-y: 0; + } + + .g-xl-1, + .gx-xl-1 { + --bs-gutter-x: 0.25rem; + } + + .g-xl-1, + .gy-xl-1 { + --bs-gutter-y: 0.25rem; + } + + .g-xl-2, + .gx-xl-2 { + --bs-gutter-x: 0.5rem; + } + + .g-xl-2, + .gy-xl-2 { + --bs-gutter-y: 0.5rem; + } + + .g-xl-3, + .gx-xl-3 { + --bs-gutter-x: 1rem; + } + + .g-xl-3, + .gy-xl-3 { + --bs-gutter-y: 1rem; + } + + .g-xl-4, + .gx-xl-4 { + --bs-gutter-x: 1.5rem; + } + + .g-xl-4, + .gy-xl-4 { + --bs-gutter-y: 1.5rem; + } + + .g-xl-5, + .gx-xl-5 { + --bs-gutter-x: 3rem; + } + + .g-xl-5, + .gy-xl-5 { + --bs-gutter-y: 3rem; + } +} + +@media (min-width: 1400px) { + .col-xxl { + flex: 1 0 0%; + } + + .row-cols-xxl-auto > * { + flex: 0 0 auto; + width: auto; + } + + .row-cols-xxl-1 > * { + flex: 0 0 auto; + width: 100%; + } + + .row-cols-xxl-2 > * { + flex: 0 0 auto; + width: 50%; + } + + .row-cols-xxl-3 > * { + flex: 0 0 auto; + width: 33.3333333333%; + } + + .row-cols-xxl-4 > * { + flex: 0 0 auto; + width: 25%; + } + + .row-cols-xxl-5 > * { + flex: 0 0 auto; + width: 20%; + } + + .row-cols-xxl-6 > * { + flex: 0 0 auto; + width: 16.6666666667%; + } + + .col-xxl-auto { + flex: 0 0 auto; + width: auto; + } + + .col-xxl-1 { + flex: 0 0 auto; + width: 8.33333333%; + } + + .col-xxl-2 { + flex: 0 0 auto; + width: 16.66666667%; + } + + .col-xxl-3 { + flex: 0 0 auto; + width: 25%; + } + + .col-xxl-4 { + flex: 0 0 auto; + width: 33.33333333%; + } + + .col-xxl-5 { + flex: 0 0 auto; + width: 41.66666667%; + } + + .col-xxl-6 { + flex: 0 0 auto; + width: 50%; + } + + .col-xxl-7 { + flex: 0 0 auto; + width: 58.33333333%; + } + + .col-xxl-8 { + flex: 0 0 auto; + width: 66.66666667%; + } + + .col-xxl-9 { + flex: 0 0 auto; + width: 75%; + } + + .col-xxl-10 { + flex: 0 0 auto; + width: 83.33333333%; + } + + .col-xxl-11 { + flex: 0 0 auto; + width: 91.66666667%; + } + + .col-xxl-12 { + flex: 0 0 auto; + width: 100%; + } + + .offset-xxl-0 { + margin-left: 0; + } + + .offset-xxl-1 { + margin-left: 8.33333333%; + } + + .offset-xxl-2 { + margin-left: 16.66666667%; + } + + .offset-xxl-3 { + margin-left: 25%; + } + + .offset-xxl-4 { + margin-left: 33.33333333%; + } + + .offset-xxl-5 { + margin-left: 41.66666667%; + } + + .offset-xxl-6 { + margin-left: 50%; + } + + .offset-xxl-7 { + margin-left: 58.33333333%; + } + + .offset-xxl-8 { + margin-left: 66.66666667%; + } + + .offset-xxl-9 { + margin-left: 75%; + } + + .offset-xxl-10 { + margin-left: 83.33333333%; + } + + .offset-xxl-11 { + margin-left: 91.66666667%; + } + + .g-xxl-0, + .gx-xxl-0 { + --bs-gutter-x: 0; + } + + .g-xxl-0, + .gy-xxl-0 { + --bs-gutter-y: 0; + } + + .g-xxl-1, + .gx-xxl-1 { + --bs-gutter-x: 0.25rem; + } + + .g-xxl-1, + .gy-xxl-1 { + --bs-gutter-y: 0.25rem; + } + + .g-xxl-2, + .gx-xxl-2 { + --bs-gutter-x: 0.5rem; + } + + .g-xxl-2, + .gy-xxl-2 { + --bs-gutter-y: 0.5rem; + } + + .g-xxl-3, + .gx-xxl-3 { + --bs-gutter-x: 1rem; + } + + .g-xxl-3, + .gy-xxl-3 { + --bs-gutter-y: 1rem; + } + + .g-xxl-4, + .gx-xxl-4 { + --bs-gutter-x: 1.5rem; + } + + .g-xxl-4, + .gy-xxl-4 { + --bs-gutter-y: 1.5rem; + } + + .g-xxl-5, + .gx-xxl-5 { + --bs-gutter-x: 3rem; + } + + .g-xxl-5, + .gy-xxl-5 { + --bs-gutter-y: 3rem; + } +} + +.table { + --bs-table-color-type: initial; + --bs-table-bg-type: initial; + --bs-table-color-state: initial; + --bs-table-bg-state: initial; + --bs-table-color: var(--bs-body-color); + --bs-table-bg: var(--bs-body-bg); + --bs-table-border-color: var(--bs-border-color); + --bs-table-accent-bg: transparent; + --bs-table-striped-color: var(--bs-body-color); + --bs-table-striped-bg: rgba(0, 0, 0, 0.05); + --bs-table-active-color: var(--bs-body-color); + --bs-table-active-bg: rgba(0, 0, 0, 0.1); + --bs-table-hover-color: var(--bs-body-color); + --bs-table-hover-bg: rgba(0, 0, 0, 0.075); + width: 100%; + margin-bottom: 1rem; + vertical-align: top; + border-color: var(--bs-table-border-color); +} + +.table > :not(caption) > * > * { + padding: 0.5rem 0.5rem; + color: var( + --bs-table-color-state, + var(--bs-table-color-type, var(--bs-table-color)) + ); + background-color: var(--bs-table-bg); + border-bottom-width: var(--bs-border-width); + box-shadow: inset 0 0 0 9999px + var(--bs-table-bg-state, var(--bs-table-bg-type, var(--bs-table-accent-bg))); +} + +.table > tbody { + vertical-align: inherit; +} + +.table > thead { + vertical-align: bottom; +} + +.table-group-divider { + border-top: calc(var(--bs-border-width) * 2) solid currentcolor; +} + +.caption-top { + caption-side: top; +} + +.table-sm > :not(caption) > * > * { + padding: 0.25rem 0.25rem; +} + +.table-bordered > :not(caption) > * { + border-width: var(--bs-border-width) 0; +} + +.table-bordered > :not(caption) > * > * { + border-width: 0 var(--bs-border-width); +} + +.table-borderless > :not(caption) > * > * { + border-bottom-width: 0; +} + +.table-borderless > :not(:first-child) { + border-top-width: 0; +} + +.table-striped > tbody > tr:nth-of-type(odd) > * { + --bs-table-color-type: var(--bs-table-striped-color); + --bs-table-bg-type: var(--bs-table-striped-bg); +} + +.table-striped-columns > :not(caption) > tr > :nth-child(even) { + --bs-table-color-type: var(--bs-table-striped-color); + --bs-table-bg-type: var(--bs-table-striped-bg); +} + +.table-active { + --bs-table-color-state: var(--bs-table-active-color); + --bs-table-bg-state: var(--bs-table-active-bg); +} + +.table-hover > tbody > tr:hover > * { + --bs-table-color-state: var(--bs-table-hover-color); + --bs-table-bg-state: var(--bs-table-hover-bg); +} + +.table-primary { + --bs-table-color: #000; + --bs-table-bg: #d6f1e1; + --bs-table-border-color: #c1d9cb; + --bs-table-striped-bg: #cbe5d6; + --bs-table-striped-color: #000; + --bs-table-active-bg: #c1d9cb; + --bs-table-active-color: #000; + --bs-table-hover-bg: #c6dfd0; + --bs-table-hover-color: #000; + color: var(--bs-table-color); + border-color: var(--bs-table-border-color); +} + +.table-secondary { + --bs-table-color: #000; + --bs-table-bg: #e2e2e2; + --bs-table-border-color: #cbcbcb; + --bs-table-striped-bg: #d7d7d7; + --bs-table-striped-color: #000; + --bs-table-active-bg: #cbcbcb; + --bs-table-active-color: #000; + --bs-table-hover-bg: #d1d1d1; + --bs-table-hover-color: #000; + color: var(--bs-table-color); + border-color: var(--bs-table-border-color); +} + +.table-success { + --bs-table-color: #000; + --bs-table-bg: #d6f1e1; + --bs-table-border-color: #c1d9cb; + --bs-table-striped-bg: #cbe5d6; + --bs-table-striped-color: #000; + --bs-table-active-bg: #c1d9cb; + --bs-table-active-color: #000; + --bs-table-hover-bg: #c6dfd0; + --bs-table-hover-color: #000; + color: var(--bs-table-color); + border-color: var(--bs-table-border-color); +} + +.table-info { + --bs-table-color: #000; + --bs-table-bg: #cff4fc; + --bs-table-border-color: #badce3; + --bs-table-striped-bg: #c5e8ef; + --bs-table-striped-color: #000; + --bs-table-active-bg: #badce3; + --bs-table-active-color: #000; + --bs-table-hover-bg: #bfe2e9; + --bs-table-hover-color: #000; + color: var(--bs-table-color); + border-color: var(--bs-table-border-color); +} + +.table-warning { + --bs-table-color: #000; + --bs-table-bg: #fff2de; + --bs-table-border-color: #e6dac8; + --bs-table-striped-bg: #f2e6d3; + --bs-table-striped-color: #000; + --bs-table-active-bg: #e6dac8; + --bs-table-active-color: #000; + --bs-table-hover-bg: #ece0cd; + --bs-table-hover-color: #000; + color: var(--bs-table-color); + border-color: var(--bs-table-border-color); +} + +.table-danger { + --bs-table-color: #000; + --bs-table-bg: #f8d7da; + --bs-table-border-color: #dfc2c4; + --bs-table-striped-bg: #eccccf; + --bs-table-striped-color: #000; + --bs-table-active-bg: #dfc2c4; + --bs-table-active-color: #000; + --bs-table-hover-bg: #e5c7ca; + --bs-table-hover-color: #000; + color: var(--bs-table-color); + border-color: var(--bs-table-border-color); +} + +.table-light { + --bs-table-color: #000; + --bs-table-bg: #f8f9fa; + --bs-table-border-color: #dfe0e1; + --bs-table-striped-bg: #ecedee; + --bs-table-striped-color: #000; + --bs-table-active-bg: #dfe0e1; + --bs-table-active-color: #000; + --bs-table-hover-bg: #e5e6e7; + --bs-table-hover-color: #000; + color: var(--bs-table-color); + border-color: var(--bs-table-border-color); +} + +.table-dark { + --bs-table-color: #fff; + --bs-table-bg: #212529; + --bs-table-border-color: #373b3e; + --bs-table-striped-bg: #2c3034; + --bs-table-striped-color: #fff; + --bs-table-active-bg: #373b3e; + --bs-table-active-color: #fff; + --bs-table-hover-bg: #323539; + --bs-table-hover-color: #fff; + color: var(--bs-table-color); + border-color: var(--bs-table-border-color); +} + +.table-responsive { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +@media (max-width: 575.98px) { + .table-responsive-sm { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } +} + +@media (max-width: 767.98px) { + .table-responsive-md { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } +} + +@media (max-width: 991.98px) { + .table-responsive-lg { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } +} + +@media (max-width: 1199.98px) { + .table-responsive-xl { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } +} + +@media (max-width: 1399.98px) { + .table-responsive-xxl { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } +} + +.form-label { + margin-bottom: 0.5rem; +} + +.col-form-label { + padding-top: 0.7rem; + padding-bottom: 0.7rem; + margin-bottom: 0; + font-size: inherit; + line-height: 1.5; +} + +.col-form-label-lg { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + font-size: 1.25rem; +} + +.col-form-label-sm { + padding-top: 0.25rem; + padding-bottom: 0.25rem; + font-size: 0.875rem; +} + +.form-text { + margin-top: 0.25rem; + font-size: 0.875em; + color: var(--bs-secondary-color); +} + +.form-control { + display: block; + width: 100%; + padding: 0.7rem 1rem; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: var(--bs-body-color); + background-color: #f2f2f2; + background-clip: padding-box; + border: 1px solid var(--bs-border-color); + appearance: none; + border-radius: var(--bs-border-radius); + transition: + border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .form-control { + transition: none; + } +} + +.form-control[type='file'] { + overflow: hidden; +} + +.form-control[type='file']:not(:disabled):not([readonly]) { + cursor: pointer; +} + +.form-control:focus { + color: var(--bs-body-color); + background-color: #f2f2f2; + border-color: #98ddb5; + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(49, 187, 107, 0.25); +} + +.form-control::-webkit-date-and-time-value { + min-width: 85px; + height: 1.5em; + margin: 0; +} + +.form-control::-webkit-datetime-edit { + display: block; + padding: 0; +} + +.form-control::placeholder { + color: var(--bs-secondary-color); + opacity: 1; +} + +.form-control:disabled { + background-color: var(--bs-secondary-bg); + opacity: 1; +} + +.form-control::file-selector-button { + padding: 0.7rem 1rem; + margin: -0.7rem -1rem; + margin-inline-end: 1rem; + color: var(--bs-body-color); + background-color: var(--bs-tertiary-bg); + pointer-events: none; + border-color: inherit; + border-style: solid; + border-width: 0; + border-inline-end-width: 0; + border-radius: 0; + transition: + color 0.15s ease-in-out, + background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .form-control::file-selector-button { + transition: none; + } +} + +.form-control:hover:not(:disabled):not([readonly])::file-selector-button { + background-color: var(--bs-secondary-bg); +} + +.form-control-plaintext { + display: block; + width: 100%; + padding: 0.7rem 0; + margin-bottom: 0; + line-height: 1.5; + color: var(--bs-body-color); + background-color: transparent; + border: solid transparent; + border-width: 0 0; +} + +.form-control-plaintext:focus { + outline: 0; +} + +.form-control-plaintext.form-control-sm, +.form-control-plaintext.form-control-lg { + padding-right: 0; + padding-left: 0; +} + +.form-control-sm { + min-height: calc(1.5em + 0.5rem + calc(0 * 2)); + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + border-radius: var(--bs-border-radius-sm); +} + +.form-control-sm::file-selector-button { + padding: 0.25rem 0.5rem; + margin: -0.25rem -0.5rem; + margin-inline-end: 0.5rem; +} + +.form-control-lg { + min-height: calc(1.5em + 1rem + calc(0 * 2)); + padding: 0.5rem 1rem; + font-size: 1.25rem; + border-radius: var(--bs-border-radius-lg); +} + +.form-control-lg::file-selector-button { + padding: 0.5rem 1rem; + margin: -0.5rem -1rem; + margin-inline-end: 1rem; +} + +textarea.form-control { + min-height: calc(1.5em + 1.4rem + calc(0 * 2)); +} + +textarea.form-control-sm { + min-height: calc(1.5em + 0.5rem + calc(0 * 2)); +} + +textarea.form-control-lg { + min-height: calc(1.5em + 1rem + calc(0 * 2)); +} + +.form-control-color { + width: 3rem; + height: calc(1.5em + 1.4rem + calc(0 * 2)); + padding: 0.7rem; +} + +.form-control-color:not(:disabled):not([readonly]) { + cursor: pointer; +} + +.form-control-color::-moz-color-swatch { + border: 0 !important; + border-radius: var(--bs-border-radius); +} + +.form-control-color::-webkit-color-swatch { + border: 0 !important; + border-radius: var(--bs-border-radius); +} + +.form-control-color.form-control-sm { + height: calc(1.5em + 0.5rem + calc(0 * 2)); +} + +.form-control-color.form-control-lg { + height: calc(1.5em + 1rem + calc(0 * 2)); +} + +.form-select { + --bs-form-select-bg-img: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); + display: block; + width: 100%; + padding: 0.7rem 3rem 0.7rem 1rem; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: var(--bs-body-color); + background-color: #f2f2f2; + background-image: var(--bs-form-select-bg-img), + var(--bs-form-select-bg-icon, none); + background-repeat: no-repeat; + background-position: right 1rem center; + background-size: 16px 12px; + border: 0 solid var(--bs-border-color); + border-radius: var(--bs-border-radius); + transition: + border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; + appearance: none; +} + +@media (prefers-reduced-motion: reduce) { + .form-select { + transition: none; + } +} + +.form-select:focus { + border-color: #98ddb5; + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(49, 187, 107, 0.25); +} + +.form-select[multiple], +.form-select[size]:not([size='1']) { + padding-right: 1rem; + background-image: none; +} + +.form-select:disabled { + background-color: var(--bs-secondary-bg); +} + +.form-select:-moz-focusring { + color: transparent; + text-shadow: 0 0 0 var(--bs-body-color); +} + +.form-select-sm { + padding-top: 0.25rem; + padding-bottom: 0.25rem; + padding-left: 0.5rem; + font-size: 0.875rem; + border-radius: var(--bs-border-radius-sm); +} + +.form-select-lg { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + padding-left: 1rem; + font-size: 1.25rem; + border-radius: var(--bs-border-radius-lg); +} + +[data-bs-theme='dark'] .form-select { + --bs-form-select-bg-img: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23adb5bd' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); +} + +.form-check { + display: block; + min-height: 1.5rem; + padding-left: 1.5em; + margin-bottom: 0.125rem; +} + +.form-check .form-check-input { + float: left; + margin-left: -1.5em; +} + +.form-check-reverse { + padding-right: 1.5em; + padding-left: 0; + text-align: right; +} + +.form-check-reverse .form-check-input { + float: right; + margin-right: -1.5em; + margin-left: 0; +} + +.form-check-input { + --bs-form-check-bg: #f2f2f2; + width: 1.3em; + height: 1.3em; + margin-top: 0.25em; + vertical-align: top; + background-color: var(--bs-form-check-bg); + background-image: var(--bs-form-check-bg-image); + background-repeat: no-repeat; + background-position: center; + background-size: contain; + border: var(--bs-border-width) solid var(--bs-border-color); + appearance: none; + print-color-adjust: exact; +} + +.form-check-input[type='checkbox'] { + border-radius: 0.25em; +} + +.form-check-input[type='radio'] { + border-radius: 50%; +} + +.form-check-input:active { + filter: brightness(90%); +} + +.form-check-input:focus { + border-color: #98ddb5; + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(49, 187, 107, 0.25); +} + +.form-check-input:checked { + background-color: #31bb6b; + border-color: #31bb6b; +} + +.form-check-input:checked[type='checkbox'] { + --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e"); +} + +.form-check-input:checked[type='radio'] { + --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e"); +} + +.form-check-input[type='checkbox']:indeterminate { + background-color: #31bb6b; + border-color: #31bb6b; + --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e"); +} + +.form-check-input:disabled { + pointer-events: none; + filter: none; + opacity: 0.5; +} + +.form-check-input[disabled] ~ .form-check-label, +.form-check-input:disabled ~ .form-check-label { + cursor: default; + opacity: 0.5; +} + +.form-switch { + padding-left: 2.5em; +} + +.form-switch .form-check-input { + --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e"); + width: 2em; + margin-left: -2.5em; + background-image: var(--bs-form-switch-bg); + background-position: left center; + border-radius: 2em; + transition: background-position 0.15s ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .form-switch .form-check-input { + transition: none; + } +} + +.form-switch .form-check-input:focus { + --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2398ddb5'/%3e%3c/svg%3e"); +} + +.form-switch .form-check-input:checked { + background-position: right center; + --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e"); +} + +.form-switch.form-check-reverse { + padding-right: 2.5em; + padding-left: 0; +} + +.form-switch.form-check-reverse .form-check-input { + margin-right: -2.5em; + margin-left: 0; +} + +.form-check-inline { + display: inline-block; + margin-right: 1rem; +} + +.btn-check { + position: absolute; + clip: rect(0, 0, 0, 0); + pointer-events: none; +} + +.btn-check[disabled] + .btn, +.btn-check:disabled + .btn { + pointer-events: none; + filter: none; + opacity: 0.65; +} + +[data-bs-theme='dark'] + .form-switch + .form-check-input:not(:checked):not(:focus) { + --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e"); +} + +.form-range { + width: 100%; + height: 1.5rem; + padding: 0; + background-color: transparent; + appearance: none; +} + +.form-range:focus { + outline: 0; +} + +.form-range:focus::-webkit-slider-thumb { + box-shadow: + 0 0 0 1px #fff, + 0 0 0 0.25rem rgba(49, 187, 107, 0.25); +} + +.form-range:focus::-moz-range-thumb { + box-shadow: + 0 0 0 1px #fff, + 0 0 0 0.25rem rgba(49, 187, 107, 0.25); +} + +.form-range::-moz-focus-outer { + border: 0; +} + +.form-range::-webkit-slider-thumb { + width: 1rem; + height: 1rem; + margin-top: -0.25rem; + background-color: #31bb6b; + border: 0; + border-radius: 1rem; + transition: + background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; + appearance: none; +} + +@media (prefers-reduced-motion: reduce) { + .form-range::-webkit-slider-thumb { + transition: none; + } +} + +.form-range::-webkit-slider-thumb:active { + background-color: #c1ebd3; +} + +.form-range::-webkit-slider-runnable-track { + width: 100%; + height: 0.5rem; + color: transparent; + cursor: pointer; + background-color: var(--bs-tertiary-bg); + border-color: transparent; + border-radius: 1rem; +} + +.form-range::-moz-range-thumb { + width: 1rem; + height: 1rem; + background-color: #31bb6b; + border: 0; + border-radius: 1rem; + transition: + background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; + appearance: none; +} + +@media (prefers-reduced-motion: reduce) { + .form-range::-moz-range-thumb { + transition: none; + } +} + +.form-range::-moz-range-thumb:active { + background-color: #c1ebd3; +} + +.form-range::-moz-range-track { + width: 100%; + height: 0.5rem; + color: transparent; + cursor: pointer; + background-color: var(--bs-tertiary-bg); + border-color: transparent; + border-radius: 1rem; +} + +.form-range:disabled { + pointer-events: none; +} + +.form-range:disabled::-webkit-slider-thumb { + background-color: var(--bs-secondary-color); +} + +.form-range:disabled::-moz-range-thumb { + background-color: var(--bs-secondary-color); +} + +.form-floating { + position: relative; +} + +.form-floating > .form-control, +.form-floating > .form-control-plaintext, +.form-floating > .form-select { + height: calc(3.5rem + calc(0 * 2)); + min-height: calc(3.5rem + calc(0 * 2)); + line-height: 1.25; +} + +.form-floating > label { + position: absolute; + top: 0; + left: 0; + z-index: 2; + height: 100%; + padding: 1rem 1rem; + overflow: hidden; + text-align: start; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; + border: 0 solid transparent; + transform-origin: 0 0; + transition: + opacity 0.1s ease-in-out, + transform 0.1s ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .form-floating > label { + transition: none; + } +} + +.form-floating > .form-control, +.form-floating > .form-control-plaintext { + padding: 1rem 1rem; +} + +.form-floating > .form-control::placeholder, +.form-floating > .form-control-plaintext::placeholder { + color: transparent; +} + +.form-floating > .form-control:focus, +.form-floating > .form-control:not(:placeholder-shown), +.form-floating > .form-control-plaintext:focus, +.form-floating > .form-control-plaintext:not(:placeholder-shown) { + padding-top: 1.625rem; + padding-bottom: 0.625rem; +} + +.form-floating > .form-control:-webkit-autofill, +.form-floating > .form-control-plaintext:-webkit-autofill { + padding-top: 1.625rem; + padding-bottom: 0.625rem; +} + +.form-floating > .form-select { + padding-top: 1.625rem; + padding-bottom: 0.625rem; +} + +.form-floating > .form-control:focus ~ label, +.form-floating > .form-control:not(:placeholder-shown) ~ label, +.form-floating > .form-control-plaintext ~ label, +.form-floating > .form-select ~ label { + color: rgba(var(--bs-body-color-rgb), 0.65); + transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); +} + +.form-floating > .form-control:focus ~ label::after, +.form-floating > .form-control:not(:placeholder-shown) ~ label::after, +.form-floating > .form-control-plaintext ~ label::after, +.form-floating > .form-select ~ label::after { + position: absolute; + inset: 1rem 0.5rem; + z-index: -1; + height: 1.5em; + content: ''; + background-color: #f2f2f2; + border-radius: var(--bs-border-radius); +} + +.form-floating > .form-control:-webkit-autofill ~ label { + color: rgba(var(--bs-body-color-rgb), 0.65); + transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); +} + +.form-floating > .form-control-plaintext ~ label { + border-width: 0 0; +} + +.form-floating > :disabled ~ label { + color: #6c757d; +} + +.form-floating > :disabled ~ label::after { + background-color: var(--bs-secondary-bg); +} + +.input-group { + position: relative; + display: flex; + flex-wrap: wrap; + align-items: stretch; + width: 100%; +} + +.input-group > .form-control, +.input-group > .form-select, +.input-group > .form-floating { + position: relative; + flex: 1 1 auto; + width: 1%; + min-width: 0; +} + +.input-group > .form-control:focus, +.input-group > .form-select:focus, +.input-group > .form-floating:focus-within { + z-index: 5; +} + +.input-group .btn { + position: relative; + z-index: 2; +} + +.input-group .btn:focus { + z-index: 5; +} + +.input-group-text { + display: flex; + align-items: center; + padding: 0.7rem 1rem; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: var(--bs-body-color); + text-align: center; + white-space: nowrap; + background-color: var(--bs-tertiary-bg); + border: 0 solid var(--bs-border-color); + border-radius: var(--bs-border-radius); +} + +.input-group-lg > .form-control, +.input-group-lg > .form-select, +.input-group-lg > .input-group-text, +.input-group-lg > .btn { + padding: 0.5rem 1rem; + font-size: 1.25rem; + border-radius: var(--bs-border-radius-lg); +} + +.input-group-sm > .form-control, +.input-group-sm > .form-select, +.input-group-sm > .input-group-text, +.input-group-sm > .btn { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + border-radius: var(--bs-border-radius-sm); +} + +.input-group-lg > .form-select, +.input-group-sm > .form-select { + padding-right: 4rem; +} + +.input-group:not(.has-validation) + > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not( + .form-floating + ), +.input-group:not(.has-validation) > .dropdown-toggle:nth-last-child(n + 3), +.input-group:not(.has-validation) + > .form-floating:not(:last-child) + > .form-control, +.input-group:not(.has-validation) + > .form-floating:not(:last-child) + > .form-select { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.input-group.has-validation + > :nth-last-child(n + 3):not(.dropdown-toggle):not(.dropdown-menu):not( + .form-floating + ), +.input-group.has-validation > .dropdown-toggle:nth-last-child(n + 4), +.input-group.has-validation + > .form-floating:nth-last-child(n + 3) + > .form-control, +.input-group.has-validation + > .form-floating:nth-last-child(n + 3) + > .form-select { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.input-group + > :not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not( + .valid-feedback + ):not(.invalid-tooltip):not(.invalid-feedback) { + margin-left: calc(0 * -1); + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.input-group > .form-floating:not(:first-child) > .form-control, +.input-group > .form-floating:not(:first-child) > .form-select { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.valid-feedback { + display: none; + width: 100%; + margin-top: 0.25rem; + font-size: 0.875em; + color: var(--bs-form-valid-color); +} + +.valid-tooltip { + position: absolute; + top: 100%; + z-index: 5; + display: none; + max-width: 100%; + padding: 0.25rem 0.5rem; + margin-top: 0.1rem; + font-size: 0.875rem; + color: #fff; + background-color: var(--bs-success); + border-radius: var(--bs-border-radius); +} + +.was-validated :valid ~ .valid-feedback, +.was-validated :valid ~ .valid-tooltip, +.is-valid ~ .valid-feedback, +.is-valid ~ .valid-tooltip { + display: block; +} + +.was-validated .form-control:valid, +.form-control.is-valid { + border-color: var(--bs-form-valid-border-color); + padding-right: calc(1.5em + 1.4rem); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2331bb6b' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right calc(0.375em + 0.35rem) center; + background-size: calc(0.75em + 0.7rem) calc(0.75em + 0.7rem); +} + +.was-validated .form-control:valid:focus, +.form-control.is-valid:focus { + border-color: var(--bs-form-valid-border-color); + box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25); +} + +.was-validated textarea.form-control:valid, +textarea.form-control.is-valid { + padding-right: calc(1.5em + 1.4rem); + background-position: top calc(0.375em + 0.35rem) right calc(0.375em + 0.35rem); +} + +.was-validated .form-select:valid, +.form-select.is-valid { + border-color: var(--bs-form-valid-border-color); +} + +.was-validated .form-select:valid:not([multiple]):not([size]), +.was-validated .form-select:valid:not([multiple])[size='1'], +.form-select.is-valid:not([multiple]):not([size]), +.form-select.is-valid:not([multiple])[size='1'] { + --bs-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2331bb6b' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e"); + padding-right: 5.5rem; + background-position: + right 1rem center, + center right 3rem; + background-size: + 16px 12px, + calc(0.75em + 0.7rem) calc(0.75em + 0.7rem); +} + +.was-validated .form-select:valid:focus, +.form-select.is-valid:focus { + border-color: var(--bs-form-valid-border-color); + box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25); +} + +.was-validated .form-control-color:valid, +.form-control-color.is-valid { + width: calc(3rem + calc(1.5em + 1.4rem)); +} + +.was-validated .form-check-input:valid, +.form-check-input.is-valid { + border-color: var(--bs-form-valid-border-color); +} + +.was-validated .form-check-input:valid:checked, +.form-check-input.is-valid:checked { + background-color: var(--bs-form-valid-color); +} + +.was-validated .form-check-input:valid:focus, +.form-check-input.is-valid:focus { + box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25); +} + +.was-validated .form-check-input:valid ~ .form-check-label, +.form-check-input.is-valid ~ .form-check-label { + color: var(--bs-form-valid-color); +} + +.form-check-inline .form-check-input ~ .valid-feedback { + margin-left: 0.5em; +} + +.was-validated .input-group > .form-control:not(:focus):valid, +.input-group > .form-control:not(:focus).is-valid, +.was-validated .input-group > .form-select:not(:focus):valid, +.input-group > .form-select:not(:focus).is-valid, +.was-validated .input-group > .form-floating:not(:focus-within):valid, +.input-group > .form-floating:not(:focus-within).is-valid { + z-index: 3; +} + +.invalid-feedback { + display: none; + width: 100%; + margin-top: 0.25rem; + font-size: 0.875em; + color: var(--bs-form-invalid-color); +} + +.invalid-tooltip { + position: absolute; + top: 100%; + z-index: 5; + display: none; + max-width: 100%; + padding: 0.25rem 0.5rem; + margin-top: 0.1rem; + font-size: 0.875rem; + color: #fff; + background-color: var(--bs-danger); + border-radius: var(--bs-border-radius); +} + +.was-validated :invalid ~ .invalid-feedback, +.was-validated :invalid ~ .invalid-tooltip, +.is-invalid ~ .invalid-feedback, +.is-invalid ~ .invalid-tooltip { + display: block; +} + +.was-validated .form-control:invalid, +.form-control.is-invalid { + border-color: var(--bs-form-invalid-border-color); + padding-right: calc(1.5em + 1.4rem); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right calc(0.375em + 0.35rem) center; + background-size: calc(0.75em + 0.7rem) calc(0.75em + 0.7rem); +} + +.was-validated .form-control:invalid:focus, +.form-control.is-invalid:focus { + border-color: var(--bs-form-invalid-border-color); + box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), 0.25); +} + +.was-validated textarea.form-control:invalid, +textarea.form-control.is-invalid { + padding-right: calc(1.5em + 1.4rem); + background-position: top calc(0.375em + 0.35rem) right calc(0.375em + 0.35rem); +} + +.was-validated .form-select:invalid, +.form-select.is-invalid { + border-color: var(--bs-form-invalid-border-color); +} + +.was-validated .form-select:invalid:not([multiple]):not([size]), +.was-validated .form-select:invalid:not([multiple])[size='1'], +.form-select.is-invalid:not([multiple]):not([size]), +.form-select.is-invalid:not([multiple])[size='1'] { + --bs-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e"); + padding-right: 5.5rem; + background-position: + right 1rem center, + center right 3rem; + background-size: + 16px 12px, + calc(0.75em + 0.7rem) calc(0.75em + 0.7rem); +} + +.was-validated .form-select:invalid:focus, +.form-select.is-invalid:focus { + border-color: var(--bs-form-invalid-border-color); + box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), 0.25); +} + +.was-validated .form-control-color:invalid, +.form-control-color.is-invalid { + width: calc(3rem + calc(1.5em + 1.4rem)); +} + +.was-validated .form-check-input:invalid, +.form-check-input.is-invalid { + border-color: var(--bs-form-invalid-border-color); +} + +.was-validated .form-check-input:invalid:checked, +.form-check-input.is-invalid:checked { + background-color: var(--bs-form-invalid-color); +} + +.was-validated .form-check-input:invalid:focus, +.form-check-input.is-invalid:focus { + box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), 0.25); +} + +.was-validated .form-check-input:invalid ~ .form-check-label, +.form-check-input.is-invalid ~ .form-check-label { + color: var(--bs-form-invalid-color); +} + +.form-check-inline .form-check-input ~ .invalid-feedback { + margin-left: 0.5em; +} + +.was-validated .input-group > .form-control:not(:focus):invalid, +.input-group > .form-control:not(:focus).is-invalid, +.was-validated .input-group > .form-select:not(:focus):invalid, +.input-group > .form-select:not(:focus).is-invalid, +.was-validated .input-group > .form-floating:not(:focus-within):invalid, +.input-group > .form-floating:not(:focus-within).is-invalid { + z-index: 4; +} + +.btn { + --bs-btn-padding-x: 1rem; + --bs-btn-padding-y: 0.7rem; + --bs-btn-font-family: ; + --bs-btn-font-size: 1rem; + --bs-btn-font-weight: 400; + --bs-btn-line-height: 1.5; + --bs-btn-color: var(--bs-body-color); + --bs-btn-bg: transparent; + --bs-btn-border-width: var(--bs-border-width); + --bs-btn-border-color: transparent; + --bs-btn-border-radius: var(--bs-border-radius); + --bs-btn-hover-border-color: transparent; + --bs-btn-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15), + 0 1px 1px rgba(0, 0, 0, 0.075); + --bs-btn-disabled-opacity: 0.65; + --bs-btn-focus-box-shadow: 0 0 0 0.25rem + rgba(var(--bs-btn-focus-shadow-rgb), 0.5); + display: inline-block; + padding: var(--bs-btn-padding-y) var(--bs-btn-padding-x); + font-family: var(--bs-btn-font-family); + font-size: var(--bs-btn-font-size); + font-weight: var(--bs-btn-font-weight); + line-height: var(--bs-btn-line-height); + color: var(--bs-btn-color); + text-align: center; + vertical-align: middle; + cursor: pointer; + user-select: none; + border: var(--bs-btn-border-width) solid var(--bs-btn-border-color); + border-radius: var(--bs-btn-border-radius); + background-color: var(--bs-btn-bg); + transition: + color 0.15s ease-in-out, + background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .btn { + transition: none; + } +} + +.btn:hover { + color: var(--bs-btn-hover-color); + background-color: var(--bs-btn-hover-bg); + border-color: var(--bs-btn-hover-border-color); +} + +.btn-check + .btn:hover { + color: var(--bs-btn-color); + background-color: var(--bs-btn-bg); + border-color: var(--bs-btn-border-color); +} + +.btn:focus-visible { + color: var(--bs-btn-hover-color); + background-color: var(--bs-btn-hover-bg); + border-color: var(--bs-btn-hover-border-color); + outline: 0; + box-shadow: var(--bs-btn-focus-box-shadow); +} + +.btn-check:focus-visible + .btn { + border-color: var(--bs-btn-hover-border-color); + outline: 0; + box-shadow: var(--bs-btn-focus-box-shadow); +} + +.btn-check:checked + .btn, +:not(.btn-check) + .btn:active, +.btn:first-child:active, +.btn.active, +.btn.show { + color: var(--bs-btn-active-color); + background-color: var(--bs-btn-active-bg); + border-color: var(--bs-btn-active-border-color); +} + +.btn-check:checked + .btn:focus-visible, +:not(.btn-check) + .btn:active:focus-visible, +.btn:first-child:active:focus-visible, +.btn.active:focus-visible, +.btn.show:focus-visible { + box-shadow: var(--bs-btn-focus-box-shadow); +} + +.btn:disabled, +.btn.disabled, +fieldset:disabled .btn { + color: var(--bs-btn-disabled-color); + pointer-events: none; + background-color: var(--bs-btn-disabled-bg); + border-color: var(--bs-btn-disabled-border-color); + opacity: var(--bs-btn-disabled-opacity); +} + +.btn-primary { + --bs-btn-color: #000; + --bs-btn-bg: #31bb6b; + --bs-btn-border-color: #31bb6b; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #50c581; + --bs-btn-hover-border-color: #46c27a; + --bs-btn-focus-shadow-rgb: 42, 159, 91; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #5ac989; + --bs-btn-active-border-color: #46c27a; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #000; + --bs-btn-disabled-bg: #31bb6b; + --bs-btn-disabled-border-color: #31bb6b; +} + +.btn-secondary { + --bs-btn-color: #fff; + --bs-btn-bg: #707070; + --bs-btn-border-color: #707070; + --bs-btn-hover-color: #fff; + --bs-btn-hover-bg: #5f5f5f; + --bs-btn-hover-border-color: #5a5a5a; + --bs-btn-focus-shadow-rgb: 133, 133, 133; + --bs-btn-active-color: #fff; + --bs-btn-active-bg: #5a5a5a; + --bs-btn-active-border-color: #545454; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #fff; + --bs-btn-disabled-bg: #707070; + --bs-btn-disabled-border-color: #707070; +} + +.btn-success { + --bs-btn-color: #000; + --bs-btn-bg: #31bb6b; + --bs-btn-border-color: #31bb6b; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #50c581; + --bs-btn-hover-border-color: #46c27a; + --bs-btn-focus-shadow-rgb: 42, 159, 91; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #5ac989; + --bs-btn-active-border-color: #46c27a; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #000; + --bs-btn-disabled-bg: #31bb6b; + --bs-btn-disabled-border-color: #31bb6b; +} + +.btn-info { + --bs-btn-color: #000; + --bs-btn-bg: #0dcaf0; + --bs-btn-border-color: #0dcaf0; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #31d2f2; + --bs-btn-hover-border-color: #25cff2; + --bs-btn-focus-shadow-rgb: 11, 172, 204; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #3dd5f3; + --bs-btn-active-border-color: #25cff2; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #000; + --bs-btn-disabled-bg: #0dcaf0; + --bs-btn-disabled-border-color: #0dcaf0; +} + +.btn-warning { + --bs-btn-color: #000; + --bs-btn-bg: #febc59; + --bs-btn-border-color: #febc59; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #fec672; + --bs-btn-hover-border-color: #fec36a; + --bs-btn-focus-shadow-rgb: 216, 160, 76; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #fec97a; + --bs-btn-active-border-color: #fec36a; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #000; + --bs-btn-disabled-bg: #febc59; + --bs-btn-disabled-border-color: #febc59; +} + +.btn-danger { + --bs-btn-color: #fff; + --bs-btn-bg: #dc3545; + --bs-btn-border-color: #dc3545; + --bs-btn-hover-color: #fff; + --bs-btn-hover-bg: #bb2d3b; + --bs-btn-hover-border-color: #b02a37; + --bs-btn-focus-shadow-rgb: 225, 83, 97; + --bs-btn-active-color: #fff; + --bs-btn-active-bg: #b02a37; + --bs-btn-active-border-color: #a52834; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #fff; + --bs-btn-disabled-bg: #dc3545; + --bs-btn-disabled-border-color: #dc3545; +} + +.btn-light { + --bs-btn-color: #000; + --bs-btn-bg: #f8f9fa; + --bs-btn-border-color: #f8f9fa; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #d3d4d5; + --bs-btn-hover-border-color: #c6c7c8; + --bs-btn-focus-shadow-rgb: 211, 212, 213; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #c6c7c8; + --bs-btn-active-border-color: #babbbc; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #000; + --bs-btn-disabled-bg: #f8f9fa; + --bs-btn-disabled-border-color: #f8f9fa; +} + +.btn-dark { + --bs-btn-color: #fff; + --bs-btn-bg: #212529; + --bs-btn-border-color: #212529; + --bs-btn-hover-color: #fff; + --bs-btn-hover-bg: #424649; + --bs-btn-hover-border-color: #373b3e; + --bs-btn-focus-shadow-rgb: 66, 70, 73; + --bs-btn-active-color: #fff; + --bs-btn-active-bg: #4d5154; + --bs-btn-active-border-color: #373b3e; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #fff; + --bs-btn-disabled-bg: #212529; + --bs-btn-disabled-border-color: #212529; +} + +.btn-outline-primary { + --bs-btn-color: #31bb6b; + --bs-btn-border-color: #31bb6b; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #31bb6b; + --bs-btn-hover-border-color: #31bb6b; + --bs-btn-focus-shadow-rgb: 49, 187, 107; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #31bb6b; + --bs-btn-active-border-color: #31bb6b; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #31bb6b; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #31bb6b; + --bs-gradient: none; +} + +.btn-outline-secondary { + --bs-btn-color: #707070; + --bs-btn-border-color: #707070; + --bs-btn-hover-color: #fff; + --bs-btn-hover-bg: #707070; + --bs-btn-hover-border-color: #707070; + --bs-btn-focus-shadow-rgb: 112, 112, 112; + --bs-btn-active-color: #fff; + --bs-btn-active-bg: #707070; + --bs-btn-active-border-color: #707070; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #707070; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #707070; + --bs-gradient: none; +} + +.btn-outline-success { + --bs-btn-color: #31bb6b; + --bs-btn-border-color: #31bb6b; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #31bb6b; + --bs-btn-hover-border-color: #31bb6b; + --bs-btn-focus-shadow-rgb: 49, 187, 107; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #31bb6b; + --bs-btn-active-border-color: #31bb6b; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #31bb6b; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #31bb6b; + --bs-gradient: none; +} + +.btn-outline-info { + --bs-btn-color: #0dcaf0; + --bs-btn-border-color: #0dcaf0; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #0dcaf0; + --bs-btn-hover-border-color: #0dcaf0; + --bs-btn-focus-shadow-rgb: 13, 202, 240; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #0dcaf0; + --bs-btn-active-border-color: #0dcaf0; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #0dcaf0; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #0dcaf0; + --bs-gradient: none; +} + +.btn-outline-warning { + --bs-btn-color: #febc59; + --bs-btn-border-color: #febc59; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #febc59; + --bs-btn-hover-border-color: #febc59; + --bs-btn-focus-shadow-rgb: 254, 188, 89; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #febc59; + --bs-btn-active-border-color: #febc59; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #febc59; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #febc59; + --bs-gradient: none; +} + +.btn-outline-danger { + --bs-btn-color: #dc3545; + --bs-btn-border-color: #dc3545; + --bs-btn-hover-color: #fff; + --bs-btn-hover-bg: #dc3545; + --bs-btn-hover-border-color: #dc3545; + --bs-btn-focus-shadow-rgb: 220, 53, 69; + --bs-btn-active-color: #fff; + --bs-btn-active-bg: #dc3545; + --bs-btn-active-border-color: #dc3545; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #dc3545; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #dc3545; + --bs-gradient: none; +} + +.btn-outline-light { + --bs-btn-color: #f8f9fa; + --bs-btn-border-color: #f8f9fa; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #f8f9fa; + --bs-btn-hover-border-color: #f8f9fa; + --bs-btn-focus-shadow-rgb: 248, 249, 250; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #f8f9fa; + --bs-btn-active-border-color: #f8f9fa; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #f8f9fa; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #f8f9fa; + --bs-gradient: none; +} + +.btn-outline-dark { + --bs-btn-color: #212529; + --bs-btn-border-color: #212529; + --bs-btn-hover-color: #fff; + --bs-btn-hover-bg: #212529; + --bs-btn-hover-border-color: #212529; + --bs-btn-focus-shadow-rgb: 33, 37, 41; + --bs-btn-active-color: #fff; + --bs-btn-active-bg: #212529; + --bs-btn-active-border-color: #212529; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #212529; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #212529; + --bs-gradient: none; +} + +.btn-link { + --bs-btn-font-weight: 400; + --bs-btn-color: var(--bs-link-color); + --bs-btn-bg: transparent; + --bs-btn-border-color: transparent; + --bs-btn-hover-color: var(--bs-link-hover-color); + --bs-btn-hover-border-color: transparent; + --bs-btn-active-color: var(--bs-link-hover-color); + --bs-btn-active-border-color: transparent; + --bs-btn-disabled-color: #6c757d; + --bs-btn-disabled-border-color: transparent; + --bs-btn-box-shadow: 0 0 0 #000; + --bs-btn-focus-shadow-rgb: 49, 132, 253; + text-decoration: none; +} + +.btn-link:focus-visible { + color: var(--bs-btn-color); +} + +.btn-link:hover { + color: var(--bs-btn-hover-color); +} + +.btn-lg, +.btn-group-lg > .btn { + --bs-btn-padding-y: 0.5rem; + --bs-btn-padding-x: 1rem; + --bs-btn-font-size: 1.25rem; + --bs-btn-border-radius: var(--bs-border-radius-lg); +} + +.btn-sm, +.btn-group-sm > .btn { + --bs-btn-padding-y: 0.25rem; + --bs-btn-padding-x: 0.5rem; + --bs-btn-font-size: 0.875rem; + --bs-btn-border-radius: var(--bs-border-radius-sm); +} + +.fade { + transition: opacity 0.15s linear; +} + +@media (prefers-reduced-motion: reduce) { + .fade { + transition: none; + } +} + +.fade:not(.show) { + opacity: 0; +} + +.collapse:not(.show) { + display: none; +} + +.collapsing { + height: 0; + overflow: hidden; + transition: height 0.35s ease; +} + +@media (prefers-reduced-motion: reduce) { + .collapsing { + transition: none; + } +} + +.collapsing.collapse-horizontal { + width: 0; + height: auto; + transition: width 0.35s ease; +} + +@media (prefers-reduced-motion: reduce) { + .collapsing.collapse-horizontal { + transition: none; + } +} + +.dropup, +.dropend, +.dropdown, +.dropstart, +.dropup-center, +.dropdown-center { + position: relative; +} + +.dropdown-toggle { + white-space: nowrap; +} + +.dropdown-toggle::after { + display: inline-block; + margin-left: 0.255em; + vertical-align: 0.255em; + content: ''; + border-top: 0.3em solid; + border-right: 0.3em solid transparent; + border-bottom: 0; + border-left: 0.3em solid transparent; +} + +.dropdown-toggle:empty::after { + margin-left: 0; +} + +.dropdown-menu { + --bs-dropdown-zindex: 1000; + --bs-dropdown-min-width: 10rem; + --bs-dropdown-padding-x: 0; + --bs-dropdown-padding-y: 0.5rem; + --bs-dropdown-spacer: 0.125rem; + --bs-dropdown-font-size: 1rem; + --bs-dropdown-color: var(--bs-body-color); + --bs-dropdown-bg: var(--bs-body-bg); + --bs-dropdown-border-color: var(--bs-border-color-translucent); + --bs-dropdown-border-radius: var(--bs-border-radius); + --bs-dropdown-border-width: var(--bs-border-width); + --bs-dropdown-inner-border-radius: calc( + var(--bs-border-radius) - var(--bs-border-width) + ); + --bs-dropdown-divider-bg: var(--bs-border-color-translucent); + --bs-dropdown-divider-margin-y: 0.5rem; + --bs-dropdown-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + --bs-dropdown-link-color: var(--bs-body-color); + --bs-dropdown-link-hover-color: var(--bs-body-color); + --bs-dropdown-link-hover-bg: var(--bs-tertiary-bg); + --bs-dropdown-link-active-color: #fff; + --bs-dropdown-link-active-bg: #31bb6b; + --bs-dropdown-link-disabled-color: var(--bs-tertiary-color); + --bs-dropdown-item-padding-x: 1rem; + --bs-dropdown-item-padding-y: 0.25rem; + --bs-dropdown-header-color: #6c757d; + --bs-dropdown-header-padding-x: 1rem; + --bs-dropdown-header-padding-y: 0.5rem; + position: absolute; + z-index: var(--bs-dropdown-zindex); + display: none; + min-width: var(--bs-dropdown-min-width); + padding: var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x); + margin: 0; + font-size: var(--bs-dropdown-font-size); + color: var(--bs-dropdown-color); + text-align: left; + list-style: none; + background-color: var(--bs-dropdown-bg); + background-clip: padding-box; + border: var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color); + border-radius: var(--bs-dropdown-border-radius); +} + +.dropdown-menu[data-bs-popper] { + top: 100%; + left: 0; + margin-top: var(--bs-dropdown-spacer); +} + +.dropdown-menu-start { + --bs-position: start; +} + +.dropdown-menu-start[data-bs-popper] { + right: auto; + left: 0; +} + +.dropdown-menu-end { + --bs-position: end; +} + +.dropdown-menu-end[data-bs-popper] { + right: 0; + left: auto; +} + +@media (min-width: 576px) { + .dropdown-menu-sm-start { + --bs-position: start; + } + + .dropdown-menu-sm-start[data-bs-popper] { + right: auto; + left: 0; + } + + .dropdown-menu-sm-end { + --bs-position: end; + } + + .dropdown-menu-sm-end[data-bs-popper] { + right: 0; + left: auto; + } +} + +@media (min-width: 768px) { + .dropdown-menu-md-start { + --bs-position: start; + } + + .dropdown-menu-md-start[data-bs-popper] { + right: auto; + left: 0; + } + + .dropdown-menu-md-end { + --bs-position: end; + } + + .dropdown-menu-md-end[data-bs-popper] { + right: 0; + left: auto; + } +} + +@media (min-width: 992px) { + .dropdown-menu-lg-start { + --bs-position: start; + } + + .dropdown-menu-lg-start[data-bs-popper] { + right: auto; + left: 0; + } + + .dropdown-menu-lg-end { + --bs-position: end; + } + + .dropdown-menu-lg-end[data-bs-popper] { + right: 0; + left: auto; + } +} + +@media (min-width: 1200px) { + .dropdown-menu-xl-start { + --bs-position: start; + } + + .dropdown-menu-xl-start[data-bs-popper] { + right: auto; + left: 0; + } + + .dropdown-menu-xl-end { + --bs-position: end; + } + + .dropdown-menu-xl-end[data-bs-popper] { + right: 0; + left: auto; + } +} + +@media (min-width: 1400px) { + .dropdown-menu-xxl-start { + --bs-position: start; + } + + .dropdown-menu-xxl-start[data-bs-popper] { + right: auto; + left: 0; + } + + .dropdown-menu-xxl-end { + --bs-position: end; + } + + .dropdown-menu-xxl-end[data-bs-popper] { + right: 0; + left: auto; + } +} + +.dropup .dropdown-menu[data-bs-popper] { + top: auto; + bottom: 100%; + margin-top: 0; + margin-bottom: var(--bs-dropdown-spacer); +} + +.dropup .dropdown-toggle::after { + display: inline-block; + margin-left: 0.255em; + vertical-align: 0.255em; + content: ''; + border-top: 0; + border-right: 0.3em solid transparent; + border-bottom: 0.3em solid; + border-left: 0.3em solid transparent; +} + +.dropup .dropdown-toggle:empty::after { + margin-left: 0; +} + +.dropend .dropdown-menu[data-bs-popper] { + top: 0; + right: auto; + left: 100%; + margin-top: 0; + margin-left: var(--bs-dropdown-spacer); +} + +.dropend .dropdown-toggle::after { + display: inline-block; + margin-left: 0.255em; + vertical-align: 0.255em; + content: ''; + border-top: 0.3em solid transparent; + border-right: 0; + border-bottom: 0.3em solid transparent; + border-left: 0.3em solid; +} + +.dropend .dropdown-toggle:empty::after { + margin-left: 0; +} + +.dropend .dropdown-toggle::after { + vertical-align: 0; +} + +.dropstart .dropdown-menu[data-bs-popper] { + top: 0; + right: 100%; + left: auto; + margin-top: 0; + margin-right: var(--bs-dropdown-spacer); +} + +.dropstart .dropdown-toggle::after { + display: inline-block; + margin-left: 0.255em; + vertical-align: 0.255em; + content: ''; +} + +.dropstart .dropdown-toggle::after { + display: none; +} + +.dropstart .dropdown-toggle::before { + display: inline-block; + margin-right: 0.255em; + vertical-align: 0.255em; + content: ''; + border-top: 0.3em solid transparent; + border-right: 0.3em solid; + border-bottom: 0.3em solid transparent; +} + +.dropstart .dropdown-toggle:empty::after { + margin-left: 0; +} + +.dropstart .dropdown-toggle::before { + vertical-align: 0; +} + +.dropdown-divider { + height: 0; + margin: var(--bs-dropdown-divider-margin-y) 0; + overflow: hidden; + border-top: 1px solid var(--bs-dropdown-divider-bg); + opacity: 1; +} + +.dropdown-item { + display: block; + width: 100%; + padding: var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x); + clear: both; + font-weight: 400; + color: var(--bs-dropdown-link-color); + text-align: inherit; + white-space: nowrap; + background-color: transparent; + border: 0; + border-radius: var(--bs-dropdown-item-border-radius, 0); +} + +.dropdown-item:hover, +.dropdown-item:focus { + color: var(--bs-dropdown-link-hover-color); + background-color: var(--bs-dropdown-link-hover-bg); +} + +.dropdown-item.active, +.dropdown-item:active { + color: var(--bs-dropdown-link-active-color); + text-decoration: none; + background-color: var(--bs-dropdown-link-active-bg); +} + +.dropdown-item.disabled, +.dropdown-item:disabled { + color: var(--bs-dropdown-link-disabled-color); + pointer-events: none; + background-color: transparent; +} + +.dropdown-menu.show { + display: block; +} + +.dropdown-header { + display: block; + padding: var(--bs-dropdown-header-padding-y) + var(--bs-dropdown-header-padding-x); + margin-bottom: 0; + font-size: 0.875rem; + color: var(--bs-dropdown-header-color); + white-space: nowrap; +} + +.dropdown-item-text { + display: block; + padding: var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x); + color: var(--bs-dropdown-link-color); +} + +.dropdown-menu-dark { + --bs-dropdown-color: #dee2e6; + --bs-dropdown-bg: #343a40; + --bs-dropdown-border-color: var(--bs-border-color-translucent); + --bs-dropdown-box-shadow: ; + --bs-dropdown-link-color: #dee2e6; + --bs-dropdown-link-hover-color: #fff; + --bs-dropdown-divider-bg: var(--bs-border-color-translucent); + --bs-dropdown-link-hover-bg: rgba(255, 255, 255, 0.15); + --bs-dropdown-link-active-color: #fff; + --bs-dropdown-link-active-bg: #31bb6b; + --bs-dropdown-link-disabled-color: #adb5bd; + --bs-dropdown-header-color: #adb5bd; +} + +.btn-group, +.btn-group-vertical { + position: relative; + display: inline-flex; + vertical-align: middle; +} + +.btn-group > .btn, +.btn-group-vertical > .btn { + position: relative; + flex: 1 1 auto; +} + +.btn-group > .btn-check:checked + .btn, +.btn-group > .btn-check:focus + .btn, +.btn-group > .btn:hover, +.btn-group > .btn:focus, +.btn-group > .btn:active, +.btn-group > .btn.active, +.btn-group-vertical > .btn-check:checked + .btn, +.btn-group-vertical > .btn-check:focus + .btn, +.btn-group-vertical > .btn:hover, +.btn-group-vertical > .btn:focus, +.btn-group-vertical > .btn:active, +.btn-group-vertical > .btn.active { + z-index: 1; +} + +.btn-toolbar { + display: flex; + flex-wrap: wrap; + justify-content: flex-start; +} + +.btn-toolbar .input-group { + width: auto; +} + +.btn-group { + border-radius: var(--bs-border-radius); +} + +.btn-group > :not(.btn-check:first-child) + .btn, +.btn-group > .btn-group:not(:first-child) { + margin-left: calc(var(--bs-border-width) * -1); +} + +.btn-group > .btn:not(:last-child):not(.dropdown-toggle), +.btn-group > .btn.dropdown-toggle-split:first-child, +.btn-group > .btn-group:not(:last-child) > .btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.btn-group > .btn:nth-child(n + 3), +.btn-group > :not(.btn-check) + .btn, +.btn-group > .btn-group:not(:first-child) > .btn { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.dropdown-toggle-split { + padding-right: 0.75rem; + padding-left: 0.75rem; +} + +.dropdown-toggle-split::after, +.dropup .dropdown-toggle-split::after, +.dropend .dropdown-toggle-split::after { + margin-left: 0; +} + +.dropstart .dropdown-toggle-split::before { + margin-right: 0; +} + +.btn-sm + .dropdown-toggle-split, +.btn-group-sm > .btn + .dropdown-toggle-split { + padding-right: 0.375rem; + padding-left: 0.375rem; +} + +.btn-lg + .dropdown-toggle-split, +.btn-group-lg > .btn + .dropdown-toggle-split { + padding-right: 0.75rem; + padding-left: 0.75rem; +} + +.btn-group-vertical { + flex-direction: column; + align-items: flex-start; + justify-content: center; +} + +.btn-group-vertical > .btn, +.btn-group-vertical > .btn-group { + width: 100%; +} + +.btn-group-vertical > .btn:not(:first-child), +.btn-group-vertical > .btn-group:not(:first-child) { + margin-top: calc(var(--bs-border-width) * -1); +} + +.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle), +.btn-group-vertical > .btn-group:not(:last-child) > .btn { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} + +.btn-group-vertical > .btn ~ .btn, +.btn-group-vertical > .btn-group:not(:first-child) > .btn { + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.nav { + --bs-nav-link-padding-x: 1rem; + --bs-nav-link-padding-y: 0.5rem; + --bs-nav-link-font-weight: ; + --bs-nav-link-color: var(--bs-link-color); + --bs-nav-link-hover-color: var(--bs-link-hover-color); + --bs-nav-link-disabled-color: var(--bs-secondary-color); + display: flex; + flex-wrap: wrap; + padding-left: 0; + margin-bottom: 0; + list-style: none; +} + +.nav-link { + display: block; + padding: var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x); + font-size: var(--bs-nav-link-font-size); + font-weight: var(--bs-nav-link-font-weight); + color: var(--bs-nav-link-color); + background: none; + border: 0; + transition: + color 0.15s ease-in-out, + background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .nav-link { + transition: none; + } +} + +.nav-link:hover, +.nav-link:focus { + color: var(--bs-nav-link-hover-color); +} + +.nav-link:focus-visible { + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(49, 187, 107, 0.25); +} + +.nav-link.disabled { + color: var(--bs-nav-link-disabled-color); + pointer-events: none; + cursor: default; +} + +.nav-tabs { + --bs-nav-tabs-border-width: var(--bs-border-width); + --bs-nav-tabs-border-color: var(--bs-border-color); + --bs-nav-tabs-border-radius: var(--bs-border-radius); + --bs-nav-tabs-link-hover-border-color: var(--bs-secondary-bg) + var(--bs-secondary-bg) var(--bs-border-color); + --bs-nav-tabs-link-active-color: var(--bs-emphasis-color); + --bs-nav-tabs-link-active-bg: var(--bs-body-bg); + --bs-nav-tabs-link-active-border-color: var(--bs-border-color) + var(--bs-border-color) var(--bs-body-bg); + border-bottom: var(--bs-nav-tabs-border-width) solid + var(--bs-nav-tabs-border-color); +} + +.nav-tabs .nav-link { + margin-bottom: calc(-1 * var(--bs-nav-tabs-border-width)); + border: var(--bs-nav-tabs-border-width) solid transparent; + border-top-left-radius: var(--bs-nav-tabs-border-radius); + border-top-right-radius: var(--bs-nav-tabs-border-radius); +} + +.nav-tabs .nav-link:hover, +.nav-tabs .nav-link:focus { + isolation: isolate; + border-color: var(--bs-nav-tabs-link-hover-border-color); +} + +.nav-tabs .nav-link.disabled, +.nav-tabs .nav-link:disabled { + color: var(--bs-nav-link-disabled-color); + background-color: transparent; + border-color: transparent; +} + +.nav-tabs .nav-link.active, +.nav-tabs .nav-item.show .nav-link { + color: var(--bs-nav-tabs-link-active-color); + background-color: var(--bs-nav-tabs-link-active-bg); + border-color: var(--bs-nav-tabs-link-active-border-color); +} + +.nav-tabs .dropdown-menu { + margin-top: calc(-1 * var(--bs-nav-tabs-border-width)); + border-top-left-radius: 0; + border-top-right-radius: 0; +} + +.nav-pills { + --bs-nav-pills-border-radius: var(--bs-border-radius); + --bs-nav-pills-link-active-color: #fff; + --bs-nav-pills-link-active-bg: #31bb6b; +} + +.nav-pills .nav-link { + border-radius: var(--bs-nav-pills-border-radius); +} + +.nav-pills .nav-link:disabled { + color: var(--bs-nav-link-disabled-color); + background-color: transparent; + border-color: transparent; +} + +.nav-pills .nav-link.active, +.nav-pills .show > .nav-link { + color: var(--bs-nav-pills-link-active-color); + background-color: var(--bs-nav-pills-link-active-bg); +} + +.nav-underline { + --bs-nav-underline-gap: 1rem; + --bs-nav-underline-border-width: 0.125rem; + --bs-nav-underline-link-active-color: var(--bs-emphasis-color); + gap: var(--bs-nav-underline-gap); +} + +.nav-underline .nav-link { + padding-right: 0; + padding-left: 0; + border-bottom: var(--bs-nav-underline-border-width) solid transparent; +} + +.nav-underline .nav-link:hover, +.nav-underline .nav-link:focus { + border-bottom-color: currentcolor; +} + +.nav-underline .nav-link.active, +.nav-underline .show > .nav-link { + font-weight: 700; + color: var(--bs-nav-underline-link-active-color); + border-bottom-color: currentcolor; +} + +.nav-fill > .nav-link, +.nav-fill .nav-item { + flex: 1 1 auto; + text-align: center; +} + +.nav-justified > .nav-link, +.nav-justified .nav-item { + flex-basis: 0; + flex-grow: 1; + text-align: center; +} + +.nav-fill .nav-item .nav-link, +.nav-justified .nav-item .nav-link { + width: 100%; +} + +.tab-content > .tab-pane { + display: none; +} + +.tab-content > .active { + display: block; +} + +.navbar { + --bs-navbar-padding-x: 0; + --bs-navbar-padding-y: 0.5rem; + --bs-navbar-color: rgba(var(--bs-emphasis-color-rgb), 0.65); + --bs-navbar-hover-color: rgba(var(--bs-emphasis-color-rgb), 0.8); + --bs-navbar-disabled-color: rgba(var(--bs-emphasis-color-rgb), 0.3); + --bs-navbar-active-color: rgba(var(--bs-emphasis-color-rgb), 1); + --bs-navbar-brand-padding-y: 0.3125rem; + --bs-navbar-brand-margin-end: 1rem; + --bs-navbar-brand-font-size: 1.25rem; + --bs-navbar-brand-color: rgba(var(--bs-emphasis-color-rgb), 1); + --bs-navbar-brand-hover-color: rgba(var(--bs-emphasis-color-rgb), 1); + --bs-navbar-nav-link-padding-x: 0.5rem; + --bs-navbar-toggler-padding-y: 0.25rem; + --bs-navbar-toggler-padding-x: 0.75rem; + --bs-navbar-toggler-font-size: 1.25rem; + --bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); + --bs-navbar-toggler-border-color: rgba(var(--bs-emphasis-color-rgb), 0.15); + --bs-navbar-toggler-border-radius: var(--bs-border-radius); + --bs-navbar-toggler-focus-width: 0.25rem; + --bs-navbar-toggler-transition: box-shadow 0.15s ease-in-out; + position: relative; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + padding: var(--bs-navbar-padding-y) var(--bs-navbar-padding-x); +} + +.navbar > .container, +.navbar > .container-fluid, +.navbar > .container-sm, +.navbar > .container-md, +.navbar > .container-lg, +.navbar > .container-xl, +.navbar > .container-xxl { + display: flex; + flex-wrap: inherit; + align-items: center; + justify-content: space-between; +} + +.navbar-brand { + padding-top: var(--bs-navbar-brand-padding-y); + padding-bottom: var(--bs-navbar-brand-padding-y); + margin-right: var(--bs-navbar-brand-margin-end); + font-size: var(--bs-navbar-brand-font-size); + color: var(--bs-navbar-brand-color); + white-space: nowrap; +} + +.navbar-brand:hover, +.navbar-brand:focus { + color: var(--bs-navbar-brand-hover-color); +} + +.navbar-nav { + --bs-nav-link-padding-x: 0; + --bs-nav-link-padding-y: 0.5rem; + --bs-nav-link-font-weight: ; + --bs-nav-link-color: var(--bs-navbar-color); + --bs-nav-link-hover-color: var(--bs-navbar-hover-color); + --bs-nav-link-disabled-color: var(--bs-navbar-disabled-color); + display: flex; + flex-direction: column; + padding-left: 0; + margin-bottom: 0; + list-style: none; +} + +.navbar-nav .nav-link.active, +.navbar-nav .nav-link.show { + color: var(--bs-navbar-active-color); +} + +.navbar-nav .dropdown-menu { + position: static; +} + +.navbar-text { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + color: var(--bs-navbar-color); +} + +.navbar-text a, +.navbar-text a:hover, +.navbar-text a:focus { + color: var(--bs-navbar-active-color); +} + +.navbar-collapse { + flex-basis: 100%; + flex-grow: 1; + align-items: center; +} + +.navbar-toggler { + padding: var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x); + font-size: var(--bs-navbar-toggler-font-size); + line-height: 1; + color: var(--bs-navbar-color); + background-color: transparent; + border: var(--bs-border-width) solid var(--bs-navbar-toggler-border-color); + border-radius: var(--bs-navbar-toggler-border-radius); + transition: var(--bs-navbar-toggler-transition); +} + +@media (prefers-reduced-motion: reduce) { + .navbar-toggler { + transition: none; + } +} + +.navbar-toggler:hover { + text-decoration: none; +} + +.navbar-toggler:focus { + text-decoration: none; + outline: 0; + box-shadow: 0 0 0 var(--bs-navbar-toggler-focus-width); +} + +.navbar-toggler-icon { + display: inline-block; + width: 1.5em; + height: 1.5em; + vertical-align: middle; + background-image: var(--bs-navbar-toggler-icon-bg); + background-repeat: no-repeat; + background-position: center; + background-size: 100%; +} + +.navbar-nav-scroll { + max-height: var(--bs-scroll-height, 75vh); + overflow-y: auto; +} + +@media (min-width: 576px) { + .navbar-expand-sm { + flex-wrap: nowrap; + justify-content: flex-start; + } + + .navbar-expand-sm .navbar-nav { + flex-direction: row; + } + + .navbar-expand-sm .navbar-nav .dropdown-menu { + position: absolute; + } + + .navbar-expand-sm .navbar-nav .nav-link { + padding-right: var(--bs-navbar-nav-link-padding-x); + padding-left: var(--bs-navbar-nav-link-padding-x); + } + + .navbar-expand-sm .navbar-nav-scroll { + overflow: visible; + } + + .navbar-expand-sm .navbar-collapse { + display: flex !important; + flex-basis: auto; + } + + .navbar-expand-sm .navbar-toggler { + display: none; + } + + .navbar-expand-sm .offcanvas { + position: static; + z-index: auto; + flex-grow: 1; + width: auto !important; + height: auto !important; + visibility: visible !important; + background-color: transparent !important; + border: 0 !important; + transform: none !important; + transition: none; + } + + .navbar-expand-sm .offcanvas .offcanvas-header { + display: none; + } + + .navbar-expand-sm .offcanvas .offcanvas-body { + display: flex; + flex-grow: 0; + padding: 0; + overflow-y: visible; + } +} + +@media (min-width: 768px) { + .navbar-expand-md { + flex-wrap: nowrap; + justify-content: flex-start; + } + + .navbar-expand-md .navbar-nav { + flex-direction: row; + } + + .navbar-expand-md .navbar-nav .dropdown-menu { + position: absolute; + } + + .navbar-expand-md .navbar-nav .nav-link { + padding-right: var(--bs-navbar-nav-link-padding-x); + padding-left: var(--bs-navbar-nav-link-padding-x); + } + + .navbar-expand-md .navbar-nav-scroll { + overflow: visible; + } + + .navbar-expand-md .navbar-collapse { + display: flex !important; + flex-basis: auto; + } + + .navbar-expand-md .navbar-toggler { + display: none; + } + + .navbar-expand-md .offcanvas { + position: static; + z-index: auto; + flex-grow: 1; + width: auto !important; + height: auto !important; + visibility: visible !important; + background-color: transparent !important; + border: 0 !important; + transform: none !important; + transition: none; + } + + .navbar-expand-md .offcanvas .offcanvas-header { + display: none; + } + + .navbar-expand-md .offcanvas .offcanvas-body { + display: flex; + flex-grow: 0; + padding: 0; + overflow-y: visible; + } +} + +@media (min-width: 992px) { + .navbar-expand-lg { + flex-wrap: nowrap; + justify-content: flex-start; + } + + .navbar-expand-lg .navbar-nav { + flex-direction: row; + } + + .navbar-expand-lg .navbar-nav .dropdown-menu { + position: absolute; + } + + .navbar-expand-lg .navbar-nav .nav-link { + padding-right: var(--bs-navbar-nav-link-padding-x); + padding-left: var(--bs-navbar-nav-link-padding-x); + } + + .navbar-expand-lg .navbar-nav-scroll { + overflow: visible; + } + + .navbar-expand-lg .navbar-collapse { + display: flex !important; + flex-basis: auto; + } + + .navbar-expand-lg .navbar-toggler { + display: none; + } + + .navbar-expand-lg .offcanvas { + position: static; + z-index: auto; + flex-grow: 1; + width: auto !important; + height: auto !important; + visibility: visible !important; + background-color: transparent !important; + border: 0 !important; + transform: none !important; + transition: none; + } + + .navbar-expand-lg .offcanvas .offcanvas-header { + display: none; + } + + .navbar-expand-lg .offcanvas .offcanvas-body { + display: flex; + flex-grow: 0; + padding: 0; + overflow-y: visible; + } +} + +@media (min-width: 1200px) { + .navbar-expand-xl { + flex-wrap: nowrap; + justify-content: flex-start; + } + + .navbar-expand-xl .navbar-nav { + flex-direction: row; + } + + .navbar-expand-xl .navbar-nav .dropdown-menu { + position: absolute; + } + + .navbar-expand-xl .navbar-nav .nav-link { + padding-right: var(--bs-navbar-nav-link-padding-x); + padding-left: var(--bs-navbar-nav-link-padding-x); + } + + .navbar-expand-xl .navbar-nav-scroll { + overflow: visible; + } + + .navbar-expand-xl .navbar-collapse { + display: flex !important; + flex-basis: auto; + } + + .navbar-expand-xl .navbar-toggler { + display: none; + } + + .navbar-expand-xl .offcanvas { + position: static; + z-index: auto; + flex-grow: 1; + width: auto !important; + height: auto !important; + visibility: visible !important; + background-color: transparent !important; + border: 0 !important; + transform: none !important; + transition: none; + } + + .navbar-expand-xl .offcanvas .offcanvas-header { + display: none; + } + + .navbar-expand-xl .offcanvas .offcanvas-body { + display: flex; + flex-grow: 0; + padding: 0; + overflow-y: visible; + } +} + +@media (min-width: 1400px) { + .navbar-expand-xxl { + flex-wrap: nowrap; + justify-content: flex-start; + } + + .navbar-expand-xxl .navbar-nav { + flex-direction: row; + } + + .navbar-expand-xxl .navbar-nav .dropdown-menu { + position: absolute; + } + + .navbar-expand-xxl .navbar-nav .nav-link { + padding-right: var(--bs-navbar-nav-link-padding-x); + padding-left: var(--bs-navbar-nav-link-padding-x); + } + + .navbar-expand-xxl .navbar-nav-scroll { + overflow: visible; + } + + .navbar-expand-xxl .navbar-collapse { + display: flex !important; + flex-basis: auto; + } + + .navbar-expand-xxl .navbar-toggler { + display: none; + } + + .navbar-expand-xxl .offcanvas { + position: static; + z-index: auto; + flex-grow: 1; + width: auto !important; + height: auto !important; + visibility: visible !important; + background-color: transparent !important; + border: 0 !important; + transform: none !important; + transition: none; + } + + .navbar-expand-xxl .offcanvas .offcanvas-header { + display: none; + } + + .navbar-expand-xxl .offcanvas .offcanvas-body { + display: flex; + flex-grow: 0; + padding: 0; + overflow-y: visible; + } +} + +.navbar-expand { + flex-wrap: nowrap; + justify-content: flex-start; +} + +.navbar-expand .navbar-nav { + flex-direction: row; +} + +.navbar-expand .navbar-nav .dropdown-menu { + position: absolute; +} + +.navbar-expand .navbar-nav .nav-link { + padding-right: var(--bs-navbar-nav-link-padding-x); + padding-left: var(--bs-navbar-nav-link-padding-x); +} + +.navbar-expand .navbar-nav-scroll { + overflow: visible; +} + +.navbar-expand .navbar-collapse { + display: flex !important; + flex-basis: auto; +} + +.navbar-expand .navbar-toggler { + display: none; +} + +.navbar-expand .offcanvas { + position: static; + z-index: auto; + flex-grow: 1; + width: auto !important; + height: auto !important; + visibility: visible !important; + background-color: transparent !important; + border: 0 !important; + transform: none !important; + transition: none; +} + +.navbar-expand .offcanvas .offcanvas-header { + display: none; +} + +.navbar-expand .offcanvas .offcanvas-body { + display: flex; + flex-grow: 0; + padding: 0; + overflow-y: visible; +} + +.navbar-dark, +.navbar[data-bs-theme='dark'] { + --bs-navbar-color: rgba(255, 255, 255, 0.55); + --bs-navbar-hover-color: rgba(255, 255, 255, 0.75); + --bs-navbar-disabled-color: rgba(255, 255, 255, 0.25); + --bs-navbar-active-color: #fff; + --bs-navbar-brand-color: #fff; + --bs-navbar-brand-hover-color: #fff; + --bs-navbar-toggler-border-color: rgba(255, 255, 255, 0.1); + --bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); +} + +[data-bs-theme='dark'] .navbar-toggler-icon { + --bs-navbar-toggler-icon-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e"); +} + +.card { + --bs-card-spacer-y: 1rem; + --bs-card-spacer-x: 1rem; + --bs-card-title-spacer-y: 0.5rem; + --bs-card-title-color: ; + --bs-card-subtitle-color: ; + --bs-card-border-width: var(--bs-border-width); + --bs-card-border-color: var(--bs-border-color-translucent); + --bs-card-border-radius: var(--bs-border-radius); + --bs-card-box-shadow: ; + --bs-card-inner-border-radius: calc( + var(--bs-border-radius) - (var(--bs-border-width)) + ); + --bs-card-cap-padding-y: 0.5rem; + --bs-card-cap-padding-x: 1rem; + --bs-card-cap-bg: rgba(var(--bs-body-color-rgb), 0.03); + --bs-card-cap-color: ; + --bs-card-height: ; + --bs-card-color: ; + --bs-card-bg: var(--bs-body-bg); + --bs-card-img-overlay-padding: 1rem; + --bs-card-group-margin: 0.75rem; + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + height: var(--bs-card-height); + color: var(--bs-body-color); + word-wrap: break-word; + background-color: var(--bs-card-bg); + background-clip: border-box; + border: var(--bs-card-border-width) solid var(--bs-card-border-color); + border-radius: var(--bs-card-border-radius); +} + +.card > hr { + margin-right: 0; + margin-left: 0; +} + +.card > .list-group { + border-top: inherit; + border-bottom: inherit; +} + +.card > .list-group:first-child { + border-top-width: 0; + border-top-left-radius: var(--bs-card-inner-border-radius); + border-top-right-radius: var(--bs-card-inner-border-radius); +} + +.card > .list-group:last-child { + border-bottom-width: 0; + border-bottom-right-radius: var(--bs-card-inner-border-radius); + border-bottom-left-radius: var(--bs-card-inner-border-radius); +} + +.card > .card-header + .list-group, +.card > .list-group + .card-footer { + border-top: 0; +} + +.card-body { + flex: 1 1 auto; + padding: var(--bs-card-spacer-y) var(--bs-card-spacer-x); + color: var(--bs-card-color); +} + +.card-title { + margin-bottom: var(--bs-card-title-spacer-y); + color: var(--bs-card-title-color); +} + +.card-subtitle { + margin-top: calc(-0.5 * var(--bs-card-title-spacer-y)); + margin-bottom: 0; + color: var(--bs-card-subtitle-color); +} + +.card-text:last-child { + margin-bottom: 0; +} + +.card-link + .card-link { + margin-left: var(--bs-card-spacer-x); +} + +.card-header { + padding: var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x); + margin-bottom: 0; + color: var(--bs-card-cap-color); + background-color: var(--bs-card-cap-bg); + border-bottom: var(--bs-card-border-width) solid var(--bs-card-border-color); +} + +.card-header:first-child { + border-radius: var(--bs-card-inner-border-radius) + var(--bs-card-inner-border-radius) 0 0; +} + +.card-footer { + padding: var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x); + color: var(--bs-card-cap-color); + background-color: var(--bs-card-cap-bg); + border-top: var(--bs-card-border-width) solid var(--bs-card-border-color); +} + +.card-footer:last-child { + border-radius: 0 0 var(--bs-card-inner-border-radius) + var(--bs-card-inner-border-radius); +} + +.card-header-tabs { + margin-right: calc(-0.5 * var(--bs-card-cap-padding-x)); + margin-bottom: calc(-1 * var(--bs-card-cap-padding-y)); + margin-left: calc(-0.5 * var(--bs-card-cap-padding-x)); + border-bottom: 0; +} + +.card-header-tabs .nav-link.active { + background-color: var(--bs-card-bg); + border-bottom-color: var(--bs-card-bg); +} + +.card-header-pills { + margin-right: calc(-0.5 * var(--bs-card-cap-padding-x)); + margin-left: calc(-0.5 * var(--bs-card-cap-padding-x)); +} + +.card-img-overlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + padding: var(--bs-card-img-overlay-padding); + border-radius: var(--bs-card-inner-border-radius); +} + +.card-img, +.card-img-top, +.card-img-bottom { + width: 100%; +} + +.card-img, +.card-img-top { + border-top-left-radius: var(--bs-card-inner-border-radius); + border-top-right-radius: var(--bs-card-inner-border-radius); +} + +.card-img, +.card-img-bottom { + border-bottom-right-radius: var(--bs-card-inner-border-radius); + border-bottom-left-radius: var(--bs-card-inner-border-radius); +} + +.card-group > .card { + margin-bottom: var(--bs-card-group-margin); +} + +@media (min-width: 576px) { + .card-group { + display: flex; + flex-flow: row wrap; + } + + .card-group > .card { + flex: 1 0 0%; + margin-bottom: 0; + } + + .card-group > .card + .card { + margin-left: 0; + border-left: 0; + } + + .card-group > .card:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + .card-group > .card:not(:last-child) .card-img-top, + .card-group > .card:not(:last-child) .card-header { + border-top-right-radius: 0; + } + + .card-group > .card:not(:last-child) .card-img-bottom, + .card-group > .card:not(:last-child) .card-footer { + border-bottom-right-radius: 0; + } + + .card-group > .card:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + .card-group > .card:not(:first-child) .card-img-top, + .card-group > .card:not(:first-child) .card-header { + border-top-left-radius: 0; + } + + .card-group > .card:not(:first-child) .card-img-bottom, + .card-group > .card:not(:first-child) .card-footer { + border-bottom-left-radius: 0; + } +} + +.accordion { + --bs-accordion-color: var(--bs-body-color); + --bs-accordion-bg: var(--bs-body-bg); + --bs-accordion-transition: color 0.15s ease-in-out, + background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out, border-radius 0.15s ease; + --bs-accordion-border-color: var(--bs-border-color); + --bs-accordion-border-width: var(--bs-border-width); + --bs-accordion-border-radius: var(--bs-border-radius); + --bs-accordion-inner-border-radius: calc( + var(--bs-border-radius) - (var(--bs-border-width)) + ); + --bs-accordion-btn-padding-x: 1.25rem; + --bs-accordion-btn-padding-y: 1rem; + --bs-accordion-btn-color: var(--bs-body-color); + --bs-accordion-btn-bg: var(--bs-accordion-bg); + --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23212529'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); + --bs-accordion-btn-icon-width: 1.25rem; + --bs-accordion-btn-icon-transform: rotate(-180deg); + --bs-accordion-btn-icon-transition: transform 0.2s ease-in-out; + --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23144b2b'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); + --bs-accordion-btn-focus-border-color: #98ddb5; + --bs-accordion-btn-focus-box-shadow: 0 0 0 0.25rem rgba(49, 187, 107, 0.25); + --bs-accordion-body-padding-x: 1.25rem; + --bs-accordion-body-padding-y: 1rem; + --bs-accordion-active-color: var(--bs-primary-text-emphasis); + --bs-accordion-active-bg: var(--bs-primary-bg-subtle); +} + +.accordion-button { + position: relative; + display: flex; + align-items: center; + width: 100%; + padding: var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x); + font-size: 1rem; + color: var(--bs-accordion-btn-color); + text-align: left; + background-color: var(--bs-accordion-btn-bg); + border: 0; + border-radius: 0; + overflow-anchor: none; + transition: var(--bs-accordion-transition); +} + +@media (prefers-reduced-motion: reduce) { + .accordion-button { + transition: none; + } +} + +.accordion-button:not(.collapsed) { + color: var(--bs-accordion-active-color); + background-color: var(--bs-accordion-active-bg); + box-shadow: inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 + var(--bs-accordion-border-color); +} + +.accordion-button:not(.collapsed)::after { + background-image: var(--bs-accordion-btn-active-icon); + transform: var(--bs-accordion-btn-icon-transform); +} + +.accordion-button::after { + flex-shrink: 0; + width: var(--bs-accordion-btn-icon-width); + height: var(--bs-accordion-btn-icon-width); + margin-left: auto; + content: ''; + background-image: var(--bs-accordion-btn-icon); + background-repeat: no-repeat; + background-size: var(--bs-accordion-btn-icon-width); + transition: var(--bs-accordion-btn-icon-transition); +} + +@media (prefers-reduced-motion: reduce) { + .accordion-button::after { + transition: none; + } +} + +.accordion-button:hover { + z-index: 2; +} + +.accordion-button:focus { + z-index: 3; + border-color: var(--bs-accordion-btn-focus-border-color); + outline: 0; + box-shadow: var(--bs-accordion-btn-focus-box-shadow); +} + +.accordion-header { + margin-bottom: 0; +} + +.accordion-item { + color: var(--bs-accordion-color); + background-color: var(--bs-accordion-bg); + border: var(--bs-accordion-border-width) solid + var(--bs-accordion-border-color); +} + +.accordion-item:first-of-type { + border-top-left-radius: var(--bs-accordion-border-radius); + border-top-right-radius: var(--bs-accordion-border-radius); +} + +.accordion-item:first-of-type .accordion-button { + border-top-left-radius: var(--bs-accordion-inner-border-radius); + border-top-right-radius: var(--bs-accordion-inner-border-radius); +} + +.accordion-item:not(:first-of-type) { + border-top: 0; +} + +.accordion-item:last-of-type { + border-bottom-right-radius: var(--bs-accordion-border-radius); + border-bottom-left-radius: var(--bs-accordion-border-radius); +} + +.accordion-item:last-of-type .accordion-button.collapsed { + border-bottom-right-radius: var(--bs-accordion-inner-border-radius); + border-bottom-left-radius: var(--bs-accordion-inner-border-radius); +} + +.accordion-item:last-of-type .accordion-collapse { + border-bottom-right-radius: var(--bs-accordion-border-radius); + border-bottom-left-radius: var(--bs-accordion-border-radius); +} + +.accordion-body { + padding: var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x); +} + +.accordion-flush .accordion-collapse { + border-width: 0; +} + +.accordion-flush .accordion-item { + border-right: 0; + border-left: 0; + border-radius: 0; +} + +.accordion-flush .accordion-item:first-child { + border-top: 0; +} + +.accordion-flush .accordion-item:last-child { + border-bottom: 0; +} + +.accordion-flush .accordion-item .accordion-button, +.accordion-flush .accordion-item .accordion-button.collapsed { + border-radius: 0; +} + +[data-bs-theme='dark'] .accordion-button::after { + --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%2383d6a6'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); + --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%2383d6a6'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); +} + +.breadcrumb { + --bs-breadcrumb-padding-x: 0; + --bs-breadcrumb-padding-y: 0; + --bs-breadcrumb-margin-bottom: 1rem; + --bs-breadcrumb-bg: ; + --bs-breadcrumb-border-radius: ; + --bs-breadcrumb-divider-color: var(--bs-secondary-color); + --bs-breadcrumb-item-padding-x: 0.5rem; + --bs-breadcrumb-item-active-color: var(--bs-secondary-color); + display: flex; + flex-wrap: wrap; + padding: var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x); + margin-bottom: var(--bs-breadcrumb-margin-bottom); + font-size: var(--bs-breadcrumb-font-size); + list-style: none; + background-color: var(--bs-breadcrumb-bg); + border-radius: var(--bs-breadcrumb-border-radius); +} + +.breadcrumb-item + .breadcrumb-item { + padding-left: var(--bs-breadcrumb-item-padding-x); +} + +.breadcrumb-item + .breadcrumb-item::before { + float: left; + padding-right: var(--bs-breadcrumb-item-padding-x); + color: var(--bs-breadcrumb-divider-color); + content: var(--bs-breadcrumb-divider, '/') + /* rtl: var(--bs-breadcrumb-divider, "/") */; +} + +.breadcrumb-item.active { + color: var(--bs-breadcrumb-item-active-color); +} + +.pagination { + --bs-pagination-padding-x: 0.75rem; + --bs-pagination-padding-y: 0.375rem; + --bs-pagination-font-size: 1rem; + --bs-pagination-color: var(--bs-link-color); + --bs-pagination-bg: var(--bs-body-bg); + --bs-pagination-border-width: var(--bs-border-width); + --bs-pagination-border-color: var(--bs-border-color); + --bs-pagination-border-radius: var(--bs-border-radius); + --bs-pagination-hover-color: var(--bs-link-hover-color); + --bs-pagination-hover-bg: var(--bs-tertiary-bg); + --bs-pagination-hover-border-color: var(--bs-border-color); + --bs-pagination-focus-color: var(--bs-link-hover-color); + --bs-pagination-focus-bg: var(--bs-secondary-bg); + --bs-pagination-focus-box-shadow: 0 0 0 0.25rem rgba(49, 187, 107, 0.25); + --bs-pagination-active-color: #fff; + --bs-pagination-active-bg: #31bb6b; + --bs-pagination-active-border-color: #31bb6b; + --bs-pagination-disabled-color: var(--bs-secondary-color); + --bs-pagination-disabled-bg: var(--bs-secondary-bg); + --bs-pagination-disabled-border-color: var(--bs-border-color); + display: flex; + padding-left: 0; + list-style: none; +} + +.page-link { + position: relative; + display: block; + padding: var(--bs-pagination-padding-y) var(--bs-pagination-padding-x); + font-size: var(--bs-pagination-font-size); + color: var(--bs-pagination-color); + background-color: var(--bs-pagination-bg); + border: var(--bs-pagination-border-width) solid + var(--bs-pagination-border-color); + transition: + color 0.15s ease-in-out, + background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .page-link { + transition: none; + } +} + +.page-link:hover { + z-index: 2; + color: var(--bs-pagination-hover-color); + background-color: var(--bs-pagination-hover-bg); + border-color: var(--bs-pagination-hover-border-color); +} + +.page-link:focus { + z-index: 3; + color: var(--bs-pagination-focus-color); + background-color: var(--bs-pagination-focus-bg); + outline: 0; + box-shadow: var(--bs-pagination-focus-box-shadow); +} + +.page-link.active, +.active > .page-link { + z-index: 3; + color: var(--bs-pagination-active-color); + background-color: var(--bs-pagination-active-bg); + border-color: var(--bs-pagination-active-border-color); +} + +.page-link.disabled, +.disabled > .page-link { + color: var(--bs-pagination-disabled-color); + pointer-events: none; + background-color: var(--bs-pagination-disabled-bg); + border-color: var(--bs-pagination-disabled-border-color); +} + +.page-item:not(:first-child) .page-link { + margin-left: calc(var(--bs-border-width) * -1); +} + +.page-item:first-child .page-link { + border-top-left-radius: var(--bs-pagination-border-radius); + border-bottom-left-radius: var(--bs-pagination-border-radius); +} + +.page-item:last-child .page-link { + border-top-right-radius: var(--bs-pagination-border-radius); + border-bottom-right-radius: var(--bs-pagination-border-radius); +} + +.pagination-lg { + --bs-pagination-padding-x: 1.5rem; + --bs-pagination-padding-y: 0.75rem; + --bs-pagination-font-size: 1.25rem; + --bs-pagination-border-radius: var(--bs-border-radius-lg); +} + +.pagination-sm { + --bs-pagination-padding-x: 0.5rem; + --bs-pagination-padding-y: 0.25rem; + --bs-pagination-font-size: 0.875rem; + --bs-pagination-border-radius: var(--bs-border-radius-sm); +} + +.badge { + --bs-badge-padding-x: 0.65em; + --bs-badge-padding-y: 0.35em; + --bs-badge-font-size: 0.75em; + --bs-badge-font-weight: 700; + --bs-badge-color: #fff; + --bs-badge-border-radius: var(--bs-border-radius); + display: inline-block; + padding: var(--bs-badge-padding-y) var(--bs-badge-padding-x); + font-size: var(--bs-badge-font-size); + font-weight: var(--bs-badge-font-weight); + line-height: 1; + color: var(--bs-badge-color); + text-align: center; + white-space: nowrap; + vertical-align: baseline; + border-radius: var(--bs-badge-border-radius); +} + +.badge:empty { + display: none; +} + +.btn .badge { + position: relative; + top: -1px; +} + +.alert { + --bs-alert-bg: transparent; + --bs-alert-padding-x: 1rem; + --bs-alert-padding-y: 1rem; + --bs-alert-margin-bottom: 1rem; + --bs-alert-color: inherit; + --bs-alert-border-color: transparent; + --bs-alert-border: var(--bs-border-width) solid var(--bs-alert-border-color); + --bs-alert-border-radius: var(--bs-border-radius); + --bs-alert-link-color: inherit; + position: relative; + padding: var(--bs-alert-padding-y) var(--bs-alert-padding-x); + margin-bottom: var(--bs-alert-margin-bottom); + color: var(--bs-alert-color); + background-color: var(--bs-alert-bg); + border: var(--bs-alert-border); + border-radius: var(--bs-alert-border-radius); +} + +.alert-heading { + color: inherit; +} + +.alert-link { + font-weight: 700; + color: var(--bs-alert-link-color); +} + +.alert-dismissible { + padding-right: 3rem; +} + +.alert-dismissible .btn-close { + position: absolute; + top: 0; + right: 0; + z-index: 2; + padding: 1.25rem 1rem; +} + +.alert-primary { + --bs-alert-color: var(--bs-primary-text-emphasis); + --bs-alert-bg: var(--bs-primary-bg-subtle); + --bs-alert-border-color: var(--bs-primary-border-subtle); + --bs-alert-link-color: var(--bs-primary-text-emphasis); +} + +.alert-secondary { + --bs-alert-color: var(--bs-secondary-text-emphasis); + --bs-alert-bg: var(--bs-secondary-bg-subtle); + --bs-alert-border-color: var(--bs-secondary-border-subtle); + --bs-alert-link-color: var(--bs-secondary-text-emphasis); +} + +.alert-success { + --bs-alert-color: var(--bs-success-text-emphasis); + --bs-alert-bg: var(--bs-success-bg-subtle); + --bs-alert-border-color: var(--bs-success-border-subtle); + --bs-alert-link-color: var(--bs-success-text-emphasis); +} + +.alert-info { + --bs-alert-color: var(--bs-info-text-emphasis); + --bs-alert-bg: var(--bs-info-bg-subtle); + --bs-alert-border-color: var(--bs-info-border-subtle); + --bs-alert-link-color: var(--bs-info-text-emphasis); +} + +.alert-warning { + --bs-alert-color: var(--bs-warning-text-emphasis); + --bs-alert-bg: var(--bs-warning-bg-subtle); + --bs-alert-border-color: var(--bs-warning-border-subtle); + --bs-alert-link-color: var(--bs-warning-text-emphasis); +} + +.alert-danger { + --bs-alert-color: var(--bs-danger-text-emphasis); + --bs-alert-bg: var(--bs-danger-bg-subtle); + --bs-alert-border-color: var(--bs-danger-border-subtle); + --bs-alert-link-color: var(--bs-danger-text-emphasis); +} + +.alert-light { + --bs-alert-color: var(--bs-light-text-emphasis); + --bs-alert-bg: var(--bs-light-bg-subtle); + --bs-alert-border-color: var(--bs-light-border-subtle); + --bs-alert-link-color: var(--bs-light-text-emphasis); +} + +.alert-dark { + --bs-alert-color: var(--bs-dark-text-emphasis); + --bs-alert-bg: var(--bs-dark-bg-subtle); + --bs-alert-border-color: var(--bs-dark-border-subtle); + --bs-alert-link-color: var(--bs-dark-text-emphasis); +} + +@keyframes progress-bar-stripes { + 0% { + background-position-x: 1rem; + } +} + +.progress, +.progress-stacked { + --bs-progress-height: 1rem; + --bs-progress-font-size: 0.75rem; + --bs-progress-bg: var(--bs-secondary-bg); + --bs-progress-border-radius: var(--bs-border-radius); + --bs-progress-box-shadow: var(--bs-box-shadow-inset); + --bs-progress-bar-color: #fff; + --bs-progress-bar-bg: #31bb6b; + --bs-progress-bar-transition: width 0.6s ease; + display: flex; + height: var(--bs-progress-height); + overflow: hidden; + font-size: var(--bs-progress-font-size); + background-color: var(--bs-progress-bg); + border-radius: var(--bs-progress-border-radius); +} + +.progress-bar { + display: flex; + flex-direction: column; + justify-content: center; + overflow: hidden; + color: var(--bs-progress-bar-color); + text-align: center; + white-space: nowrap; + background-color: var(--bs-progress-bar-bg); + transition: var(--bs-progress-bar-transition); +} + +@media (prefers-reduced-motion: reduce) { + .progress-bar { + transition: none; + } +} + +.progress-bar-striped { + background-image: linear-gradient( + 45deg, + rgba(255, 255, 255, 0.15) 25%, + transparent 25%, + transparent 50%, + rgba(255, 255, 255, 0.15) 50%, + rgba(255, 255, 255, 0.15) 75%, + transparent 75%, + transparent + ); + background-size: var(--bs-progress-height) var(--bs-progress-height); +} + +.progress-stacked > .progress { + overflow: visible; +} + +.progress-stacked > .progress > .progress-bar { + width: 100%; +} + +.progress-bar-animated { + animation: 1s linear infinite progress-bar-stripes; +} + +@media (prefers-reduced-motion: reduce) { + .progress-bar-animated { + animation: none; + } +} + +.list-group { + --bs-list-group-color: var(--bs-body-color); + --bs-list-group-bg: var(--bs-body-bg); + --bs-list-group-border-color: var(--bs-border-color); + --bs-list-group-border-width: var(--bs-border-width); + --bs-list-group-border-radius: var(--bs-border-radius); + --bs-list-group-item-padding-x: 1rem; + --bs-list-group-item-padding-y: 0.5rem; + --bs-list-group-action-color: var(--bs-secondary-color); + --bs-list-group-action-hover-color: var(--bs-emphasis-color); + --bs-list-group-action-hover-bg: var(--bs-tertiary-bg); + --bs-list-group-action-active-color: var(--bs-body-color); + --bs-list-group-action-active-bg: var(--bs-secondary-bg); + --bs-list-group-disabled-color: var(--bs-secondary-color); + --bs-list-group-disabled-bg: var(--bs-body-bg); + --bs-list-group-active-color: #fff; + --bs-list-group-active-bg: #31bb6b; + --bs-list-group-active-border-color: #31bb6b; + display: flex; + flex-direction: column; + padding-left: 0; + margin-bottom: 0; + border-radius: var(--bs-list-group-border-radius); +} + +.list-group-numbered { + list-style-type: none; + counter-reset: section; +} + +.list-group-numbered > .list-group-item::before { + content: counters(section, '.') '. '; + counter-increment: section; +} + +.list-group-item-action { + width: 100%; + color: var(--bs-list-group-action-color); + text-align: inherit; +} + +.list-group-item-action:hover, +.list-group-item-action:focus { + z-index: 1; + color: var(--bs-list-group-action-hover-color); + text-decoration: none; + background-color: var(--bs-list-group-action-hover-bg); +} + +.list-group-item-action:active { + color: var(--bs-list-group-action-active-color); + background-color: var(--bs-list-group-action-active-bg); +} + +.list-group-item { + position: relative; + display: block; + padding: var(--bs-list-group-item-padding-y) + var(--bs-list-group-item-padding-x); + color: var(--bs-list-group-color); + background-color: var(--bs-list-group-bg); + border: var(--bs-list-group-border-width) solid + var(--bs-list-group-border-color); +} + +.list-group-item:first-child { + border-top-left-radius: inherit; + border-top-right-radius: inherit; +} + +.list-group-item:last-child { + border-bottom-right-radius: inherit; + border-bottom-left-radius: inherit; +} + +.list-group-item.disabled, +.list-group-item:disabled { + color: var(--bs-list-group-disabled-color); + pointer-events: none; + background-color: var(--bs-list-group-disabled-bg); +} + +.list-group-item.active { + z-index: 2; + color: var(--bs-list-group-active-color); + background-color: var(--bs-list-group-active-bg); + border-color: var(--bs-list-group-active-border-color); +} + +.list-group-item + .list-group-item { + border-top-width: 0; +} + +.list-group-item + .list-group-item.active { + margin-top: calc(-1 * var(--bs-list-group-border-width)); + border-top-width: var(--bs-list-group-border-width); +} + +.list-group-horizontal { + flex-direction: row; +} + +.list-group-horizontal > .list-group-item:first-child:not(:last-child) { + border-bottom-left-radius: var(--bs-list-group-border-radius); + border-top-right-radius: 0; +} + +.list-group-horizontal > .list-group-item:last-child:not(:first-child) { + border-top-right-radius: var(--bs-list-group-border-radius); + border-bottom-left-radius: 0; +} + +.list-group-horizontal > .list-group-item.active { + margin-top: 0; +} + +.list-group-horizontal > .list-group-item + .list-group-item { + border-top-width: var(--bs-list-group-border-width); + border-left-width: 0; +} + +.list-group-horizontal > .list-group-item + .list-group-item.active { + margin-left: calc(-1 * var(--bs-list-group-border-width)); + border-left-width: var(--bs-list-group-border-width); +} + +@media (min-width: 576px) { + .list-group-horizontal-sm { + flex-direction: row; + } + + .list-group-horizontal-sm > .list-group-item:first-child:not(:last-child) { + border-bottom-left-radius: var(--bs-list-group-border-radius); + border-top-right-radius: 0; + } + + .list-group-horizontal-sm > .list-group-item:last-child:not(:first-child) { + border-top-right-radius: var(--bs-list-group-border-radius); + border-bottom-left-radius: 0; + } + + .list-group-horizontal-sm > .list-group-item.active { + margin-top: 0; + } + + .list-group-horizontal-sm > .list-group-item + .list-group-item { + border-top-width: var(--bs-list-group-border-width); + border-left-width: 0; + } + + .list-group-horizontal-sm > .list-group-item + .list-group-item.active { + margin-left: calc(-1 * var(--bs-list-group-border-width)); + border-left-width: var(--bs-list-group-border-width); + } +} + +@media (min-width: 768px) { + .list-group-horizontal-md { + flex-direction: row; + } + + .list-group-horizontal-md > .list-group-item:first-child:not(:last-child) { + border-bottom-left-radius: var(--bs-list-group-border-radius); + border-top-right-radius: 0; + } + + .list-group-horizontal-md > .list-group-item:last-child:not(:first-child) { + border-top-right-radius: var(--bs-list-group-border-radius); + border-bottom-left-radius: 0; + } + + .list-group-horizontal-md > .list-group-item.active { + margin-top: 0; + } + + .list-group-horizontal-md > .list-group-item + .list-group-item { + border-top-width: var(--bs-list-group-border-width); + border-left-width: 0; + } + + .list-group-horizontal-md > .list-group-item + .list-group-item.active { + margin-left: calc(-1 * var(--bs-list-group-border-width)); + border-left-width: var(--bs-list-group-border-width); + } +} + +@media (min-width: 992px) { + .list-group-horizontal-lg { + flex-direction: row; + } + + .list-group-horizontal-lg > .list-group-item:first-child:not(:last-child) { + border-bottom-left-radius: var(--bs-list-group-border-radius); + border-top-right-radius: 0; + } + + .list-group-horizontal-lg > .list-group-item:last-child:not(:first-child) { + border-top-right-radius: var(--bs-list-group-border-radius); + border-bottom-left-radius: 0; + } + + .list-group-horizontal-lg > .list-group-item.active { + margin-top: 0; + } + + .list-group-horizontal-lg > .list-group-item + .list-group-item { + border-top-width: var(--bs-list-group-border-width); + border-left-width: 0; + } + + .list-group-horizontal-lg > .list-group-item + .list-group-item.active { + margin-left: calc(-1 * var(--bs-list-group-border-width)); + border-left-width: var(--bs-list-group-border-width); + } +} + +@media (min-width: 1200px) { + .list-group-horizontal-xl { + flex-direction: row; + } + + .list-group-horizontal-xl > .list-group-item:first-child:not(:last-child) { + border-bottom-left-radius: var(--bs-list-group-border-radius); + border-top-right-radius: 0; + } + + .list-group-horizontal-xl > .list-group-item:last-child:not(:first-child) { + border-top-right-radius: var(--bs-list-group-border-radius); + border-bottom-left-radius: 0; + } + + .list-group-horizontal-xl > .list-group-item.active { + margin-top: 0; + } + + .list-group-horizontal-xl > .list-group-item + .list-group-item { + border-top-width: var(--bs-list-group-border-width); + border-left-width: 0; + } + + .list-group-horizontal-xl > .list-group-item + .list-group-item.active { + margin-left: calc(-1 * var(--bs-list-group-border-width)); + border-left-width: var(--bs-list-group-border-width); + } +} + +@media (min-width: 1400px) { + .list-group-horizontal-xxl { + flex-direction: row; + } + + .list-group-horizontal-xxl > .list-group-item:first-child:not(:last-child) { + border-bottom-left-radius: var(--bs-list-group-border-radius); + border-top-right-radius: 0; + } + + .list-group-horizontal-xxl > .list-group-item:last-child:not(:first-child) { + border-top-right-radius: var(--bs-list-group-border-radius); + border-bottom-left-radius: 0; + } + + .list-group-horizontal-xxl > .list-group-item.active { + margin-top: 0; + } + + .list-group-horizontal-xxl > .list-group-item + .list-group-item { + border-top-width: var(--bs-list-group-border-width); + border-left-width: 0; + } + + .list-group-horizontal-xxl > .list-group-item + .list-group-item.active { + margin-left: calc(-1 * var(--bs-list-group-border-width)); + border-left-width: var(--bs-list-group-border-width); + } +} + +.list-group-flush { + border-radius: 0; +} + +.list-group-flush > .list-group-item { + border-width: 0 0 var(--bs-list-group-border-width); +} + +.list-group-flush > .list-group-item:last-child { + border-bottom-width: 0; +} + +.list-group-item-primary { + --bs-list-group-color: var(--bs-primary-text-emphasis); + --bs-list-group-bg: var(--bs-primary-bg-subtle); + --bs-list-group-border-color: var(--bs-primary-border-subtle); + --bs-list-group-action-hover-color: var(--bs-emphasis-color); + --bs-list-group-action-hover-bg: var(--bs-primary-border-subtle); + --bs-list-group-action-active-color: var(--bs-emphasis-color); + --bs-list-group-action-active-bg: var(--bs-primary-border-subtle); + --bs-list-group-active-color: var(--bs-primary-bg-subtle); + --bs-list-group-active-bg: var(--bs-primary-text-emphasis); + --bs-list-group-active-border-color: var(--bs-primary-text-emphasis); +} + +.list-group-item-secondary { + --bs-list-group-color: var(--bs-secondary-text-emphasis); + --bs-list-group-bg: var(--bs-secondary-bg-subtle); + --bs-list-group-border-color: var(--bs-secondary-border-subtle); + --bs-list-group-action-hover-color: var(--bs-emphasis-color); + --bs-list-group-action-hover-bg: var(--bs-secondary-border-subtle); + --bs-list-group-action-active-color: var(--bs-emphasis-color); + --bs-list-group-action-active-bg: var(--bs-secondary-border-subtle); + --bs-list-group-active-color: var(--bs-secondary-bg-subtle); + --bs-list-group-active-bg: var(--bs-secondary-text-emphasis); + --bs-list-group-active-border-color: var(--bs-secondary-text-emphasis); +} + +.list-group-item-success { + --bs-list-group-color: var(--bs-success-text-emphasis); + --bs-list-group-bg: var(--bs-success-bg-subtle); + --bs-list-group-border-color: var(--bs-success-border-subtle); + --bs-list-group-action-hover-color: var(--bs-emphasis-color); + --bs-list-group-action-hover-bg: var(--bs-success-border-subtle); + --bs-list-group-action-active-color: var(--bs-emphasis-color); + --bs-list-group-action-active-bg: var(--bs-success-border-subtle); + --bs-list-group-active-color: var(--bs-success-bg-subtle); + --bs-list-group-active-bg: var(--bs-success-text-emphasis); + --bs-list-group-active-border-color: var(--bs-success-text-emphasis); +} + +.list-group-item-info { + --bs-list-group-color: var(--bs-info-text-emphasis); + --bs-list-group-bg: var(--bs-info-bg-subtle); + --bs-list-group-border-color: var(--bs-info-border-subtle); + --bs-list-group-action-hover-color: var(--bs-emphasis-color); + --bs-list-group-action-hover-bg: var(--bs-info-border-subtle); + --bs-list-group-action-active-color: var(--bs-emphasis-color); + --bs-list-group-action-active-bg: var(--bs-info-border-subtle); + --bs-list-group-active-color: var(--bs-info-bg-subtle); + --bs-list-group-active-bg: var(--bs-info-text-emphasis); + --bs-list-group-active-border-color: var(--bs-info-text-emphasis); +} + +.list-group-item-warning { + --bs-list-group-color: var(--bs-warning-text-emphasis); + --bs-list-group-bg: var(--bs-warning-bg-subtle); + --bs-list-group-border-color: var(--bs-warning-border-subtle); + --bs-list-group-action-hover-color: var(--bs-emphasis-color); + --bs-list-group-action-hover-bg: var(--bs-warning-border-subtle); + --bs-list-group-action-active-color: var(--bs-emphasis-color); + --bs-list-group-action-active-bg: var(--bs-warning-border-subtle); + --bs-list-group-active-color: var(--bs-warning-bg-subtle); + --bs-list-group-active-bg: var(--bs-warning-text-emphasis); + --bs-list-group-active-border-color: var(--bs-warning-text-emphasis); +} + +.list-group-item-danger { + --bs-list-group-color: var(--bs-danger-text-emphasis); + --bs-list-group-bg: var(--bs-danger-bg-subtle); + --bs-list-group-border-color: var(--bs-danger-border-subtle); + --bs-list-group-action-hover-color: var(--bs-emphasis-color); + --bs-list-group-action-hover-bg: var(--bs-danger-border-subtle); + --bs-list-group-action-active-color: var(--bs-emphasis-color); + --bs-list-group-action-active-bg: var(--bs-danger-border-subtle); + --bs-list-group-active-color: var(--bs-danger-bg-subtle); + --bs-list-group-active-bg: var(--bs-danger-text-emphasis); + --bs-list-group-active-border-color: var(--bs-danger-text-emphasis); +} + +.list-group-item-light { + --bs-list-group-color: var(--bs-light-text-emphasis); + --bs-list-group-bg: var(--bs-light-bg-subtle); + --bs-list-group-border-color: var(--bs-light-border-subtle); + --bs-list-group-action-hover-color: var(--bs-emphasis-color); + --bs-list-group-action-hover-bg: var(--bs-light-border-subtle); + --bs-list-group-action-active-color: var(--bs-emphasis-color); + --bs-list-group-action-active-bg: var(--bs-light-border-subtle); + --bs-list-group-active-color: var(--bs-light-bg-subtle); + --bs-list-group-active-bg: var(--bs-light-text-emphasis); + --bs-list-group-active-border-color: var(--bs-light-text-emphasis); +} + +.list-group-item-dark { + --bs-list-group-color: var(--bs-dark-text-emphasis); + --bs-list-group-bg: var(--bs-dark-bg-subtle); + --bs-list-group-border-color: var(--bs-dark-border-subtle); + --bs-list-group-action-hover-color: var(--bs-emphasis-color); + --bs-list-group-action-hover-bg: var(--bs-dark-border-subtle); + --bs-list-group-action-active-color: var(--bs-emphasis-color); + --bs-list-group-action-active-bg: var(--bs-dark-border-subtle); + --bs-list-group-active-color: var(--bs-dark-bg-subtle); + --bs-list-group-active-bg: var(--bs-dark-text-emphasis); + --bs-list-group-active-border-color: var(--bs-dark-text-emphasis); +} + +.btn-close { + --bs-btn-close-color: #000; + --bs-btn-close-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e"); + --bs-btn-close-opacity: 0.5; + --bs-btn-close-hover-opacity: 0.75; + --bs-btn-close-focus-shadow: 0 0 0 0.25rem rgba(49, 187, 107, 0.25); + --bs-btn-close-focus-opacity: 1; + --bs-btn-close-disabled-opacity: 0.25; + --bs-btn-close-white-filter: invert(1) grayscale(100%) brightness(200%); + box-sizing: content-box; + width: 1em; + height: 1em; + padding: 0.25em 0.25em; + color: var(--bs-btn-close-color); + background: transparent var(--bs-btn-close-bg) center/1em auto no-repeat; + border: 0; + border-radius: 0.375rem; + opacity: var(--bs-btn-close-opacity); +} + +.btn-close:hover { + color: var(--bs-btn-close-color); + text-decoration: none; + opacity: var(--bs-btn-close-hover-opacity); +} + +.btn-close:focus { + outline: 0; + box-shadow: var(--bs-btn-close-focus-shadow); + opacity: var(--bs-btn-close-focus-opacity); +} + +.btn-close:disabled, +.btn-close.disabled { + pointer-events: none; + user-select: none; + opacity: var(--bs-btn-close-disabled-opacity); +} + +.btn-close-white { + filter: var(--bs-btn-close-white-filter); +} + +[data-bs-theme='dark'] .btn-close { + filter: var(--bs-btn-close-white-filter); +} + +.toast { + --bs-toast-zindex: 1090; + --bs-toast-padding-x: 0.75rem; + --bs-toast-padding-y: 0.5rem; + --bs-toast-spacing: 1.5rem; + --bs-toast-max-width: 350px; + --bs-toast-font-size: 0.875rem; + --bs-toast-color: ; + --bs-toast-bg: rgba(var(--bs-body-bg-rgb), 0.85); + --bs-toast-border-width: var(--bs-border-width); + --bs-toast-border-color: var(--bs-border-color-translucent); + --bs-toast-border-radius: var(--bs-border-radius); + --bs-toast-box-shadow: var(--bs-box-shadow); + --bs-toast-header-color: var(--bs-secondary-color); + --bs-toast-header-bg: rgba(var(--bs-body-bg-rgb), 0.85); + --bs-toast-header-border-color: var(--bs-border-color-translucent); + width: var(--bs-toast-max-width); + max-width: 100%; + font-size: var(--bs-toast-font-size); + color: var(--bs-toast-color); + pointer-events: auto; + background-color: var(--bs-toast-bg); + background-clip: padding-box; + border: var(--bs-toast-border-width) solid var(--bs-toast-border-color); + box-shadow: var(--bs-toast-box-shadow); + border-radius: var(--bs-toast-border-radius); +} + +.toast.showing { + opacity: 0; +} + +.toast:not(.show) { + display: none; +} + +.toast-container { + --bs-toast-zindex: 1090; + position: absolute; + z-index: var(--bs-toast-zindex); + width: max-content; + max-width: 100%; + pointer-events: none; +} + +.toast-container > :not(:last-child) { + margin-bottom: var(--bs-toast-spacing); +} + +.toast-header { + display: flex; + align-items: center; + padding: var(--bs-toast-padding-y) var(--bs-toast-padding-x); + color: var(--bs-toast-header-color); + background-color: var(--bs-toast-header-bg); + background-clip: padding-box; + border-bottom: var(--bs-toast-border-width) solid + var(--bs-toast-header-border-color); + border-top-left-radius: calc( + var(--bs-toast-border-radius) - var(--bs-toast-border-width) + ); + border-top-right-radius: calc( + var(--bs-toast-border-radius) - var(--bs-toast-border-width) + ); +} + +.toast-header .btn-close { + margin-right: calc(-0.5 * var(--bs-toast-padding-x)); + margin-left: var(--bs-toast-padding-x); +} + +.toast-body { + padding: var(--bs-toast-padding-x); + word-wrap: break-word; +} + +.modal { + --bs-modal-zindex: 1055; + --bs-modal-width: 500px; + --bs-modal-padding: 1rem; + --bs-modal-margin: 0.5rem; + --bs-modal-color: ; + --bs-modal-bg: var(--bs-body-bg); + --bs-modal-border-color: var(--bs-border-color-translucent); + --bs-modal-border-width: var(--bs-border-width); + --bs-modal-border-radius: var(--bs-border-radius-lg); + --bs-modal-box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + --bs-modal-inner-border-radius: calc( + var(--bs-border-radius-lg) - (var(--bs-border-width)) + ); + --bs-modal-header-padding-x: 1rem; + --bs-modal-header-padding-y: 1rem; + --bs-modal-header-padding: 1rem 1rem; + --bs-modal-header-border-color: var(--bs-border-color); + --bs-modal-header-border-width: var(--bs-border-width); + --bs-modal-title-line-height: 1.5; + --bs-modal-footer-gap: 0.5rem; + --bs-modal-footer-bg: ; + --bs-modal-footer-border-color: var(--bs-border-color); + --bs-modal-footer-border-width: var(--bs-border-width); + position: fixed; + top: 0; + left: 0; + z-index: var(--bs-modal-zindex); + display: none; + width: 100%; + height: 100%; + overflow-x: hidden; + overflow-y: auto; + outline: 0; +} + +.modal-dialog { + position: relative; + width: auto; + margin: var(--bs-modal-margin); + pointer-events: none; +} + +.modal.fade .modal-dialog { + transition: transform 0.3s ease-out; + transform: translate(0, -50px); +} + +@media (prefers-reduced-motion: reduce) { + .modal.fade .modal-dialog { + transition: none; + } +} + +.modal.show .modal-dialog { + transform: none; +} + +.modal.modal-static .modal-dialog { + transform: scale(1.02); +} + +.modal-dialog-scrollable { + height: calc(100% - var(--bs-modal-margin) * 2); +} + +.modal-dialog-scrollable .modal-content { + max-height: 100%; + overflow: hidden; +} + +.modal-dialog-scrollable .modal-body { + overflow-y: auto; +} + +.modal-dialog-centered { + display: flex; + align-items: center; + min-height: calc(100% - var(--bs-modal-margin) * 2); +} + +.modal-content { + position: relative; + display: flex; + flex-direction: column; + width: 100%; + color: var(--bs-modal-color); + pointer-events: auto; + background-color: var(--bs-modal-bg); + background-clip: padding-box; + border: var(--bs-modal-border-width) solid var(--bs-modal-border-color); + border-radius: var(--bs-modal-border-radius); + outline: 0; +} + +.modal-backdrop { + --bs-backdrop-zindex: 1050; + --bs-backdrop-bg: #000; + --bs-backdrop-opacity: 0.5; + position: fixed; + top: 0; + left: 0; + z-index: var(--bs-backdrop-zindex); + width: 100vw; + height: 100vh; + background-color: var(--bs-backdrop-bg); +} + +.modal-backdrop.fade { + opacity: 0; +} + +.modal-backdrop.show { + opacity: var(--bs-backdrop-opacity); +} + +.modal-header { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: space-between; + padding: var(--bs-modal-header-padding); + border-bottom: var(--bs-modal-header-border-width) solid + var(--bs-modal-header-border-color); + border-top-left-radius: var(--bs-modal-inner-border-radius); + border-top-right-radius: var(--bs-modal-inner-border-radius); +} + +.modal-header .btn-close { + padding: calc(var(--bs-modal-header-padding-y) * 0.5) + calc(var(--bs-modal-header-padding-x) * 0.5); + margin: calc(-0.5 * var(--bs-modal-header-padding-y)) + calc(-0.5 * var(--bs-modal-header-padding-x)) + calc(-0.5 * var(--bs-modal-header-padding-y)) auto; +} + +.modal-title { + margin-bottom: 0; + line-height: var(--bs-modal-title-line-height); +} + +.modal-body { + position: relative; + flex: 1 1 auto; + padding: var(--bs-modal-padding); +} + +.modal-footer { + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + padding: calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * 0.5); + background-color: var(--bs-modal-footer-bg); + border-top: var(--bs-modal-footer-border-width) solid + var(--bs-modal-footer-border-color); + border-bottom-right-radius: var(--bs-modal-inner-border-radius); + border-bottom-left-radius: var(--bs-modal-inner-border-radius); +} + +.modal-footer > * { + margin: calc(var(--bs-modal-footer-gap) * 0.5); +} + +@media (min-width: 576px) { + .modal { + --bs-modal-margin: 1.75rem; + --bs-modal-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + } + + .modal-dialog { + max-width: var(--bs-modal-width); + margin-right: auto; + margin-left: auto; + } + + .modal-sm { + --bs-modal-width: 300px; + } +} + +@media (min-width: 992px) { + .modal-lg, + .modal-xl { + --bs-modal-width: 800px; + } +} + +@media (min-width: 1200px) { + .modal-xl { + --bs-modal-width: 1140px; + } +} + +.modal-fullscreen { + width: 100vw; + max-width: none; + height: 100%; + margin: 0; +} + +.modal-fullscreen .modal-content { + height: 100%; + border: 0; + border-radius: 0; +} + +.modal-fullscreen .modal-header, +.modal-fullscreen .modal-footer { + border-radius: 0; +} + +.modal-fullscreen .modal-body { + overflow-y: auto; +} + +@media (max-width: 575.98px) { + .modal-fullscreen-sm-down { + width: 100vw; + max-width: none; + height: 100%; + margin: 0; + } + + .modal-fullscreen-sm-down .modal-content { + height: 100%; + border: 0; + border-radius: 0; + } + + .modal-fullscreen-sm-down .modal-header, + .modal-fullscreen-sm-down .modal-footer { + border-radius: 0; + } + + .modal-fullscreen-sm-down .modal-body { + overflow-y: auto; + } +} + +@media (max-width: 767.98px) { + .modal-fullscreen-md-down { + width: 100vw; + max-width: none; + height: 100%; + margin: 0; + } + + .modal-fullscreen-md-down .modal-content { + height: 100%; + border: 0; + border-radius: 0; + } + + .modal-fullscreen-md-down .modal-header, + .modal-fullscreen-md-down .modal-footer { + border-radius: 0; + } + + .modal-fullscreen-md-down .modal-body { + overflow-y: auto; + } +} + +@media (max-width: 991.98px) { + .modal-fullscreen-lg-down { + width: 100vw; + max-width: none; + height: 100%; + margin: 0; + } + + .modal-fullscreen-lg-down .modal-content { + height: 100%; + border: 0; + border-radius: 0; + } + + .modal-fullscreen-lg-down .modal-header, + .modal-fullscreen-lg-down .modal-footer { + border-radius: 0; + } + + .modal-fullscreen-lg-down .modal-body { + overflow-y: auto; + } +} + +@media (max-width: 1199.98px) { + .modal-fullscreen-xl-down { + width: 100vw; + max-width: none; + height: 100%; + margin: 0; + } + + .modal-fullscreen-xl-down .modal-content { + height: 100%; + border: 0; + border-radius: 0; + } + + .modal-fullscreen-xl-down .modal-header, + .modal-fullscreen-xl-down .modal-footer { + border-radius: 0; + } + + .modal-fullscreen-xl-down .modal-body { + overflow-y: auto; + } +} + +@media (max-width: 1399.98px) { + .modal-fullscreen-xxl-down { + width: 100vw; + max-width: none; + height: 100%; + margin: 0; + } + + .modal-fullscreen-xxl-down .modal-content { + height: 100%; + border: 0; + border-radius: 0; + } + + .modal-fullscreen-xxl-down .modal-header, + .modal-fullscreen-xxl-down .modal-footer { + border-radius: 0; + } + + .modal-fullscreen-xxl-down .modal-body { + overflow-y: auto; + } +} + +.tooltip { + --bs-tooltip-zindex: 1080; + --bs-tooltip-max-width: 200px; + --bs-tooltip-padding-x: 0.5rem; + --bs-tooltip-padding-y: 0.25rem; + --bs-tooltip-margin: ; + --bs-tooltip-font-size: 0.875rem; + --bs-tooltip-color: var(--bs-body-bg); + --bs-tooltip-bg: var(--bs-emphasis-color); + --bs-tooltip-border-radius: var(--bs-border-radius); + --bs-tooltip-opacity: 0.9; + --bs-tooltip-arrow-width: 0.8rem; + --bs-tooltip-arrow-height: 0.4rem; + z-index: var(--bs-tooltip-zindex); + display: block; + margin: var(--bs-tooltip-margin); + font-family: var(--bs-font-sans-serif); + font-style: normal; + font-weight: 400; + line-height: 1.5; + text-align: left; + text-align: start; + text-decoration: none; + text-shadow: none; + text-transform: none; + letter-spacing: normal; + word-break: normal; + white-space: normal; + word-spacing: normal; + line-break: auto; + font-size: var(--bs-tooltip-font-size); + word-wrap: break-word; + opacity: 0; +} + +.tooltip.show { + opacity: var(--bs-tooltip-opacity); +} + +.tooltip .tooltip-arrow { + display: block; + width: var(--bs-tooltip-arrow-width); + height: var(--bs-tooltip-arrow-height); +} + +.tooltip .tooltip-arrow::before { + position: absolute; + content: ''; + border-color: transparent; + border-style: solid; +} + +.bs-tooltip-top .tooltip-arrow, +.bs-tooltip-auto[data-popper-placement^='top'] .tooltip-arrow { + bottom: calc(-1 * var(--bs-tooltip-arrow-height)); +} + +.bs-tooltip-top .tooltip-arrow::before, +.bs-tooltip-auto[data-popper-placement^='top'] .tooltip-arrow::before { + top: -1px; + border-width: var(--bs-tooltip-arrow-height) + calc(var(--bs-tooltip-arrow-width) * 0.5) 0; + border-top-color: var(--bs-tooltip-bg); +} + +/* rtl:begin:ignore */ +.bs-tooltip-end .tooltip-arrow, +.bs-tooltip-auto[data-popper-placement^='right'] .tooltip-arrow { + left: calc(-1 * var(--bs-tooltip-arrow-height)); + width: var(--bs-tooltip-arrow-height); + height: var(--bs-tooltip-arrow-width); +} + +.bs-tooltip-end .tooltip-arrow::before, +.bs-tooltip-auto[data-popper-placement^='right'] .tooltip-arrow::before { + right: -1px; + border-width: calc(var(--bs-tooltip-arrow-width) * 0.5) + var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * 0.5) 0; + border-right-color: var(--bs-tooltip-bg); +} + +/* rtl:end:ignore */ +.bs-tooltip-bottom .tooltip-arrow, +.bs-tooltip-auto[data-popper-placement^='bottom'] .tooltip-arrow { + top: calc(-1 * var(--bs-tooltip-arrow-height)); +} + +.bs-tooltip-bottom .tooltip-arrow::before, +.bs-tooltip-auto[data-popper-placement^='bottom'] .tooltip-arrow::before { + bottom: -1px; + border-width: 0 calc(var(--bs-tooltip-arrow-width) * 0.5) + var(--bs-tooltip-arrow-height); + border-bottom-color: var(--bs-tooltip-bg); +} + +/* rtl:begin:ignore */ +.bs-tooltip-start .tooltip-arrow, +.bs-tooltip-auto[data-popper-placement^='left'] .tooltip-arrow { + right: calc(-1 * var(--bs-tooltip-arrow-height)); + width: var(--bs-tooltip-arrow-height); + height: var(--bs-tooltip-arrow-width); +} + +.bs-tooltip-start .tooltip-arrow::before, +.bs-tooltip-auto[data-popper-placement^='left'] .tooltip-arrow::before { + left: -1px; + border-width: calc(var(--bs-tooltip-arrow-width) * 0.5) 0 + calc(var(--bs-tooltip-arrow-width) * 0.5) var(--bs-tooltip-arrow-height); + border-left-color: var(--bs-tooltip-bg); +} + +/* rtl:end:ignore */ +.tooltip-inner { + max-width: var(--bs-tooltip-max-width); + padding: var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x); + color: var(--bs-tooltip-color); + text-align: center; + background-color: var(--bs-tooltip-bg); + border-radius: var(--bs-tooltip-border-radius); +} + +.popover { + --bs-popover-zindex: 1070; + --bs-popover-max-width: 276px; + --bs-popover-font-size: 0.875rem; + --bs-popover-bg: var(--bs-body-bg); + --bs-popover-border-width: var(--bs-border-width); + --bs-popover-border-color: var(--bs-border-color-translucent); + --bs-popover-border-radius: var(--bs-border-radius-lg); + --bs-popover-inner-border-radius: calc( + var(--bs-border-radius-lg) - var(--bs-border-width) + ); + --bs-popover-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + --bs-popover-header-padding-x: 1rem; + --bs-popover-header-padding-y: 0.5rem; + --bs-popover-header-font-size: 1rem; + --bs-popover-header-color: inherit; + --bs-popover-header-bg: var(--bs-secondary-bg); + --bs-popover-body-padding-x: 1rem; + --bs-popover-body-padding-y: 1rem; + --bs-popover-body-color: var(--bs-body-color); + --bs-popover-arrow-width: 1rem; + --bs-popover-arrow-height: 0.5rem; + --bs-popover-arrow-border: var(--bs-popover-border-color); + z-index: var(--bs-popover-zindex); + display: block; + max-width: var(--bs-popover-max-width); + font-family: var(--bs-font-sans-serif); + font-style: normal; + font-weight: 400; + line-height: 1.5; + text-align: left; + text-align: start; + text-decoration: none; + text-shadow: none; + text-transform: none; + letter-spacing: normal; + word-break: normal; + white-space: normal; + word-spacing: normal; + line-break: auto; + font-size: var(--bs-popover-font-size); + word-wrap: break-word; + background-color: var(--bs-popover-bg); + background-clip: padding-box; + border: var(--bs-popover-border-width) solid var(--bs-popover-border-color); + border-radius: var(--bs-popover-border-radius); +} + +.popover .popover-arrow { + display: block; + width: var(--bs-popover-arrow-width); + height: var(--bs-popover-arrow-height); +} + +.popover .popover-arrow::before, +.popover .popover-arrow::after { + position: absolute; + display: block; + content: ''; + border-color: transparent; + border-style: solid; + border-width: 0; +} + +.bs-popover-top > .popover-arrow, +.bs-popover-auto[data-popper-placement^='top'] > .popover-arrow { + bottom: calc( + -1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width) + ); +} + +.bs-popover-top > .popover-arrow::before, +.bs-popover-auto[data-popper-placement^='top'] > .popover-arrow::before, +.bs-popover-top > .popover-arrow::after, +.bs-popover-auto[data-popper-placement^='top'] > .popover-arrow::after { + border-width: var(--bs-popover-arrow-height) + calc(var(--bs-popover-arrow-width) * 0.5) 0; +} + +.bs-popover-top > .popover-arrow::before, +.bs-popover-auto[data-popper-placement^='top'] > .popover-arrow::before { + bottom: 0; + border-top-color: var(--bs-popover-arrow-border); +} + +.bs-popover-top > .popover-arrow::after, +.bs-popover-auto[data-popper-placement^='top'] > .popover-arrow::after { + bottom: var(--bs-popover-border-width); + border-top-color: var(--bs-popover-bg); +} + +/* rtl:begin:ignore */ +.bs-popover-end > .popover-arrow, +.bs-popover-auto[data-popper-placement^='right'] > .popover-arrow { + left: calc( + -1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width) + ); + width: var(--bs-popover-arrow-height); + height: var(--bs-popover-arrow-width); +} + +.bs-popover-end > .popover-arrow::before, +.bs-popover-auto[data-popper-placement^='right'] > .popover-arrow::before, +.bs-popover-end > .popover-arrow::after, +.bs-popover-auto[data-popper-placement^='right'] > .popover-arrow::after { + border-width: calc(var(--bs-popover-arrow-width) * 0.5) + var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * 0.5) 0; +} + +.bs-popover-end > .popover-arrow::before, +.bs-popover-auto[data-popper-placement^='right'] > .popover-arrow::before { + left: 0; + border-right-color: var(--bs-popover-arrow-border); +} + +.bs-popover-end > .popover-arrow::after, +.bs-popover-auto[data-popper-placement^='right'] > .popover-arrow::after { + left: var(--bs-popover-border-width); + border-right-color: var(--bs-popover-bg); +} + +/* rtl:end:ignore */ +.bs-popover-bottom > .popover-arrow, +.bs-popover-auto[data-popper-placement^='bottom'] > .popover-arrow { + top: calc( + -1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width) + ); +} + +.bs-popover-bottom > .popover-arrow::before, +.bs-popover-auto[data-popper-placement^='bottom'] > .popover-arrow::before, +.bs-popover-bottom > .popover-arrow::after, +.bs-popover-auto[data-popper-placement^='bottom'] > .popover-arrow::after { + border-width: 0 calc(var(--bs-popover-arrow-width) * 0.5) + var(--bs-popover-arrow-height); +} + +.bs-popover-bottom > .popover-arrow::before, +.bs-popover-auto[data-popper-placement^='bottom'] > .popover-arrow::before { + top: 0; + border-bottom-color: var(--bs-popover-arrow-border); +} + +.bs-popover-bottom > .popover-arrow::after, +.bs-popover-auto[data-popper-placement^='bottom'] > .popover-arrow::after { + top: var(--bs-popover-border-width); + border-bottom-color: var(--bs-popover-bg); +} + +.bs-popover-bottom .popover-header::before, +.bs-popover-auto[data-popper-placement^='bottom'] .popover-header::before { + position: absolute; + top: 0; + left: 50%; + display: block; + width: var(--bs-popover-arrow-width); + margin-left: calc(-0.5 * var(--bs-popover-arrow-width)); + content: ''; + border-bottom: var(--bs-popover-border-width) solid + var(--bs-popover-header-bg); +} + +/* rtl:begin:ignore */ +.bs-popover-start > .popover-arrow, +.bs-popover-auto[data-popper-placement^='left'] > .popover-arrow { + right: calc( + -1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width) + ); + width: var(--bs-popover-arrow-height); + height: var(--bs-popover-arrow-width); +} + +.bs-popover-start > .popover-arrow::before, +.bs-popover-auto[data-popper-placement^='left'] > .popover-arrow::before, +.bs-popover-start > .popover-arrow::after, +.bs-popover-auto[data-popper-placement^='left'] > .popover-arrow::after { + border-width: calc(var(--bs-popover-arrow-width) * 0.5) 0 + calc(var(--bs-popover-arrow-width) * 0.5) var(--bs-popover-arrow-height); +} + +.bs-popover-start > .popover-arrow::before, +.bs-popover-auto[data-popper-placement^='left'] > .popover-arrow::before { + right: 0; + border-left-color: var(--bs-popover-arrow-border); +} + +.bs-popover-start > .popover-arrow::after, +.bs-popover-auto[data-popper-placement^='left'] > .popover-arrow::after { + right: var(--bs-popover-border-width); + border-left-color: var(--bs-popover-bg); +} + +/* rtl:end:ignore */ +.popover-header { + padding: var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x); + margin-bottom: 0; + font-size: var(--bs-popover-header-font-size); + color: var(--bs-popover-header-color); + background-color: var(--bs-popover-header-bg); + border-bottom: var(--bs-popover-border-width) solid + var(--bs-popover-border-color); + border-top-left-radius: var(--bs-popover-inner-border-radius); + border-top-right-radius: var(--bs-popover-inner-border-radius); +} + +.popover-header:empty { + display: none; +} + +.popover-body { + padding: var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x); + color: var(--bs-popover-body-color); +} + +.carousel { + position: relative; +} + +.carousel.pointer-event { + touch-action: pan-y; +} + +.carousel-inner { + position: relative; + width: 100%; + overflow: hidden; +} + +.carousel-inner::after { + display: block; + clear: both; + content: ''; +} + +.carousel-item { + position: relative; + display: none; + float: left; + width: 100%; + margin-right: -100%; + backface-visibility: hidden; + transition: transform 0.6s ease-in-out; +} + +@media (prefers-reduced-motion: reduce) { + .carousel-item { + transition: none; + } +} + +.carousel-item.active, +.carousel-item-next, +.carousel-item-prev { + display: block; +} + +.carousel-item-next:not(.carousel-item-start), +.active.carousel-item-end { + transform: translateX(100%); +} + +.carousel-item-prev:not(.carousel-item-end), +.active.carousel-item-start { + transform: translateX(-100%); +} + +.carousel-fade .carousel-item { + opacity: 0; + transition-property: opacity; + transform: none; +} + +.carousel-fade .carousel-item.active, +.carousel-fade .carousel-item-next.carousel-item-start, +.carousel-fade .carousel-item-prev.carousel-item-end { + z-index: 1; + opacity: 1; +} + +.carousel-fade .active.carousel-item-start, +.carousel-fade .active.carousel-item-end { + z-index: 0; + opacity: 0; + transition: opacity 0s 0.6s; +} + +@media (prefers-reduced-motion: reduce) { + .carousel-fade .active.carousel-item-start, + .carousel-fade .active.carousel-item-end { + transition: none; + } +} + +.carousel-control-prev, +.carousel-control-next { + position: absolute; + top: 0; + bottom: 0; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 15%; + padding: 0; + color: #fff; + text-align: center; + background: none; + border: 0; + opacity: 0.5; + transition: opacity 0.15s ease; +} + +@media (prefers-reduced-motion: reduce) { + .carousel-control-prev, + .carousel-control-next { + transition: none; + } +} + +.carousel-control-prev:hover, +.carousel-control-prev:focus, +.carousel-control-next:hover, +.carousel-control-next:focus { + color: #fff; + text-decoration: none; + outline: 0; + opacity: 0.9; +} + +.carousel-control-prev { + left: 0; +} + +.carousel-control-next { + right: 0; +} + +.carousel-control-prev-icon, +.carousel-control-next-icon { + display: inline-block; + width: 2rem; + height: 2rem; + background-repeat: no-repeat; + background-position: 50%; + background-size: 100% 100%; +} + +/* rtl:options: { + "autoRename": true, + "stringMap":[ { + "name" : "prev-next", + "search" : "prev", + "replace" : "next" + } ] + } */ +.carousel-control-prev-icon { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e"); +} + +.carousel-control-next-icon { + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e"); +} + +.carousel-indicators { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 2; + display: flex; + justify-content: center; + padding: 0; + margin-right: 15%; + margin-bottom: 1rem; + margin-left: 15%; +} + +.carousel-indicators [data-bs-target] { + box-sizing: content-box; + flex: 0 1 auto; + width: 30px; + height: 3px; + padding: 0; + margin-right: 3px; + margin-left: 3px; + text-indent: -999px; + cursor: pointer; + background-color: #fff; + background-clip: padding-box; + border: 0; + border-top: 10px solid transparent; + border-bottom: 10px solid transparent; + opacity: 0.5; + transition: opacity 0.6s ease; +} + +@media (prefers-reduced-motion: reduce) { + .carousel-indicators [data-bs-target] { + transition: none; + } +} + +.carousel-indicators .active { + opacity: 1; +} + +.carousel-caption { + position: absolute; + right: 15%; + bottom: 1.25rem; + left: 15%; + padding-top: 1.25rem; + padding-bottom: 1.25rem; + color: #fff; + text-align: center; +} + +.carousel-dark .carousel-control-prev-icon, +.carousel-dark .carousel-control-next-icon { + filter: invert(1) grayscale(100); +} + +.carousel-dark .carousel-indicators [data-bs-target] { + background-color: #000; +} + +.carousel-dark .carousel-caption { + color: #000; +} + +[data-bs-theme='dark'] .carousel .carousel-control-prev-icon, +[data-bs-theme='dark'] .carousel .carousel-control-next-icon, +[data-bs-theme='dark'].carousel .carousel-control-prev-icon, +[data-bs-theme='dark'].carousel .carousel-control-next-icon { + filter: invert(1) grayscale(100); +} + +[data-bs-theme='dark'] .carousel .carousel-indicators [data-bs-target], +[data-bs-theme='dark'].carousel .carousel-indicators [data-bs-target] { + background-color: #000; +} + +[data-bs-theme='dark'] .carousel .carousel-caption, +[data-bs-theme='dark'].carousel .carousel-caption { + color: #000; +} + +.spinner-grow, +.spinner-border { + display: inline-block; + width: var(--bs-spinner-width); + height: var(--bs-spinner-height); + vertical-align: var(--bs-spinner-vertical-align); + border-radius: 50%; + animation: var(--bs-spinner-animation-speed) linear infinite + var(--bs-spinner-animation-name); +} + +@keyframes spinner-border { + to { + transform: rotate(360deg) /* rtl:ignore */; + } +} + +.spinner-border { + --bs-spinner-width: 2rem; + --bs-spinner-height: 2rem; + --bs-spinner-vertical-align: -0.125em; + --bs-spinner-border-width: 0.25em; + --bs-spinner-animation-speed: 0.75s; + --bs-spinner-animation-name: spinner-border; + border: var(--bs-spinner-border-width) solid currentcolor; + border-right-color: transparent; +} + +.spinner-border-sm { + --bs-spinner-width: 1rem; + --bs-spinner-height: 1rem; + --bs-spinner-border-width: 0.2em; +} + +@keyframes spinner-grow { + 0% { + transform: scale(0); + } + + 50% { + opacity: 1; + transform: none; + } +} + +.spinner-grow { + --bs-spinner-width: 2rem; + --bs-spinner-height: 2rem; + --bs-spinner-vertical-align: -0.125em; + --bs-spinner-animation-speed: 0.75s; + --bs-spinner-animation-name: spinner-grow; + background-color: currentcolor; + opacity: 0; +} + +.spinner-grow-sm { + --bs-spinner-width: 1rem; + --bs-spinner-height: 1rem; +} + +@media (prefers-reduced-motion: reduce) { + .spinner-border, + .spinner-grow { + --bs-spinner-animation-speed: 1.5s; + } +} + +.offcanvas, +.offcanvas-xxl, +.offcanvas-xl, +.offcanvas-lg, +.offcanvas-md, +.offcanvas-sm { + --bs-offcanvas-zindex: 1045; + --bs-offcanvas-width: 400px; + --bs-offcanvas-height: 30vh; + --bs-offcanvas-padding-x: 1rem; + --bs-offcanvas-padding-y: 1rem; + --bs-offcanvas-color: var(--bs-body-color); + --bs-offcanvas-bg: var(--bs-body-bg); + --bs-offcanvas-border-width: var(--bs-border-width); + --bs-offcanvas-border-color: var(--bs-border-color-translucent); + --bs-offcanvas-box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + --bs-offcanvas-transition: transform 0.3s ease-in-out; + --bs-offcanvas-title-line-height: 1.5; +} + +@media (max-width: 575.98px) { + .offcanvas-sm { + position: fixed; + bottom: 0; + z-index: var(--bs-offcanvas-zindex); + display: flex; + flex-direction: column; + max-width: 100%; + color: var(--bs-offcanvas-color); + visibility: hidden; + background-color: var(--bs-offcanvas-bg); + background-clip: padding-box; + outline: 0; + transition: var(--bs-offcanvas-transition); + } +} + +@media (max-width: 575.98px) and (prefers-reduced-motion: reduce) { + .offcanvas-sm { + transition: none; + } +} + +@media (max-width: 575.98px) { + .offcanvas-sm.offcanvas-start { + top: 0; + left: 0; + width: var(--bs-offcanvas-width); + border-right: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateX(-100%); + } + + .offcanvas-sm.offcanvas-end { + top: 0; + right: 0; + width: var(--bs-offcanvas-width); + border-left: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateX(100%); + } + + .offcanvas-sm.offcanvas-top { + top: 0; + right: 0; + left: 0; + height: var(--bs-offcanvas-height); + max-height: 100%; + border-bottom: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateY(-100%); + } + + .offcanvas-sm.offcanvas-bottom { + right: 0; + left: 0; + height: var(--bs-offcanvas-height); + max-height: 100%; + border-top: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateY(100%); + } + + .offcanvas-sm.showing, + .offcanvas-sm.show:not(.hiding) { + transform: none; + } + + .offcanvas-sm.showing, + .offcanvas-sm.hiding, + .offcanvas-sm.show { + visibility: visible; + } +} + +@media (min-width: 576px) { + .offcanvas-sm { + --bs-offcanvas-height: auto; + --bs-offcanvas-border-width: 0; + background-color: transparent !important; + } + + .offcanvas-sm .offcanvas-header { + display: none; + } + + .offcanvas-sm .offcanvas-body { + display: flex; + flex-grow: 0; + padding: 0; + overflow-y: visible; + background-color: transparent !important; + } +} + +@media (max-width: 767.98px) { + .offcanvas-md { + position: fixed; + bottom: 0; + z-index: var(--bs-offcanvas-zindex); + display: flex; + flex-direction: column; + max-width: 100%; + color: var(--bs-offcanvas-color); + visibility: hidden; + background-color: var(--bs-offcanvas-bg); + background-clip: padding-box; + outline: 0; + transition: var(--bs-offcanvas-transition); + } +} + +@media (max-width: 767.98px) and (prefers-reduced-motion: reduce) { + .offcanvas-md { + transition: none; + } +} + +@media (max-width: 767.98px) { + .offcanvas-md.offcanvas-start { + top: 0; + left: 0; + width: var(--bs-offcanvas-width); + border-right: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateX(-100%); + } + + .offcanvas-md.offcanvas-end { + top: 0; + right: 0; + width: var(--bs-offcanvas-width); + border-left: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateX(100%); + } + + .offcanvas-md.offcanvas-top { + top: 0; + right: 0; + left: 0; + height: var(--bs-offcanvas-height); + max-height: 100%; + border-bottom: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateY(-100%); + } + + .offcanvas-md.offcanvas-bottom { + right: 0; + left: 0; + height: var(--bs-offcanvas-height); + max-height: 100%; + border-top: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateY(100%); + } + + .offcanvas-md.showing, + .offcanvas-md.show:not(.hiding) { + transform: none; + } + + .offcanvas-md.showing, + .offcanvas-md.hiding, + .offcanvas-md.show { + visibility: visible; + } +} + +@media (min-width: 768px) { + .offcanvas-md { + --bs-offcanvas-height: auto; + --bs-offcanvas-border-width: 0; + background-color: transparent !important; + } + + .offcanvas-md .offcanvas-header { + display: none; + } + + .offcanvas-md .offcanvas-body { + display: flex; + flex-grow: 0; + padding: 0; + overflow-y: visible; + background-color: transparent !important; + } +} + +@media (max-width: 991.98px) { + .offcanvas-lg { + position: fixed; + bottom: 0; + z-index: var(--bs-offcanvas-zindex); + display: flex; + flex-direction: column; + max-width: 100%; + color: var(--bs-offcanvas-color); + visibility: hidden; + background-color: var(--bs-offcanvas-bg); + background-clip: padding-box; + outline: 0; + transition: var(--bs-offcanvas-transition); + } +} + +@media (max-width: 991.98px) and (prefers-reduced-motion: reduce) { + .offcanvas-lg { + transition: none; + } +} + +@media (max-width: 991.98px) { + .offcanvas-lg.offcanvas-start { + top: 0; + left: 0; + width: var(--bs-offcanvas-width); + border-right: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateX(-100%); + } + + .offcanvas-lg.offcanvas-end { + top: 0; + right: 0; + width: var(--bs-offcanvas-width); + border-left: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateX(100%); + } + + .offcanvas-lg.offcanvas-top { + top: 0; + right: 0; + left: 0; + height: var(--bs-offcanvas-height); + max-height: 100%; + border-bottom: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateY(-100%); + } + + .offcanvas-lg.offcanvas-bottom { + right: 0; + left: 0; + height: var(--bs-offcanvas-height); + max-height: 100%; + border-top: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateY(100%); + } + + .offcanvas-lg.showing, + .offcanvas-lg.show:not(.hiding) { + transform: none; + } + + .offcanvas-lg.showing, + .offcanvas-lg.hiding, + .offcanvas-lg.show { + visibility: visible; + } +} + +@media (min-width: 992px) { + .offcanvas-lg { + --bs-offcanvas-height: auto; + --bs-offcanvas-border-width: 0; + background-color: transparent !important; + } + + .offcanvas-lg .offcanvas-header { + display: none; + } + + .offcanvas-lg .offcanvas-body { + display: flex; + flex-grow: 0; + padding: 0; + overflow-y: visible; + background-color: transparent !important; + } +} + +@media (max-width: 1199.98px) { + .offcanvas-xl { + position: fixed; + bottom: 0; + z-index: var(--bs-offcanvas-zindex); + display: flex; + flex-direction: column; + max-width: 100%; + color: var(--bs-offcanvas-color); + visibility: hidden; + background-color: var(--bs-offcanvas-bg); + background-clip: padding-box; + outline: 0; + transition: var(--bs-offcanvas-transition); + } +} + +@media (max-width: 1199.98px) and (prefers-reduced-motion: reduce) { + .offcanvas-xl { + transition: none; + } +} + +@media (max-width: 1199.98px) { + .offcanvas-xl.offcanvas-start { + top: 0; + left: 0; + width: var(--bs-offcanvas-width); + border-right: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateX(-100%); + } + + .offcanvas-xl.offcanvas-end { + top: 0; + right: 0; + width: var(--bs-offcanvas-width); + border-left: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateX(100%); + } + + .offcanvas-xl.offcanvas-top { + top: 0; + right: 0; + left: 0; + height: var(--bs-offcanvas-height); + max-height: 100%; + border-bottom: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateY(-100%); + } + + .offcanvas-xl.offcanvas-bottom { + right: 0; + left: 0; + height: var(--bs-offcanvas-height); + max-height: 100%; + border-top: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateY(100%); + } + + .offcanvas-xl.showing, + .offcanvas-xl.show:not(.hiding) { + transform: none; + } + + .offcanvas-xl.showing, + .offcanvas-xl.hiding, + .offcanvas-xl.show { + visibility: visible; + } +} + +@media (min-width: 1200px) { + .offcanvas-xl { + --bs-offcanvas-height: auto; + --bs-offcanvas-border-width: 0; + background-color: transparent !important; + } + + .offcanvas-xl .offcanvas-header { + display: none; + } + + .offcanvas-xl .offcanvas-body { + display: flex; + flex-grow: 0; + padding: 0; + overflow-y: visible; + background-color: transparent !important; + } +} + +@media (max-width: 1399.98px) { + .offcanvas-xxl { + position: fixed; + bottom: 0; + z-index: var(--bs-offcanvas-zindex); + display: flex; + flex-direction: column; + max-width: 100%; + color: var(--bs-offcanvas-color); + visibility: hidden; + background-color: var(--bs-offcanvas-bg); + background-clip: padding-box; + outline: 0; + transition: var(--bs-offcanvas-transition); + } +} + +@media (max-width: 1399.98px) and (prefers-reduced-motion: reduce) { + .offcanvas-xxl { + transition: none; + } +} + +@media (max-width: 1399.98px) { + .offcanvas-xxl.offcanvas-start { + top: 0; + left: 0; + width: var(--bs-offcanvas-width); + border-right: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateX(-100%); + } + + .offcanvas-xxl.offcanvas-end { + top: 0; + right: 0; + width: var(--bs-offcanvas-width); + border-left: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateX(100%); + } + + .offcanvas-xxl.offcanvas-top { + top: 0; + right: 0; + left: 0; + height: var(--bs-offcanvas-height); + max-height: 100%; + border-bottom: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateY(-100%); + } + + .offcanvas-xxl.offcanvas-bottom { + right: 0; + left: 0; + height: var(--bs-offcanvas-height); + max-height: 100%; + border-top: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateY(100%); + } + + .offcanvas-xxl.showing, + .offcanvas-xxl.show:not(.hiding) { + transform: none; + } + + .offcanvas-xxl.showing, + .offcanvas-xxl.hiding, + .offcanvas-xxl.show { + visibility: visible; + } +} + +@media (min-width: 1400px) { + .offcanvas-xxl { + --bs-offcanvas-height: auto; + --bs-offcanvas-border-width: 0; + background-color: transparent !important; + } + + .offcanvas-xxl .offcanvas-header { + display: none; + } + + .offcanvas-xxl .offcanvas-body { + display: flex; + flex-grow: 0; + padding: 0; + overflow-y: visible; + background-color: transparent !important; + } +} + +.offcanvas { + position: fixed; + bottom: 0; + z-index: var(--bs-offcanvas-zindex); + display: flex; + flex-direction: column; + max-width: 100%; + color: var(--bs-offcanvas-color); + visibility: hidden; + background-color: var(--bs-offcanvas-bg); + background-clip: padding-box; + outline: 0; + transition: var(--bs-offcanvas-transition); +} + +@media (prefers-reduced-motion: reduce) { + .offcanvas { + transition: none; + } +} + +.offcanvas.offcanvas-start { + top: 0; + left: 0; + width: var(--bs-offcanvas-width); + border-right: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateX(-100%); +} + +.offcanvas.offcanvas-end { + top: 0; + right: 0; + width: var(--bs-offcanvas-width); + border-left: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateX(100%); +} + +.offcanvas.offcanvas-top { + top: 0; + right: 0; + left: 0; + height: var(--bs-offcanvas-height); + max-height: 100%; + border-bottom: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateY(-100%); +} + +.offcanvas.offcanvas-bottom { + right: 0; + left: 0; + height: var(--bs-offcanvas-height); + max-height: 100%; + border-top: var(--bs-offcanvas-border-width) solid + var(--bs-offcanvas-border-color); + transform: translateY(100%); +} + +.offcanvas.showing, +.offcanvas.show:not(.hiding) { + transform: none; +} + +.offcanvas.showing, +.offcanvas.hiding, +.offcanvas.show { + visibility: visible; +} + +.offcanvas-backdrop { + position: fixed; + top: 0; + left: 0; + z-index: 1040; + width: 100vw; + height: 100vh; + background-color: #000; +} + +.offcanvas-backdrop.fade { + opacity: 0; +} + +.offcanvas-backdrop.show { + opacity: 0.5; +} + +.offcanvas-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x); +} + +.offcanvas-header .btn-close { + padding: calc(var(--bs-offcanvas-padding-y) * 0.5) + calc(var(--bs-offcanvas-padding-x) * 0.5); + margin-top: calc(-0.5 * var(--bs-offcanvas-padding-y)); + margin-right: calc(-0.5 * var(--bs-offcanvas-padding-x)); + margin-bottom: calc(-0.5 * var(--bs-offcanvas-padding-y)); +} + +.offcanvas-title { + margin-bottom: 0; + line-height: var(--bs-offcanvas-title-line-height); +} + +.offcanvas-body { + flex-grow: 1; + padding: var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x); + overflow-y: auto; +} + +.placeholder { + display: inline-block; + min-height: 1em; + vertical-align: middle; + cursor: wait; + background-color: currentcolor; + opacity: 0.5; +} + +.placeholder.btn::before { + display: inline-block; + content: ''; +} + +.placeholder-xs { + min-height: 0.6em; +} + +.placeholder-sm { + min-height: 0.8em; +} + +.placeholder-lg { + min-height: 1.2em; +} + +.placeholder-glow .placeholder { + animation: placeholder-glow 2s ease-in-out infinite; +} + +@keyframes placeholder-glow { + 50% { + opacity: 0.2; + } +} + +.placeholder-wave { + mask-image: linear-gradient( + 130deg, + #000 55%, + rgba(0, 0, 0, 0.8) 75%, + #000 95% + ); + mask-size: 200% 100%; + animation: placeholder-wave 2s linear infinite; +} + +@keyframes placeholder-wave { + 100% { + mask-position: -200% 0%; + } +} + +.clearfix::after { + display: block; + clear: both; + content: ''; +} + +.text-bg-primary { + color: #000 !important; + background-color: RGBA(49, 187, 107, var(--bs-bg-opacity, 1)) !important; +} + +.text-bg-secondary { + color: #fff !important; + background-color: RGBA(112, 112, 112, var(--bs-bg-opacity, 1)) !important; +} + +.text-bg-success { + color: #000 !important; + background-color: RGBA(49, 187, 107, var(--bs-bg-opacity, 1)) !important; +} + +.text-bg-info { + color: #000 !important; + background-color: RGBA(13, 202, 240, var(--bs-bg-opacity, 1)) !important; +} + +.text-bg-warning { + color: #000 !important; + background-color: RGBA(254, 188, 89, var(--bs-bg-opacity, 1)) !important; +} + +.text-bg-danger { + color: #fff !important; + background-color: RGBA(220, 53, 69, var(--bs-bg-opacity, 1)) !important; +} + +.text-bg-light { + color: #000 !important; + background-color: RGBA(248, 249, 250, var(--bs-bg-opacity, 1)) !important; +} + +.text-bg-dark { + color: #fff !important; + background-color: RGBA(33, 37, 41, var(--bs-bg-opacity, 1)) !important; +} + +.link-primary { + color: RGBA(var(--bs-primary-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA( + var(--bs-primary-rgb), + var(--bs-link-underline-opacity, 1) + ) !important; +} + +.link-primary:hover, +.link-primary:focus { + color: RGBA(90, 201, 137, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA( + 90, + 201, + 137, + var(--bs-link-underline-opacity, 1) + ) !important; +} + +.link-secondary { + color: RGBA(var(--bs-secondary-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA( + var(--bs-secondary-rgb), + var(--bs-link-underline-opacity, 1) + ) !important; +} + +.link-secondary:hover, +.link-secondary:focus { + color: RGBA(90, 90, 90, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA( + 90, + 90, + 90, + var(--bs-link-underline-opacity, 1) + ) !important; +} + +.link-success { + color: RGBA(var(--bs-success-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA( + var(--bs-success-rgb), + var(--bs-link-underline-opacity, 1) + ) !important; +} + +.link-success:hover, +.link-success:focus { + color: RGBA(90, 201, 137, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA( + 90, + 201, + 137, + var(--bs-link-underline-opacity, 1) + ) !important; +} + +.link-info { + color: RGBA(var(--bs-info-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA( + var(--bs-info-rgb), + var(--bs-link-underline-opacity, 1) + ) !important; +} + +.link-info:hover, +.link-info:focus { + color: RGBA(61, 213, 243, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA( + 61, + 213, + 243, + var(--bs-link-underline-opacity, 1) + ) !important; +} + +.link-warning { + color: RGBA(var(--bs-warning-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA( + var(--bs-warning-rgb), + var(--bs-link-underline-opacity, 1) + ) !important; +} + +.link-warning:hover, +.link-warning:focus { + color: RGBA(254, 201, 122, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA( + 254, + 201, + 122, + var(--bs-link-underline-opacity, 1) + ) !important; +} + +.link-danger { + color: RGBA(var(--bs-danger-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA( + var(--bs-danger-rgb), + var(--bs-link-underline-opacity, 1) + ) !important; +} + +.link-danger:hover, +.link-danger:focus { + color: RGBA(176, 42, 55, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA( + 176, + 42, + 55, + var(--bs-link-underline-opacity, 1) + ) !important; +} + +.link-light { + color: RGBA(var(--bs-light-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA( + var(--bs-light-rgb), + var(--bs-link-underline-opacity, 1) + ) !important; +} + +.link-light:hover, +.link-light:focus { + color: RGBA(249, 250, 251, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA( + 249, + 250, + 251, + var(--bs-link-underline-opacity, 1) + ) !important; +} + +.link-dark { + color: RGBA(var(--bs-dark-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA( + var(--bs-dark-rgb), + var(--bs-link-underline-opacity, 1) + ) !important; +} + +.link-dark:hover, +.link-dark:focus { + color: RGBA(26, 30, 33, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA( + 26, + 30, + 33, + var(--bs-link-underline-opacity, 1) + ) !important; +} + +.link-body-emphasis { + color: RGBA( + var(--bs-emphasis-color-rgb), + var(--bs-link-opacity, 1) + ) !important; + text-decoration-color: RGBA( + var(--bs-emphasis-color-rgb), + var(--bs-link-underline-opacity, 1) + ) !important; +} + +.link-body-emphasis:hover, +.link-body-emphasis:focus { + color: RGBA( + var(--bs-emphasis-color-rgb), + var(--bs-link-opacity, 0.75) + ) !important; + text-decoration-color: RGBA( + var(--bs-emphasis-color-rgb), + var(--bs-link-underline-opacity, 0.75) + ) !important; +} + +.focus-ring:focus { + outline: 0; + box-shadow: var(--bs-focus-ring-x, 0) var(--bs-focus-ring-y, 0) + var(--bs-focus-ring-blur, 0) var(--bs-focus-ring-width) + var(--bs-focus-ring-color); +} + +.icon-link { + display: inline-flex; + gap: 0.375rem; + align-items: center; + text-decoration-color: rgba( + var(--bs-link-color-rgb), + var(--bs-link-opacity, 0.5) + ); + text-underline-offset: 0.25em; + backface-visibility: hidden; +} + +.icon-link > .bi { + flex-shrink: 0; + width: 1em; + height: 1em; + fill: currentcolor; + transition: 0.2s ease-in-out transform; +} + +@media (prefers-reduced-motion: reduce) { + .icon-link > .bi { + transition: none; + } +} + +.icon-link-hover:hover > .bi, +.icon-link-hover:focus-visible > .bi { + transform: var(--bs-icon-link-transform, translate3d(0.25em, 0, 0)); +} + +.ratio { + position: relative; + width: 100%; +} + +.ratio::before { + display: block; + padding-top: var(--bs-aspect-ratio); + content: ''; +} + +.ratio > * { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.ratio-1x1 { + --bs-aspect-ratio: 100%; +} + +.ratio-4x3 { + --bs-aspect-ratio: 75%; +} + +.ratio-16x9 { + --bs-aspect-ratio: 56.25%; +} + +.ratio-21x9 { + --bs-aspect-ratio: 42.8571428571%; +} + +.fixed-top { + position: fixed; + top: 0; + right: 0; + left: 0; + z-index: 1030; +} + +.fixed-bottom { + position: fixed; + right: 0; + bottom: 0; + left: 0; + z-index: 1030; +} + +.sticky-top { + position: sticky; + top: 0; + z-index: 1020; +} + +.sticky-bottom { + position: sticky; + bottom: 0; + z-index: 1020; +} + +@media (min-width: 576px) { + .sticky-sm-top { + position: sticky; + top: 0; + z-index: 1020; + } + + .sticky-sm-bottom { + position: sticky; + bottom: 0; + z-index: 1020; + } +} + +@media (min-width: 768px) { + .sticky-md-top { + position: sticky; + top: 0; + z-index: 1020; + } + + .sticky-md-bottom { + position: sticky; + bottom: 0; + z-index: 1020; + } +} + +@media (min-width: 992px) { + .sticky-lg-top { + position: sticky; + top: 0; + z-index: 1020; + } + + .sticky-lg-bottom { + position: sticky; + bottom: 0; + z-index: 1020; + } +} + +@media (min-width: 1200px) { + .sticky-xl-top { + position: sticky; + top: 0; + z-index: 1020; + } + + .sticky-xl-bottom { + position: sticky; + bottom: 0; + z-index: 1020; + } +} + +@media (min-width: 1400px) { + .sticky-xxl-top { + position: sticky; + top: 0; + z-index: 1020; + } + + .sticky-xxl-bottom { + position: sticky; + bottom: 0; + z-index: 1020; + } +} + +.hstack { + display: flex; + flex-direction: row; + align-items: center; + align-self: stretch; +} + +.vstack { + display: flex; + flex: 1 1 auto; + flex-direction: column; + align-self: stretch; +} + +.visually-hidden, +.visually-hidden-focusable:not(:focus):not(:focus-within) { + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +.visually-hidden:not(caption), +.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption) { + position: absolute !important; +} + +.stretched-link::after { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1; + content: ''; +} + +.text-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.vr { + display: inline-block; + align-self: stretch; + width: 1px; + min-height: 1em; + background-color: currentcolor; + opacity: 0.25; +} + +.align-baseline { + vertical-align: baseline !important; +} + +.align-top { + vertical-align: top !important; +} + +.align-middle { + vertical-align: middle !important; +} + +.align-bottom { + vertical-align: bottom !important; +} + +.align-text-bottom { + vertical-align: text-bottom !important; +} + +.align-text-top { + vertical-align: text-top !important; +} + +.float-start { + float: left !important; +} + +.float-end { + float: right !important; +} + +.float-none { + float: none !important; +} + +.object-fit-contain { + object-fit: contain !important; +} + +.object-fit-cover { + object-fit: cover !important; +} + +.object-fit-fill { + object-fit: fill !important; +} + +.object-fit-scale { + object-fit: scale-down !important; +} + +.object-fit-none { + object-fit: none !important; +} + +.opacity-0 { + opacity: 0 !important; +} + +.opacity-25 { + opacity: 0.25 !important; +} + +.opacity-50 { + opacity: 0.5 !important; +} + +.opacity-75 { + opacity: 0.75 !important; +} + +.opacity-100 { + opacity: 1 !important; +} + +.overflow-auto { + overflow: auto !important; +} + +.overflow-hidden { + overflow: hidden !important; +} + +.overflow-visible { + overflow: visible !important; +} + +.overflow-scroll { + overflow: scroll !important; +} + +.overflow-x-auto { + overflow-x: auto !important; +} + +.overflow-x-hidden { + overflow-x: hidden !important; +} + +.overflow-x-visible { + overflow-x: visible !important; +} + +.overflow-x-scroll { + overflow-x: scroll !important; +} + +.overflow-y-auto { + overflow-y: auto !important; +} + +.overflow-y-hidden { + overflow-y: hidden !important; +} + +.overflow-y-visible { + overflow-y: visible !important; +} + +.overflow-y-scroll { + overflow-y: scroll !important; +} + +.d-inline { + display: inline !important; +} + +.d-inline-block { + display: inline-block !important; +} + +.d-block { + display: block !important; +} + +.d-grid { + display: grid !important; +} + +.d-inline-grid { + display: inline-grid !important; +} + +.d-table { + display: table !important; +} + +.d-table-row { + display: table-row !important; +} + +.d-table-cell { + display: table-cell !important; +} + +.d-flex { + display: flex !important; +} + +.d-inline-flex { + display: inline-flex !important; +} + +.d-none { + display: none !important; +} + +.shadow { + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; +} + +.shadow-sm { + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important; +} + +.shadow-lg { + box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.175) !important; +} + +.shadow-none { + box-shadow: none !important; +} + +.focus-ring-primary { + --bs-focus-ring-color: rgba( + var(--bs-primary-rgb), + var(--bs-focus-ring-opacity) + ); +} + +.focus-ring-secondary { + --bs-focus-ring-color: rgba( + var(--bs-secondary-rgb), + var(--bs-focus-ring-opacity) + ); +} + +.focus-ring-success { + --bs-focus-ring-color: rgba( + var(--bs-success-rgb), + var(--bs-focus-ring-opacity) + ); +} + +.focus-ring-info { + --bs-focus-ring-color: rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity)); +} + +.focus-ring-warning { + --bs-focus-ring-color: rgba( + var(--bs-warning-rgb), + var(--bs-focus-ring-opacity) + ); +} + +.focus-ring-danger { + --bs-focus-ring-color: rgba( + var(--bs-danger-rgb), + var(--bs-focus-ring-opacity) + ); +} + +.focus-ring-light { + --bs-focus-ring-color: rgba( + var(--bs-light-rgb), + var(--bs-focus-ring-opacity) + ); +} + +.focus-ring-dark { + --bs-focus-ring-color: rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity)); +} + +.position-static { + position: static !important; +} + +.position-relative { + position: relative !important; +} + +.position-absolute { + position: absolute !important; +} + +.position-fixed { + position: fixed !important; +} + +.position-sticky { + position: sticky !important; +} + +.top-0 { + top: 0 !important; +} + +.top-50 { + top: 50% !important; +} + +.top-100 { + top: 100% !important; +} + +.bottom-0 { + bottom: 0 !important; +} + +.bottom-50 { + bottom: 50% !important; +} + +.bottom-100 { + bottom: 100% !important; +} + +.start-0 { + left: 0 !important; +} + +.start-50 { + left: 50% !important; +} + +.start-100 { + left: 100% !important; +} + +.end-0 { + right: 0 !important; +} + +.end-50 { + right: 50% !important; +} + +.end-100 { + right: 100% !important; +} + +.translate-middle { + transform: translate(-50%, -50%) !important; +} + +.translate-middle-x { + transform: translateX(-50%) !important; +} + +.translate-middle-y { + transform: translateY(-50%) !important; +} + +.border { + border: var(--bs-border-width) var(--bs-border-style) var(--bs-border-color) !important; +} + +.border-0 { + border: 0 !important; +} + +.border-top { + border-top: var(--bs-border-width) var(--bs-border-style) + var(--bs-border-color) !important; +} + +.border-top-0 { + border-top: 0 !important; +} + +.border-end { + border-right: var(--bs-border-width) var(--bs-border-style) + var(--bs-border-color) !important; +} + +.border-end-0 { + border-right: 0 !important; +} + +.border-bottom { + border-bottom: var(--bs-border-width) var(--bs-border-style) + var(--bs-border-color) !important; +} + +.border-bottom-0 { + border-bottom: 0 !important; +} + +.border-start { + border-left: var(--bs-border-width) var(--bs-border-style) + var(--bs-border-color) !important; +} + +.border-start-0 { + border-left: 0 !important; +} + +.border-primary { + --bs-border-opacity: 1; + border-color: rgba( + var(--bs-primary-rgb), + var(--bs-border-opacity) + ) !important; +} + +.border-secondary { + --bs-border-opacity: 1; + border-color: rgba( + var(--bs-secondary-rgb), + var(--bs-border-opacity) + ) !important; +} + +.border-success { + --bs-border-opacity: 1; + border-color: rgba( + var(--bs-success-rgb), + var(--bs-border-opacity) + ) !important; +} + +.border-info { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-info-rgb), var(--bs-border-opacity)) !important; +} + +.border-warning { + --bs-border-opacity: 1; + border-color: rgba( + var(--bs-warning-rgb), + var(--bs-border-opacity) + ) !important; +} + +.border-danger { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-danger-rgb), var(--bs-border-opacity)) !important; +} + +.border-light { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-light-rgb), var(--bs-border-opacity)) !important; +} + +.border-dark { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-dark-rgb), var(--bs-border-opacity)) !important; +} + +.border-black { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-black-rgb), var(--bs-border-opacity)) !important; +} + +.border-white { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-white-rgb), var(--bs-border-opacity)) !important; +} + +.border-primary-subtle { + border-color: var(--bs-primary-border-subtle) !important; +} + +.border-secondary-subtle { + border-color: var(--bs-secondary-border-subtle) !important; +} + +.border-success-subtle { + border-color: var(--bs-success-border-subtle) !important; +} + +.border-info-subtle { + border-color: var(--bs-info-border-subtle) !important; +} + +.border-warning-subtle { + border-color: var(--bs-warning-border-subtle) !important; +} + +.border-danger-subtle { + border-color: var(--bs-danger-border-subtle) !important; +} + +.border-light-subtle { + border-color: var(--bs-light-border-subtle) !important; +} + +.border-dark-subtle { + border-color: var(--bs-dark-border-subtle) !important; +} + +.border-1 { + border-width: 1px !important; +} + +.border-2 { + border-width: 2px !important; +} + +.border-3 { + border-width: 3px !important; +} + +.border-4 { + border-width: 4px !important; +} + +.border-5 { + border-width: 5px !important; +} + +.border-opacity-10 { + --bs-border-opacity: 0.1; +} + +.border-opacity-25 { + --bs-border-opacity: 0.25; +} + +.border-opacity-50 { + --bs-border-opacity: 0.5; +} + +.border-opacity-75 { + --bs-border-opacity: 0.75; +} + +.border-opacity-100 { + --bs-border-opacity: 1; +} + +.w-25 { + width: 25% !important; +} + +.w-50 { + width: 50% !important; +} + +.w-75 { + width: 75% !important; +} + +.w-100 { + width: 100% !important; +} + +.w-auto { + width: auto !important; +} + +.mw-100 { + max-width: 100% !important; +} + +.vw-100 { + width: 100vw !important; +} + +.min-vw-100 { + min-width: 100vw !important; +} + +.h-25 { + height: 25% !important; +} + +.h-50 { + height: 50% !important; +} + +.h-75 { + height: 75% !important; +} + +.h-100 { + height: 100% !important; +} + +.h-auto { + height: auto !important; +} + +.mh-100 { + max-height: 100% !important; +} + +.vh-100 { + height: 100vh !important; +} + +.min-vh-100 { + min-height: 100vh !important; +} + +.flex-fill { + flex: 1 1 auto !important; +} + +.flex-row { + flex-direction: row !important; +} + +.flex-column { + flex-direction: column !important; +} + +.flex-row-reverse { + flex-direction: row-reverse !important; +} + +.flex-column-reverse { + flex-direction: column-reverse !important; +} + +.flex-grow-0 { + flex-grow: 0 !important; +} + +.flex-grow-1 { + flex-grow: 1 !important; +} + +.flex-shrink-0 { + flex-shrink: 0 !important; +} + +.flex-shrink-1 { + flex-shrink: 1 !important; +} + +.flex-wrap { + flex-wrap: wrap !important; +} + +.flex-nowrap { + flex-wrap: nowrap !important; +} + +.flex-wrap-reverse { + flex-wrap: wrap-reverse !important; +} + +.justify-content-start { + justify-content: flex-start !important; +} + +.justify-content-end { + justify-content: flex-end !important; +} + +.justify-content-center { + justify-content: center !important; +} + +.justify-content-between { + justify-content: space-between !important; +} + +.justify-content-around { + justify-content: space-around !important; +} + +.justify-content-evenly { + justify-content: space-evenly !important; +} + +.align-items-start { + align-items: flex-start !important; +} + +.align-items-end { + align-items: flex-end !important; +} + +.align-items-center { + align-items: center !important; +} + +.align-items-baseline { + align-items: baseline !important; +} + +.align-items-stretch { + align-items: stretch !important; +} + +.align-content-start { + align-content: flex-start !important; +} + +.align-content-end { + align-content: flex-end !important; +} + +.align-content-center { + align-content: center !important; +} + +.align-content-between { + align-content: space-between !important; +} + +.align-content-around { + align-content: space-around !important; +} + +.align-content-stretch { + align-content: stretch !important; +} + +.align-self-auto { + align-self: auto !important; +} + +.align-self-start { + align-self: flex-start !important; +} + +.align-self-end { + align-self: flex-end !important; +} + +.align-self-center { + align-self: center !important; +} + +.align-self-baseline { + align-self: baseline !important; +} + +.align-self-stretch { + align-self: stretch !important; +} + +.order-first { + order: -1 !important; +} + +.order-0 { + order: 0 !important; +} + +.order-1 { + order: 1 !important; +} + +.order-2 { + order: 2 !important; +} + +.order-3 { + order: 3 !important; +} + +.order-4 { + order: 4 !important; +} + +.order-5 { + order: 5 !important; +} + +.order-last { + order: 6 !important; +} + +.m-0 { + margin: 0 !important; +} + +.m-1 { + margin: 0.25rem !important; +} + +.m-2 { + margin: 0.5rem !important; +} + +.m-3 { + margin: 1rem !important; +} + +.m-4 { + margin: 1.5rem !important; +} + +.m-5 { + margin: 3rem !important; +} + +.m-auto { + margin: auto !important; +} + +.mx-0 { + margin-right: 0 !important; + margin-left: 0 !important; +} + +.mx-1 { + margin-right: 0.25rem !important; + margin-left: 0.25rem !important; +} + +.mx-2 { + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; +} + +.mx-3 { + margin-right: 1rem !important; + margin-left: 1rem !important; +} + +.mx-4 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; +} + +.mx-5 { + margin-right: 3rem !important; + margin-left: 3rem !important; +} + +.mx-auto { + margin-right: auto !important; + margin-left: auto !important; +} + +.my-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; +} + +.my-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; +} + +.my-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; +} + +.my-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; +} + +.my-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; +} + +.my-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; +} + +.my-auto { + margin-top: auto !important; + margin-bottom: auto !important; +} + +.mt-0 { + margin-top: 0 !important; +} + +.mt-1 { + margin-top: 0.25rem !important; +} + +.mt-2 { + margin-top: 0.5rem !important; +} + +.mt-3 { + margin-top: 1rem !important; +} + +.mt-4 { + margin-top: 1.5rem !important; +} + +.mt-5 { + margin-top: 3rem !important; +} + +.mt-auto { + margin-top: auto !important; +} + +.me-0 { + margin-right: 0 !important; +} + +.me-1 { + margin-right: 0.25rem !important; +} + +.me-2 { + margin-right: 0.5rem !important; +} + +.me-3 { + margin-right: 1rem !important; +} + +.me-4 { + margin-right: 1.5rem !important; +} + +.me-5 { + margin-right: 3rem !important; +} + +.me-auto { + margin-right: auto !important; +} + +.mb-0 { + margin-bottom: 0 !important; +} + +.mb-1 { + margin-bottom: 0.25rem !important; +} + +.mb-2 { + margin-bottom: 0.5rem !important; +} + +.mb-3 { + margin-bottom: 1rem !important; +} + +.mb-4 { + margin-bottom: 1.5rem !important; +} + +.mb-5 { + margin-bottom: 3rem !important; +} + +.mb-auto { + margin-bottom: auto !important; +} + +.ms-0 { + margin-left: 0 !important; +} + +.ms-1 { + margin-left: 0.25rem !important; +} + +.ms-2 { + margin-left: 0.5rem !important; +} + +.ms-3 { + margin-left: 1rem !important; +} + +.ms-4 { + margin-left: 1.5rem !important; +} + +.ms-5 { + margin-left: 3rem !important; +} + +.ms-auto { + margin-left: auto !important; +} + +.p-0 { + padding: 0 !important; +} + +.p-1 { + padding: 0.25rem !important; +} + +.p-2 { + padding: 0.5rem !important; +} + +.p-3 { + padding: 1rem !important; +} + +.p-4 { + padding: 1.5rem !important; +} + +.p-5 { + padding: 3rem !important; +} + +.px-0 { + padding-right: 0 !important; + padding-left: 0 !important; +} + +.px-1 { + padding-right: 0.25rem !important; + padding-left: 0.25rem !important; +} + +.px-2 { + padding-right: 0.5rem !important; + padding-left: 0.5rem !important; +} + +.px-3 { + padding-right: 1rem !important; + padding-left: 1rem !important; +} + +.px-4 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; +} + +.px-5 { + padding-right: 3rem !important; + padding-left: 3rem !important; +} + +.py-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; +} + +.py-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; +} + +.py-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; +} + +.py-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; +} + +.py-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; +} + +.py-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; +} + +.pt-0 { + padding-top: 0 !important; +} + +.pt-1 { + padding-top: 0.25rem !important; +} + +.pt-2 { + padding-top: 0.5rem !important; +} + +.pt-3 { + padding-top: 1rem !important; +} + +.pt-4 { + padding-top: 1.5rem !important; +} + +.pt-5 { + padding-top: 3rem !important; +} + +.pe-0 { + padding-right: 0 !important; +} + +.pe-1 { + padding-right: 0.25rem !important; +} + +.pe-2 { + padding-right: 0.5rem !important; +} + +.pe-3 { + padding-right: 1rem !important; +} + +.pe-4 { + padding-right: 1.5rem !important; +} + +.pe-5 { + padding-right: 3rem !important; +} + +.pb-0 { + padding-bottom: 0 !important; +} + +.pb-1 { + padding-bottom: 0.25rem !important; +} + +.pb-2 { + padding-bottom: 0.5rem !important; +} + +.pb-3 { + padding-bottom: 1rem !important; +} + +.pb-4 { + padding-bottom: 1.5rem !important; +} + +.pb-5 { + padding-bottom: 3rem !important; +} + +.ps-0 { + padding-left: 0 !important; +} + +.ps-1 { + padding-left: 0.25rem !important; +} + +.ps-2 { + padding-left: 0.5rem !important; +} + +.ps-3 { + padding-left: 1rem !important; +} + +.ps-4 { + padding-left: 1.5rem !important; +} + +.ps-5 { + padding-left: 3rem !important; +} + +.gap-0 { + gap: 0 !important; +} + +.gap-1 { + gap: 0.25rem !important; +} + +.gap-2 { + gap: 0.5rem !important; +} + +.gap-3 { + gap: 1rem !important; +} + +.gap-4 { + gap: 1.5rem !important; +} + +.gap-5 { + gap: 3rem !important; +} + +.row-gap-0 { + row-gap: 0 !important; +} + +.row-gap-1 { + row-gap: 0.25rem !important; +} + +.row-gap-2 { + row-gap: 0.5rem !important; +} + +.row-gap-3 { + row-gap: 1rem !important; +} + +.row-gap-4 { + row-gap: 1.5rem !important; +} + +.row-gap-5 { + row-gap: 3rem !important; +} + +.column-gap-0 { + column-gap: 0 !important; +} + +.column-gap-1 { + column-gap: 0.25rem !important; +} + +.column-gap-2 { + column-gap: 0.5rem !important; +} + +.column-gap-3 { + column-gap: 1rem !important; +} + +.column-gap-4 { + column-gap: 1.5rem !important; +} + +.column-gap-5 { + column-gap: 3rem !important; +} + +.font-monospace { + font-family: var(--bs-font-monospace) !important; +} + +.fs-1 { + font-size: calc(1.375rem + 1.5vw) !important; +} + +.fs-2 { + font-size: calc(1.325rem + 0.9vw) !important; +} + +.fs-3 { + font-size: calc(1.3rem + 0.6vw) !important; +} + +.fs-4 { + font-size: calc(1.275rem + 0.3vw) !important; +} + +.fs-5 { + font-size: 1.25rem !important; +} + +.fs-6 { + font-size: 1rem !important; +} + +.fst-italic { + font-style: italic !important; +} + +.fst-normal { + font-style: normal !important; +} + +.fw-lighter { + font-weight: lighter !important; +} + +.fw-light { + font-weight: 300 !important; +} + +.fw-normal { + font-weight: 400 !important; +} + +.fw-medium { + font-weight: 500 !important; +} + +.fw-semibold { + font-weight: 600 !important; +} + +.fw-bold { + font-weight: 700 !important; +} + +.fw-bolder { + font-weight: bolder !important; +} + +.lh-1 { + line-height: 1 !important; +} + +.lh-sm { + line-height: 1.25 !important; +} + +.lh-base { + line-height: 1.5 !important; +} + +.lh-lg { + line-height: 2 !important; +} + +.text-start { + text-align: left !important; +} + +.text-end { + text-align: right !important; +} + +.text-center { + text-align: center !important; +} + +.text-decoration-none { + text-decoration: none !important; +} + +.text-decoration-underline { + text-decoration: underline !important; +} + +.text-decoration-line-through { + text-decoration: line-through !important; +} + +.text-lowercase { + text-transform: lowercase !important; +} + +.text-uppercase { + text-transform: uppercase !important; +} + +.text-capitalize { + text-transform: capitalize !important; +} + +.text-wrap { + white-space: normal !important; +} + +.text-nowrap { + white-space: nowrap !important; +} + +/* rtl:begin:remove */ +.text-break { + word-wrap: break-word !important; + word-break: break-word !important; +} + +/* rtl:end:remove */ +.text-primary { + --bs-text-opacity: 1; + color: rgba(var(--bs-primary-rgb), var(--bs-text-opacity)) !important; +} + +.text-secondary { + --bs-text-opacity: 1; + color: rgba(var(--bs-secondary-rgb), var(--bs-text-opacity)) !important; +} + +.text-success { + --bs-text-opacity: 1; + color: rgba(var(--bs-success-rgb), var(--bs-text-opacity)) !important; +} + +.text-info { + --bs-text-opacity: 1; + color: rgba(var(--bs-info-rgb), var(--bs-text-opacity)) !important; +} + +.text-warning { + --bs-text-opacity: 1; + color: rgba(var(--bs-warning-rgb), var(--bs-text-opacity)) !important; +} + +.text-danger { + --bs-text-opacity: 1; + color: rgba(var(--bs-danger-rgb), var(--bs-text-opacity)) !important; +} + +.text-light { + --bs-text-opacity: 1; + color: rgba(var(--bs-light-rgb), var(--bs-text-opacity)) !important; +} + +.text-dark { + --bs-text-opacity: 1; + color: rgba(var(--bs-dark-rgb), var(--bs-text-opacity)) !important; +} + +.text-black { + --bs-text-opacity: 1; + color: rgba(var(--bs-black-rgb), var(--bs-text-opacity)) !important; +} + +.text-white { + --bs-text-opacity: 1; + color: rgba(var(--bs-white-rgb), var(--bs-text-opacity)) !important; +} + +.text-body { + --bs-text-opacity: 1; + color: rgba(var(--bs-body-color-rgb), var(--bs-text-opacity)) !important; +} + +.text-muted { + --bs-text-opacity: 1; + color: var(--bs-secondary-color) !important; +} + +.text-black-50 { + --bs-text-opacity: 1; + color: rgba(0, 0, 0, 0.5) !important; +} + +.text-white-50 { + --bs-text-opacity: 1; + color: rgba(255, 255, 255, 0.5) !important; +} + +.text-body-secondary { + --bs-text-opacity: 1; + color: var(--bs-secondary-color) !important; +} + +.text-body-tertiary { + --bs-text-opacity: 1; + color: var(--bs-tertiary-color) !important; +} + +.text-body-emphasis { + --bs-text-opacity: 1; + color: var(--bs-emphasis-color) !important; +} + +.text-reset { + --bs-text-opacity: 1; + color: inherit !important; +} + +.text-opacity-25 { + --bs-text-opacity: 0.25; +} + +.text-opacity-50 { + --bs-text-opacity: 0.5; +} + +.text-opacity-75 { + --bs-text-opacity: 0.75; +} + +.text-opacity-100 { + --bs-text-opacity: 1; +} + +.text-primary-emphasis { + color: var(--bs-primary-text-emphasis) !important; +} + +.text-secondary-emphasis { + color: var(--bs-secondary-text-emphasis) !important; +} + +.text-success-emphasis { + color: var(--bs-success-text-emphasis) !important; +} + +.text-info-emphasis { + color: var(--bs-info-text-emphasis) !important; +} + +.text-warning-emphasis { + color: var(--bs-warning-text-emphasis) !important; +} + +.text-danger-emphasis { + color: var(--bs-danger-text-emphasis) !important; +} + +.text-light-emphasis { + color: var(--bs-light-text-emphasis) !important; +} + +.text-dark-emphasis { + color: var(--bs-dark-text-emphasis) !important; +} + +.link-opacity-10 { + --bs-link-opacity: 0.1; +} + +.link-opacity-10-hover:hover { + --bs-link-opacity: 0.1; +} + +.link-opacity-25 { + --bs-link-opacity: 0.25; +} + +.link-opacity-25-hover:hover { + --bs-link-opacity: 0.25; +} + +.link-opacity-50 { + --bs-link-opacity: 0.5; +} + +.link-opacity-50-hover:hover { + --bs-link-opacity: 0.5; +} + +.link-opacity-75 { + --bs-link-opacity: 0.75; +} + +.link-opacity-75-hover:hover { + --bs-link-opacity: 0.75; +} + +.link-opacity-100 { + --bs-link-opacity: 1; +} + +.link-opacity-100-hover:hover { + --bs-link-opacity: 1; +} + +.link-offset-1 { + text-underline-offset: 0.125em !important; +} + +.link-offset-1-hover:hover { + text-underline-offset: 0.125em !important; +} + +.link-offset-2 { + text-underline-offset: 0.25em !important; +} + +.link-offset-2-hover:hover { + text-underline-offset: 0.25em !important; +} + +.link-offset-3 { + text-underline-offset: 0.375em !important; +} + +.link-offset-3-hover:hover { + text-underline-offset: 0.375em !important; +} + +.link-underline-primary { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba( + var(--bs-primary-rgb), + var(--bs-link-underline-opacity) + ) !important; +} + +.link-underline-secondary { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba( + var(--bs-secondary-rgb), + var(--bs-link-underline-opacity) + ) !important; +} + +.link-underline-success { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba( + var(--bs-success-rgb), + var(--bs-link-underline-opacity) + ) !important; +} + +.link-underline-info { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba( + var(--bs-info-rgb), + var(--bs-link-underline-opacity) + ) !important; +} + +.link-underline-warning { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba( + var(--bs-warning-rgb), + var(--bs-link-underline-opacity) + ) !important; +} + +.link-underline-danger { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba( + var(--bs-danger-rgb), + var(--bs-link-underline-opacity) + ) !important; +} + +.link-underline-light { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba( + var(--bs-light-rgb), + var(--bs-link-underline-opacity) + ) !important; +} + +.link-underline-dark { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba( + var(--bs-dark-rgb), + var(--bs-link-underline-opacity) + ) !important; +} + +.link-underline { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba( + var(--bs-link-color-rgb), + var(--bs-link-underline-opacity, 1) + ) !important; +} + +.link-underline-opacity-0 { + --bs-link-underline-opacity: 0; +} + +.link-underline-opacity-0-hover:hover { + --bs-link-underline-opacity: 0; +} + +.link-underline-opacity-10 { + --bs-link-underline-opacity: 0.1; +} + +.link-underline-opacity-10-hover:hover { + --bs-link-underline-opacity: 0.1; +} + +.link-underline-opacity-25 { + --bs-link-underline-opacity: 0.25; +} + +.link-underline-opacity-25-hover:hover { + --bs-link-underline-opacity: 0.25; +} + +.link-underline-opacity-50 { + --bs-link-underline-opacity: 0.5; +} + +.link-underline-opacity-50-hover:hover { + --bs-link-underline-opacity: 0.5; +} + +.link-underline-opacity-75 { + --bs-link-underline-opacity: 0.75; +} + +.link-underline-opacity-75-hover:hover { + --bs-link-underline-opacity: 0.75; +} + +.link-underline-opacity-100 { + --bs-link-underline-opacity: 1; +} + +.link-underline-opacity-100-hover:hover { + --bs-link-underline-opacity: 1; +} + +.bg-primary { + --bs-bg-opacity: 1; + background-color: rgba( + var(--bs-primary-rgb), + var(--bs-bg-opacity) + ) !important; +} + +.bg-secondary { + --bs-bg-opacity: 1; + background-color: rgba( + var(--bs-secondary-rgb), + var(--bs-bg-opacity) + ) !important; +} + +.bg-success { + --bs-bg-opacity: 1; + background-color: rgba( + var(--bs-success-rgb), + var(--bs-bg-opacity) + ) !important; +} + +.bg-info { + --bs-bg-opacity: 1; + background-color: rgba(var(--bs-info-rgb), var(--bs-bg-opacity)) !important; +} + +.bg-warning { + --bs-bg-opacity: 1; + background-color: rgba( + var(--bs-warning-rgb), + var(--bs-bg-opacity) + ) !important; +} + +.bg-danger { + --bs-bg-opacity: 1; + background-color: rgba(var(--bs-danger-rgb), var(--bs-bg-opacity)) !important; +} + +.bg-light { + --bs-bg-opacity: 1; + background-color: rgba(var(--bs-light-rgb), var(--bs-bg-opacity)) !important; +} + +.bg-dark { + --bs-bg-opacity: 1; + background-color: rgba(var(--bs-dark-rgb), var(--bs-bg-opacity)) !important; +} + +.bg-black { + --bs-bg-opacity: 1; + background-color: rgba(var(--bs-black-rgb), var(--bs-bg-opacity)) !important; +} + +.bg-white { + --bs-bg-opacity: 1; + background-color: rgba(var(--bs-white-rgb), var(--bs-bg-opacity)) !important; +} + +.bg-body { + --bs-bg-opacity: 1; + background-color: rgba( + var(--bs-body-bg-rgb), + var(--bs-bg-opacity) + ) !important; +} + +.bg-transparent { + --bs-bg-opacity: 1; + background-color: transparent !important; +} + +.bg-body-secondary { + --bs-bg-opacity: 1; + background-color: rgba( + var(--bs-secondary-bg-rgb), + var(--bs-bg-opacity) + ) !important; +} + +.bg-body-tertiary { + --bs-bg-opacity: 1; + background-color: rgba( + var(--bs-tertiary-bg-rgb), + var(--bs-bg-opacity) + ) !important; +} + +.bg-opacity-10 { + --bs-bg-opacity: 0.1; +} + +.bg-opacity-25 { + --bs-bg-opacity: 0.25; +} + +.bg-opacity-50 { + --bs-bg-opacity: 0.5; +} + +.bg-opacity-75 { + --bs-bg-opacity: 0.75; +} + +.bg-opacity-100 { + --bs-bg-opacity: 1; +} + +.bg-primary-subtle { + background-color: var(--bs-primary-bg-subtle) !important; +} + +.bg-secondary-subtle { + background-color: var(--bs-secondary-bg-subtle) !important; +} + +.bg-success-subtle { + background-color: var(--bs-success-bg-subtle) !important; +} + +.bg-info-subtle { + background-color: var(--bs-info-bg-subtle) !important; +} + +.bg-warning-subtle { + background-color: var(--bs-warning-bg-subtle) !important; +} + +.bg-danger-subtle { + background-color: var(--bs-danger-bg-subtle) !important; +} + +.bg-light-subtle { + background-color: var(--bs-light-bg-subtle) !important; +} + +.bg-dark-subtle { + background-color: var(--bs-dark-bg-subtle) !important; +} + +.bg-gradient { + background-image: var(--bs-gradient) !important; +} + +.user-select-all { + user-select: all !important; +} + +.user-select-auto { + user-select: auto !important; +} + +.user-select-none { + user-select: none !important; +} + +.pe-none { + pointer-events: none !important; +} + +.pe-auto { + pointer-events: auto !important; +} + +.rounded { + border-radius: var(--bs-border-radius) !important; +} + +.rounded-0 { + border-radius: 0 !important; +} + +.rounded-1 { + border-radius: var(--bs-border-radius-sm) !important; +} + +.rounded-2 { + border-radius: var(--bs-border-radius) !important; +} + +.rounded-3 { + border-radius: var(--bs-border-radius-lg) !important; +} + +.rounded-4 { + border-radius: var(--bs-border-radius-xl) !important; +} + +.rounded-5 { + border-radius: var(--bs-border-radius-xxl) !important; +} + +.rounded-circle { + border-radius: 50% !important; +} + +.rounded-pill { + border-radius: var(--bs-border-radius-pill) !important; +} + +.rounded-top { + border-top-left-radius: var(--bs-border-radius) !important; + border-top-right-radius: var(--bs-border-radius) !important; +} + +.rounded-top-0 { + border-top-left-radius: 0 !important; + border-top-right-radius: 0 !important; +} + +.rounded-top-1 { + border-top-left-radius: var(--bs-border-radius-sm) !important; + border-top-right-radius: var(--bs-border-radius-sm) !important; +} + +.rounded-top-2 { + border-top-left-radius: var(--bs-border-radius) !important; + border-top-right-radius: var(--bs-border-radius) !important; +} + +.rounded-top-3 { + border-top-left-radius: var(--bs-border-radius-lg) !important; + border-top-right-radius: var(--bs-border-radius-lg) !important; +} + +.rounded-top-4 { + border-top-left-radius: var(--bs-border-radius-xl) !important; + border-top-right-radius: var(--bs-border-radius-xl) !important; +} + +.rounded-top-5 { + border-top-left-radius: var(--bs-border-radius-xxl) !important; + border-top-right-radius: var(--bs-border-radius-xxl) !important; +} + +.rounded-top-circle { + border-top-left-radius: 50% !important; + border-top-right-radius: 50% !important; +} + +.rounded-top-pill { + border-top-left-radius: var(--bs-border-radius-pill) !important; + border-top-right-radius: var(--bs-border-radius-pill) !important; +} + +.rounded-end { + border-top-right-radius: var(--bs-border-radius) !important; + border-bottom-right-radius: var(--bs-border-radius) !important; +} + +.rounded-end-0 { + border-top-right-radius: 0 !important; + border-bottom-right-radius: 0 !important; +} + +.rounded-end-1 { + border-top-right-radius: var(--bs-border-radius-sm) !important; + border-bottom-right-radius: var(--bs-border-radius-sm) !important; +} + +.rounded-end-2 { + border-top-right-radius: var(--bs-border-radius) !important; + border-bottom-right-radius: var(--bs-border-radius) !important; +} + +.rounded-end-3 { + border-top-right-radius: var(--bs-border-radius-lg) !important; + border-bottom-right-radius: var(--bs-border-radius-lg) !important; +} + +.rounded-end-4 { + border-top-right-radius: var(--bs-border-radius-xl) !important; + border-bottom-right-radius: var(--bs-border-radius-xl) !important; +} + +.rounded-end-5 { + border-top-right-radius: var(--bs-border-radius-xxl) !important; + border-bottom-right-radius: var(--bs-border-radius-xxl) !important; +} + +.rounded-end-circle { + border-top-right-radius: 50% !important; + border-bottom-right-radius: 50% !important; +} + +.rounded-end-pill { + border-top-right-radius: var(--bs-border-radius-pill) !important; + border-bottom-right-radius: var(--bs-border-radius-pill) !important; +} + +.rounded-bottom { + border-bottom-right-radius: var(--bs-border-radius) !important; + border-bottom-left-radius: var(--bs-border-radius) !important; +} + +.rounded-bottom-0 { + border-bottom-right-radius: 0 !important; + border-bottom-left-radius: 0 !important; +} + +.rounded-bottom-1 { + border-bottom-right-radius: var(--bs-border-radius-sm) !important; + border-bottom-left-radius: var(--bs-border-radius-sm) !important; +} + +.rounded-bottom-2 { + border-bottom-right-radius: var(--bs-border-radius) !important; + border-bottom-left-radius: var(--bs-border-radius) !important; +} + +.rounded-bottom-3 { + border-bottom-right-radius: var(--bs-border-radius-lg) !important; + border-bottom-left-radius: var(--bs-border-radius-lg) !important; +} + +.rounded-bottom-4 { + border-bottom-right-radius: var(--bs-border-radius-xl) !important; + border-bottom-left-radius: var(--bs-border-radius-xl) !important; +} + +.rounded-bottom-5 { + border-bottom-right-radius: var(--bs-border-radius-xxl) !important; + border-bottom-left-radius: var(--bs-border-radius-xxl) !important; +} + +.rounded-bottom-circle { + border-bottom-right-radius: 50% !important; + border-bottom-left-radius: 50% !important; +} + +.rounded-bottom-pill { + border-bottom-right-radius: var(--bs-border-radius-pill) !important; + border-bottom-left-radius: var(--bs-border-radius-pill) !important; +} + +.rounded-start { + border-bottom-left-radius: var(--bs-border-radius) !important; + border-top-left-radius: var(--bs-border-radius) !important; +} + +.rounded-start-0 { + border-bottom-left-radius: 0 !important; + border-top-left-radius: 0 !important; +} + +.rounded-start-1 { + border-bottom-left-radius: var(--bs-border-radius-sm) !important; + border-top-left-radius: var(--bs-border-radius-sm) !important; +} + +.rounded-start-2 { + border-bottom-left-radius: var(--bs-border-radius) !important; + border-top-left-radius: var(--bs-border-radius) !important; +} + +.rounded-start-3 { + border-bottom-left-radius: var(--bs-border-radius-lg) !important; + border-top-left-radius: var(--bs-border-radius-lg) !important; +} + +.rounded-start-4 { + border-bottom-left-radius: var(--bs-border-radius-xl) !important; + border-top-left-radius: var(--bs-border-radius-xl) !important; +} + +.rounded-start-5 { + border-bottom-left-radius: var(--bs-border-radius-xxl) !important; + border-top-left-radius: var(--bs-border-radius-xxl) !important; +} + +.rounded-start-circle { + border-bottom-left-radius: 50% !important; + border-top-left-radius: 50% !important; +} + +.rounded-start-pill { + border-bottom-left-radius: var(--bs-border-radius-pill) !important; + border-top-left-radius: var(--bs-border-radius-pill) !important; +} + +.visible { + visibility: visible !important; +} + +.invisible { + visibility: hidden !important; +} + +.z-n1 { + z-index: -1 !important; +} + +.z-0 { + z-index: 0 !important; +} + +.z-1 { + z-index: 1 !important; +} + +.z-2 { + z-index: 2 !important; +} + +.z-3 { + z-index: 3 !important; +} + +@media (min-width: 576px) { + .float-sm-start { + float: left !important; + } + + .float-sm-end { + float: right !important; + } + + .float-sm-none { + float: none !important; + } + + .object-fit-sm-contain { + object-fit: contain !important; + } + + .object-fit-sm-cover { + object-fit: cover !important; + } + + .object-fit-sm-fill { + object-fit: fill !important; + } + + .object-fit-sm-scale { + object-fit: scale-down !important; + } + + .object-fit-sm-none { + object-fit: none !important; + } + + .d-sm-inline { + display: inline !important; + } + + .d-sm-inline-block { + display: inline-block !important; + } + + .d-sm-block { + display: block !important; + } + + .d-sm-grid { + display: grid !important; + } + + .d-sm-inline-grid { + display: inline-grid !important; + } + + .d-sm-table { + display: table !important; + } + + .d-sm-table-row { + display: table-row !important; + } + + .d-sm-table-cell { + display: table-cell !important; + } + + .d-sm-flex { + display: flex !important; + } + + .d-sm-inline-flex { + display: inline-flex !important; + } + + .d-sm-none { + display: none !important; + } + + .flex-sm-fill { + flex: 1 1 auto !important; + } + + .flex-sm-row { + flex-direction: row !important; + } + + .flex-sm-column { + flex-direction: column !important; + } + + .flex-sm-row-reverse { + flex-direction: row-reverse !important; + } + + .flex-sm-column-reverse { + flex-direction: column-reverse !important; + } + + .flex-sm-grow-0 { + flex-grow: 0 !important; + } + + .flex-sm-grow-1 { + flex-grow: 1 !important; + } + + .flex-sm-shrink-0 { + flex-shrink: 0 !important; + } + + .flex-sm-shrink-1 { + flex-shrink: 1 !important; + } + + .flex-sm-wrap { + flex-wrap: wrap !important; + } + + .flex-sm-nowrap { + flex-wrap: nowrap !important; + } + + .flex-sm-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + + .justify-content-sm-start { + justify-content: flex-start !important; + } + + .justify-content-sm-end { + justify-content: flex-end !important; + } + + .justify-content-sm-center { + justify-content: center !important; + } + + .justify-content-sm-between { + justify-content: space-between !important; + } + + .justify-content-sm-around { + justify-content: space-around !important; + } + + .justify-content-sm-evenly { + justify-content: space-evenly !important; + } + + .align-items-sm-start { + align-items: flex-start !important; + } + + .align-items-sm-end { + align-items: flex-end !important; + } + + .align-items-sm-center { + align-items: center !important; + } + + .align-items-sm-baseline { + align-items: baseline !important; + } + + .align-items-sm-stretch { + align-items: stretch !important; + } + + .align-content-sm-start { + align-content: flex-start !important; + } + + .align-content-sm-end { + align-content: flex-end !important; + } + + .align-content-sm-center { + align-content: center !important; + } + + .align-content-sm-between { + align-content: space-between !important; + } + + .align-content-sm-around { + align-content: space-around !important; + } + + .align-content-sm-stretch { + align-content: stretch !important; + } + + .align-self-sm-auto { + align-self: auto !important; + } + + .align-self-sm-start { + align-self: flex-start !important; + } + + .align-self-sm-end { + align-self: flex-end !important; + } + + .align-self-sm-center { + align-self: center !important; + } + + .align-self-sm-baseline { + align-self: baseline !important; + } + + .align-self-sm-stretch { + align-self: stretch !important; + } + + .order-sm-first { + order: -1 !important; + } + + .order-sm-0 { + order: 0 !important; + } + + .order-sm-1 { + order: 1 !important; + } + + .order-sm-2 { + order: 2 !important; + } + + .order-sm-3 { + order: 3 !important; + } + + .order-sm-4 { + order: 4 !important; + } + + .order-sm-5 { + order: 5 !important; + } + + .order-sm-last { + order: 6 !important; + } + + .m-sm-0 { + margin: 0 !important; + } + + .m-sm-1 { + margin: 0.25rem !important; + } + + .m-sm-2 { + margin: 0.5rem !important; + } + + .m-sm-3 { + margin: 1rem !important; + } + + .m-sm-4 { + margin: 1.5rem !important; + } + + .m-sm-5 { + margin: 3rem !important; + } + + .m-sm-auto { + margin: auto !important; + } + + .mx-sm-0 { + margin-right: 0 !important; + margin-left: 0 !important; + } + + .mx-sm-1 { + margin-right: 0.25rem !important; + margin-left: 0.25rem !important; + } + + .mx-sm-2 { + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; + } + + .mx-sm-3 { + margin-right: 1rem !important; + margin-left: 1rem !important; + } + + .mx-sm-4 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; + } + + .mx-sm-5 { + margin-right: 3rem !important; + margin-left: 3rem !important; + } + + .mx-sm-auto { + margin-right: auto !important; + margin-left: auto !important; + } + + .my-sm-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + + .my-sm-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + + .my-sm-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + + .my-sm-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + + .my-sm-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + + .my-sm-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + + .my-sm-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + + .mt-sm-0 { + margin-top: 0 !important; + } + + .mt-sm-1 { + margin-top: 0.25rem !important; + } + + .mt-sm-2 { + margin-top: 0.5rem !important; + } + + .mt-sm-3 { + margin-top: 1rem !important; + } + + .mt-sm-4 { + margin-top: 1.5rem !important; + } + + .mt-sm-5 { + margin-top: 3rem !important; + } + + .mt-sm-auto { + margin-top: auto !important; + } + + .me-sm-0 { + margin-right: 0 !important; + } + + .me-sm-1 { + margin-right: 0.25rem !important; + } + + .me-sm-2 { + margin-right: 0.5rem !important; + } + + .me-sm-3 { + margin-right: 1rem !important; + } + + .me-sm-4 { + margin-right: 1.5rem !important; + } + + .me-sm-5 { + margin-right: 3rem !important; + } + + .me-sm-auto { + margin-right: auto !important; + } + + .mb-sm-0 { + margin-bottom: 0 !important; + } + + .mb-sm-1 { + margin-bottom: 0.25rem !important; + } + + .mb-sm-2 { + margin-bottom: 0.5rem !important; + } + + .mb-sm-3 { + margin-bottom: 1rem !important; + } + + .mb-sm-4 { + margin-bottom: 1.5rem !important; + } + + .mb-sm-5 { + margin-bottom: 3rem !important; + } + + .mb-sm-auto { + margin-bottom: auto !important; + } + + .ms-sm-0 { + margin-left: 0 !important; + } + + .ms-sm-1 { + margin-left: 0.25rem !important; + } + + .ms-sm-2 { + margin-left: 0.5rem !important; + } + + .ms-sm-3 { + margin-left: 1rem !important; + } + + .ms-sm-4 { + margin-left: 1.5rem !important; + } + + .ms-sm-5 { + margin-left: 3rem !important; + } + + .ms-sm-auto { + margin-left: auto !important; + } + + .p-sm-0 { + padding: 0 !important; + } + + .p-sm-1 { + padding: 0.25rem !important; + } + + .p-sm-2 { + padding: 0.5rem !important; + } + + .p-sm-3 { + padding: 1rem !important; + } + + .p-sm-4 { + padding: 1.5rem !important; + } + + .p-sm-5 { + padding: 3rem !important; + } + + .px-sm-0 { + padding-right: 0 !important; + padding-left: 0 !important; + } + + .px-sm-1 { + padding-right: 0.25rem !important; + padding-left: 0.25rem !important; + } + + .px-sm-2 { + padding-right: 0.5rem !important; + padding-left: 0.5rem !important; + } + + .px-sm-3 { + padding-right: 1rem !important; + padding-left: 1rem !important; + } + + .px-sm-4 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; + } + + .px-sm-5 { + padding-right: 3rem !important; + padding-left: 3rem !important; + } + + .py-sm-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + + .py-sm-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + + .py-sm-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + + .py-sm-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + + .py-sm-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + + .py-sm-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + + .pt-sm-0 { + padding-top: 0 !important; + } + + .pt-sm-1 { + padding-top: 0.25rem !important; + } + + .pt-sm-2 { + padding-top: 0.5rem !important; + } + + .pt-sm-3 { + padding-top: 1rem !important; + } + + .pt-sm-4 { + padding-top: 1.5rem !important; + } + + .pt-sm-5 { + padding-top: 3rem !important; + } + + .pe-sm-0 { + padding-right: 0 !important; + } + + .pe-sm-1 { + padding-right: 0.25rem !important; + } + + .pe-sm-2 { + padding-right: 0.5rem !important; + } + + .pe-sm-3 { + padding-right: 1rem !important; + } + + .pe-sm-4 { + padding-right: 1.5rem !important; + } + + .pe-sm-5 { + padding-right: 3rem !important; + } + + .pb-sm-0 { + padding-bottom: 0 !important; + } + + .pb-sm-1 { + padding-bottom: 0.25rem !important; + } + + .pb-sm-2 { + padding-bottom: 0.5rem !important; + } + + .pb-sm-3 { + padding-bottom: 1rem !important; + } + + .pb-sm-4 { + padding-bottom: 1.5rem !important; + } + + .pb-sm-5 { + padding-bottom: 3rem !important; + } + + .ps-sm-0 { + padding-left: 0 !important; + } + + .ps-sm-1 { + padding-left: 0.25rem !important; + } + + .ps-sm-2 { + padding-left: 0.5rem !important; + } + + .ps-sm-3 { + padding-left: 1rem !important; + } + + .ps-sm-4 { + padding-left: 1.5rem !important; + } + + .ps-sm-5 { + padding-left: 3rem !important; + } + + .gap-sm-0 { + gap: 0 !important; + } + + .gap-sm-1 { + gap: 0.25rem !important; + } + + .gap-sm-2 { + gap: 0.5rem !important; + } + + .gap-sm-3 { + gap: 1rem !important; + } + + .gap-sm-4 { + gap: 1.5rem !important; + } + + .gap-sm-5 { + gap: 3rem !important; + } + + .row-gap-sm-0 { + row-gap: 0 !important; + } + + .row-gap-sm-1 { + row-gap: 0.25rem !important; + } + + .row-gap-sm-2 { + row-gap: 0.5rem !important; + } + + .row-gap-sm-3 { + row-gap: 1rem !important; + } + + .row-gap-sm-4 { + row-gap: 1.5rem !important; + } + + .row-gap-sm-5 { + row-gap: 3rem !important; + } + + .column-gap-sm-0 { + column-gap: 0 !important; + } + + .column-gap-sm-1 { + column-gap: 0.25rem !important; + } + + .column-gap-sm-2 { + column-gap: 0.5rem !important; + } + + .column-gap-sm-3 { + column-gap: 1rem !important; + } + + .column-gap-sm-4 { + column-gap: 1.5rem !important; + } + + .column-gap-sm-5 { + column-gap: 3rem !important; + } + + .text-sm-start { + text-align: left !important; + } + + .text-sm-end { + text-align: right !important; + } + + .text-sm-center { + text-align: center !important; + } +} + +@media (min-width: 768px) { + .float-md-start { + float: left !important; + } + + .float-md-end { + float: right !important; + } + + .float-md-none { + float: none !important; + } + + .object-fit-md-contain { + object-fit: contain !important; + } + + .object-fit-md-cover { + object-fit: cover !important; + } + + .object-fit-md-fill { + object-fit: fill !important; + } + + .object-fit-md-scale { + object-fit: scale-down !important; + } + + .object-fit-md-none { + object-fit: none !important; + } + + .d-md-inline { + display: inline !important; + } + + .d-md-inline-block { + display: inline-block !important; + } + + .d-md-block { + display: block !important; + } + + .d-md-grid { + display: grid !important; + } + + .d-md-inline-grid { + display: inline-grid !important; + } + + .d-md-table { + display: table !important; + } + + .d-md-table-row { + display: table-row !important; + } + + .d-md-table-cell { + display: table-cell !important; + } + + .d-md-flex { + display: flex !important; + } + + .d-md-inline-flex { + display: inline-flex !important; + } + + .d-md-none { + display: none !important; + } + + .flex-md-fill { + flex: 1 1 auto !important; + } + + .flex-md-row { + flex-direction: row !important; + } + + .flex-md-column { + flex-direction: column !important; + } + + .flex-md-row-reverse { + flex-direction: row-reverse !important; + } + + .flex-md-column-reverse { + flex-direction: column-reverse !important; + } + + .flex-md-grow-0 { + flex-grow: 0 !important; + } + + .flex-md-grow-1 { + flex-grow: 1 !important; + } + + .flex-md-shrink-0 { + flex-shrink: 0 !important; + } + + .flex-md-shrink-1 { + flex-shrink: 1 !important; + } + + .flex-md-wrap { + flex-wrap: wrap !important; + } + + .flex-md-nowrap { + flex-wrap: nowrap !important; + } + + .flex-md-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + + .justify-content-md-start { + justify-content: flex-start !important; + } + + .justify-content-md-end { + justify-content: flex-end !important; + } + + .justify-content-md-center { + justify-content: center !important; + } + + .justify-content-md-between { + justify-content: space-between !important; + } + + .justify-content-md-around { + justify-content: space-around !important; + } + + .justify-content-md-evenly { + justify-content: space-evenly !important; + } + + .align-items-md-start { + align-items: flex-start !important; + } + + .align-items-md-end { + align-items: flex-end !important; + } + + .align-items-md-center { + align-items: center !important; + } + + .align-items-md-baseline { + align-items: baseline !important; + } + + .align-items-md-stretch { + align-items: stretch !important; + } + + .align-content-md-start { + align-content: flex-start !important; + } + + .align-content-md-end { + align-content: flex-end !important; + } + + .align-content-md-center { + align-content: center !important; + } + + .align-content-md-between { + align-content: space-between !important; + } + + .align-content-md-around { + align-content: space-around !important; + } + + .align-content-md-stretch { + align-content: stretch !important; + } + + .align-self-md-auto { + align-self: auto !important; + } + + .align-self-md-start { + align-self: flex-start !important; + } + + .align-self-md-end { + align-self: flex-end !important; + } + + .align-self-md-center { + align-self: center !important; + } + + .align-self-md-baseline { + align-self: baseline !important; + } + + .align-self-md-stretch { + align-self: stretch !important; + } + + .order-md-first { + order: -1 !important; + } + + .order-md-0 { + order: 0 !important; + } + + .order-md-1 { + order: 1 !important; + } + + .order-md-2 { + order: 2 !important; + } + + .order-md-3 { + order: 3 !important; + } + + .order-md-4 { + order: 4 !important; + } + + .order-md-5 { + order: 5 !important; + } + + .order-md-last { + order: 6 !important; + } + + .m-md-0 { + margin: 0 !important; + } + + .m-md-1 { + margin: 0.25rem !important; + } + + .m-md-2 { + margin: 0.5rem !important; + } + + .m-md-3 { + margin: 1rem !important; + } + + .m-md-4 { + margin: 1.5rem !important; + } + + .m-md-5 { + margin: 3rem !important; + } + + .m-md-auto { + margin: auto !important; + } + + .mx-md-0 { + margin-right: 0 !important; + margin-left: 0 !important; + } + + .mx-md-1 { + margin-right: 0.25rem !important; + margin-left: 0.25rem !important; + } + + .mx-md-2 { + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; + } + + .mx-md-3 { + margin-right: 1rem !important; + margin-left: 1rem !important; + } + + .mx-md-4 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; + } + + .mx-md-5 { + margin-right: 3rem !important; + margin-left: 3rem !important; + } + + .mx-md-auto { + margin-right: auto !important; + margin-left: auto !important; + } + + .my-md-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + + .my-md-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + + .my-md-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + + .my-md-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + + .my-md-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + + .my-md-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + + .my-md-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + + .mt-md-0 { + margin-top: 0 !important; + } + + .mt-md-1 { + margin-top: 0.25rem !important; + } + + .mt-md-2 { + margin-top: 0.5rem !important; + } + + .mt-md-3 { + margin-top: 1rem !important; + } + + .mt-md-4 { + margin-top: 1.5rem !important; + } + + .mt-md-5 { + margin-top: 3rem !important; + } + + .mt-md-auto { + margin-top: auto !important; + } + + .me-md-0 { + margin-right: 0 !important; + } + + .me-md-1 { + margin-right: 0.25rem !important; + } + + .me-md-2 { + margin-right: 0.5rem !important; + } + + .me-md-3 { + margin-right: 1rem !important; + } + + .me-md-4 { + margin-right: 1.5rem !important; + } + + .me-md-5 { + margin-right: 3rem !important; + } + + .me-md-auto { + margin-right: auto !important; + } + + .mb-md-0 { + margin-bottom: 0 !important; + } + + .mb-md-1 { + margin-bottom: 0.25rem !important; + } + + .mb-md-2 { + margin-bottom: 0.5rem !important; + } + + .mb-md-3 { + margin-bottom: 1rem !important; + } + + .mb-md-4 { + margin-bottom: 1.5rem !important; + } + + .mb-md-5 { + margin-bottom: 3rem !important; + } + + .mb-md-auto { + margin-bottom: auto !important; + } + + .ms-md-0 { + margin-left: 0 !important; + } + + .ms-md-1 { + margin-left: 0.25rem !important; + } + + .ms-md-2 { + margin-left: 0.5rem !important; + } + + .ms-md-3 { + margin-left: 1rem !important; + } + + .ms-md-4 { + margin-left: 1.5rem !important; + } + + .ms-md-5 { + margin-left: 3rem !important; + } + + .ms-md-auto { + margin-left: auto !important; + } + + .p-md-0 { + padding: 0 !important; + } + + .p-md-1 { + padding: 0.25rem !important; + } + + .p-md-2 { + padding: 0.5rem !important; + } + + .p-md-3 { + padding: 1rem !important; + } + + .p-md-4 { + padding: 1.5rem !important; + } + + .p-md-5 { + padding: 3rem !important; + } + + .px-md-0 { + padding-right: 0 !important; + padding-left: 0 !important; + } + + .px-md-1 { + padding-right: 0.25rem !important; + padding-left: 0.25rem !important; + } + + .px-md-2 { + padding-right: 0.5rem !important; + padding-left: 0.5rem !important; + } + + .px-md-3 { + padding-right: 1rem !important; + padding-left: 1rem !important; + } + + .px-md-4 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; + } + + .px-md-5 { + padding-right: 3rem !important; + padding-left: 3rem !important; + } + + .py-md-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + + .py-md-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + + .py-md-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + + .py-md-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + + .py-md-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + + .py-md-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + + .pt-md-0 { + padding-top: 0 !important; + } + + .pt-md-1 { + padding-top: 0.25rem !important; + } + + .pt-md-2 { + padding-top: 0.5rem !important; + } + + .pt-md-3 { + padding-top: 1rem !important; + } + + .pt-md-4 { + padding-top: 1.5rem !important; + } + + .pt-md-5 { + padding-top: 3rem !important; + } + + .pe-md-0 { + padding-right: 0 !important; + } + + .pe-md-1 { + padding-right: 0.25rem !important; + } + + .pe-md-2 { + padding-right: 0.5rem !important; + } + + .pe-md-3 { + padding-right: 1rem !important; + } + + .pe-md-4 { + padding-right: 1.5rem !important; + } + + .pe-md-5 { + padding-right: 3rem !important; + } + + .pb-md-0 { + padding-bottom: 0 !important; + } + + .pb-md-1 { + padding-bottom: 0.25rem !important; + } + + .pb-md-2 { + padding-bottom: 0.5rem !important; + } + + .pb-md-3 { + padding-bottom: 1rem !important; + } + + .pb-md-4 { + padding-bottom: 1.5rem !important; + } + + .pb-md-5 { + padding-bottom: 3rem !important; + } + + .ps-md-0 { + padding-left: 0 !important; + } + + .ps-md-1 { + padding-left: 0.25rem !important; + } + + .ps-md-2 { + padding-left: 0.5rem !important; + } + + .ps-md-3 { + padding-left: 1rem !important; + } + + .ps-md-4 { + padding-left: 1.5rem !important; + } + + .ps-md-5 { + padding-left: 3rem !important; + } + + .gap-md-0 { + gap: 0 !important; + } + + .gap-md-1 { + gap: 0.25rem !important; + } + + .gap-md-2 { + gap: 0.5rem !important; + } + + .gap-md-3 { + gap: 1rem !important; + } + + .gap-md-4 { + gap: 1.5rem !important; + } + + .gap-md-5 { + gap: 3rem !important; + } + + .row-gap-md-0 { + row-gap: 0 !important; + } + + .row-gap-md-1 { + row-gap: 0.25rem !important; + } + + .row-gap-md-2 { + row-gap: 0.5rem !important; + } + + .row-gap-md-3 { + row-gap: 1rem !important; + } + + .row-gap-md-4 { + row-gap: 1.5rem !important; + } + + .row-gap-md-5 { + row-gap: 3rem !important; + } + + .column-gap-md-0 { + column-gap: 0 !important; + } + + .column-gap-md-1 { + column-gap: 0.25rem !important; + } + + .column-gap-md-2 { + column-gap: 0.5rem !important; + } + + .column-gap-md-3 { + column-gap: 1rem !important; + } + + .column-gap-md-4 { + column-gap: 1.5rem !important; + } + + .column-gap-md-5 { + column-gap: 3rem !important; + } + + .text-md-start { + text-align: left !important; + } + + .text-md-end { + text-align: right !important; + } + + .text-md-center { + text-align: center !important; + } +} + +@media (min-width: 992px) { + .float-lg-start { + float: left !important; + } + + .float-lg-end { + float: right !important; + } + + .float-lg-none { + float: none !important; + } + + .object-fit-lg-contain { + object-fit: contain !important; + } + + .object-fit-lg-cover { + object-fit: cover !important; + } + + .object-fit-lg-fill { + object-fit: fill !important; + } + + .object-fit-lg-scale { + object-fit: scale-down !important; + } + + .object-fit-lg-none { + object-fit: none !important; + } + + .d-lg-inline { + display: inline !important; + } + + .d-lg-inline-block { + display: inline-block !important; + } + + .d-lg-block { + display: block !important; + } + + .d-lg-grid { + display: grid !important; + } + + .d-lg-inline-grid { + display: inline-grid !important; + } + + .d-lg-table { + display: table !important; + } + + .d-lg-table-row { + display: table-row !important; + } + + .d-lg-table-cell { + display: table-cell !important; + } + + .d-lg-flex { + display: flex !important; + } + + .d-lg-inline-flex { + display: inline-flex !important; + } + + .d-lg-none { + display: none !important; + } + + .flex-lg-fill { + flex: 1 1 auto !important; + } + + .flex-lg-row { + flex-direction: row !important; + } + + .flex-lg-column { + flex-direction: column !important; + } + + .flex-lg-row-reverse { + flex-direction: row-reverse !important; + } + + .flex-lg-column-reverse { + flex-direction: column-reverse !important; + } + + .flex-lg-grow-0 { + flex-grow: 0 !important; + } + + .flex-lg-grow-1 { + flex-grow: 1 !important; + } + + .flex-lg-shrink-0 { + flex-shrink: 0 !important; + } + + .flex-lg-shrink-1 { + flex-shrink: 1 !important; + } + + .flex-lg-wrap { + flex-wrap: wrap !important; + } + + .flex-lg-nowrap { + flex-wrap: nowrap !important; + } + + .flex-lg-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + + .justify-content-lg-start { + justify-content: flex-start !important; + } + + .justify-content-lg-end { + justify-content: flex-end !important; + } + + .justify-content-lg-center { + justify-content: center !important; + } + + .justify-content-lg-between { + justify-content: space-between !important; + } + + .justify-content-lg-around { + justify-content: space-around !important; + } + + .justify-content-lg-evenly { + justify-content: space-evenly !important; + } + + .align-items-lg-start { + align-items: flex-start !important; + } + + .align-items-lg-end { + align-items: flex-end !important; + } + + .align-items-lg-center { + align-items: center !important; + } + + .align-items-lg-baseline { + align-items: baseline !important; + } + + .align-items-lg-stretch { + align-items: stretch !important; + } + + .align-content-lg-start { + align-content: flex-start !important; + } + + .align-content-lg-end { + align-content: flex-end !important; + } + + .align-content-lg-center { + align-content: center !important; + } + + .align-content-lg-between { + align-content: space-between !important; + } + + .align-content-lg-around { + align-content: space-around !important; + } + + .align-content-lg-stretch { + align-content: stretch !important; + } + + .align-self-lg-auto { + align-self: auto !important; + } + + .align-self-lg-start { + align-self: flex-start !important; + } + + .align-self-lg-end { + align-self: flex-end !important; + } + + .align-self-lg-center { + align-self: center !important; + } + + .align-self-lg-baseline { + align-self: baseline !important; + } + + .align-self-lg-stretch { + align-self: stretch !important; + } + + .order-lg-first { + order: -1 !important; + } + + .order-lg-0 { + order: 0 !important; + } + + .order-lg-1 { + order: 1 !important; + } + + .order-lg-2 { + order: 2 !important; + } + + .order-lg-3 { + order: 3 !important; + } + + .order-lg-4 { + order: 4 !important; + } + + .order-lg-5 { + order: 5 !important; + } + + .order-lg-last { + order: 6 !important; + } + + .m-lg-0 { + margin: 0 !important; + } + + .m-lg-1 { + margin: 0.25rem !important; + } + + .m-lg-2 { + margin: 0.5rem !important; + } + + .m-lg-3 { + margin: 1rem !important; + } + + .m-lg-4 { + margin: 1.5rem !important; + } + + .m-lg-5 { + margin: 3rem !important; + } + + .m-lg-auto { + margin: auto !important; + } + + .mx-lg-0 { + margin-right: 0 !important; + margin-left: 0 !important; + } + + .mx-lg-1 { + margin-right: 0.25rem !important; + margin-left: 0.25rem !important; + } + + .mx-lg-2 { + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; + } + + .mx-lg-3 { + margin-right: 1rem !important; + margin-left: 1rem !important; + } + + .mx-lg-4 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; + } + + .mx-lg-5 { + margin-right: 3rem !important; + margin-left: 3rem !important; + } + + .mx-lg-auto { + margin-right: auto !important; + margin-left: auto !important; + } + + .my-lg-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + + .my-lg-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + + .my-lg-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + + .my-lg-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + + .my-lg-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + + .my-lg-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + + .my-lg-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + + .mt-lg-0 { + margin-top: 0 !important; + } + + .mt-lg-1 { + margin-top: 0.25rem !important; + } + + .mt-lg-2 { + margin-top: 0.5rem !important; + } + + .mt-lg-3 { + margin-top: 1rem !important; + } + + .mt-lg-4 { + margin-top: 1.5rem !important; + } + + .mt-lg-5 { + margin-top: 3rem !important; + } + + .mt-lg-auto { + margin-top: auto !important; + } + + .me-lg-0 { + margin-right: 0 !important; + } + + .me-lg-1 { + margin-right: 0.25rem !important; + } + + .me-lg-2 { + margin-right: 0.5rem !important; + } + + .me-lg-3 { + margin-right: 1rem !important; + } + + .me-lg-4 { + margin-right: 1.5rem !important; + } + + .me-lg-5 { + margin-right: 3rem !important; + } + + .me-lg-auto { + margin-right: auto !important; + } + + .mb-lg-0 { + margin-bottom: 0 !important; + } + + .mb-lg-1 { + margin-bottom: 0.25rem !important; + } + + .mb-lg-2 { + margin-bottom: 0.5rem !important; + } + + .mb-lg-3 { + margin-bottom: 1rem !important; + } + + .mb-lg-4 { + margin-bottom: 1.5rem !important; + } + + .mb-lg-5 { + margin-bottom: 3rem !important; + } + + .mb-lg-auto { + margin-bottom: auto !important; + } + + .ms-lg-0 { + margin-left: 0 !important; + } + + .ms-lg-1 { + margin-left: 0.25rem !important; + } + + .ms-lg-2 { + margin-left: 0.5rem !important; + } + + .ms-lg-3 { + margin-left: 1rem !important; + } + + .ms-lg-4 { + margin-left: 1.5rem !important; + } + + .ms-lg-5 { + margin-left: 3rem !important; + } + + .ms-lg-auto { + margin-left: auto !important; + } + + .p-lg-0 { + padding: 0 !important; + } + + .p-lg-1 { + padding: 0.25rem !important; + } + + .p-lg-2 { + padding: 0.5rem !important; + } + + .p-lg-3 { + padding: 1rem !important; + } + + .p-lg-4 { + padding: 1.5rem !important; + } + + .p-lg-5 { + padding: 3rem !important; + } + + .px-lg-0 { + padding-right: 0 !important; + padding-left: 0 !important; + } + + .px-lg-1 { + padding-right: 0.25rem !important; + padding-left: 0.25rem !important; + } + + .px-lg-2 { + padding-right: 0.5rem !important; + padding-left: 0.5rem !important; + } + + .px-lg-3 { + padding-right: 1rem !important; + padding-left: 1rem !important; + } + + .px-lg-4 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; + } + + .px-lg-5 { + padding-right: 3rem !important; + padding-left: 3rem !important; + } + + .py-lg-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + + .py-lg-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + + .py-lg-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + + .py-lg-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + + .py-lg-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + + .py-lg-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + + .pt-lg-0 { + padding-top: 0 !important; + } + + .pt-lg-1 { + padding-top: 0.25rem !important; + } + + .pt-lg-2 { + padding-top: 0.5rem !important; + } + + .pt-lg-3 { + padding-top: 1rem !important; + } + + .pt-lg-4 { + padding-top: 1.5rem !important; + } + + .pt-lg-5 { + padding-top: 3rem !important; + } + + .pe-lg-0 { + padding-right: 0 !important; + } + + .pe-lg-1 { + padding-right: 0.25rem !important; + } + + .pe-lg-2 { + padding-right: 0.5rem !important; + } + + .pe-lg-3 { + padding-right: 1rem !important; + } + + .pe-lg-4 { + padding-right: 1.5rem !important; + } + + .pe-lg-5 { + padding-right: 3rem !important; + } + + .pb-lg-0 { + padding-bottom: 0 !important; + } + + .pb-lg-1 { + padding-bottom: 0.25rem !important; + } + + .pb-lg-2 { + padding-bottom: 0.5rem !important; + } + + .pb-lg-3 { + padding-bottom: 1rem !important; + } + + .pb-lg-4 { + padding-bottom: 1.5rem !important; + } + + .pb-lg-5 { + padding-bottom: 3rem !important; + } + + .ps-lg-0 { + padding-left: 0 !important; + } + + .ps-lg-1 { + padding-left: 0.25rem !important; + } + + .ps-lg-2 { + padding-left: 0.5rem !important; + } + + .ps-lg-3 { + padding-left: 1rem !important; + } + + .ps-lg-4 { + padding-left: 1.5rem !important; + } + + .ps-lg-5 { + padding-left: 3rem !important; + } + + .gap-lg-0 { + gap: 0 !important; + } + + .gap-lg-1 { + gap: 0.25rem !important; + } + + .gap-lg-2 { + gap: 0.5rem !important; + } + + .gap-lg-3 { + gap: 1rem !important; + } + + .gap-lg-4 { + gap: 1.5rem !important; + } + + .gap-lg-5 { + gap: 3rem !important; + } + + .row-gap-lg-0 { + row-gap: 0 !important; + } + + .row-gap-lg-1 { + row-gap: 0.25rem !important; + } + + .row-gap-lg-2 { + row-gap: 0.5rem !important; + } + + .row-gap-lg-3 { + row-gap: 1rem !important; + } + + .row-gap-lg-4 { + row-gap: 1.5rem !important; + } + + .row-gap-lg-5 { + row-gap: 3rem !important; + } + + .column-gap-lg-0 { + column-gap: 0 !important; + } + + .column-gap-lg-1 { + column-gap: 0.25rem !important; + } + + .column-gap-lg-2 { + column-gap: 0.5rem !important; + } + + .column-gap-lg-3 { + column-gap: 1rem !important; + } + + .column-gap-lg-4 { + column-gap: 1.5rem !important; + } + + .column-gap-lg-5 { + column-gap: 3rem !important; + } + + .text-lg-start { + text-align: left !important; + } + + .text-lg-end { + text-align: right !important; + } + + .text-lg-center { + text-align: center !important; + } +} + +@media (min-width: 1200px) { + .float-xl-start { + float: left !important; + } + + .float-xl-end { + float: right !important; + } + + .float-xl-none { + float: none !important; + } + + .object-fit-xl-contain { + object-fit: contain !important; + } + + .object-fit-xl-cover { + object-fit: cover !important; + } + + .object-fit-xl-fill { + object-fit: fill !important; + } + + .object-fit-xl-scale { + object-fit: scale-down !important; + } + + .object-fit-xl-none { + object-fit: none !important; + } + + .d-xl-inline { + display: inline !important; + } + + .d-xl-inline-block { + display: inline-block !important; + } + + .d-xl-block { + display: block !important; + } + + .d-xl-grid { + display: grid !important; + } + + .d-xl-inline-grid { + display: inline-grid !important; + } + + .d-xl-table { + display: table !important; + } + + .d-xl-table-row { + display: table-row !important; + } + + .d-xl-table-cell { + display: table-cell !important; + } + + .d-xl-flex { + display: flex !important; + } + + .d-xl-inline-flex { + display: inline-flex !important; + } + + .d-xl-none { + display: none !important; + } + + .flex-xl-fill { + flex: 1 1 auto !important; + } + + .flex-xl-row { + flex-direction: row !important; + } + + .flex-xl-column { + flex-direction: column !important; + } + + .flex-xl-row-reverse { + flex-direction: row-reverse !important; + } + + .flex-xl-column-reverse { + flex-direction: column-reverse !important; + } + + .flex-xl-grow-0 { + flex-grow: 0 !important; + } + + .flex-xl-grow-1 { + flex-grow: 1 !important; + } + + .flex-xl-shrink-0 { + flex-shrink: 0 !important; + } + + .flex-xl-shrink-1 { + flex-shrink: 1 !important; + } + + .flex-xl-wrap { + flex-wrap: wrap !important; + } + + .flex-xl-nowrap { + flex-wrap: nowrap !important; + } + + .flex-xl-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + + .justify-content-xl-start { + justify-content: flex-start !important; + } + + .justify-content-xl-end { + justify-content: flex-end !important; + } + + .justify-content-xl-center { + justify-content: center !important; + } + + .justify-content-xl-between { + justify-content: space-between !important; + } + + .justify-content-xl-around { + justify-content: space-around !important; + } + + .justify-content-xl-evenly { + justify-content: space-evenly !important; + } + + .align-items-xl-start { + align-items: flex-start !important; + } + + .align-items-xl-end { + align-items: flex-end !important; + } + + .align-items-xl-center { + align-items: center !important; + } + + .align-items-xl-baseline { + align-items: baseline !important; + } + + .align-items-xl-stretch { + align-items: stretch !important; + } + + .align-content-xl-start { + align-content: flex-start !important; + } + + .align-content-xl-end { + align-content: flex-end !important; + } + + .align-content-xl-center { + align-content: center !important; + } + + .align-content-xl-between { + align-content: space-between !important; + } + + .align-content-xl-around { + align-content: space-around !important; + } + + .align-content-xl-stretch { + align-content: stretch !important; + } + + .align-self-xl-auto { + align-self: auto !important; + } + + .align-self-xl-start { + align-self: flex-start !important; + } + + .align-self-xl-end { + align-self: flex-end !important; + } + + .align-self-xl-center { + align-self: center !important; + } + + .align-self-xl-baseline { + align-self: baseline !important; + } + + .align-self-xl-stretch { + align-self: stretch !important; + } + + .order-xl-first { + order: -1 !important; + } + + .order-xl-0 { + order: 0 !important; + } + + .order-xl-1 { + order: 1 !important; + } + + .order-xl-2 { + order: 2 !important; + } + + .order-xl-3 { + order: 3 !important; + } + + .order-xl-4 { + order: 4 !important; + } + + .order-xl-5 { + order: 5 !important; + } + + .order-xl-last { + order: 6 !important; + } + + .m-xl-0 { + margin: 0 !important; + } + + .m-xl-1 { + margin: 0.25rem !important; + } + + .m-xl-2 { + margin: 0.5rem !important; + } + + .m-xl-3 { + margin: 1rem !important; + } + + .m-xl-4 { + margin: 1.5rem !important; + } + + .m-xl-5 { + margin: 3rem !important; + } + + .m-xl-auto { + margin: auto !important; + } + + .mx-xl-0 { + margin-right: 0 !important; + margin-left: 0 !important; + } + + .mx-xl-1 { + margin-right: 0.25rem !important; + margin-left: 0.25rem !important; + } + + .mx-xl-2 { + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; + } + + .mx-xl-3 { + margin-right: 1rem !important; + margin-left: 1rem !important; + } + + .mx-xl-4 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; + } + + .mx-xl-5 { + margin-right: 3rem !important; + margin-left: 3rem !important; + } + + .mx-xl-auto { + margin-right: auto !important; + margin-left: auto !important; + } + + .my-xl-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + + .my-xl-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + + .my-xl-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + + .my-xl-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + + .my-xl-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + + .my-xl-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + + .my-xl-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + + .mt-xl-0 { + margin-top: 0 !important; + } + + .mt-xl-1 { + margin-top: 0.25rem !important; + } + + .mt-xl-2 { + margin-top: 0.5rem !important; + } + + .mt-xl-3 { + margin-top: 1rem !important; + } + + .mt-xl-4 { + margin-top: 1.5rem !important; + } + + .mt-xl-5 { + margin-top: 3rem !important; + } + + .mt-xl-auto { + margin-top: auto !important; + } + + .me-xl-0 { + margin-right: 0 !important; + } + + .me-xl-1 { + margin-right: 0.25rem !important; + } + + .me-xl-2 { + margin-right: 0.5rem !important; + } + + .me-xl-3 { + margin-right: 1rem !important; + } + + .me-xl-4 { + margin-right: 1.5rem !important; + } + + .me-xl-5 { + margin-right: 3rem !important; + } + + .me-xl-auto { + margin-right: auto !important; + } + + .mb-xl-0 { + margin-bottom: 0 !important; + } + + .mb-xl-1 { + margin-bottom: 0.25rem !important; + } + + .mb-xl-2 { + margin-bottom: 0.5rem !important; + } + + .mb-xl-3 { + margin-bottom: 1rem !important; + } + + .mb-xl-4 { + margin-bottom: 1.5rem !important; + } + + .mb-xl-5 { + margin-bottom: 3rem !important; + } + + .mb-xl-auto { + margin-bottom: auto !important; + } + + .ms-xl-0 { + margin-left: 0 !important; + } + + .ms-xl-1 { + margin-left: 0.25rem !important; + } + + .ms-xl-2 { + margin-left: 0.5rem !important; + } + + .ms-xl-3 { + margin-left: 1rem !important; + } + + .ms-xl-4 { + margin-left: 1.5rem !important; + } + + .ms-xl-5 { + margin-left: 3rem !important; + } + + .ms-xl-auto { + margin-left: auto !important; + } + + .p-xl-0 { + padding: 0 !important; + } + + .p-xl-1 { + padding: 0.25rem !important; + } + + .p-xl-2 { + padding: 0.5rem !important; + } + + .p-xl-3 { + padding: 1rem !important; + } + + .p-xl-4 { + padding: 1.5rem !important; + } + + .p-xl-5 { + padding: 3rem !important; + } + + .px-xl-0 { + padding-right: 0 !important; + padding-left: 0 !important; + } + + .px-xl-1 { + padding-right: 0.25rem !important; + padding-left: 0.25rem !important; + } + + .px-xl-2 { + padding-right: 0.5rem !important; + padding-left: 0.5rem !important; + } + + .px-xl-3 { + padding-right: 1rem !important; + padding-left: 1rem !important; + } + + .px-xl-4 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; + } + + .px-xl-5 { + padding-right: 3rem !important; + padding-left: 3rem !important; + } + + .py-xl-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + + .py-xl-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + + .py-xl-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + + .py-xl-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + + .py-xl-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + + .py-xl-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + + .pt-xl-0 { + padding-top: 0 !important; + } + + .pt-xl-1 { + padding-top: 0.25rem !important; + } + + .pt-xl-2 { + padding-top: 0.5rem !important; + } + + .pt-xl-3 { + padding-top: 1rem !important; + } + + .pt-xl-4 { + padding-top: 1.5rem !important; + } + + .pt-xl-5 { + padding-top: 3rem !important; + } + + .pe-xl-0 { + padding-right: 0 !important; + } + + .pe-xl-1 { + padding-right: 0.25rem !important; + } + + .pe-xl-2 { + padding-right: 0.5rem !important; + } + + .pe-xl-3 { + padding-right: 1rem !important; + } + + .pe-xl-4 { + padding-right: 1.5rem !important; + } + + .pe-xl-5 { + padding-right: 3rem !important; + } + + .pb-xl-0 { + padding-bottom: 0 !important; + } + + .pb-xl-1 { + padding-bottom: 0.25rem !important; + } + + .pb-xl-2 { + padding-bottom: 0.5rem !important; + } + + .pb-xl-3 { + padding-bottom: 1rem !important; + } + + .pb-xl-4 { + padding-bottom: 1.5rem !important; + } + + .pb-xl-5 { + padding-bottom: 3rem !important; + } + + .ps-xl-0 { + padding-left: 0 !important; + } + + .ps-xl-1 { + padding-left: 0.25rem !important; + } + + .ps-xl-2 { + padding-left: 0.5rem !important; + } + + .ps-xl-3 { + padding-left: 1rem !important; + } + + .ps-xl-4 { + padding-left: 1.5rem !important; + } + + .ps-xl-5 { + padding-left: 3rem !important; + } + + .gap-xl-0 { + gap: 0 !important; + } + + .gap-xl-1 { + gap: 0.25rem !important; + } + + .gap-xl-2 { + gap: 0.5rem !important; + } + + .gap-xl-3 { + gap: 1rem !important; + } + + .gap-xl-4 { + gap: 1.5rem !important; + } + + .gap-xl-5 { + gap: 3rem !important; + } + + .row-gap-xl-0 { + row-gap: 0 !important; + } + + .row-gap-xl-1 { + row-gap: 0.25rem !important; + } + + .row-gap-xl-2 { + row-gap: 0.5rem !important; + } + + .row-gap-xl-3 { + row-gap: 1rem !important; + } + + .row-gap-xl-4 { + row-gap: 1.5rem !important; + } + + .row-gap-xl-5 { + row-gap: 3rem !important; + } + + .column-gap-xl-0 { + column-gap: 0 !important; + } + + .column-gap-xl-1 { + column-gap: 0.25rem !important; + } + + .column-gap-xl-2 { + column-gap: 0.5rem !important; + } + + .column-gap-xl-3 { + column-gap: 1rem !important; + } + + .column-gap-xl-4 { + column-gap: 1.5rem !important; + } + + .column-gap-xl-5 { + column-gap: 3rem !important; + } + + .text-xl-start { + text-align: left !important; + } + + .text-xl-end { + text-align: right !important; + } + + .text-xl-center { + text-align: center !important; + } +} + +@media (min-width: 1400px) { + .float-xxl-start { + float: left !important; + } + + .float-xxl-end { + float: right !important; + } + + .float-xxl-none { + float: none !important; + } + + .object-fit-xxl-contain { + object-fit: contain !important; + } + + .object-fit-xxl-cover { + object-fit: cover !important; + } + + .object-fit-xxl-fill { + object-fit: fill !important; + } + + .object-fit-xxl-scale { + object-fit: scale-down !important; + } + + .object-fit-xxl-none { + object-fit: none !important; + } + + .d-xxl-inline { + display: inline !important; + } + + .d-xxl-inline-block { + display: inline-block !important; + } + + .d-xxl-block { + display: block !important; + } + + .d-xxl-grid { + display: grid !important; + } + + .d-xxl-inline-grid { + display: inline-grid !important; + } + + .d-xxl-table { + display: table !important; + } + + .d-xxl-table-row { + display: table-row !important; + } + + .d-xxl-table-cell { + display: table-cell !important; + } + + .d-xxl-flex { + display: flex !important; + } + + .d-xxl-inline-flex { + display: inline-flex !important; + } + + .d-xxl-none { + display: none !important; + } + + .flex-xxl-fill { + flex: 1 1 auto !important; + } + + .flex-xxl-row { + flex-direction: row !important; + } + + .flex-xxl-column { + flex-direction: column !important; + } + + .flex-xxl-row-reverse { + flex-direction: row-reverse !important; + } + + .flex-xxl-column-reverse { + flex-direction: column-reverse !important; + } + + .flex-xxl-grow-0 { + flex-grow: 0 !important; + } + + .flex-xxl-grow-1 { + flex-grow: 1 !important; + } + + .flex-xxl-shrink-0 { + flex-shrink: 0 !important; + } + + .flex-xxl-shrink-1 { + flex-shrink: 1 !important; + } + + .flex-xxl-wrap { + flex-wrap: wrap !important; + } + + .flex-xxl-nowrap { + flex-wrap: nowrap !important; + } + + .flex-xxl-wrap-reverse { + flex-wrap: wrap-reverse !important; + } + + .justify-content-xxl-start { + justify-content: flex-start !important; + } + + .justify-content-xxl-end { + justify-content: flex-end !important; + } + + .justify-content-xxl-center { + justify-content: center !important; + } + + .justify-content-xxl-between { + justify-content: space-between !important; + } + + .justify-content-xxl-around { + justify-content: space-around !important; + } + + .justify-content-xxl-evenly { + justify-content: space-evenly !important; + } + + .align-items-xxl-start { + align-items: flex-start !important; + } + + .align-items-xxl-end { + align-items: flex-end !important; + } + + .align-items-xxl-center { + align-items: center !important; + } + + .align-items-xxl-baseline { + align-items: baseline !important; + } + + .align-items-xxl-stretch { + align-items: stretch !important; + } + + .align-content-xxl-start { + align-content: flex-start !important; + } + + .align-content-xxl-end { + align-content: flex-end !important; + } + + .align-content-xxl-center { + align-content: center !important; + } + + .align-content-xxl-between { + align-content: space-between !important; + } + + .align-content-xxl-around { + align-content: space-around !important; + } + + .align-content-xxl-stretch { + align-content: stretch !important; + } + + .align-self-xxl-auto { + align-self: auto !important; + } + + .align-self-xxl-start { + align-self: flex-start !important; + } + + .align-self-xxl-end { + align-self: flex-end !important; + } + + .align-self-xxl-center { + align-self: center !important; + } + + .align-self-xxl-baseline { + align-self: baseline !important; + } + + .align-self-xxl-stretch { + align-self: stretch !important; + } + + .order-xxl-first { + order: -1 !important; + } + + .order-xxl-0 { + order: 0 !important; + } + + .order-xxl-1 { + order: 1 !important; + } + + .order-xxl-2 { + order: 2 !important; + } + + .order-xxl-3 { + order: 3 !important; + } + + .order-xxl-4 { + order: 4 !important; + } + + .order-xxl-5 { + order: 5 !important; + } + + .order-xxl-last { + order: 6 !important; + } + + .m-xxl-0 { + margin: 0 !important; + } + + .m-xxl-1 { + margin: 0.25rem !important; + } + + .m-xxl-2 { + margin: 0.5rem !important; + } + + .m-xxl-3 { + margin: 1rem !important; + } + + .m-xxl-4 { + margin: 1.5rem !important; + } + + .m-xxl-5 { + margin: 3rem !important; + } + + .m-xxl-auto { + margin: auto !important; + } + + .mx-xxl-0 { + margin-right: 0 !important; + margin-left: 0 !important; + } + + .mx-xxl-1 { + margin-right: 0.25rem !important; + margin-left: 0.25rem !important; + } + + .mx-xxl-2 { + margin-right: 0.5rem !important; + margin-left: 0.5rem !important; + } + + .mx-xxl-3 { + margin-right: 1rem !important; + margin-left: 1rem !important; + } + + .mx-xxl-4 { + margin-right: 1.5rem !important; + margin-left: 1.5rem !important; + } + + .mx-xxl-5 { + margin-right: 3rem !important; + margin-left: 3rem !important; + } + + .mx-xxl-auto { + margin-right: auto !important; + margin-left: auto !important; + } + + .my-xxl-0 { + margin-top: 0 !important; + margin-bottom: 0 !important; + } + + .my-xxl-1 { + margin-top: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + + .my-xxl-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.5rem !important; + } + + .my-xxl-3 { + margin-top: 1rem !important; + margin-bottom: 1rem !important; + } + + .my-xxl-4 { + margin-top: 1.5rem !important; + margin-bottom: 1.5rem !important; + } + + .my-xxl-5 { + margin-top: 3rem !important; + margin-bottom: 3rem !important; + } + + .my-xxl-auto { + margin-top: auto !important; + margin-bottom: auto !important; + } + + .mt-xxl-0 { + margin-top: 0 !important; + } + + .mt-xxl-1 { + margin-top: 0.25rem !important; + } + + .mt-xxl-2 { + margin-top: 0.5rem !important; + } + + .mt-xxl-3 { + margin-top: 1rem !important; + } + + .mt-xxl-4 { + margin-top: 1.5rem !important; + } + + .mt-xxl-5 { + margin-top: 3rem !important; + } + + .mt-xxl-auto { + margin-top: auto !important; + } + + .me-xxl-0 { + margin-right: 0 !important; + } + + .me-xxl-1 { + margin-right: 0.25rem !important; + } + + .me-xxl-2 { + margin-right: 0.5rem !important; + } + + .me-xxl-3 { + margin-right: 1rem !important; + } + + .me-xxl-4 { + margin-right: 1.5rem !important; + } + + .me-xxl-5 { + margin-right: 3rem !important; + } + + .me-xxl-auto { + margin-right: auto !important; + } + + .mb-xxl-0 { + margin-bottom: 0 !important; + } + + .mb-xxl-1 { + margin-bottom: 0.25rem !important; + } + + .mb-xxl-2 { + margin-bottom: 0.5rem !important; + } + + .mb-xxl-3 { + margin-bottom: 1rem !important; + } + + .mb-xxl-4 { + margin-bottom: 1.5rem !important; + } + + .mb-xxl-5 { + margin-bottom: 3rem !important; + } + + .mb-xxl-auto { + margin-bottom: auto !important; + } + + .ms-xxl-0 { + margin-left: 0 !important; + } + + .ms-xxl-1 { + margin-left: 0.25rem !important; + } + + .ms-xxl-2 { + margin-left: 0.5rem !important; + } + + .ms-xxl-3 { + margin-left: 1rem !important; + } + + .ms-xxl-4 { + margin-left: 1.5rem !important; + } + + .ms-xxl-5 { + margin-left: 3rem !important; + } + + .ms-xxl-auto { + margin-left: auto !important; + } + + .p-xxl-0 { + padding: 0 !important; + } + + .p-xxl-1 { + padding: 0.25rem !important; + } + + .p-xxl-2 { + padding: 0.5rem !important; + } + + .p-xxl-3 { + padding: 1rem !important; + } + + .p-xxl-4 { + padding: 1.5rem !important; + } + + .p-xxl-5 { + padding: 3rem !important; + } + + .px-xxl-0 { + padding-right: 0 !important; + padding-left: 0 !important; + } + + .px-xxl-1 { + padding-right: 0.25rem !important; + padding-left: 0.25rem !important; + } + + .px-xxl-2 { + padding-right: 0.5rem !important; + padding-left: 0.5rem !important; + } + + .px-xxl-3 { + padding-right: 1rem !important; + padding-left: 1rem !important; + } + + .px-xxl-4 { + padding-right: 1.5rem !important; + padding-left: 1.5rem !important; + } + + .px-xxl-5 { + padding-right: 3rem !important; + padding-left: 3rem !important; + } + + .py-xxl-0 { + padding-top: 0 !important; + padding-bottom: 0 !important; + } + + .py-xxl-1 { + padding-top: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + + .py-xxl-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + } + + .py-xxl-3 { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + } + + .py-xxl-4 { + padding-top: 1.5rem !important; + padding-bottom: 1.5rem !important; + } + + .py-xxl-5 { + padding-top: 3rem !important; + padding-bottom: 3rem !important; + } + + .pt-xxl-0 { + padding-top: 0 !important; + } + + .pt-xxl-1 { + padding-top: 0.25rem !important; + } + + .pt-xxl-2 { + padding-top: 0.5rem !important; + } + + .pt-xxl-3 { + padding-top: 1rem !important; + } + + .pt-xxl-4 { + padding-top: 1.5rem !important; + } + + .pt-xxl-5 { + padding-top: 3rem !important; + } + + .pe-xxl-0 { + padding-right: 0 !important; + } + + .pe-xxl-1 { + padding-right: 0.25rem !important; + } + + .pe-xxl-2 { + padding-right: 0.5rem !important; + } + + .pe-xxl-3 { + padding-right: 1rem !important; + } + + .pe-xxl-4 { + padding-right: 1.5rem !important; + } + + .pe-xxl-5 { + padding-right: 3rem !important; + } + + .pb-xxl-0 { + padding-bottom: 0 !important; + } + + .pb-xxl-1 { + padding-bottom: 0.25rem !important; + } + + .pb-xxl-2 { + padding-bottom: 0.5rem !important; + } + + .pb-xxl-3 { + padding-bottom: 1rem !important; + } + + .pb-xxl-4 { + padding-bottom: 1.5rem !important; + } + + .pb-xxl-5 { + padding-bottom: 3rem !important; + } + + .ps-xxl-0 { + padding-left: 0 !important; + } + + .ps-xxl-1 { + padding-left: 0.25rem !important; + } + + .ps-xxl-2 { + padding-left: 0.5rem !important; + } + + .ps-xxl-3 { + padding-left: 1rem !important; + } + + .ps-xxl-4 { + padding-left: 1.5rem !important; + } + + .ps-xxl-5 { + padding-left: 3rem !important; + } + + .gap-xxl-0 { + gap: 0 !important; + } + + .gap-xxl-1 { + gap: 0.25rem !important; + } + + .gap-xxl-2 { + gap: 0.5rem !important; + } + + .gap-xxl-3 { + gap: 1rem !important; + } + + .gap-xxl-4 { + gap: 1.5rem !important; + } + + .gap-xxl-5 { + gap: 3rem !important; + } + + .row-gap-xxl-0 { + row-gap: 0 !important; + } + + .row-gap-xxl-1 { + row-gap: 0.25rem !important; + } + + .row-gap-xxl-2 { + row-gap: 0.5rem !important; + } + + .row-gap-xxl-3 { + row-gap: 1rem !important; + } + + .row-gap-xxl-4 { + row-gap: 1.5rem !important; + } + + .row-gap-xxl-5 { + row-gap: 3rem !important; + } + + .column-gap-xxl-0 { + column-gap: 0 !important; + } + + .column-gap-xxl-1 { + column-gap: 0.25rem !important; + } + + .column-gap-xxl-2 { + column-gap: 0.5rem !important; + } + + .column-gap-xxl-3 { + column-gap: 1rem !important; + } + + .column-gap-xxl-4 { + column-gap: 1.5rem !important; + } + + .column-gap-xxl-5 { + column-gap: 3rem !important; + } + + .text-xxl-start { + text-align: left !important; + } + + .text-xxl-end { + text-align: right !important; + } + + .text-xxl-center { + text-align: center !important; + } +} + +@media (min-width: 1200px) { + .fs-1 { + font-size: 2.5rem !important; + } + + .fs-2 { + font-size: 2rem !important; + } + + .fs-3 { + font-size: 1.75rem !important; + } + + .fs-4 { + font-size: 1.5rem !important; + } +} + +@media print { + .d-print-inline { + display: inline !important; + } + + .d-print-inline-block { + display: inline-block !important; + } + + .d-print-block { + display: block !important; + } + + .d-print-grid { + display: grid !important; + } + + .d-print-inline-grid { + display: inline-grid !important; + } + + .d-print-table { + display: table !important; + } + + .d-print-table-row { + display: table-row !important; + } + + .d-print-table-cell { + display: table-cell !important; + } + + .d-print-flex { + display: flex !important; + } + + .d-print-inline-flex { + display: inline-flex !important; + } + + .d-print-none { + display: none !important; + } +} + +/* + TALAWA SCSS + ----------- + This file is used to import all partial scss files in the project. + It is used to compile the final CSS file to the CSS folder as main.css . + + ========= Table of Contents ========= + 1. Components + 2. Content + 3. Forms + 4. Utilities + 5. General + 6. Colors + + */ +/* + + 1. COMPONENTS + + */ +.btn-primary, +.btn-secondary, +.btn-success, +.btn-warning, +.btn-info { + color: #fff; +} + +.btn-primary:hover, +.btn-primary:active, +.btn-secondary:hover, +.btn-secondary:active, +.btn-success:hover, +.btn-success:active, +.btn-warning:hover, +.btn-warning:active, +.btn-info:hover, +.btn-info:active { + color: #fff !important; +} + +.btn-outline-primary:hover, +.btn-outline-primary:active, +.btn-outline-secondary:hover, +.btn-outline-secondary:active, +.btn-outline-success:hover, +.btn-outline-success:active, +.btn-outline-warning:hover, +.btn-outline-warning:active, +.btn-outline-info:hover, +.btn-outline-info:active { + color: #fff !important; +} + +@keyframes progress-bar-stripes { + 0% { + background-position-x: 1rem; + } +} + +@keyframes spinner-border { + to { + transform: rotate(360deg) /* rtl:ignore */; + } +} + +@keyframes spinner-grow { + 0% { + transform: scale(0); + } + + 50% { + opacity: 1; + transform: none; + } +} + +/* + + 2. CONTENT + + */ +/* + DISPLAY SASS VARIABLES + */ +/* + DISPLAY SASS VARIABLES + */ +/* + + 3. FORMS + + */ +/* + + 4. UTILITIES + + */ +/* + + 5. General + + */ +:root { + --bs-body-font-family: Arial, Helvetica, sans-serif; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + overflow-x: hidden; +} + +body { + background-color: var(--bs-body-bg); +} + +#root { + min-height: 100vh; + background-color: #f2f7ff; +} + +input[type='checkbox'] { + transform: scale(1.5); +} + +.form-switch { + padding-left: 3rem; +} + +input[type='file']::file-selector-button { + background: var(--bs-gray-400); +} + +.shimmer { + animation-duration: 2.2s; + animation-fill-mode: forwards; + animation-iteration-count: infinite; + animation-name: shimmer; + animation-timing-function: linear; + background: var(--bs-gray-200); + background: linear-gradient(to right, #f6f6f6 8%, #f0f0f0 18%, #f6f6f6 33%); + background-size: 1200px 100%; +} + +@-webkit-keyframes shimmer { + 0% { + background-position: -100% 0; + } + + 100% { + background-position: 100% 0; + } +} + +@keyframes shimmer { + 0% { + background-position: -1200px 0; + } + + 100% { + background-position: 1200px 0; + } +} + +/* + + 6. COLORS + +*/ + +/*# sourceMappingURL=app.css.map */ diff --git a/src/assets/css/app.css.map b/src/assets/css/app.css.map new file mode 100644 index 0000000000..00a8ba5443 --- /dev/null +++ b/src/assets/css/app.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["../../../node_modules/bootstrap/scss/mixins/_banner.scss","../../../node_modules/bootstrap/scss/_root.scss","../../../node_modules/bootstrap/scss/vendor/_rfs.scss","../../../node_modules/bootstrap/scss/mixins/_color-mode.scss","../../../node_modules/bootstrap/scss/_reboot.scss","../../../node_modules/bootstrap/scss/_variables.scss","../scss/_variables.scss","../../../node_modules/bootstrap/scss/mixins/_border-radius.scss","../../../node_modules/bootstrap/scss/_type.scss","../../../node_modules/bootstrap/scss/mixins/_lists.scss","../../../node_modules/bootstrap/scss/_images.scss","../../../node_modules/bootstrap/scss/mixins/_image.scss","../../../node_modules/bootstrap/scss/_containers.scss","../../../node_modules/bootstrap/scss/mixins/_container.scss","../../../node_modules/bootstrap/scss/mixins/_breakpoints.scss","../../../node_modules/bootstrap/scss/_grid.scss","../../../node_modules/bootstrap/scss/mixins/_grid.scss","../../../node_modules/bootstrap/scss/_tables.scss","../../../node_modules/bootstrap/scss/mixins/_table-variants.scss","../../../node_modules/bootstrap/scss/forms/_labels.scss","../../../node_modules/bootstrap/scss/forms/_form-text.scss","../../../node_modules/bootstrap/scss/forms/_form-control.scss","../../../node_modules/bootstrap/scss/mixins/_transition.scss","../../../node_modules/bootstrap/scss/mixins/_gradients.scss","../../../node_modules/bootstrap/scss/forms/_form-select.scss","../../../node_modules/bootstrap/scss/forms/_form-check.scss","../../../node_modules/bootstrap/scss/forms/_form-range.scss","../../../node_modules/bootstrap/scss/forms/_floating-labels.scss","../../../node_modules/bootstrap/scss/forms/_input-group.scss","../../../node_modules/bootstrap/scss/mixins/_forms.scss","../../../node_modules/bootstrap/scss/_buttons.scss","../../../node_modules/bootstrap/scss/mixins/_buttons.scss","../../../node_modules/bootstrap/scss/_transitions.scss","../../../node_modules/bootstrap/scss/_dropdown.scss","../../../node_modules/bootstrap/scss/mixins/_caret.scss","../../../node_modules/bootstrap/scss/_button-group.scss","../../../node_modules/bootstrap/scss/_nav.scss","../../../node_modules/bootstrap/scss/_navbar.scss","../../../node_modules/bootstrap/scss/_card.scss","../../../node_modules/bootstrap/scss/_accordion.scss","../../../node_modules/bootstrap/scss/_breadcrumb.scss","../../../node_modules/bootstrap/scss/_pagination.scss","../../../node_modules/bootstrap/scss/mixins/_pagination.scss","../../../node_modules/bootstrap/scss/_badge.scss","../../../node_modules/bootstrap/scss/_alert.scss","../../../node_modules/bootstrap/scss/_progress.scss","../../../node_modules/bootstrap/scss/_list-group.scss","../../../node_modules/bootstrap/scss/_close.scss","../../../node_modules/bootstrap/scss/_toasts.scss","../../../node_modules/bootstrap/scss/_modal.scss","../../../node_modules/bootstrap/scss/mixins/_backdrop.scss","../../../node_modules/bootstrap/scss/_tooltip.scss","../../../node_modules/bootstrap/scss/mixins/_reset-text.scss","../../../node_modules/bootstrap/scss/_popover.scss","../../../node_modules/bootstrap/scss/_carousel.scss","../../../node_modules/bootstrap/scss/mixins/_clearfix.scss","../../../node_modules/bootstrap/scss/_spinners.scss","../../../node_modules/bootstrap/scss/_offcanvas.scss","../../../node_modules/bootstrap/scss/_placeholders.scss","../../../node_modules/bootstrap/scss/helpers/_color-bg.scss","../../../node_modules/bootstrap/scss/helpers/_colored-links.scss","../../../node_modules/bootstrap/scss/helpers/_focus-ring.scss","../../../node_modules/bootstrap/scss/helpers/_icon-link.scss","../../../node_modules/bootstrap/scss/helpers/_ratio.scss","../../../node_modules/bootstrap/scss/helpers/_position.scss","../../../node_modules/bootstrap/scss/helpers/_stacks.scss","../../../node_modules/bootstrap/scss/helpers/_visually-hidden.scss","../../../node_modules/bootstrap/scss/mixins/_visually-hidden.scss","../../../node_modules/bootstrap/scss/helpers/_stretched-link.scss","../../../node_modules/bootstrap/scss/helpers/_text-truncation.scss","../../../node_modules/bootstrap/scss/mixins/_text-truncate.scss","../../../node_modules/bootstrap/scss/helpers/_vr.scss","../../../node_modules/bootstrap/scss/mixins/_utilities.scss","../../../node_modules/bootstrap/scss/utilities/_api.scss","../scss/_talawa.scss","../scss/components/_buttons.scss","../scss/components/_progress.scss","../scss/components/_spinners.scss","../scss/content/_typography.scss","../scss/_general.scss"],"names":[],"mappings":";AACE;AAAA;AAAA;AAAA;AAAA;ACDF;AAAA;EASI;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAIA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAIA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAIA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAIA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAIA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAIA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAGF;EACA;EAMA;EACA;EACA;EAOA;EC2OI,qBALI;EDpOR;EACA;EAKA;EACA;EACA;EACA;EAEA;EACA;EAEA;EACA;EACA;EACA;EAEA;EACA;EACA;EACA;EAGA;EAEA;EACA;EACA;EAEA;EACA;EAMA;EACA;EAGA;EACA;EACA;EACA;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;EAIA;EACA;EACA;EAIA;EACA;EACA;EACA;;;AE/GE;EFqHA;EAGA;EACA;EACA;EACA;EAEA;EACA;EAEA;EACA;EACA;EACA;EAEA;EACA;EACA;EACA;EAGE;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAIA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAIA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAGF;EAEA;EACA;EACA;EACA;EAEA;EAEA;EACA;EAEA;EACA;EACA;EACA;;;AGrKJ;AAAA;AAAA;EAGE;;;AAeE;EANJ;IAOM;;;;AAcN;EACE;EACA;EF6OI,WALI;EEtOR;EACA;EACA;EACA;EACA;EACA;EACA;;;AASF;EACE;EACA,OCmnB4B;EDlnB5B;EACA;EACA,SCynB4B;;;AD/mB9B;EACE;EACA,eCwjB4B;EDrjB5B,aCwjB4B;EDvjB5B,aCwjB4B;EDvjB5B;;;AAGF;EFuMQ;;AA5JJ;EE3CJ;IF8MQ;;;;AEzMR;EFkMQ;;AA5JJ;EEtCJ;IFyMQ;;;;AEpMR;EF6LQ;;AA5JJ;EEjCJ;IFoMQ;;;;AE/LR;EFwLQ;;AA5JJ;EE5BJ;IF+LQ;;;;AE1LR;EF+KM,WALI;;;AErKV;EF0KM,WALI;;;AE1JV;EACE;EACA,eCwV0B;;;AD9U5B;EACE;EACA;EACA;;;AAMF;EACE;EACA;EACA;;;AAMF;AAAA;EAEE;;;AAGF;AAAA;AAAA;EAGE;EACA;;;AAGF;AAAA;AAAA;AAAA;EAIE;;;AAGF;EACE,aC6b4B;;;ADxb9B;EACE;EACA;;;AAMF;EACE;;;AAQF;AAAA;EAEE,aCsa4B;;;AD9Z9B;EF6EM,WALI;;;AEjEV;EACE,SCif4B;EDhf5B;;;AASF;AAAA;EAEE;EFyDI,WALI;EElDR;EACA;;;AAGF;EAAM;;;AACN;EAAM;;;AAKN;EACE;EACA,iBE9NgB;;AFgOhB;EACE;;;AAWF;EAEE;EACA;;;AAOJ;AAAA;AAAA;AAAA;EAIE,aCiV4B;EHlUxB,WALI;;;AEFV;EACE;EACA;EACA;EACA;EFGI,WALI;;AEOR;EFFI,WALI;EESN;EACA;;;AAIJ;EFTM,WALI;EEgBR;EACA;;AAGA;EACE;;;AAIJ;EACE;EFrBI,WALI;EE4BR,OCo5CkC;EDn5ClC,kBCo5CkC;EExrDhC;;AHuSF;EACE;EF5BE,WALI;;;AE4CV;EACE;;;AAMF;AAAA;EAEE;;;AAQF;EACE;EACA;;;AAGF;EACE,aCwX4B;EDvX5B,gBCuX4B;EDtX5B,OCwZ4B;EDvZ5B;;;AAOF;EAEE;EACA;;;AAGF;AAAA;AAAA;AAAA;AAAA;AAAA;EAME;EACA;EACA;;;AAQF;EACE;;;AAMF;EAEE;;;AAQF;EACE;;;AAKF;AAAA;AAAA;AAAA;AAAA;EAKE;EACA;EF3HI,WALI;EEkIR;;;AAIF;AAAA;EAEE;;;AAKF;EACE;;;AAGF;EAGE;;AAGA;EACE;;;AAOJ;EACE;;;AAQF;AAAA;AAAA;AAAA;EAIE;;AAGE;AAAA;AAAA;AAAA;EACE;;;AAON;EACE;EACA;;;AAKF;EACE;;;AAUF;EACE;EACA;EACA;EACA;;;AAQF;EACE;EACA;EACA;EACA,eCgN4B;EHhatB;EEmNN;;AF/WE;EEwWJ;IFrMQ;;;AE8MN;EACE;;;AAOJ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EAOE;;;AAGF;EACE;;;AASF;EACE;EACA;;;AAQF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAWA;EACE;;;AAKF;EACE;;;AAOF;EACE;EACA;;;AAKF;EACE;;;AAKF;EACE;;;AAOF;EACE;EACA;;;AAQF;EACE;;;AAQF;EACE;;;AIpkBF;ENmQM,WALI;EM5PR,aHwoB4B;;;AGnoB5B;ENgQM;EM5PJ,aHynBkB;EGxnBlB,aHwmB0B;;AHzgB1B;EMpGF;INuQM;;;;AMvQN;ENgQM;EM5PJ,aHynBkB;EGxnBlB,aHwmB0B;;AHzgB1B;EMpGF;INuQM;;;;AMvQN;ENgQM;EM5PJ,aHynBkB;EGxnBlB,aHwmB0B;;AHzgB1B;EMpGF;INuQM;;;;AMvQN;ENgQM;EM5PJ,aHynBkB;EGxnBlB,aHwmB0B;;AHzgB1B;EMpGF;INuQM;;;;AMvQN;ENgQM;EM5PJ,aHynBkB;EGxnBlB,aHwmB0B;;AHzgB1B;EMpGF;INuQM;;;;AMvQN;ENgQM;EM5PJ,aHynBkB;EGxnBlB,aHwmB0B;;AHzgB1B;EMpGF;INuQM;;;;AM/OR;ECvDE;EACA;;;AD2DF;EC5DE;EACA;;;AD8DF;EACE;;AAEA;EACE,cHkoB0B;;;AGxnB9B;EN8MM,WALI;EMvMR;;;AAIF;EACE,eHiUO;EH1HH,WALI;;AM/LR;EACE;;;AAIJ;EACE;EACA,eHuTO;EH1HH,WALI;EMtLR,OHtFS;;AGwFT;EACE;;;AEhGJ;ECIE;EAGA;;;ADDF;EACE,SLyjDkC;EKxjDlC,kBLyjDkC;EKxjDlC;EHGE;EIRF;EAGA;;;ADcF;EAEE;;;AAGF;EACE;EACA;;;AAGF;ERyPM,WALI;EQlPR,OL4iDkC;;;AO9kDlC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;ECHA;EACA;EACA;EACA;EACA;EACA;EACA;;;ACsDE;EF5CE;IACE,WPkee;;;ASvbnB;EF5CE;IACE,WPkee;;;ASvbnB;EF5CE;IACE,WPkee;;;ASvbnB;EF5CE;IACE,WPkee;;;ASvbnB;EF5CE;IACE,WPkee;;;AUlfvB;EAEI;EAAA;EAAA;EAAA;EAAA;EAAA;;;AAKF;ECNA;EACA;EACA;EACA;EAEA;EACA;EACA;;ADEE;ECOF;EACA;EACA;EACA;EACA;EACA;;;AA+CI;EACE;;;AAGF;EApCJ;EACA;;;AAcA;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AA+BE;EAhDJ;EACA;;;AAqDQ;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AAuEQ;EAxDV;;;AAwDU;EAxDV;;;AAwDU;EAxDV;;;AAwDU;EAxDV;;;AAwDU;EAxDV;;;AAwDU;EAxDV;;;AAwDU;EAxDV;;;AAwDU;EAxDV;;;AAwDU;EAxDV;;;AAwDU;EAxDV;;;AAwDU;EAxDV;;;AAmEM;AAAA;EAEE;;;AAGF;AAAA;EAEE;;;AAPF;AAAA;EAEE;;;AAGF;AAAA;EAEE;;;AAPF;AAAA;EAEE;;;AAGF;AAAA;EAEE;;;AAPF;AAAA;EAEE;;;AAGF;AAAA;EAEE;;;AAPF;AAAA;EAEE;;;AAGF;AAAA;EAEE;;;AAPF;AAAA;EAEE;;;AAGF;AAAA;EAEE;;;AF1DN;EEUE;IACE;;EAGF;IApCJ;IACA;;EAcA;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EA+BE;IAhDJ;IACA;;EAqDQ;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EAuEQ;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAmEM;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;;AF1DN;EEUE;IACE;;EAGF;IApCJ;IACA;;EAcA;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EA+BE;IAhDJ;IACA;;EAqDQ;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EAuEQ;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAmEM;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;;AF1DN;EEUE;IACE;;EAGF;IApCJ;IACA;;EAcA;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EA+BE;IAhDJ;IACA;;EAqDQ;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EAuEQ;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAmEM;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;;AF1DN;EEUE;IACE;;EAGF;IApCJ;IACA;;EAcA;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EA+BE;IAhDJ;IACA;;EAqDQ;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EAuEQ;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAmEM;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;;AF1DN;EEUE;IACE;;EAGF;IApCJ;IACA;;EAcA;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EA+BE;IAhDJ;IACA;;EAqDQ;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EAuEQ;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAmEM;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;;ACrHV;EAEE;EACA;EACA;EACA;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA,eZkYO;EYjYP,gBZksB4B;EYjsB5B;;AAOA;EACE;EAEA;EACA;EACA,qBZ0sB0B;EYzsB1B;;AAGF;EACE;;AAGF;EACE;;;AAIJ;EACE;;;AAOF;EACE;;;AAUA;EACE;;;AAeF;EACE;;AAGA;EACE;;;AAOJ;EACE;;AAGF;EACE;;;AAUF;EACE;EACA;;;AAMF;EACE;EACA;;;AAQJ;EACE;EACA;;;AAQA;EACE;EACA;;;AC5IF;EAOE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;;;AAlBF;EAOE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;;;AAlBF;EAOE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;;;AAlBF;EAOE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;;;AAlBF;EAOE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;;;AAlBF;EAOE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;;;AAlBF;EAOE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;;;AAlBF;EAOE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;;;ADiJA;EACE;EACA;;;AH3FF;EGyFA;IACE;IACA;;;AH3FF;EGyFA;IACE;IACA;;;AH3FF;EGyFA;IACE;IACA;;;AH3FF;EGyFA;IACE;IACA;;;AH3FF;EGyFA;IACE;IACA;;;AEnKN;EACE,edi2BsC;;;Acx1BxC;EACE;EACA;EACA;EjB8QI,WALI;EiBrQR,ad+lB4B;;;Ac3lB9B;EACE;EACA;EjBoQI,WALI;;;AiB3PV;EACE;EACA;EjB8PI,WALI;;;AkBtRV;EACE,Yfy1BsC;EH/jBlC,WALI;EkBjRR,Ofy1BsC;;;AgB91BxC;EACE;EACA;EACA;EnBwRI,WALI;EmBhRR,ahBkmB4B;EgBjmB5B,ahBymB4B;EgBxmB5B,OhBs3BsC;EgBr3BtC,kBfIe;EeHf;EACA;EACA;EdGE;EeHE,YDMJ;;ACFI;EDhBN;ICiBQ;;;ADGN;EACE;;AAEA;EACE;;AAKJ;EACE,OhBg2BoC;EgB/1BpC,kBflBa;EemBb,chBw2BoC;EgBv2BpC;EAKE,YhBkhBkB;;AgB9gBtB;EAME;EAMA;EAKA;;AAKF;EACE;EACA;;AAIF;EACE,OhBs0BoC;EgBp0BpC;;AAQF;EAEE,kBhBwyBoC;EgBryBpC;;AAIF;EACE;EACA;EACA,mBfpEkB;EeqElB,OhBgyBoC;EkB93BtC,kBlB+hCgC;EgB/7B9B;EACA;EACA;EACA;EACA,yBf9EiB;Ee+EjB;ECzFE,YD0FF;;ACtFE;ED0EJ;ICzEM;;;ADwFN;EACE,kBhBs7B8B;;;AgB76BlC;EACE;EACA;EACA;EACA;EACA,ahBwf4B;EgBvf5B,OhBqxBsC;EgBpxBtC;EACA;EACA;;AAEA;EACE;;AAGF;EAEE;EACA;;;AAWJ;EACE,YhBswBsC;EgBrwBtC;EnByII,WALI;EKvQN;;AcuIF;EACE;EACA;EACA,mBhB+nB0B;;;AgB3nB9B;EACE,YhB0vBsC;EgBzvBtC;EnB4HI,WALI;EKvQN;;AcoJF;EACE;EACA;EACA,mBhBsnB0B;;;AgB9mB5B;EACE,YhBuuBoC;;AgBpuBtC;EACE,YhBouBoC;;AgBjuBtC;EACE,YhBiuBoC;;;AgB5tBxC;EACE,OhB+tBsC;EgB9tBtC,QhBwtBsC;EgBvtBtC,SfvKoB;;AeyKpB;EACE;;AAGF;EACE;EdvLA;;Ac2LF;EACE;Ed5LA;;AcgMF;EAAoB,QhBwsBkB;;AgBvsBtC;EAAoB,QhBwsBkB;;;AmBv5BxC;EACE;EAEA;EACA;EACA;EtBqRI,WALI;EsB7QR,anB+lB4B;EmB9lB5B,anBsmB4B;EmBrmB5B,OnBm3BsC;EmBl3BtC,kBlBCe;EkBAf;EACA;EACA,qBnB09BkC;EmBz9BlC,iBnB09BkC;EmBz9BlC;EjBFE;EeHE,YEQJ;EACA;;AFLI;EEfN;IFgBQ;;;AEMN;EACE,cnBg3BoC;EmB/2BpC;EAKE,YnB29B4B;;AmBv9BhC;EAEE,elBXkB;EkBYlB;;AAGF;EAEE,kBnBi1BoC;;AmB50BtC;EACE;EACA;;;AAIJ;EACE,anBiuB4B;EmBhuB5B,gBnBguB4B;EmB/tB5B,cnBguB4B;EH7fxB,WALI;EKvQN;;;AiB8CJ;EACE,anB6tB4B;EmB5tB5B,gBnB4tB4B;EmB3tB5B,cnB4tB4B;EHjgBxB,WALI;EKvQN;;;AiBwDA;EACE;;;ACxEN;EACE;EACA,YpB+5BwC;EoB95BxC,cpB+5BwC;EoB95BxC,epB+5BwC;;AoB75BxC;EACE;EACA;;;AAIJ;EACE,epBq5BwC;EoBp5BxC;EACA;;AAEA;EACE;EACA;EACA;;;AAIJ;EACE;EAEA,OpBq4BwC;EoBp4BxC,QpBo4BwC;EoBn4BxC;EACA;EACA;EACA;EACA;EACA;EACA;EACA,QpBu4BwC;EoBt4BxC;EACA;;AAGA;ElB1BE;;AkB8BF;EAEE,epB83BsC;;AoB33BxC;EACE,QpBq3BsC;;AoBl3BxC;EACE,cpBi1BoC;EoBh1BpC;EACA,YpB+foB;;AoB5ftB;EACE,kBnB/DM;EmBgEN,cnBhEM;;AmBkEN;EAII;;AAIJ;EAII;;AAKN;EACE,kBnBpFM;EmBqFN,cnBrFM;EmB0FJ;;AAIJ;EACE;EACA;EACA,SpB61BuC;;AoBt1BvC;EACE;EACA,SpBo1BqC;;;AoBt0B3C;EACE,cpB+0BgC;;AoB70BhC;EACE;EAEA,OpBy0B8B;EoBx0B9B;EACA;EACA;ElBhHA;EeHE,YGqHF;;AHjHE;EGyGJ;IHxGM;;;AGkHJ;EACE;;AAGF;EACE,qBpBw0B4B;EoBn0B1B;;AAKN;EACE,epBmzB8B;EoBlzB9B;;AAEA;EACE;EACA;;;AAKN;EACE;EACA,cpBiyBgC;;;AoB9xBlC;EACE;EACA;EACA;;AAIE;EACE;EACA;EACA,SpBkpBwB;;;AoB3oB1B;EACE;;;AClLN;EACE;EACA;EACA;EACA;EACA;;AAEA;EACE;;AAIA;EAA0B,YrBwgCa;;AqBvgCvC;EAA0B,YrBugCa;;AqBpgCzC;EACE;;AAGF;EACE,OrBy/BuC;EqBx/BvC,QrBw/BuC;EqBv/BvC;EHzBF,kBjBFQ;EoB6BN,QrBw/BuC;EEpgCvC;EeHE,YIkBF;EACA;;AJfE;EIMJ;IJLM;;;AIgBJ;EHjCF,kBlBwhCyC;;AqBl/BzC;EACE,OrBk+B8B;EqBj+B9B,QrBk+B8B;EqBj+B9B;EACA,QrBi+B8B;EqBh+B9B,kBrBi+B8B;EqBh+B9B;EnB7BA;;AmBkCF;EACE,OrB89BuC;EqB79BvC,QrB69BuC;EkBhhCzC,kBjBFQ;EoBuDN,QrB89BuC;EEpgCvC;EeHE,YI4CF;EACA;;AJzCE;EIiCJ;IJhCM;;;AI0CJ;EH3DF,kBlBwhCyC;;AqBx9BzC;EACE,OrBw8B8B;EqBv8B9B,QrBw8B8B;EqBv8B9B;EACA,QrBu8B8B;EqBt8B9B,kBrBu8B8B;EqBt8B9B;EnBvDA;;AmB4DF;EACE;;AAEA;EACE,kBrB08BqC;;AqBv8BvC;EACE,kBrBs8BqC;;;AsB7hC3C;EACE;;AAEA;AAAA;AAAA;EAGE,QtBkiCoC;EsBjiCpC,YtBiiCoC;EsBhiCpC,atBiiCoC;;AsB9hCtC;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;ELRE,YKSF;;ALLE;EKTJ;ILUM;;;AKON;AAAA;EAEE;;AAEA;AAAA;EACE;;AAGF;AAAA;AAAA;EAEE,atBsgCkC;EsBrgClC,gBtBsgCkC;;AsBngCpC;AAAA;EACE,atBigCkC;EsBhgClC,gBtBigCkC;;AsB7/BtC;EACE,atB2/BoC;EsB1/BpC,gBtB2/BoC;;AsBp/BpC;AAAA;AAAA;AAAA;EACE;EACA,WtBq/BkC;;AsBn/BlC;AAAA;AAAA;AAAA;EACE;EACA;EACA;EACA,QtB6+BgC;EsB5+BhC;EACA,kBrBlDS;ECEb;;AoBuDA;EACE;EACA,WtBo+BkC;;AsB/9BpC;EACE;;AAIJ;EACE,OtBzEO;;AsB2EP;EACE,kBtBqyBkC;;;AuB33BxC;EACE;EACA;EACA;EACA;EACA;;AAEA;AAAA;AAAA;EAGE;EACA;EACA;EACA;;AAIF;AAAA;AAAA;EAGE;;AAMF;EACE;EACA;;AAEA;EACE;;;AAWN;EACE;EACA;EACA;E1B8OI,WALI;E0BvOR,avByjB4B;EuBxjB5B,avBgkB4B;EuB/jB5B,OvB60BsC;EuB50BtC;EACA;EACA,kBvBo6BsC;EuBn6BtC;ErBtCE;;;AqBgDJ;AAAA;AAAA;AAAA;EAIE;E1BwNI,WALI;EKvQN;;;AqByDJ;AAAA;AAAA;AAAA;EAIE;E1B+MI,WALI;EKvQN;;;AqBkEJ;AAAA;EAEE;;;AAaE;AAAA;AAAA;AAAA;ErBjEA;EACA;;AqByEA;AAAA;AAAA;AAAA;ErB1EA;EACA;;AqBsFF;EACE;ErB1EA;EACA;;AqB6EF;AAAA;ErB9EE;EACA;;;AsBxBF;EACE;EACA;EACA,YxBi0BoC;EH/jBlC,WALI;E2B1PN,OxB4iCqB;;;AwBziCvB;EACE;EACA;EACA;EACA;EACA;EACA;EACA;E3BqPE,WALI;E2B7ON,OxB+hCqB;EwB9hCrB,kBxB8hCqB;EEzjCrB;;;AsBgCA;AAAA;AAAA;AAAA;EAEE;;;AA/CF;EAqDE,cxBihCmB;EwB9gCjB,exBw1BgC;EwBv1BhC;EACA;EACA;EACA;;AAGF;EACE,cxBsgCiB;EwBrgCjB,YxBqgCiB;;;AwBtkCrB;EA0EI,exBs0BgC;EwBr0BhC;;;AA3EJ;EAkFE,cxBo/BmB;;AwBj/BjB;EAEE;EACA,exBo5B8B;EwBn5B9B;EACA;;AAIJ;EACE,cxBu+BiB;EwBt+BjB,YxBs+BiB;;;AwBtkCrB;EAwGI;;;AAxGJ;EA+GE,cxBu9BmB;;AwBr9BnB;EACE,kBxBo9BiB;;AwBj9BnB;EACE,YxBg9BiB;;AwB78BnB;EACE,OxB48BiB;;;AwBv8BrB;EACE;;;AAhIF;AAAA;AAAA;AAAA;AAAA;EA0IM;;;AAtHR;EACE;EACA;EACA,YxBi0BoC;EH/jBlC,WALI;E2B1PN,OxB4iCqB;;;AwBziCvB;EACE;EACA;EACA;EACA;EACA;EACA;EACA;E3BqPE,WALI;E2B7ON,OxB+hCqB;EwB9hCrB,kBxB8hCqB;EEzjCrB;;;AsBgCA;AAAA;AAAA;AAAA;EAEE;;;AA/CF;EAqDE,cxBihCmB;EwB9gCjB,exBw1BgC;EwBv1BhC;EACA;EACA;EACA;;AAGF;EACE,cxBsgCiB;EwBrgCjB,YxBqgCiB;;;AwBtkCrB;EA0EI,exBs0BgC;EwBr0BhC;;;AA3EJ;EAkFE,cxBo/BmB;;AwBj/BjB;EAEE;EACA,exBo5B8B;EwBn5B9B;EACA;;AAIJ;EACE,cxBu+BiB;EwBt+BjB,YxBs+BiB;;;AwBtkCrB;EAwGI;;;AAxGJ;EA+GE,cxBu9BmB;;AwBr9BnB;EACE,kBxBo9BiB;;AwBj9BnB;EACE,YxBg9BiB;;AwB78BnB;EACE,OxB48BiB;;;AwBv8BrB;EACE;;;AAhIF;AAAA;AAAA;AAAA;AAAA;EA4IM;;;AC9IV;EAEE;EACA;EACA;E5BuRI,oBALI;E4BhRR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;E5BsQI,WALI;E4B/PR;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;EvBjBE;EgBfF,kBOkCqB;ERtBjB,YQwBJ;;ARpBI;EQhBN;IRiBQ;;;AQqBN;EACE;EAEA;EACA;;AAGF;EAEE;EACA;EACA;;AAGF;EACE;EPrDF,kBOsDuB;EACrB;EACA;EAKE;;AAIJ;EACE;EACA;EAKE;;AAIJ;EAKE;EACA;EAGA;;AAGA;EAKI;;AAKN;EAGE;EACA;EACA;EAEA;EACA;;;AAYF;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADmHA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADsGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA,iBxBxJgB;;AwBkKhB;EACE;;AAGF;EACE;;;AAWJ;ECxIE;EACA;E7B8NI,oBALI;E6BvNR;;;ADyIF;EC5IE;EACA;E7B8NI,oBALI;E6BvNR;;;ACnEF;EVgBM,YUfJ;;AVmBI;EUpBN;IVqBQ;;;AUlBN;EACE;;;AAMF;EACE;;;AAIJ;EACE;EACA;EVDI,YUEJ;;AVEI;EULN;IVMQ;;;AUDN;EACE;EACA;EVNE,YUOF;;AVHE;EUAJ;IVCM;;;;AWpBR;AAAA;AAAA;AAAA;AAAA;AAAA;EAME;;;AAGF;EACE;;ACwBE;EACE;EACA,a7B6hBwB;E6B5hBxB,gB7B2hBwB;E6B1hBxB;EArCJ;EACA;EACA;EACA;;AA0DE;EACE;;;AD9CN;EAEE;EACA;EACA;EACA;EACA;E/BuQI,yBALI;E+BhQR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;EACA;EACA;E/B0OI,WALI;E+BnOR;EACA;EACA;EACA;EACA;EACA;E1BzCE;;A0B6CF;EACE;EACA;EACA;;;AAwBA;EACE;;AAEA;EACE;EACA;;;AAIJ;EACE;;AAEA;EACE;EACA;;;AnB1CJ;EmB4BA;IACE;;EAEA;IACE;IACA;;EAIJ;IACE;;EAEA;IACE;IACA;;;AnB1CJ;EmB4BA;IACE;;EAEA;IACE;IACA;;EAIJ;IACE;;EAEA;IACE;IACA;;;AnB1CJ;EmB4BA;IACE;;EAEA;IACE;IACA;;EAIJ;IACE;;EAEA;IACE;IACA;;;AnB1CJ;EmB4BA;IACE;;EAEA;IACE;IACA;;EAIJ;IACE;;EAEA;IACE;IACA;;;AnB1CJ;EmB4BA;IACE;;EAEA;IACE;IACA;;EAIJ;IACE;;EAEA;IACE;IACA;;;AAUN;EACE;EACA;EACA;EACA;;ACpFA;EACE;EACA,a7B6hBwB;E6B5hBxB,gB7B2hBwB;E6B1hBxB;EA9BJ;EACA;EACA;EACA;;AAmDE;EACE;;;ADgEJ;EACE;EACA;EACA;EACA;EACA;;AClGA;EACE;EACA,a7B6hBwB;E6B5hBxB,gB7B2hBwB;E6B1hBxB;EAvBJ;EACA;EACA;EACA;;AA4CE;EACE;;AD0EF;EACE;;;AAMJ;EACE;EACA;EACA;EACA;EACA;;ACnHA;EACE;EACA,a7B6hBwB;E6B5hBxB,gB7B2hBwB;E6B1hBxB;;AAWA;EACE;;AAGF;EACE;EACA,c7B0gBsB;E6BzgBtB,gB7BwgBsB;E6BvgBtB;EAnCN;EACA;EACA;;AAsCE;EACE;;AD2FF;EACE;;;AAON;EACE;EACA;EACA;EACA;EACA;;;AAMF;EACE;EACA;EACA;EACA;EACA,a5Byb4B;E4Bxb5B;EACA;EAEA;EACA;EACA;E1BtKE;;A0ByKF;EAEE;EV1LF,kBU4LuB;;AAGvB;EAEE;EACA;EVlMF,kBUmMuB;;AAGvB;EAEE;EACA;EACA;;;AAMJ;EACE;;;AAIF;EACE;EACA;EACA;E/BmEI,WALI;E+B5DR;EACA;;;AAIF;EACE;EACA;EACA;;;AAIF;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AEtPF;AAAA;EAEE;EACA;EACA;;AAEA;AAAA;EACE;EACA;;AAKF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EAME;;;AAKJ;EACE;EACA;EACA;;AAEA;EACE;;;AAIJ;E5BhBI;;A4BoBF;AAAA;EAEE;;AAIF;AAAA;AAAA;E5BVE;EACA;;A4BmBF;AAAA;AAAA;E5BNE;EACA;;;A4BwBJ;EACE;EACA;;AAEA;EAGE;;AAGF;EACE;;;AAIJ;EACE;EACA;;;AAGF;EACE;EACA;;;AAoBF;EACE;EACA;EACA;;AAEA;AAAA;EAEE;;AAGF;AAAA;EAEE;;AAIF;AAAA;E5B1FE;EACA;;A4B8FF;AAAA;E5B7GE;EACA;;;A6BxBJ;EAEE;EACA;EAEA;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;ElCsQI,WALI;EkC/PR;EACA;EAEA;EACA;EdfI,YcgBJ;;AdZI;EcGN;IdFQ;;;AcaN;EAEE;;AAIF;EACE;EACA,Y/BkhBoB;;A+B9gBtB;EACE;EACA;EACA;;;AAQJ;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;;AAEA;EACE;EACA;E7B5CA;EACA;;A6B8CA;EAGE;EACA;;AAGF;EAEE;EACA;EACA;;AAIJ;AAAA;EAEE;EACA;EACA;;AAGF;EAEE;E7BvEA;EACA;;;A6BiFJ;EAEE;EACA;EACA;;AAGA;E7BlGE;;A6BqGA;EACE;EACA;EACA;;AAIJ;AAAA;EAEE;Eb7HF,kBa8HuB;;;AASzB;EAEE;EACA;EACA;EAGA;;AAEA;EACE;EACA;EACA;;AAEA;EAEE;;AAIJ;AAAA;EAEE,a/B8c0B;E+B7c1B;EACA;;;AAUF;AAAA;EAEE;EACA;;;AAKF;AAAA;EAEE;EACA;EACA;;;AAMF;AAAA;EACE;;;AAUF;EACE;;AAEF;EACE;;;ACzMJ;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;EACA;EACA;;AAMA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EACE;EACA;EACA;EACA;;AAoBJ;EACE;EACA;EACA;EnC4NI,WALI;EmCrNR;EAEA;;AAEA;EAEE;;;AAUJ;EAEE;EACA;EAEA;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;EACA;;AAGE;EAEE;;AAIJ;EACE;;;AASJ;EACE,ahCwgCkC;EgCvgClC,gBhCugCkC;EgCtgClC;;AAEA;AAAA;AAAA;EAGE;;;AAaJ;EACE;EACA;EAGA;;;AAIF;EACE;EnCyII,WALI;EmClIR;EACA;EACA;EACA;E9BxIE;EeHE,Ye6IJ;;AfzII;EeiIN;IfhIQ;;;Ae0IN;EACE;;AAGF;EACE;EACA;EACA;;;AAMJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AvB1HE;EuBsIA;IAEI;IACA;;EAEA;IACE;;EAEA;IACE;;EAGF;IACE;IACA;;EAIJ;IACE;;EAGF;IACE;IACA;;EAGF;IACE;;EAGF;IAEE;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;If9NJ,YegOI;;EAGA;IACE;;EAGF;IACE;IACA;IACA;IACA;;;AvB5LR;EuBsIA;IAEI;IACA;;EAEA;IACE;;EAEA;IACE;;EAGF;IACE;IACA;;EAIJ;IACE;;EAGF;IACE;IACA;;EAGF;IACE;;EAGF;IAEE;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;If9NJ,YegOI;;EAGA;IACE;;EAGF;IACE;IACA;IACA;IACA;;;AvB5LR;EuBsIA;IAEI;IACA;;EAEA;IACE;;EAEA;IACE;;EAGF;IACE;IACA;;EAIJ;IACE;;EAGF;IACE;IACA;;EAGF;IACE;;EAGF;IAEE;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;If9NJ,YegOI;;EAGA;IACE;;EAGF;IACE;IACA;IACA;IACA;;;AvB5LR;EuBsIA;IAEI;IACA;;EAEA;IACE;;EAEA;IACE;;EAGF;IACE;IACA;;EAIJ;IACE;;EAGF;IACE;IACA;;EAGF;IACE;;EAGF;IAEE;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;If9NJ,YegOI;;EAGA;IACE;;EAGF;IACE;IACA;IACA;IACA;;;AvB5LR;EuBsIA;IAEI;IACA;;EAEA;IACE;;EAEA;IACE;;EAGF;IACE;IACA;;EAIJ;IACE;;EAGF;IACE;IACA;;EAGF;IACE;;EAGF;IAEE;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;If9NJ,YegOI;;EAGA;IACE;;EAGF;IACE;IACA;IACA;IACA;;;AAtDR;EAEI;EACA;;AAEA;EACE;;AAEA;EACE;;AAGF;EACE;EACA;;AAIJ;EACE;;AAGF;EACE;EACA;;AAGF;EACE;;AAGF;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;Ef9NJ,YegOI;;AAGA;EACE;;AAGF;EACE;EACA;EACA;EACA;;;AAiBZ;AAAA;EAGE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAME;EACE;;;ACzRN;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;E/BjBE;;A+BqBF;EACE;EACA;;AAGF;EACE;EACA;;AAEA;EACE;E/BtBF;EACA;;A+ByBA;EACE;E/BbF;EACA;;A+BmBF;AAAA;EAEE;;;AAIJ;EAGE;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;;;AAQA;EACE;;;AAQJ;EACE;EACA;EACA;EACA;EACA;;AAEA;E/B7FE;;;A+BkGJ;EACE;EACA;EACA;EACA;;AAEA;E/BxGE;;;A+BkHJ;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;;;AAIJ;EACE;EACA;;;AAIF;EACE;EACA;EACA;EACA;EACA;EACA;E/B1IE;;;A+B8IJ;AAAA;AAAA;EAGE;;;AAGF;AAAA;E/B3II;EACA;;;A+B+IJ;AAAA;E/BlII;EACA;;;A+B8IF;EACE;;AxB3HA;EwBuHJ;IAQI;IACA;;EAGA;IAEE;IACA;;EAEA;IACE;IACA;;EAKA;I/B3KJ;IACA;;E+B6KM;AAAA;IAGE;;EAEF;AAAA;IAGE;;EAIJ;I/B5KJ;IACA;;E+B8KM;AAAA;IAGE;;EAEF;AAAA;IAGE;;;;ACpOZ;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAIF;EACE;EACA;EACA;EACA;EACA;ErC2PI,WALI;EqCpPR;EACA;EACA;EACA;EhCtBE;EgCwBF;EjB3BI,YiB4BJ;;AjBxBI;EiBWN;IjBVQ;;;AiByBN;EACE;EACA;EACA;;AAEA;EACE;EACA;;AAKJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EjBlDE,YiBmDF;;AjB/CE;EiBsCJ;IjBrCM;;;AiBiDN;EACE;;AAGF;EACE;EACA;EACA;EACA;;;AAIJ;EACE;;;AAGF;EACE;EACA;EACA;;AAEA;EhC/DE;EACA;;AgCiEA;EhClEA;EACA;;AgCsEF;EACE;;AAIF;EhC9DE;EACA;;AgCiEE;EhClEF;EACA;;AgCsEA;EhCvEA;EACA;;;AgC4EJ;EACE;;;AASA;EACE;;AAGF;EACE;EACA;EhCpHA;;AgCuHA;EAAgB;;AAChB;EAAe;;AAGb;EhC3HF;;;AgCqIA;EACE;EACA;;;AC1JN;EAEE;EACA;EACA;EAEA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;EtC+QI,WALI;EsCxQR;EACA;EjCAE;;;AiCMF;EACE;;AAEA;EACE;EACA;EACA;EACA;;AAIJ;EACE;;;ACrCJ;EAEE;EACA;EvC4RI,2BALI;EuCrRR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EhCpBA;EACA;;;AgCuBF;EACE;EACA;EACA;EvCgQI,WALI;EuCzPR;EAEA;EACA;EnBpBI,YmBqBJ;;AnBjBI;EmBQN;InBPQ;;;AmBkBN;EACE;EACA;EAEA;EACA;;AAGF;EACE;EACA;EACA;EACA,SpCouCgC;EoCnuChC;;AAGF;EAEE;EACA;ElBtDF,kBkBuDuB;EACrB;;AAGF;EAEE;EACA;EACA;EACA;;;AAKF;EACE,apCusCgC;;AoClsC9B;ElC9BF;EACA;;AkCmCE;ElClDF;EACA;;;AkCkEJ;EClGE;EACA;ExC0RI,2BALI;EwCnRR;;;ADmGF;ECtGE;EACA;ExC0RI,2BALI;EwCnRR;;;ACFF;EAEE;EACA;EzCuRI,sBALI;EyChRR;EACA;EACA;EAGA;EACA;EzC+QI,WALI;EyCxQR;EACA;EACA;EACA;EACA;EACA;EpCJE;;AoCSF;EACE;;;AAKJ;EACE;EACA;;;AChCF;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;EACA;EACA;ErCHE;;;AqCQJ;EAEE;;;AAIF;EACE,avC6kB4B;EuC5kB5B;;;AAQF;EACE,evCg+C8B;;AuC79C9B;EACE;EACA;EACA;EACA;EACA;;;AAQF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AC5DF;EACE;IAAK,uBxCmhD2B;;;AwC9gDpC;AAAA;EAGE;E3CkRI,yBALI;E2C3QR;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;E3CsQI,WALI;E2C/PR;EtCRE;;;AsCaJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EvBxBI,YuByBJ;;AvBrBI;EuBYN;IvBXQ;;;;AuBuBR;EtBAE;EsBEA;;;AAGF;EACE;;;AAGF;EACE;;;AAIA;EACE;;AAGE;EAJJ;IAKM;;;;AC3DR;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EAGA;EACA;EvCXE;;;AuCeJ;EACE;EACA;;AAEA;EAEE;EACA;;;AASJ;EACE;EACA;EACA;;AAGA;EAEE;EACA;EACA;EACA;;AAGF;EACE;EACA;;;AAQJ;EACE;EACA;EACA;EACA;EAEA;EACA;;AAEA;EvCvDE;EACA;;AuC0DF;EvC7CE;EACA;;AuCgDF;EAEE;EACA;EACA;;AAIF;EACE;EACA;EACA;EACA;;AAIF;EACE;;AAEA;EACE;EACA;;;AAaF;EACE;;AAGE;EvCvDJ;EAZA;;AuCwEI;EvCxEJ;EAYA;;AuCiEI;EACE;;AAGF;EACE;EACA;;AAEA;EACE;EACA;;;AhCtFR;EgC8DA;IACE;;EAGE;IvCvDJ;IAZA;;EuCwEI;IvCxEJ;IAYA;;EuCiEI;IACE;;EAGF;IACE;IACA;;EAEA;IACE;IACA;;;AhCtFR;EgC8DA;IACE;;EAGE;IvCvDJ;IAZA;;EuCwEI;IvCxEJ;IAYA;;EuCiEI;IACE;;EAGF;IACE;IACA;;EAEA;IACE;IACA;;;AhCtFR;EgC8DA;IACE;;EAGE;IvCvDJ;IAZA;;EuCwEI;IvCxEJ;IAYA;;EuCiEI;IACE;;EAGF;IACE;IACA;;EAEA;IACE;IACA;;;AhCtFR;EgC8DA;IACE;;EAGE;IvCvDJ;IAZA;;EuCwEI;IvCxEJ;IAYA;;EuCiEI;IACE;;EAGF;IACE;IACA;;EAEA;IACE;IACA;;;AhCtFR;EgC8DA;IACE;;EAGE;IvCvDJ;IAZA;;EuCwEI;IvCxEJ;IAYA;;EuCiEI;IACE;;EAGF;IACE;IACA;;EAEA;IACE;IACA;;;AAcZ;EvChJI;;AuCmJF;EACE;;AAEA;EACE;;;AAaJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AC5LJ;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA,O1C+oD2B;E0C9oD3B,Q1C8oD2B;E0C7oD3B;EACA;EACA;EACA;ExCJE;EwCMF;;AAGA;EACE;EACA;EACA;;AAGF;EACE;EACA;EACA;;AAGF;EAEE;EACA;EACA;;;AAQJ;EAHE;;;AASE;EATF;;;ACjDF;EAEE;EACA;EACA;EACA;EACA;E9CyRI,sBALI;E8ClRR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;E9C2QI,WALI;E8CpQR;EACA;EACA;EACA;EACA;EACA;EzCRE;;AyCWF;EACE;;AAGF;EACE;;;AAIJ;EACE;EAEA;EACA;EACA;EACA;EACA;;AAEA;EACE;;;AAIJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EzChCE;EACA;;AyCkCF;EACE;EACA;;;AAIJ;EACE;EACA;;;AC9DF;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;;;AAOF;EACE;EACA;EACA;EAEA;;AAGA;E3B5CI,Y2B6CF;EACA,W5Cy7CgC;;AiBn+C9B;E2BwCJ;I3BvCM;;;A2B2CN;EACE,W5Cu7CgC;;A4Cn7ClC;EACE,W5Co7CgC;;;A4Ch7CpC;EACE;;AAEA;EACE;EACA;;AAGF;EACE;;;AAIJ;EACE;EACA;EACA;;;AAIF;EACE;EACA;EACA;EACA;EAEA;EACA;EACA;EACA;EACA;E1CrFE;E0CyFF;;;AAIF;EAEE;EACA;EACA;EClHA;EACA;EACA;EACA,SDkH0B;ECjH1B;EACA;EACA,kBD+G4D;;AC5G5D;EAAS;;AACT;EAAS,SD2GiF;;;AAK5F;EACE;EACA;EACA;EACA;EACA;EACA;E1CtGE;EACA;;A0CwGF;EACE;EACA;;;AAKJ;EACE;EACA;;;AAKF;EACE;EAGA;EACA;;;AAIF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;E1C1HE;EACA;;A0C+HF;EACE;;;AnC5GA;EmCkHF;IACE;IACA;;EAIF;IACE;IACA;IACA;;EAGF;IACE;;;AnC/HA;EmCoIF;AAAA;IAEE;;;AnCtIA;EmC2IF;IACE;;;AAUA;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;E1C1MJ;;A0C8ME;AAAA;E1C9MF;;A0CmNE;EACE;;;AnC3JJ;EmCyIA;IACE;IACA;IACA;IACA;;EAEA;IACE;IACA;I1C1MJ;;E0C8ME;AAAA;I1C9MF;;E0CmNE;IACE;;;AnC3JJ;EmCyIA;IACE;IACA;IACA;IACA;;EAEA;IACE;IACA;I1C1MJ;;E0C8ME;AAAA;I1C9MF;;E0CmNE;IACE;;;AnC3JJ;EmCyIA;IACE;IACA;IACA;IACA;;EAEA;IACE;IACA;I1C1MJ;;E0C8ME;AAAA;I1C9MF;;E0CmNE;IACE;;;AnC3JJ;EmCyIA;IACE;IACA;IACA;IACA;;EAEA;IACE;IACA;I1C1MJ;;E0C8ME;AAAA;I1C9MF;;E0CmNE;IACE;;;AnC3JJ;EmCyIA;IACE;IACA;IACA;IACA;;EAEA;IACE;IACA;I1C1MJ;;E0C8ME;AAAA;I1C9MF;;E0CmNE;IACE;;;AEtOR;EAEE;EACA;EACA;EACA;EACA;EjDwRI,wBALI;EiDjRR;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;EClBA,a/C+lB4B;E+C7lB5B;EACA,a/CwmB4B;E+CvmB5B,a/C+mB4B;E+C9mB5B;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;ElDgRI,WALI;EiDhQR;EACA;;AAEA;EAAS;;AAET;EACE;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;;;AAKN;EACE;;AAEA;EACE;EACA;EACA;;;AAIJ;AACA;EACE;EACA;EACA;;AAEA;EACE;EACA;EACA;;;AAIJ;AAEA;EACE;;AAEA;EACE;EACA;EACA;;;AAIJ;AACA;EACE;EACA;EACA;;AAEA;EACE;EACA;EACA;;;AAIJ;AAkBA;EACE;EACA;EACA;EACA;EACA;E5CjGE;;;A8CnBJ;EAEE;EACA;EnD4RI,wBALI;EmDrRR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EnDmRI,+BALI;EmD5QR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;EDzBA,a/C+lB4B;E+C7lB5B;EACA,a/CwmB4B;E+CvmB5B,a/C+mB4B;E+C9mB5B;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;ElDgRI,WALI;EmD1PR;EACA;EACA;EACA;E9ChBE;;A8CoBF;EACE;EACA;EACA;;AAEA;EAEE;EACA;EACA;EACA;EACA;EACA;;;AAMJ;EACE;;AAEA;EAEE;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;;AAKN;AAEE;EACE;EACA;EACA;;AAEA;EAEE;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;;AAKN;AAGE;EACE;;AAEA;EAEE;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;AAKJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAIJ;AAEE;EACE;EACA;EACA;;AAEA;EAEE;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;;AAKN;AAkBA;EACE;EACA;EnD2GI,WALI;EmDpGR;EACA;EACA;E9C5JE;EACA;;A8C8JF;EACE;;;AAIJ;EACE;EACA;;;ACrLF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;ACtBA;EACE;EACA;EACA;;;ADuBJ;EACE;EACA;EACA;EACA;EACA;EACA;EhClBI,YgCmBJ;;AhCfI;EgCQN;IhCPQ;;;;AgCiBR;AAAA;AAAA;EAGE;;;AAGF;AAAA;EAEE;;;AAGF;AAAA;EAEE;;;AASA;EACE;EACA;EACA;;AAGF;AAAA;AAAA;EAGE;EACA;;AAGF;AAAA;EAEE;EACA;EhC5DE,YgC6DF;;AhCzDE;EgCqDJ;AAAA;IhCpDM;;;;AgCiER;AAAA;EAEE;EACA;EACA;EACA;EAEA;EACA;EACA;EACA,OjD4gDmC;EiD3gDnC;EACA,OjD1FS;EiD2FT;EACA;EACA;EACA,SjDugDmC;EiB7lD/B,YgCuFJ;;AhCnFI;EgCkEN;AAAA;IhCjEQ;;;AgCqFN;AAAA;AAAA;EAEE,OjDpGO;EiDqGP;EACA;EACA,SjD+/CiC;;;AiD5/CrC;EACE;;;AAGF;EACE;;;AAKF;AAAA;EAEE;EACA,OjDggDmC;EiD//CnC,QjD+/CmC;EiD9/CnC;EACA;EACA;;;AAGF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQA;EACE;;;AAEF;EACE;;;AAQF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA,cjDw8CmC;EiDv8CnC;EACA,ajDs8CmC;;AiDp8CnC;EACE;EACA;EACA,OjDs8CiC;EiDr8CjC,QjDs8CiC;EiDr8CjC;EACA,cjDs8CiC;EiDr8CjC,ajDq8CiC;EiDp8CjC;EACA;EACA,kBjD1KO;EiD2KP;EACA;EAEA;EACA;EACA,SjD67CiC;EiBrmD/B,YgCyKF;;AhCrKE;EgCoJJ;IhCnJM;;;AgCuKN;EACE,SjD07CiC;;;AiDj7CrC;EACE;EACA;EACA,QjDo7CmC;EiDn7CnC;EACA,ajDi7CmC;EiDh7CnC,gBjDg7CmC;EiD/6CnC,OjDrMS;EiDsMT;;;AAMA;AAAA;EAEE,QjDq7CiC;;AiDl7CnC;EACE,kBjDxMO;;AiD2MT;EACE,OjD5MO;;;AiDkMT;AAAA;AAAA;EAEE,QjDq7CiC;;AiDl7CnC;EACE,kBjDxMO;;AiD2MT;EACE,OjD5MO;;;AmDdX;AAAA;EAEE;EACA;EACA;EACA;EAEA;EACA;;;AAIF;EACE;IAAK;;;AAIP;EAEE;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;;;AAGF;EAEE;EACA;EACA;;;AASF;EACE;IACE;;EAEF;IACE;IACA;;;AAKJ;EAEE;EACA;EACA;EACA;EACA;EAGA;EACA;;;AAGF;EACE;EACA;;;AAIA;EACE;AAAA;IAEE;;;AC/EN;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;A3C6DE;E2C5CF;IAEI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;InC5BA,YmC8BA;;;AnC1BA;EmCYJ;InCXM;;;ARuDJ;E2C5BE;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;;EAGF;IAEE;;EAGF;IAGE;;;A3C5BJ;E2C/BF;IAiEM;IACA;IACA;;EAEA;IACE;;EAGF;IACE;IACA;IACA;IACA;IAEA;;;;A3CnCN;E2C5CF;IAEI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;InC5BA,YmC8BA;;;AnC1BA;EmCYJ;InCXM;;;ARuDJ;E2C5BE;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;;EAGF;IAEE;;EAGF;IAGE;;;A3C5BJ;E2C/BF;IAiEM;IACA;IACA;;EAEA;IACE;;EAGF;IACE;IACA;IACA;IACA;IAEA;;;;A3CnCN;E2C5CF;IAEI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;InC5BA,YmC8BA;;;AnC1BA;EmCYJ;InCXM;;;ARuDJ;E2C5BE;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;;EAGF;IAEE;;EAGF;IAGE;;;A3C5BJ;E2C/BF;IAiEM;IACA;IACA;;EAEA;IACE;;EAGF;IACE;IACA;IACA;IACA;IAEA;;;;A3CnCN;E2C5CF;IAEI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;InC5BA,YmC8BA;;;AnC1BA;EmCYJ;InCXM;;;ARuDJ;E2C5BE;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;;EAGF;IAEE;;EAGF;IAGE;;;A3C5BJ;E2C/BF;IAiEM;IACA;IACA;;EAEA;IACE;;EAGF;IACE;IACA;IACA;IACA;IAEA;;;;A3CnCN;E2C5CF;IAEI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;InC5BA,YmC8BA;;;AnC1BA;EmCYJ;InCXM;;;ARuDJ;E2C5BE;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;;EAGF;IAEE;;EAGF;IAGE;;;A3C5BJ;E2C/BF;IAiEM;IACA;IACA;;EAEA;IACE;;EAGF;IACE;IACA;IACA;IACA;IAEA;;;;AA/ER;EAEI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EnC5BA,YmC8BA;;AnC1BA;EmCYJ;InCXM;;;AmC2BF;EACE;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;AAGF;EAEE;;AAGF;EAGE;;;AA2BR;EPpHE;EACA;EACA;EACA,S7ComCkC;E6CnmClC;EACA;EACA,kB7CUS;;A6CPT;EAAS;;AACT;EAAS,S7C09CyB;;;AoD52CpC;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;;;AAIJ;EACE;EACA;;;AAGF;EACE;EACA;EACA;;;AChJF;EACE;EACA;EACA;EACA;EACA;EACA,SrDyyCkC;;AqDvyClC;EACE;EACA;;;AAKJ;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAKA;EACE;;;AAIJ;EACE;IACE,SrD4wCgC;;;AqDxwCpC;EACE;EACA;EACA;;;AAGF;EACE;IACE;;;AH9CF;EACE;EACA;EACA;;;AIFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;ACHF;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AAOR;EACE;EACA;;AAGE;EAEE;EACA;;;AC1BN;EACE;EAEA;;;ACHF;EACE;EACA,KzD6c4B;EyD5c5B;EACA;EACA,uBzD2c4B;EyD1c5B;;AAEA;EACE;EACA,OzDuc0B;EyDtc1B,QzDsc0B;EyDrc1B;ExCIE,YwCHF;;AxCOE;EwCZJ;IxCaM;;;;AwCDJ;EACE;;;ACnBN;EACE;EACA;;AAEA;EACE;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;;;AAKF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;ACrBJ;EACE;EACA;EACA;EACA;EACA,S3DimCkC;;;A2D9lCpC;EACE;EACA;EACA;EACA;EACA,S3DylCkC;;;A2DjlChC;EACE;EACA;EACA,S3D6kC8B;;;A2D1kChC;EACE;EACA;EACA,S3DukC8B;;;ASxiChC;EkDxCA;IACE;IACA;IACA,S3D6kC8B;;E2D1kChC;IACE;IACA;IACA,S3DukC8B;;;ASxiChC;EkDxCA;IACE;IACA;IACA,S3D6kC8B;;E2D1kChC;IACE;IACA;IACA,S3DukC8B;;;ASxiChC;EkDxCA;IACE;IACA;IACA,S3D6kC8B;;E2D1kChC;IACE;IACA;IACA,S3DukC8B;;;ASxiChC;EkDxCA;IACE;IACA;IACA,S3D6kC8B;;E2D1kChC;IACE;IACA;IACA,S3DukC8B;;;ASxiChC;EkDxCA;IACE;IACA;IACA,S3D6kC8B;;E2D1kChC;IACE;IACA;IACA,S3DukC8B;;;A4DtmCpC;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;ACRF;AAAA;ECIE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGA;AAAA;EACE;;;ACdF;EACE;EACA;EACA;EACA;EACA;EACA,S/DgcsC;E+D/btC;;;ACRJ;ECAE;EACA;EACA;;;ACNF;EACE;EACA;EACA;EACA;EACA;EACA,SlE2rB4B;;;AmE/nBtB;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAjBJ;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AASF;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAjBJ;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AASF;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AArBJ;AAcA;EAOI;EAAA;;;AAmBJ;AA1BA;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAjBJ;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AASF;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAjBJ;EACE;;;AAIA;EACE;;;AANJ;EACE;;;AAIA;EACE;;;AANJ;EACE;;;AAIA;EACE;;;AANJ;EACE;;;AAIA;EACE;;;AANJ;EACE;;;AAIA;EACE;;;AAIJ;EAOI;;;AAKF;EAOI;;;AAnBN;EAOI;;;AAKF;EAOI;;;AAnBN;EAOI;;;AAKF;EAOI;;;AAnBN;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAjBJ;EACE;;;AAIA;EACE;;;AANJ;EACE;;;AAIA;EACE;;;AANJ;EACE;;;AAIA;EACE;;;AANJ;EACE;;;AAIA;EACE;;;AANJ;EACE;;;AAIA;EACE;;;AANJ;EACE;;;AAIA;EACE;;;AAIJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAjBJ;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AASF;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;A1DVR;E0DGI;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;;A1DVR;E0DGI;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;;A1DVR;E0DGI;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;;A1DVR;E0DGI;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;;A1DVR;E0DGI;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;;ACtDZ;ED+CQ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;;ACnCZ;ED4BQ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;;AEzEZ;AAAA;AAAA;AAAA;AAAA;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;AAAA;AAgBA;;AAAA;;AAAA;ACmCA;AAAA;AAAA;AAAA;AAAA;EAKE,OtEhDS;;AsEiDT;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EAEE;;;AASF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EAEE;;;AC3DF;EACE;IACE,uBAbY;;;ACUlB;EACE;IACE;;;AAGJ;EACE;IACE;;EAEF;IACE;IACA;;;AHuDJ;;AAAA;;AAAA;AI5EA;AAAA;AAAA;AAkBA;AAAA;AAAA;AJsEA;;AAAA;;AAAA;AA2BA;;AAAA;;AAAA;AAQA;;AAAA;;AAAA;AK3HA;EACE;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;;;AAGF;EACE;;;AAEF;EACE;;;AAEF;EACE;;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;IACE;;EAGF;IACE;;;AAIJ;EACE;IACE;;EAGF;IACE;;;ALsEJ;;AAAA;;AAAA","file":"app.css"} \ No newline at end of file diff --git a/src/assets/css/scrollStyles.css b/src/assets/css/scrollStyles.css new file mode 100644 index 0000000000..71248ac1b6 --- /dev/null +++ b/src/assets/css/scrollStyles.css @@ -0,0 +1,18 @@ +.customScroll { + overflow-y: scroll; +} +.customScroll::-webkit-scrollbar { + width: 5px; +} +.customScroll::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 6px; +} +.customScroll::-webkit-scrollbar-thumb { + background: var(--bs-gray-500); + border-radius: 6px; +} +.customScroll::-webkit-scrollbar-thumb:hover { + background: var(--bs-gray-600); + border-radius: 6px; +} diff --git a/src/assets/images/blank.png b/src/assets/images/blank.png new file mode 100644 index 0000000000..ae6f25f049 Binary files /dev/null and b/src/assets/images/blank.png differ diff --git a/src/assets/images/bronze.png b/src/assets/images/bronze.png new file mode 100644 index 0000000000..e5b748a572 Binary files /dev/null and b/src/assets/images/bronze.png differ diff --git a/src/assets/images/defaultImg.png b/src/assets/images/defaultImg.png new file mode 100644 index 0000000000..310a79c130 Binary files /dev/null and b/src/assets/images/defaultImg.png differ diff --git a/src/assets/images/gold.png b/src/assets/images/gold.png new file mode 100644 index 0000000000..cdbb0efdc4 Binary files /dev/null and b/src/assets/images/gold.png differ diff --git a/src/assets/images/palisadoes_logo.png b/src/assets/images/palisadoes_logo.png new file mode 100644 index 0000000000..2e91c02233 Binary files /dev/null and b/src/assets/images/palisadoes_logo.png differ diff --git a/src/assets/images/silver.png b/src/assets/images/silver.png new file mode 100644 index 0000000000..b13d604de5 Binary files /dev/null and b/src/assets/images/silver.png differ diff --git a/src/assets/images/talawa-logo-600x600.png b/src/assets/images/talawa-logo-600x600.png new file mode 100644 index 0000000000..9498132600 Binary files /dev/null and b/src/assets/images/talawa-logo-600x600.png differ diff --git a/src/assets/talawa-logo-dark-200x200.png b/src/assets/images/talawa-logo-dark-200x200.png similarity index 100% rename from src/assets/talawa-logo-dark-200x200.png rename to src/assets/images/talawa-logo-dark-200x200.png diff --git a/src/assets/images/talawa-logo-lite-200x200.png b/src/assets/images/talawa-logo-lite-200x200.png new file mode 100644 index 0000000000..a8d0a8fa7b Binary files /dev/null and b/src/assets/images/talawa-logo-lite-200x200.png differ diff --git a/src/App.css b/src/assets/scss/_colors.scss similarity index 100% rename from src/App.css rename to src/assets/scss/_colors.scss diff --git a/src/assets/scss/_general.scss b/src/assets/scss/_general.scss new file mode 100644 index 0000000000..f2309504ce --- /dev/null +++ b/src/assets/scss/_general.scss @@ -0,0 +1,63 @@ +:root { + --bs-body-font-family: Arial, Helvetica, sans-serif; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html { + overflow-x: hidden; +} + +body { + background-color: var(--bs-body-bg); +} + +#root { + min-height: 100vh; + background-color: #f2f7ff; +} + +input[type='checkbox'] { + transform: scale(1.5); +} +.form-switch { + padding-left: 3rem; +} +input[type='file']::file-selector-button { + background: var(--bs-gray-400); +} + +.shimmer { + animation-duration: 2.2s; + animation-fill-mode: forwards; + animation-iteration-count: infinite; + animation-name: shimmer; + animation-timing-function: linear; + background: var(--bs-gray-200); + background: linear-gradient(to right, #f6f6f6 8%, #f0f0f0 18%, #f6f6f6 33%); + background-size: 1200px 100%; +} + +@-webkit-keyframes shimmer { + 0% { + background-position: -100% 0; + } + + 100% { + background-position: 100% 0; + } +} + +@keyframes shimmer { + 0% { + background-position: -1200px 0; + } + + 100% { + background-position: 1200px 0; + } +} diff --git a/src/assets/scss/_talawa.scss b/src/assets/scss/_talawa.scss new file mode 100644 index 0000000000..2537c2c682 --- /dev/null +++ b/src/assets/scss/_talawa.scss @@ -0,0 +1,136 @@ +/* + TALAWA SCSS + ----------- + This file is used to import all partial scss files in the project. + It is used to compile the final CSS file to the CSS folder as main.css . + +========= Table of Contents ========= +1. Components +2. Content +3. Forms +4. Utilities +5. General +6. Colors + +*/ + +/* + + 1. COMPONENTS + +*/ + +// 1.1. Accordion +@import './components/accordion'; + +// 1.2. Alert +@import './components/alert'; + +// 1.3. Badge +@import './components/badge'; + +// 1.4. Breadcrumb +@import './components/breadcrumb'; + +// 1.5. Button +@import './components/buttons'; + +// 1.6. Card +@import './components/card'; + +// 1.7. Carousel +@import './components/carousel'; + +// 1.8. Close +@import './components/close'; + +// 1.9. Dropdown +@import './components/dropdown'; + +// 1.10. List Group +@import './components/list-group'; + +// 1.11. Modal +@import './components/modal'; + +// 1.12. Navbar +@import './components/navbar'; + +// 1.13. Nav and Nav Tabs +@import './components/nav'; + +// 1.14 Offcanvas +@import './components/offcanvas'; + +// 1.15 Pagination +@import './components/pagination'; + +// 1.16 Placeholder +@import './components/placeholder'; + +// 1.17 Progress +@import './components/progress'; + +// 1.18 Spinners +@import './components/spinners'; + +/* + + 2. CONTENT + +*/ + +// 2.1. Table +@import './content/table'; + +// 2.2. Typography +@import './content/typography'; + +/* + + 3. FORMS + +*/ + +// 3.1. Checkbox & Radio +@import './forms/check-radios'; + +// 3.2. Floating Labels +@import './forms/floating-label'; + +// 3.3. Form Controls +@import './forms/form-control'; + +// 3.4. Input Group +@import './forms/input-group'; + +// 3.5. Range +@import './forms/range'; + +// 3.6. Select +@import './forms/select'; + +// 3.7. Validation +@import './forms/validation'; + +/* + + 4. UTILITIES + +*/ + +@import './utilities'; + +/* + + 5. General + +*/ +@import './general'; + +/* + + 6. COLORS + +*/ +@import './colors'; diff --git a/src/assets/scss/_utilities.scss b/src/assets/scss/_utilities.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/assets/scss/_variables.scss b/src/assets/scss/_variables.scss new file mode 100644 index 0000000000..2871cb9df2 --- /dev/null +++ b/src/assets/scss/_variables.scss @@ -0,0 +1,30 @@ +// Colors + +$primary: #31bb6b; +$secondary: #707070; +$success: #31bb6b; +$warning: #febc59; + +$blue: #0d6efd; +$indigo: #6610f2; +$purple: #6f42c1; +$pink: #d63384; +$red: #dc3545; +$orange: #fd7e14; +$yellow: #ffc107; +$green: #198754; +$teal: #20c997; +$cyan: #0dcaf0; +$placeholder-bg: #f2f2f2; +// Colors + +// Links +$link-color: $blue !default; +$link-decoration: none !default; + +// Inputs and buttons +$input-bg: $placeholder-bg; +$input-border-width: 0; + +$input-btn-padding-y: 0.7rem; +$input-btn-padding-x: 1rem; diff --git a/src/assets/scss/app.scss b/src/assets/scss/app.scss new file mode 100644 index 0000000000..8685a742db --- /dev/null +++ b/src/assets/scss/app.scss @@ -0,0 +1,14 @@ +// Importing Bootstrap SCSS Functions and Mixins +@import '../../../node_modules/bootstrap/scss/functions'; +@import '../../../node_modules/bootstrap/scss/mixins'; + +// Importing Our Bootstrap SCSS Variables +@import './variables'; + +// Importing Bootstrap Variables and SCSS +@import '../../../node_modules/bootstrap/scss/variables'; +@import '../../../node_modules/bootstrap/scss/variables-dark'; +@import '../../../node_modules/bootstrap/scss/bootstrap.scss'; + +// Importing Our Bootstrap SCSS Overrides +@import './talawa'; diff --git a/src/assets/scss/components/_accordion.scss b/src/assets/scss/components/_accordion.scss new file mode 100644 index 0000000000..64eb323b55 --- /dev/null +++ b/src/assets/scss/components/_accordion.scss @@ -0,0 +1,36 @@ +$accordion-padding-y: 1.25rem; +$accordion-padding-x: 1.5rem; +$accordion-color: var(--#{$prefix}body-color); +$accordion-bg: var(--#{$prefix}body-bg); +$accordion-border-width: var(--#{$prefix}border-width); +$accordion-border-color: var(--#{$prefix}border-color); +$accordion-border-radius: var(--#{$prefix}border-radius); +$accordion-inner-border-radius: subtract( + $accordion-border-radius, + $accordion-border-width +); + +$accordion-body-padding-y: $accordion-padding-y; +$accordion-body-padding-x: $accordion-padding-x; + +$accordion-button-padding-y: $accordion-padding-y; +$accordion-button-padding-x: $accordion-padding-x; +$accordion-button-color: var(--#{$prefix}body-color); +$accordion-button-bg: var(--#{$prefix}accordion-bg); +$accordion-transition: + $btn-transition, + border-radius 0.15s ease; +$accordion-button-active-bg: var(--#{$prefix}primary-bg-subtle); +$accordion-button-active-color: var(--#{$prefix}primary-text-emphasis); + +$accordion-button-focus-border-color: $input-focus-border-color; +$accordion-button-focus-box-shadow: $btn-focus-box-shadow; + +$accordion-icon-width: 1.25rem; +$accordion-icon-color: $body-color; +$accordion-icon-active-color: $primary-text-emphasis; +$accordion-icon-transition: transform 0.2s ease-in-out; +$accordion-icon-transform: rotate(-180deg); + +$accordion-button-icon: url("data:image/svg+xml,"); +$accordion-button-active-icon: url("data:image/svg+xml,"); diff --git a/src/assets/scss/components/_alert.scss b/src/assets/scss/components/_alert.scss new file mode 100644 index 0000000000..64f29bf3c0 --- /dev/null +++ b/src/assets/scss/components/_alert.scss @@ -0,0 +1,10 @@ +$alert-padding-y: $spacer; +$alert-padding-x: $spacer; +$alert-margin-bottom: 1rem; +$alert-border-radius: var(--#{$prefix}border-radius); +$alert-link-font-weight: $font-weight-bold; +$alert-border-width: var(--#{$prefix}border-width); +$alert-bg-scale: -80%; +$alert-border-scale: -70%; +$alert-color-scale: 40%; +$alert-dismissible-padding-r: $alert-padding-x * 3; // 3x covers width of x plus default padding on either side diff --git a/src/assets/scss/components/_badge.scss b/src/assets/scss/components/_badge.scss new file mode 100644 index 0000000000..9b0c6a8a3a --- /dev/null +++ b/src/assets/scss/components/_badge.scss @@ -0,0 +1,6 @@ +$badge-font-size: 0.75em; +$badge-font-weight: $font-weight-bold; +$badge-color: $white; +$badge-padding-y: 0.35em; +$badge-padding-x: 0.65em; +$badge-border-radius: var(--#{$prefix}border-radius); diff --git a/src/assets/scss/components/_breadcrumb.scss b/src/assets/scss/components/_breadcrumb.scss new file mode 100644 index 0000000000..73fb6bd692 --- /dev/null +++ b/src/assets/scss/components/_breadcrumb.scss @@ -0,0 +1,11 @@ +$breadcrumb-font-size: null; +$breadcrumb-padding-y: 0; +$breadcrumb-padding-x: 0; +$breadcrumb-item-padding-x: 0.5rem; +$breadcrumb-margin-bottom: 1rem; +$breadcrumb-bg: null; +$breadcrumb-divider-color: var(--#{$prefix}secondary-color); +$breadcrumb-active-color: var(--#{$prefix}secondary-color); +$breadcrumb-divider: quote('/'); +$breadcrumb-divider-flipped: $breadcrumb-divider; +$breadcrumb-border-radius: null; diff --git a/src/assets/scss/components/_buttons.scss b/src/assets/scss/components/_buttons.scss new file mode 100644 index 0000000000..c0400fbeae --- /dev/null +++ b/src/assets/scss/components/_buttons.scss @@ -0,0 +1,73 @@ +$btn-color: $white; +$btn-padding-y: $input-btn-padding-y; +$btn-padding-x: $input-btn-padding-x; +$btn-font-family: $input-btn-font-family; +$btn-font-size: $input-btn-font-size; +$btn-line-height: $input-btn-line-height; +$btn-white-space: null; // Set to `nowrap` to prevent text wrapping + +$btn-padding-y-sm: $input-btn-padding-y-sm; +$btn-padding-x-sm: $input-btn-padding-x-sm; +$btn-font-size-sm: $input-btn-font-size-sm; + +$btn-padding-y-lg: $input-btn-padding-y-lg; +$btn-padding-x-lg: $input-btn-padding-x-lg; +$btn-font-size-lg: $input-btn-font-size-lg; + +$btn-border-width: $input-btn-border-width; + +$btn-font-weight: $font-weight-normal; +$btn-box-shadow: + inset 0 1px 0 rgba($white, 0.15), + 0 1px 1px rgba($black, 0.075); +$btn-focus-width: $input-btn-focus-width; +$btn-focus-box-shadow: $input-btn-focus-box-shadow; +$btn-disabled-opacity: 0.65; +$btn-active-box-shadow: inset 0 3px 5px rgba($black, 0.125); + +$btn-link-color: var(--#{$prefix}link-color); +$btn-link-hover-color: var(--#{$prefix}link-hover-color); +$btn-link-disabled-color: $gray-600; + +// Allows for customizing button radius independently from global border radius +$btn-border-radius: var(--#{$prefix}border-radius); +$btn-border-radius-sm: var(--#{$prefix}border-radius-sm); +$btn-border-radius-lg: var(--#{$prefix}border-radius-lg); + +$btn-transition: + color 0.15s ease-in-out, + background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; + +$btn-hover-bg-shade-amount: 15%; +$btn-hover-bg-tint-amount: 15%; +$btn-hover-border-shade-amount: 20%; +$btn-hover-border-tint-amount: 10%; +$btn-active-bg-shade-amount: 20%; +$btn-active-bg-tint-amount: 20%; +$btn-active-border-shade-amount: 25%; +$btn-active-border-tint-amount: 10%; + +.btn-primary, +.btn-secondary, +.btn-success, +.btn-warning, +.btn-info { + color: $white; + &:hover, + &:active { + color: $white !important; + } +} + +.btn-outline-primary, +.btn-outline-secondary, +.btn-outline-success, +.btn-outline-warning, +.btn-outline-info { + &:hover, + &:active { + color: $white !important; + } +} diff --git a/src/assets/scss/components/_card.scss b/src/assets/scss/components/_card.scss new file mode 100644 index 0000000000..5fa5abb4ee --- /dev/null +++ b/src/assets/scss/components/_card.scss @@ -0,0 +1,19 @@ +$card-spacer-y: $spacer; +$card-spacer-x: $spacer; +$card-title-spacer-y: $spacer * 0.5; +$card-title-color: null; +$card-subtitle-color: null; +$card-border-width: var(--#{$prefix}border-width); +$card-border-color: var(--#{$prefix}border-color-translucent); +$card-border-radius: var(--#{$prefix}border-radius); +$card-box-shadow: null; +$card-inner-border-radius: subtract($card-border-radius, $card-border-width); +$card-cap-padding-y: $card-spacer-y * 0.5; +$card-cap-padding-x: $card-spacer-x; +$card-cap-bg: rgba(var(--#{$prefix}body-color-rgb), 0.03); +$card-cap-color: null; +$card-height: null; +$card-color: null; +$card-bg: var(--#{$prefix}body-bg); +$card-img-overlay-padding: $spacer; +$card-group-margin: $grid-gutter-width * 0.5; diff --git a/src/assets/scss/components/_carousel.scss b/src/assets/scss/components/_carousel.scss new file mode 100644 index 0000000000..5f7d77e4c6 --- /dev/null +++ b/src/assets/scss/components/_carousel.scss @@ -0,0 +1,27 @@ +$carousel-control-color: $white; +$carousel-control-width: 15%; +$carousel-control-opacity: 0.5; +$carousel-control-hover-opacity: 0.9; +$carousel-control-transition: opacity 0.15s ease; + +$carousel-indicator-width: 30px; +$carousel-indicator-height: 3px; +$carousel-indicator-hit-area-height: 10px; +$carousel-indicator-spacer: 3px; +$carousel-indicator-opacity: 0.5; +$carousel-indicator-active-bg: $white; +$carousel-indicator-active-opacity: 1; +$carousel-indicator-transition: opacity 0.6s ease; + +$carousel-caption-width: 70%; +$carousel-caption-color: $white; +$carousel-caption-padding-y: 1.25rem; +$carousel-caption-spacer: 1.25rem; + +$carousel-control-icon-width: 2rem; + +$carousel-control-prev-icon-bg: url("data:image/svg+xml,"); +$carousel-control-next-icon-bg: url("data:image/svg+xml,"); + +$carousel-transition-duration: 0.6s; +$carousel-transition: transform $carousel-transition-duration ease-in-out; // Define transform transition first if using multiple transitions (e.g., `transform 2s ease, opacity .5s ease-out`) diff --git a/src/assets/scss/components/_close.scss b/src/assets/scss/components/_close.scss new file mode 100644 index 0000000000..2ae25d5353 --- /dev/null +++ b/src/assets/scss/components/_close.scss @@ -0,0 +1,12 @@ +$btn-close-width: 1em; +$btn-close-height: $btn-close-width; +$btn-close-padding-x: 0.25em; +$btn-close-padding-y: $btn-close-padding-x; +$btn-close-color: $black; +$btn-close-bg: url("data:image/svg+xml,"); +$btn-close-focus-shadow: $focus-ring-box-shadow; +$btn-close-opacity: 0.5; +$btn-close-hover-opacity: 0.75; +$btn-close-focus-opacity: 1; +$btn-close-disabled-opacity: 0.25; +$btn-close-white-filter: invert(1) grayscale(100%) brightness(200%); diff --git a/src/assets/scss/components/_dropdown.scss b/src/assets/scss/components/_dropdown.scss new file mode 100644 index 0000000000..1e1f1fa040 --- /dev/null +++ b/src/assets/scss/components/_dropdown.scss @@ -0,0 +1,35 @@ +$dropdown-min-width: 10rem; +$dropdown-padding-x: 0; +$dropdown-padding-y: 0.5rem; +$dropdown-spacer: 0.125rem; +$dropdown-font-size: $font-size-base; +$dropdown-color: var(--#{$prefix}body-color); +$dropdown-bg: var(--#{$prefix}body-bg); +$dropdown-border-color: var(--#{$prefix}border-color-translucent); +$dropdown-border-radius: var(--#{$prefix}border-radius); +$dropdown-border-width: var(--#{$prefix}border-width); +$dropdown-inner-border-radius: calc( + #{$dropdown-border-radius} - #{$dropdown-border-width} +); // stylelint-disable-line function-disallowed-list +$dropdown-divider-bg: $dropdown-border-color; +$dropdown-divider-margin-y: $spacer * 0.5; +$dropdown-box-shadow: $box-shadow; + +$dropdown-link-color: var(--#{$prefix}body-color); +$dropdown-link-hover-color: $dropdown-link-color; +$dropdown-link-hover-bg: var(--#{$prefix}tertiary-bg); + +$dropdown-link-active-color: $component-active-color; +$dropdown-link-active-bg: $component-active-bg; + +$dropdown-link-disabled-color: var(--#{$prefix}tertiary-color); + +$dropdown-item-padding-y: $spacer * 0.25; +$dropdown-item-padding-x: $spacer; + +$dropdown-header-color: $gray-600; +$dropdown-header-padding-x: $dropdown-item-padding-x; +$dropdown-header-padding-y: $dropdown-padding-y; +// fusv-disable +$dropdown-header-padding: $dropdown-header-padding-y $dropdown-header-padding-x; // Deprecated in v5.2.0 +// fusv-enable diff --git a/src/assets/scss/components/_list-group.scss b/src/assets/scss/components/_list-group.scss new file mode 100644 index 0000000000..2579f2e823 --- /dev/null +++ b/src/assets/scss/components/_list-group.scss @@ -0,0 +1,26 @@ +$list-group-color: var(--#{$prefix}body-color); +$list-group-bg: var(--#{$prefix}body-bg); +$list-group-border-color: var(--#{$prefix}border-color); +$list-group-border-width: var(--#{$prefix}border-width); +$list-group-border-radius: var(--#{$prefix}border-radius); + +$list-group-item-padding-y: $spacer * 0.5; +$list-group-item-padding-x: $spacer; +// fusv-disable +$list-group-item-bg-scale: -80%; // Deprecated in v5.3.0 +$list-group-item-color-scale: 40%; // Deprecated in v5.3.0 +// fusv-enable + +$list-group-hover-bg: var(--#{$prefix}tertiary-bg); +$list-group-active-color: $component-active-color; +$list-group-active-bg: $component-active-bg; +$list-group-active-border-color: $list-group-active-bg; + +$list-group-disabled-color: var(--#{$prefix}secondary-color); +$list-group-disabled-bg: $list-group-bg; + +$list-group-action-color: var(--#{$prefix}secondary-color); +$list-group-action-hover-color: var(--#{$prefix}emphasis-color); + +$list-group-action-active-color: var(--#{$prefix}body-color); +$list-group-action-active-bg: var(--#{$prefix}secondary-bg); diff --git a/src/assets/scss/components/_modal.scss b/src/assets/scss/components/_modal.scss new file mode 100644 index 0000000000..b491a19f1c --- /dev/null +++ b/src/assets/scss/components/_modal.scss @@ -0,0 +1,43 @@ +$modal-inner-padding: $spacer; + +$modal-footer-margin-between: 0.5rem; + +$modal-dialog-margin: 0.5rem; +$modal-dialog-margin-y-sm-up: 1.75rem; + +$modal-title-line-height: $line-height-base; + +$modal-content-color: null; +$modal-content-bg: var(--#{$prefix}body-bg); +$modal-content-border-color: var(--#{$prefix}border-color-translucent); +$modal-content-border-width: var(--#{$prefix}border-width); +$modal-content-border-radius: var(--#{$prefix}border-radius-lg); +$modal-content-inner-border-radius: subtract( + $modal-content-border-radius, + $modal-content-border-width +); +$modal-content-box-shadow-xs: $box-shadow-sm; +$modal-content-box-shadow-sm-up: $box-shadow; + +$modal-backdrop-bg: $black; +$modal-backdrop-opacity: 0.5; + +$modal-header-border-color: var(--#{$prefix}border-color); +$modal-header-border-width: 0; // We dont want border here +$modal-header-padding-y: $modal-inner-padding * 0.8; +$modal-header-padding-x: $modal-inner-padding; +$modal-header-padding: $modal-header-padding-y $modal-header-padding-x; // Keep this for backwards compatibility + +$modal-footer-bg: null; +$modal-footer-border-color: $modal-header-border-color; +$modal-footer-border-width: 0; // We don't want border here + +$modal-sm: 300px; +$modal-md: 500px; +$modal-lg: 800px; +$modal-xl: 1140px; + +$modal-fade-transform: translate(0, -50px); +$modal-show-transform: none; +$modal-transition: transform 0.3s ease-out; +$modal-scale-transform: scale(1.02); diff --git a/src/assets/scss/components/_nav.scss b/src/assets/scss/components/_nav.scss new file mode 100644 index 0000000000..84aeab343c --- /dev/null +++ b/src/assets/scss/components/_nav.scss @@ -0,0 +1,30 @@ +$nav-link-padding-y: 0.5rem; +$nav-link-padding-x: 1rem; +$nav-link-font-size: null; +$nav-link-font-weight: null; +$nav-link-color: var(--#{$prefix}link-color); +$nav-link-hover-color: var(--#{$prefix}link-hover-color); +$nav-link-transition: + color 0.15s ease-in-out, + background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out; +$nav-link-disabled-color: var(--#{$prefix}secondary-color); +$nav-link-focus-box-shadow: $focus-ring-box-shadow; + +$nav-tabs-border-color: var(--#{$prefix}border-color); +$nav-tabs-border-width: var(--#{$prefix}border-width); +$nav-tabs-border-radius: var(--#{$prefix}border-radius); +$nav-tabs-link-hover-border-color: var(--#{$prefix}secondary-bg) + var(--#{$prefix}secondary-bg) $nav-tabs-border-color; +$nav-tabs-link-active-color: var(--#{$prefix}emphasis-color); +$nav-tabs-link-active-bg: var(--#{$prefix}body-bg); +$nav-tabs-link-active-border-color: var(--#{$prefix}border-color) + var(--#{$prefix}border-color) $nav-tabs-link-active-bg; + +$nav-pills-border-radius: var(--#{$prefix}border-radius); +$nav-pills-link-active-color: $component-active-color; +$nav-pills-link-active-bg: $component-active-bg; + +$nav-underline-gap: 1rem; +$nav-underline-border-width: 0.125rem; +$nav-underline-link-active-color: var(--#{$prefix}emphasis-color); diff --git a/src/assets/scss/components/_navbar.scss b/src/assets/scss/components/_navbar.scss new file mode 100644 index 0000000000..aaf27bc8b2 --- /dev/null +++ b/src/assets/scss/components/_navbar.scss @@ -0,0 +1,31 @@ +$navbar-padding-y: $spacer * 0.5; +$navbar-padding-x: null; + +$navbar-nav-link-padding-x: 0.5rem; + +$navbar-brand-font-size: $font-size-lg; +// Compute the navbar-brand padding-y so the navbar-brand will have the same height as navbar-text and nav-link +$nav-link-height: $font-size-base * $line-height-base + $nav-link-padding-y * 2; +$navbar-brand-height: $navbar-brand-font-size * $line-height-base; +$navbar-brand-padding-y: ($nav-link-height - $navbar-brand-height) * 0.5; +$navbar-brand-margin-end: 1rem; + +$navbar-toggler-padding-y: 0.25rem; +$navbar-toggler-padding-x: 0.75rem; +$navbar-toggler-font-size: $font-size-lg; +$navbar-toggler-border-radius: $btn-border-radius; +$navbar-toggler-focus-width: $btn-focus-width; +$navbar-toggler-transition: box-shadow 0.15s ease-in-out; + +$navbar-light-color: rgba(var(--#{$prefix}emphasis-color-rgb), 0.65); +$navbar-light-hover-color: rgba(var(--#{$prefix}emphasis-color-rgb), 0.8); +$navbar-light-active-color: rgba(var(--#{$prefix}emphasis-color-rgb), 1); +$navbar-light-disabled-color: rgba(var(--#{$prefix}emphasis-color-rgb), 0.3); +$navbar-light-icon-color: rgba($body-color, 0.75); +$navbar-light-toggler-icon-bg: url("data:image/svg+xml,"); +$navbar-light-toggler-border-color: rgba( + var(--#{$prefix}emphasis-color-rgb), + 0.15 +); +$navbar-light-brand-color: $navbar-light-active-color; +$navbar-light-brand-hover-color: $navbar-light-active-color; diff --git a/src/assets/scss/components/_offcanvas.scss b/src/assets/scss/components/_offcanvas.scss new file mode 100644 index 0000000000..3226f970f4 --- /dev/null +++ b/src/assets/scss/components/_offcanvas.scss @@ -0,0 +1,13 @@ +$offcanvas-padding-y: $modal-inner-padding; +$offcanvas-padding-x: $modal-inner-padding; +$offcanvas-horizontal-width: 400px; +$offcanvas-vertical-height: 30vh; +$offcanvas-transition-duration: 0.3s; +$offcanvas-border-color: $modal-content-border-color; +$offcanvas-border-width: $modal-content-border-width; +$offcanvas-title-line-height: $modal-title-line-height; +$offcanvas-bg-color: var(--#{$prefix}body-bg); +$offcanvas-color: var(--#{$prefix}body-color); +$offcanvas-box-shadow: $modal-content-box-shadow-xs; +$offcanvas-backdrop-bg: $modal-backdrop-bg; +$offcanvas-backdrop-opacity: $modal-backdrop-opacity; diff --git a/src/assets/scss/components/_pagination.scss b/src/assets/scss/components/_pagination.scss new file mode 100644 index 0000000000..830c140492 --- /dev/null +++ b/src/assets/scss/components/_pagination.scss @@ -0,0 +1,44 @@ +$pagination-padding-y: 0.375rem; +$pagination-padding-x: 0.75rem; +$pagination-padding-y-sm: 0.25rem; +$pagination-padding-x-sm: 0.5rem; +$pagination-padding-y-lg: 0.75rem; +$pagination-padding-x-lg: 1.5rem; + +$pagination-font-size: $font-size-base; + +$pagination-color: var(--#{$prefix}link-color); +$pagination-bg: var(--#{$prefix}body-bg); +$pagination-border-radius: var(--#{$prefix}border-radius); +$pagination-border-width: var(--#{$prefix}border-width); +$pagination-margin-start: calc( + #{$pagination-border-width} * -1 +); // stylelint-disable-line function-disallowed-list +$pagination-border-color: var(--#{$prefix}border-color); + +$pagination-focus-color: var(--#{$prefix}link-hover-color); +$pagination-focus-bg: var(--#{$prefix}secondary-bg); +$pagination-focus-box-shadow: $focus-ring-box-shadow; +$pagination-focus-outline: 0; + +$pagination-hover-color: var(--#{$prefix}link-hover-color); +$pagination-hover-bg: var(--#{$prefix}tertiary-bg); +$pagination-hover-border-color: var( + --#{$prefix}border-color +); // Todo in v6: remove this? + +$pagination-active-color: $component-active-color; +$pagination-active-bg: $component-active-bg; +$pagination-active-border-color: $component-active-bg; + +$pagination-disabled-color: var(--#{$prefix}secondary-color); +$pagination-disabled-bg: var(--#{$prefix}secondary-bg); +$pagination-disabled-border-color: var(--#{$prefix}border-color); + +$pagination-transition: + color 0.15s ease-in-out, + background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out; + +$pagination-border-radius-sm: var(--#{$prefix}border-radius-sm); +$pagination-border-radius-lg: var(--#{$prefix}border-radius-lg); diff --git a/src/assets/scss/components/_placeholder.scss b/src/assets/scss/components/_placeholder.scss new file mode 100644 index 0000000000..8e7b77d0a4 --- /dev/null +++ b/src/assets/scss/components/_placeholder.scss @@ -0,0 +1,2 @@ +$placeholder-opacity-max: 0.5; +$placeholder-opacity-min: 0.2; diff --git a/src/assets/scss/components/_progress.scss b/src/assets/scss/components/_progress.scss new file mode 100644 index 0000000000..a40fa4676e --- /dev/null +++ b/src/assets/scss/components/_progress.scss @@ -0,0 +1,17 @@ +$progress-height: 1rem; +$progress-font-size: $font-size-base * 0.75; +$progress-bg: var(--#{$prefix}secondary-bg); +$progress-border-radius: var(--#{$prefix}border-radius); +$progress-box-shadow: var(--#{$prefix}box-shadow-inset); +$progress-bar-color: $white; +$progress-bar-bg: $primary; +$progress-bar-animation-timing: 1s linear infinite; +$progress-bar-transition: width 0.6s ease; + +@if $enable-transitions { + @keyframes progress-bar-stripes { + 0% { + background-position-x: $progress-height; + } + } +} diff --git a/src/assets/scss/components/_spinners.scss b/src/assets/scss/components/_spinners.scss new file mode 100644 index 0000000000..08f4c51c90 --- /dev/null +++ b/src/assets/scss/components/_spinners.scss @@ -0,0 +1,24 @@ +$spinner-width: 2rem; +$spinner-height: $spinner-width; +$spinner-vertical-align: -0.125em; +$spinner-border-width: 0.25em; +$spinner-animation-speed: 0.75s; + +$spinner-width-sm: 1rem; +$spinner-height-sm: $spinner-width-sm; +$spinner-border-width-sm: 0.2em; + +@keyframes spinner-border { + to { + transform: rotate(360deg) #{'/* rtl:ignore */'}; + } +} +@keyframes spinner-grow { + 0% { + transform: scale(0); + } + 50% { + opacity: 1; + transform: none; + } +} diff --git a/src/assets/scss/content/_table.scss b/src/assets/scss/content/_table.scss new file mode 100644 index 0000000000..f5c9c608fb --- /dev/null +++ b/src/assets/scss/content/_table.scss @@ -0,0 +1,37 @@ +$table-cell-padding-y: 0.5rem; +$table-cell-padding-x: 0.5rem; +$table-cell-padding-y-sm: 0.25rem; +$table-cell-padding-x-sm: 0.25rem; + +$table-cell-vertical-align: top; + +$table-color: var(--#{$prefix}body-color); +$table-bg: var(--#{$prefix}body-bg); +$table-accent-bg: transparent; + +$table-th-font-weight: null; + +$table-striped-color: $table-color; +$table-striped-bg-factor: 0.05; +$table-striped-bg: rgba($black, $table-striped-bg-factor); + +$table-active-color: $table-color; +$table-active-bg-factor: 0.1; +$table-active-bg: rgba($black, $table-active-bg-factor); + +$table-hover-color: $table-color; +$table-hover-bg-factor: 0.075; +$table-hover-bg: rgba($black, $table-hover-bg-factor); + +$table-border-factor: 0.1; +$table-border-width: var(--#{$prefix}border-width); +$table-border-color: var(--#{$prefix}border-color); + +$table-striped-order: odd; +$table-striped-columns-order: even; + +$table-group-separator-color: currentcolor; + +$table-caption-color: var(--#{$prefix}secondary-color); + +$table-bg-scale: -80%; diff --git a/src/assets/scss/content/_typography.scss b/src/assets/scss/content/_typography.scss new file mode 100644 index 0000000000..6336f8554e --- /dev/null +++ b/src/assets/scss/content/_typography.scss @@ -0,0 +1,69 @@ +/* + DISPLAY SASS VARIABLES +*/ + +$display-font-sizes: ( + 1: 5rem, + 2: 4.5rem, + 3: 4rem, + 4: 3.5rem, + 5: 3rem, + 6: 2.5rem, +); + +$display-font-family: null; +$display-font-style: null; +$display-font-weight: 300; +$display-line-height: $headings-line-height; + +/* + DISPLAY SASS VARIABLES +*/ + +$lead-font-size: $font-size-base * 1.25; +$lead-font-weight: 300; + +$small-font-size: 0.875em; + +$sub-sup-font-size: 0.75em; + +// fusv-disable +$text-muted: var(--#{$prefix}secondary-color); // Deprecated in 5.3.0 +// fusv-enable + +$headings-margin-bottom: $spacer * 0.5; +$headings-font-family: null; +$headings-font-style: null; +$headings-font-weight: 500; +$headings-line-height: 1.2; +$headings-color: inherit; + +$initialism-font-size: $small-font-size; + +$blockquote-margin-y: $spacer; +$blockquote-font-size: $font-size-base * 1.25; +$blockquote-footer-color: $gray-600; +$blockquote-footer-font-size: $small-font-size; + +$hr-margin-y: $spacer; +$hr-color: inherit; + +// fusv-disable +$hr-bg-color: null; // Deprecated in v5.2.0 +$hr-height: null; // Deprecated in v5.2.0 +// fusv-enable + +$hr-border-color: null; // Allows for inherited colors +$hr-border-width: var(--#{$prefix}border-width); +$hr-opacity: 0.25; + +$legend-margin-bottom: 0.5rem; +$legend-font-size: 1.5rem; +$legend-font-weight: null; + +$dt-font-weight: $font-weight-bold; + +$list-inline-padding: 0.5rem; + +$mark-padding: 0.1875em; +$mark-bg: $yellow-100; diff --git a/src/assets/scss/forms/_check-radios.scss b/src/assets/scss/forms/_check-radios.scss new file mode 100644 index 0000000000..1ed4d32fd0 --- /dev/null +++ b/src/assets/scss/forms/_check-radios.scss @@ -0,0 +1,34 @@ +$form-check-input-width: 1em; +$form-check-min-height: $font-size-base * $line-height-base; +$form-check-padding-start: $form-check-input-width + 0.5em; +$form-check-margin-bottom: 0.125rem; +$form-check-label-color: null; +$form-check-label-cursor: null; +$form-check-transition: null; + +$form-check-input-active-filter: brightness(90%); + +$form-check-input-bg: $input-bg; +$form-check-input-border: var(--#{$prefix}border-width) solid + var(--#{$prefix}border-color); +$form-check-input-border-radius: 0.25em; +$form-check-radio-border-radius: 50%; +$form-check-input-focus-border: $input-focus-border-color; +$form-check-input-focus-box-shadow: $focus-ring-box-shadow; + +$form-check-input-checked-color: $component-active-color; +$form-check-input-checked-bg-color: $component-active-bg; +$form-check-input-checked-border-color: $form-check-input-checked-bg-color; +$form-check-input-checked-bg-image: url("data:image/svg+xml,"); +$form-check-radio-checked-bg-image: url("data:image/svg+xml,"); + +$form-check-input-indeterminate-color: $component-active-color; +$form-check-input-indeterminate-bg-color: $component-active-bg; +$form-check-input-indeterminate-border-color: $form-check-input-indeterminate-bg-color; +$form-check-input-indeterminate-bg-image: url("data:image/svg+xml,"); + +$form-check-input-disabled-opacity: 0.5; +$form-check-label-disabled-opacity: $form-check-input-disabled-opacity; +$form-check-btn-check-disabled-opacity: $btn-disabled-opacity; + +$form-check-inline-margin-end: 1rem; diff --git a/src/assets/scss/forms/_floating-label.scss b/src/assets/scss/forms/_floating-label.scss new file mode 100644 index 0000000000..8ab47c4a25 --- /dev/null +++ b/src/assets/scss/forms/_floating-label.scss @@ -0,0 +1,14 @@ +$form-floating-height: add(3.5rem, $input-height-border); +$form-floating-line-height: 1.25; +$form-floating-padding-x: $input-padding-x; +$form-floating-padding-y: 1rem; +$form-floating-input-padding-t: 1.625rem; +$form-floating-input-padding-b: 0.625rem; +$form-floating-label-height: 1.5em; +$form-floating-label-opacity: 0.65; +$form-floating-label-transform: scale(0.85) translateY(-0.5rem) + translateX(0.15rem); +$form-floating-label-disabled-color: $gray-600; +$form-floating-transition: + opacity 0.1s ease-in-out, + transform 0.1s ease-in-out; diff --git a/src/assets/scss/forms/_form-control.scss b/src/assets/scss/forms/_form-control.scss new file mode 100644 index 0000000000..230b3ec76b --- /dev/null +++ b/src/assets/scss/forms/_form-control.scss @@ -0,0 +1,108 @@ +$input-btn-padding-y: 0.375rem; +$input-btn-padding-x: 0.75rem; +$input-btn-font-family: null; +$input-btn-font-size: $font-size-base; +$input-btn-line-height: $line-height-base; + +$input-btn-focus-width: $focus-ring-width; +$input-btn-focus-color-opacity: $focus-ring-opacity; +$input-btn-focus-color: $focus-ring-color; +$input-btn-focus-blur: $focus-ring-blur; +$input-btn-focus-box-shadow: $focus-ring-box-shadow; + +$input-btn-padding-y-sm: 0.25rem; +$input-btn-padding-x-sm: 0.5rem; +$input-btn-font-size-sm: $font-size-sm; + +$input-btn-padding-y-lg: 0.5rem; +$input-btn-padding-x-lg: 1rem; +$input-btn-font-size-lg: $font-size-lg; + +$input-btn-border-width: var(--#{$prefix}border-width); + +$input-padding-y: $input-btn-padding-y; +$input-padding-x: $input-btn-padding-x; +$input-font-family: $input-btn-font-family; +$input-font-size: $input-btn-font-size; +$input-font-weight: $font-weight-base; +$input-line-height: $input-btn-line-height; + +$input-padding-y-sm: $input-btn-padding-y-sm; +$input-padding-x-sm: $input-btn-padding-x-sm; +$input-font-size-sm: $input-btn-font-size-sm; + +$input-padding-y-lg: $input-btn-padding-y-lg; +$input-padding-x-lg: $input-btn-padding-x-lg; +$input-font-size-lg: $input-btn-font-size-lg; + +$input-bg: var(--#{$prefix}body-bg); +$input-disabled-color: null; +$input-disabled-bg: var(--#{$prefix}secondary-bg); +$input-disabled-border-color: null; + +$input-color: var(--#{$prefix}body-color); +$input-border-color: var(--#{$prefix}border-color); +$input-border-width: $input-btn-border-width; +$input-box-shadow: $box-shadow-inset; + +$input-border-radius: var(--#{$prefix}border-radius); +$input-border-radius-sm: var(--#{$prefix}border-radius-sm); +$input-border-radius-lg: var(--#{$prefix}border-radius-lg); + +$input-focus-bg: $input-bg; +$input-focus-border-color: tint-color($component-active-bg, 50%); +$input-focus-color: $input-color; +$input-focus-width: $input-btn-focus-width; +$input-focus-box-shadow: $input-btn-focus-box-shadow; + +$input-placeholder-color: var(--#{$prefix}secondary-color); +$input-plaintext-color: var(--#{$prefix}body-color); + +$input-height-border: calc( + #{$input-border-width} * 2 +); // stylelint-disable-line function-disallowed-list + +$input-height-inner: add($input-line-height * 1em, $input-padding-y * 2); +$input-height-inner-half: add($input-line-height * 0.5em, $input-padding-y); +$input-height-inner-quarter: add( + $input-line-height * 0.25em, + $input-padding-y * 0.5 +); + +$input-height: add( + $input-line-height * 1em, + add($input-padding-y * 2, $input-height-border, false) +); +$input-height-sm: add( + $input-line-height * 1em, + add($input-padding-y-sm * 2, $input-height-border, false) +); +$input-height-lg: add( + $input-line-height * 1em, + add($input-padding-y-lg * 2, $input-height-border, false) +); + +$input-transition: + border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; + +$form-color-width: 3rem; + +// Form Label Text +$form-label-margin-bottom: 0.5rem; +$form-label-font-size: null; +$form-label-font-style: null; +$form-label-font-weight: null; +$form-label-color: null; + +// Form Text +$form-text-margin-top: 0.25rem; +$form-text-font-size: $small-font-size; +$form-text-font-style: null; +$form-text-font-weight: null; +$form-text-color: var(--#{$prefix}secondary-color); + +// Form File Button +$form-file-button-color: $input-color; +$form-file-button-bg: var(--#{$prefix}tertiary-bg); +$form-file-button-hover-bg: var(--#{$prefix}secondary-bg); diff --git a/src/assets/scss/forms/_input-group.scss b/src/assets/scss/forms/_input-group.scss new file mode 100644 index 0000000000..cd50a59126 --- /dev/null +++ b/src/assets/scss/forms/_input-group.scss @@ -0,0 +1,6 @@ +$input-group-addon-padding-y: $input-padding-y; +$input-group-addon-padding-x: $input-padding-x; +$input-group-addon-font-weight: $input-font-weight; +$input-group-addon-color: $input-color; +$input-group-addon-bg: var(--#{$prefix}tertiary-bg); +$input-group-addon-border-color: $input-border-color; diff --git a/src/assets/scss/forms/_range.scss b/src/assets/scss/forms/_range.scss new file mode 100644 index 0000000000..c0cdc61171 --- /dev/null +++ b/src/assets/scss/forms/_range.scss @@ -0,0 +1,23 @@ +$form-range-track-width: 100%; +$form-range-track-height: 0.5rem; +$form-range-track-cursor: pointer; +$form-range-track-bg: var(--#{$prefix}tertiary-bg); +$form-range-track-border-radius: 1rem; +$form-range-track-box-shadow: $box-shadow-inset; + +$form-range-thumb-width: 1rem; +$form-range-thumb-height: $form-range-thumb-width; +$form-range-thumb-bg: $component-active-bg; +$form-range-thumb-border: 0; +$form-range-thumb-border-radius: 1rem; +$form-range-thumb-box-shadow: 0 0.1rem 0.25rem rgba($black, 0.1); +$form-range-thumb-focus-box-shadow: + 0 0 0 1px $body-bg, + $input-focus-box-shadow; +$form-range-thumb-focus-box-shadow-width: $input-focus-width; // For focus box shadow issue in Edge +$form-range-thumb-active-bg: tint-color($component-active-bg, 70%); +$form-range-thumb-disabled-bg: var(--#{$prefix}secondary-color); +$form-range-thumb-transition: + background-color 0.15s ease-in-out, + border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; diff --git a/src/assets/scss/forms/_select.scss b/src/assets/scss/forms/_select.scss new file mode 100644 index 0000000000..4d5c428636 --- /dev/null +++ b/src/assets/scss/forms/_select.scss @@ -0,0 +1,44 @@ +$form-select-padding-y: $input-padding-y; +$form-select-padding-x: $input-padding-x; +$form-select-font-family: $input-font-family; +$form-select-font-size: $input-font-size; +$form-select-indicator-padding: $form-select-padding-x * 3; // Extra padding for background-image +$form-select-font-weight: $input-font-weight; +$form-select-line-height: $input-line-height; +$form-select-color: $input-color; +$form-select-bg: $input-bg; +$form-select-disabled-color: null; +$form-select-disabled-bg: $input-disabled-bg; +$form-select-disabled-border-color: $input-disabled-border-color; +$form-select-bg-position: right $form-select-padding-x center; +$form-select-bg-size: 16px 12px; // In pixels because image dimensions +$form-select-indicator-color: $gray-800; +$form-select-indicator: url("data:image/svg+xml,"); + +$form-select-feedback-icon-padding-end: $form-select-padding-x * 2.5 + + $form-select-indicator-padding; +$form-select-feedback-icon-position: center right $form-select-indicator-padding; +$form-select-feedback-icon-size: $input-height-inner-half + $input-height-inner-half; + +$form-select-border-width: $input-border-width; +$form-select-border-color: $input-border-color; +$form-select-border-radius: $input-border-radius; +$form-select-box-shadow: $box-shadow-inset; + +$form-select-focus-border-color: $input-focus-border-color; +$form-select-focus-width: $input-focus-width; +$form-select-focus-box-shadow: 0 0 0 $form-select-focus-width + $input-btn-focus-color; + +$form-select-padding-y-sm: $input-padding-y-sm; +$form-select-padding-x-sm: $input-padding-x-sm; +$form-select-font-size-sm: $input-font-size-sm; +$form-select-border-radius-sm: $input-border-radius-sm; + +$form-select-padding-y-lg: $input-padding-y-lg; +$form-select-padding-x-lg: $input-padding-x-lg; +$form-select-font-size-lg: $input-font-size-lg; +$form-select-border-radius-lg: $input-border-radius-lg; + +$form-select-transition: $input-transition; diff --git a/src/assets/scss/forms/_validation.scss b/src/assets/scss/forms/_validation.scss new file mode 100644 index 0000000000..9877dfe327 --- /dev/null +++ b/src/assets/scss/forms/_validation.scss @@ -0,0 +1,20 @@ +$form-feedback-margin-top: $form-text-margin-top; +$form-feedback-font-size: $form-text-font-size; +$form-feedback-font-style: $form-text-font-style; +$form-feedback-valid-color: $success; +$form-feedback-invalid-color: $danger; + +$form-feedback-icon-valid-color: $form-feedback-valid-color; +$form-feedback-icon-valid: url("data:image/svg+xml,"); +$form-feedback-icon-invalid-color: $form-feedback-invalid-color; +$form-feedback-icon-invalid: url("data:image/svg+xml,"); + +$form-valid-color: $form-feedback-valid-color; +$form-valid-border-color: $form-feedback-valid-color; +$form-invalid-color: $form-feedback-invalid-color; +$form-invalid-border-color: $form-feedback-invalid-color; + +$form-valid-color-dark: $green-300; +$form-valid-border-color-dark: $green-300; +$form-invalid-color-dark: $red-300; +$form-invalid-border-color-dark: $red-300; diff --git a/src/assets/svgs/Attendance.svg b/src/assets/svgs/Attendance.svg new file mode 100644 index 0000000000..8dbc7b07ce --- /dev/null +++ b/src/assets/svgs/Attendance.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/assets/svgs/actionItem.svg b/src/assets/svgs/actionItem.svg new file mode 100644 index 0000000000..d660b378cd --- /dev/null +++ b/src/assets/svgs/actionItem.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/assets/svgs/admin.svg b/src/assets/svgs/admin.svg new file mode 100644 index 0000000000..8ee42f611d --- /dev/null +++ b/src/assets/svgs/admin.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/svgs/agenda-category-icon.svg b/src/assets/svgs/agenda-category-icon.svg new file mode 100644 index 0000000000..8e3d4562f3 --- /dev/null +++ b/src/assets/svgs/agenda-category-icon.svg @@ -0,0 +1 @@ +checklist \ No newline at end of file diff --git a/src/assets/svgs/agenda-items.svg b/src/assets/svgs/agenda-items.svg new file mode 100644 index 0000000000..343d1808b4 --- /dev/null +++ b/src/assets/svgs/agenda-items.svg @@ -0,0 +1 @@ +notebook diff --git a/src/assets/svgs/angleRight.svg b/src/assets/svgs/angleRight.svg new file mode 100644 index 0000000000..4a3a498877 --- /dev/null +++ b/src/assets/svgs/angleRight.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/article.svg b/src/assets/svgs/article.svg new file mode 100644 index 0000000000..e828aa40fc --- /dev/null +++ b/src/assets/svgs/article.svg @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/src/assets/svgs/blockUser.svg b/src/assets/svgs/blockUser.svg new file mode 100644 index 0000000000..f9aef51775 --- /dev/null +++ b/src/assets/svgs/blockUser.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/blockedUser.svg b/src/assets/svgs/blockedUser.svg new file mode 100644 index 0000000000..bbe0a51f84 --- /dev/null +++ b/src/assets/svgs/blockedUser.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/cardItemDate.svg b/src/assets/svgs/cardItemDate.svg new file mode 100644 index 0000000000..e3e738a3dc --- /dev/null +++ b/src/assets/svgs/cardItemDate.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/cardItemEvent.svg b/src/assets/svgs/cardItemEvent.svg new file mode 100644 index 0000000000..e37a084018 --- /dev/null +++ b/src/assets/svgs/cardItemEvent.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/cardItemLocation.svg b/src/assets/svgs/cardItemLocation.svg new file mode 100644 index 0000000000..1518f97a75 --- /dev/null +++ b/src/assets/svgs/cardItemLocation.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svgs/chat.svg b/src/assets/svgs/chat.svg new file mode 100644 index 0000000000..72787fab78 --- /dev/null +++ b/src/assets/svgs/chat.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svgs/checkInRegistrants.svg b/src/assets/svgs/checkInRegistrants.svg new file mode 100644 index 0000000000..0a663e3876 --- /dev/null +++ b/src/assets/svgs/checkInRegistrants.svg @@ -0,0 +1 @@ +check_inCreated with Sketch. \ No newline at end of file diff --git a/src/assets/svgs/dashboard.svg b/src/assets/svgs/dashboard.svg new file mode 100644 index 0000000000..12e8b0fe63 --- /dev/null +++ b/src/assets/svgs/dashboard.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/date.svg b/src/assets/svgs/date.svg new file mode 100644 index 0000000000..9baf0768c4 --- /dev/null +++ b/src/assets/svgs/date.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svgs/event.svg b/src/assets/svgs/event.svg new file mode 100644 index 0000000000..3c73e7b04e --- /dev/null +++ b/src/assets/svgs/event.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/events.svg b/src/assets/svgs/events.svg new file mode 100644 index 0000000000..95b8a3b587 --- /dev/null +++ b/src/assets/svgs/events.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/flask.svg b/src/assets/svgs/flask.svg new file mode 100644 index 0000000000..599220a9d2 --- /dev/null +++ b/src/assets/svgs/flask.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svgs/funds.svg b/src/assets/svgs/funds.svg new file mode 100644 index 0000000000..372a258577 --- /dev/null +++ b/src/assets/svgs/funds.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svgs/key.svg b/src/assets/svgs/key.svg new file mode 100644 index 0000000000..a1f47615e8 --- /dev/null +++ b/src/assets/svgs/key.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svgs/listEventRegistrants.svg b/src/assets/svgs/listEventRegistrants.svg new file mode 100644 index 0000000000..4d2874d641 --- /dev/null +++ b/src/assets/svgs/listEventRegistrants.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svgs/location.svg b/src/assets/svgs/location.svg new file mode 100644 index 0000000000..b75f616dd6 --- /dev/null +++ b/src/assets/svgs/location.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svgs/logout.svg b/src/assets/svgs/logout.svg new file mode 100644 index 0000000000..e71a973f0d --- /dev/null +++ b/src/assets/svgs/logout.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/media.svg b/src/assets/svgs/media.svg new file mode 100644 index 0000000000..956c1e9a19 --- /dev/null +++ b/src/assets/svgs/media.svg @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/src/assets/svgs/newChat.svg b/src/assets/svgs/newChat.svg new file mode 100644 index 0000000000..e3609e2895 --- /dev/null +++ b/src/assets/svgs/newChat.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svgs/organizations.svg b/src/assets/svgs/organizations.svg new file mode 100644 index 0000000000..5c616655d2 --- /dev/null +++ b/src/assets/svgs/organizations.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/svgs/palisadoes.svg b/src/assets/svgs/palisadoes.svg new file mode 100644 index 0000000000..dc57b69a42 --- /dev/null +++ b/src/assets/svgs/palisadoes.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/svgs/people.svg b/src/assets/svgs/people.svg new file mode 100644 index 0000000000..cd8134aa67 --- /dev/null +++ b/src/assets/svgs/people.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svgs/plugins.svg b/src/assets/svgs/plugins.svg new file mode 100644 index 0000000000..7da1701dc1 --- /dev/null +++ b/src/assets/svgs/plugins.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/svgs/post.svg b/src/assets/svgs/post.svg new file mode 100644 index 0000000000..34e468523b --- /dev/null +++ b/src/assets/svgs/post.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/posts.svg b/src/assets/svgs/posts.svg new file mode 100644 index 0000000000..181e25855a --- /dev/null +++ b/src/assets/svgs/posts.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/requests.svg b/src/assets/svgs/requests.svg new file mode 100644 index 0000000000..ccf700e5fe --- /dev/null +++ b/src/assets/svgs/requests.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/svgs/roles.svg b/src/assets/svgs/roles.svg new file mode 100644 index 0000000000..bc301784f9 --- /dev/null +++ b/src/assets/svgs/roles.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/settings.svg b/src/assets/svgs/settings.svg new file mode 100644 index 0000000000..fc111cfc08 --- /dev/null +++ b/src/assets/svgs/settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svgs/social-icons/Facebook-Logo.svg b/src/assets/svgs/social-icons/Facebook-Logo.svg new file mode 100644 index 0000000000..a70f112dc7 --- /dev/null +++ b/src/assets/svgs/social-icons/Facebook-Logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svgs/social-icons/Github-Logo.svg b/src/assets/svgs/social-icons/Github-Logo.svg new file mode 100644 index 0000000000..7679af859c --- /dev/null +++ b/src/assets/svgs/social-icons/Github-Logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svgs/social-icons/Instagram-Logo.svg b/src/assets/svgs/social-icons/Instagram-Logo.svg new file mode 100644 index 0000000000..cbf278ae75 --- /dev/null +++ b/src/assets/svgs/social-icons/Instagram-Logo.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/svgs/social-icons/Linkedin-Logo.svg b/src/assets/svgs/social-icons/Linkedin-Logo.svg new file mode 100644 index 0000000000..998dd1b826 --- /dev/null +++ b/src/assets/svgs/social-icons/Linkedin-Logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svgs/social-icons/Reddit-Logo.svg b/src/assets/svgs/social-icons/Reddit-Logo.svg new file mode 100644 index 0000000000..8111415030 --- /dev/null +++ b/src/assets/svgs/social-icons/Reddit-Logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/assets/svgs/social-icons/Slack-Logo.svg b/src/assets/svgs/social-icons/Slack-Logo.svg new file mode 100644 index 0000000000..29a9087d21 --- /dev/null +++ b/src/assets/svgs/social-icons/Slack-Logo.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/assets/svgs/social-icons/X-Logo.svg b/src/assets/svgs/social-icons/X-Logo.svg new file mode 100644 index 0000000000..efa659339d --- /dev/null +++ b/src/assets/svgs/social-icons/X-Logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svgs/social-icons/Youtube-Logo.svg b/src/assets/svgs/social-icons/Youtube-Logo.svg new file mode 100644 index 0000000000..112b9c2d3b --- /dev/null +++ b/src/assets/svgs/social-icons/Youtube-Logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svgs/social-icons/index.tsx b/src/assets/svgs/social-icons/index.tsx new file mode 100644 index 0000000000..20544051e5 --- /dev/null +++ b/src/assets/svgs/social-icons/index.tsx @@ -0,0 +1,19 @@ +import FacebookLogo from './Facebook-Logo.svg'; +import GithubLogo from './Github-Logo.svg'; +import InstagramLogo from './Instagram-Logo.svg'; +import LinkedInLogo from './Linkedin-Logo.svg'; +import SlackLogo from './Slack-Logo.svg'; +import XLogo from './X-Logo.svg'; +import YoutubeLogo from './Youtube-Logo.svg'; +import RedditLogo from './Reddit-Logo.svg'; + +export { + FacebookLogo, + GithubLogo, + InstagramLogo, + LinkedInLogo, + SlackLogo, + XLogo, + YoutubeLogo, + RedditLogo, +}; diff --git a/src/assets/svgs/tag.svg b/src/assets/svgs/tag.svg new file mode 100644 index 0000000000..e3646993be --- /dev/null +++ b/src/assets/svgs/tag.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svgs/tags.svg b/src/assets/svgs/tags.svg new file mode 100644 index 0000000000..32cb76851a --- /dev/null +++ b/src/assets/svgs/tags.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/svgs/talawa.svg b/src/assets/svgs/talawa.svg new file mode 100644 index 0000000000..0c89afbcca --- /dev/null +++ b/src/assets/svgs/talawa.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/svgs/user.svg b/src/assets/svgs/user.svg new file mode 100644 index 0000000000..b23b34481e --- /dev/null +++ b/src/assets/svgs/user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/svgs/userEvent.svg b/src/assets/svgs/userEvent.svg new file mode 100644 index 0000000000..1623ca2e07 --- /dev/null +++ b/src/assets/svgs/userEvent.svg @@ -0,0 +1,14 @@ + + + \ No newline at end of file diff --git a/src/assets/svgs/users.svg b/src/assets/svgs/users.svg new file mode 100644 index 0000000000..a1a474206d --- /dev/null +++ b/src/assets/svgs/users.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/svgs/venues.svg b/src/assets/svgs/venues.svg new file mode 100644 index 0000000000..c8cd52f4e7 --- /dev/null +++ b/src/assets/svgs/venues.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/talawa-logo-lite-200x200.png b/src/assets/talawa-logo-lite-200x200.png deleted file mode 100644 index 9d34137661..0000000000 Binary files a/src/assets/talawa-logo-lite-200x200.png and /dev/null differ diff --git a/src/components/AddOn/AddOn.module.css b/src/components/AddOn/AddOn.module.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/components/AddOn/AddOn.test.tsx b/src/components/AddOn/AddOn.test.tsx new file mode 100644 index 0000000000..68475e8196 --- /dev/null +++ b/src/components/AddOn/AddOn.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; + +import { store } from 'state/store'; +import AddOn from './AddOn'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +const link = new StaticMockLink([], true); +describe('Testing Addon component', () => { + const props = { + children: 'This is a dummy text', + }; + + test('should render with default props', () => { + const { getByTestId } = render(); + const container = getByTestId('pluginContainer'); + expect(container).toBeInTheDocument(); + expect(container).toHaveClass('plugin-container'); + expect(container).toHaveTextContent('Default text'); + }); + + test('should render props and text elements test for the page component', () => { + const { getByTestId, getByText } = render( + + + + + + + + + , + ); + + expect(getByTestId('pluginContainer')).toBeInTheDocument(); + expect(getByText(props.children)).toBeInTheDocument(); + }); +}); diff --git a/src/components/AddOn/AddOn.tsx b/src/components/AddOn/AddOn.tsx new file mode 100644 index 0000000000..850cc05ee4 --- /dev/null +++ b/src/components/AddOn/AddOn.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +interface InterfaceAddOnProps { + extras?: any; + name?: string; + children?: React.ReactNode; +} + +/** + * The AddOn component is used to wrap children within a plugin container. + * It also accepts additional properties (`extras` and `name`) to allow for + * extensibility and custom naming. + * + * @param props - The props for the AddOn component. + * @param extras - Additional properties for the AddOn component. + * @param name - The name for the AddOn component. + * @param children - The child elements to be rendered within the AddOn component. + * + * @returns The JSX element representing the AddOn component. + */ +function AddOn({ + children = 'Default text', + extras = {}, + name = '', +}: InterfaceAddOnProps): JSX.Element { + return ( +
+ {children} +
+ ); +} + +// PropTypes validation for the AddOn component +AddOn.propTypes = { + extras: PropTypes.shape({ + components: PropTypes.shape({}), + actions: PropTypes.shape({}), + }), + name: PropTypes.string, + children: PropTypes.node, +}; + +export default AddOn; diff --git a/src/components/AddOn/core/AddOnEntry/AddOnEntry.module.css b/src/components/AddOn/core/AddOnEntry/AddOnEntry.module.css new file mode 100644 index 0000000000..1f1ea89996 --- /dev/null +++ b/src/components/AddOn/core/AddOnEntry/AddOnEntry.module.css @@ -0,0 +1,20 @@ +.entrytoggle { + margin: 24px 24px 0 auto; + width: fit-content; +} + +.entryaction { + margin-left: auto; + display: flex !important; + align-items: center; +} + +.entryaction i { + margin-right: 8px; +} + +.entryaction .spinner-grow { + height: 1rem; + width: 1rem; + margin-right: 8px; +} diff --git a/src/components/AddOn/core/AddOnEntry/AddOnEntry.test.tsx b/src/components/AddOn/core/AddOnEntry/AddOnEntry.test.tsx new file mode 100644 index 0000000000..3d800eb59f --- /dev/null +++ b/src/components/AddOn/core/AddOnEntry/AddOnEntry.test.tsx @@ -0,0 +1,236 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import AddOnEntry from './AddOnEntry'; +import { + ApolloClient, + ApolloProvider, + InMemoryCache, + ApolloLink, + HttpLink, +} from '@apollo/client'; + +import type { NormalizedCacheObject } from '@apollo/client'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import { BACKEND_URL } from 'Constant/constant'; +import i18nForTest from 'utils/i18nForTest'; +import { I18nextProvider } from 'react-i18next'; +import userEvent from '@testing-library/user-event'; +import { MockedProvider, wait } from '@apollo/react-testing'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { ADD_ON_ENTRY_MOCK } from './AddOnEntryMocks'; +import { ToastContainer } from 'react-toastify'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { getItem } = useLocalStorage(); + +const link = new StaticMockLink(ADD_ON_ENTRY_MOCK, true); + +const httpLink = new HttpLink({ + uri: BACKEND_URL, + headers: { + authorization: 'Bearer ' + getItem('token') || '', + }, +}); +console.error = jest.fn(); +const client: ApolloClient = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.from([httpLink]), +}); +let mockID: string | undefined = '1'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: mockID }), +})); + +describe('Testing AddOnEntry', () => { + const props = { + id: 'string', + enabled: true, + title: 'string', + description: 'string', + createdBy: 'string', + component: 'string', + installed: true, + configurable: true, + modified: true, + isInstalled: true, + getInstalledPlugins: (): { sample: string } => { + return { sample: 'sample' }; + }, + }; + + test('should render modal and take info to add plugin for registered organization', () => { + const { getByTestId } = render( + + + + + {} + + + + , + ); + expect(getByTestId('AddOnEntry')).toBeInTheDocument(); + }); + + test('uses default values for title and description when not provided', () => { + // Render the component with only required parameters + const mockGetInstalledPlugins = jest.fn(); + render( + + + + + + + + + , + ); + + const titleElement = screen.getByText('No title provided'); // This will check for the default empty string in the title + const descriptionElement = screen.getByText('Description not available'); // This will check for the default empty string in the description + expect(titleElement).toBeInTheDocument(); // Ensure the title element with default value exists + expect(descriptionElement).toBeInTheDocument(); // Ensure the description element with default value exists + }); + + it('renders correctly', () => { + const props = { + id: '1', + title: 'Test Addon', + description: 'Test addon description', + createdBy: 'Test User', + component: 'string', + installed: true, + configurable: true, + modified: true, + isInstalled: true, + uninstalledOrgs: [], + enabled: true, + getInstalledPlugins: (): { sample: string } => { + return { sample: 'sample' }; + }, + }; + + const { getByText } = render( + + + + + {} + + + + , + ); + + expect(getByText('Test Addon')).toBeInTheDocument(); + expect(getByText('Test addon description')).toBeInTheDocument(); + expect(getByText('Test User')).toBeInTheDocument(); + }); + + it('Uninstall Button works correctly', async () => { + const props = { + id: '1', + title: 'Test Addon', + description: 'Test addon description', + createdBy: 'Test User', + component: 'string', + installed: true, + configurable: true, + modified: true, + isInstalled: true, + uninstalledOrgs: [], + enabled: true, + getInstalledPlugins: (): { sample: string } => { + return { sample: 'sample' }; + }, + }; + mockID = 'undefined'; + const { findByText, getByTestId } = render( + + + + + + {} + + + + , + ); + await wait(100); + const btn = getByTestId('AddOnEntry_btn_install'); + await userEvent.click(btn); + await wait(100); + expect(btn.innerHTML).toMatch(/Install/i); + expect( + await findByText('This feature is now removed from your organization'), + ).toBeInTheDocument(); + await userEvent.click(btn); + await wait(100); + + expect(btn.innerHTML).toMatch(/Uninstall/i); + expect( + await findByText('This feature is now enabled in your organization'), + ).toBeInTheDocument(); + }); + + it('Check if uninstalled orgs includes current org', async () => { + const props = { + id: '1', + title: 'Test Addon', + description: 'Test addon description', + createdBy: 'Test User', + component: 'string', + installed: true, + configurable: true, + modified: true, + isInstalled: true, + uninstalledOrgs: ['undefined'], + enabled: true, + getInstalledPlugins: (): { sample: string } => { + return { sample: 'sample' }; + }, + }; + + const { getByTestId } = render( + + + + + {} + + + + , + ); + await wait(100); + const btn = getByTestId('AddOnEntry_btn_install'); + expect(btn.innerHTML).toMatch(/install/i); + }); + test('should be redirected to /orglist if orgId is undefined', async () => { + mockID = undefined; + render( + + + + + {} + + + + , + ); + await wait(100); + expect(window.location.pathname).toEqual('/orglist'); + }); +}); diff --git a/src/components/AddOn/core/AddOnEntry/AddOnEntry.tsx b/src/components/AddOn/core/AddOnEntry/AddOnEntry.tsx new file mode 100644 index 0000000000..257917e2c2 --- /dev/null +++ b/src/components/AddOn/core/AddOnEntry/AddOnEntry.tsx @@ -0,0 +1,152 @@ +import React, { useState } from 'react'; +import styles from './AddOnEntry.module.css'; +import { Button, Card, Spinner } from 'react-bootstrap'; +import { UPDATE_INSTALL_STATUS_PLUGIN_MUTATION } from 'GraphQl/Mutations/mutations'; +import { useMutation } from '@apollo/client'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { Navigate, useParams } from 'react-router-dom'; + +/** + * Props for the `addOnEntry` component. + */ +interface InterfaceAddOnEntryProps { + id: string; + enabled?: boolean; // Optional props + title?: string; // Optional props + description?: string; // Optional props + createdBy: string; + component?: string; // Optional props + modified?: any; // Optional props + uninstalledOrgs: string[]; + getInstalledPlugins: () => any; +} + +/** + * A React component that represents an add-on entry, displaying its details and allowing installation or uninstallation. + * + * @param props - The properties for the component. + * @returns A JSX element containing the add-on entry. + * + * @example + * ```tsx + * {}} + * /> + * ``` + */ +function addOnEntry({ + id, + title = 'No title provided', // Default parameter + description = 'Description not available', // Default parameter + createdBy, + uninstalledOrgs, + getInstalledPlugins, + // enabled = false, // Default parameter + // component = '', // Default parameter + // modified = null, // Default parameter +}: InterfaceAddOnEntryProps): JSX.Element { + // Translation hook with namespace 'addOnEntry' + const { t } = useTranslation('translation', { keyPrefix: 'addOnEntry' }); + + // Getting orgId from URL parameters + const { orgId: currentOrg } = useParams(); + if (!currentOrg) { + // If orgId is not present in the URL, navigate to the org list page + return ; + } + + // State to manage button loading state + const [buttonLoading, setButtonLoading] = useState(false); + // State to manage local installation status of the add-on + const [isInstalledLocal, setIsInstalledLocal] = useState( + uninstalledOrgs.includes(currentOrg), + ); + + // Mutation hook for updating the install status of the plugin + const [addOrgAsUninstalled] = useMutation( + UPDATE_INSTALL_STATUS_PLUGIN_MUTATION, + ); + + /** + * Function to toggle the installation status of the plugin. + */ + const togglePluginInstall = async (): Promise => { + setButtonLoading(true); + await addOrgAsUninstalled({ + variables: { + id: id.toString(), + orgId: currentOrg.toString(), + }, + }); + + // Toggle the local installation status + setIsInstalledLocal(!isInstalledLocal); + setButtonLoading(false); + + // Display a success message based on the new installation status + const dialog: string = isInstalledLocal + ? t('installMsg') + : t('uninstallMsg'); + toast.success(dialog); + }; + + return ( + <> + + {/* {uninstalledOrgs.includes(currentOrg) && ( + {}} + disabled={switchInProgress} + checked={enabled} + /> + )} */} + + {title} + + {createdBy} + + {description} + + + +
+ + ); +} + +export default addOnEntry; diff --git a/src/components/AddOn/core/AddOnEntry/AddOnEntryMocks.ts b/src/components/AddOn/core/AddOnEntry/AddOnEntryMocks.ts new file mode 100644 index 0000000000..3967a0963a --- /dev/null +++ b/src/components/AddOn/core/AddOnEntry/AddOnEntryMocks.ts @@ -0,0 +1,25 @@ +import { UPDATE_INSTALL_STATUS_PLUGIN_MUTATION } from 'GraphQl/Mutations/mutations'; + +// Mock data representing the response structure of the mutation +const updatePluginStatus = { + _id: '123', + pluginName: 'Sample Plugin', + pluginCreatedBy: 'John Doe', + pluginDesc: 'This is a sample plugin description.', + uninstalledOrgs: [], +}; + +// Creating the mock entry +export const ADD_ON_ENTRY_MOCK = [ + { + request: { + query: UPDATE_INSTALL_STATUS_PLUGIN_MUTATION, + variables: { id: '1', orgId: 'undefined' }, + }, + result: { + data: { + updatePluginStatus: updatePluginStatus, + }, + }, + }, +]; diff --git a/src/components/AddOn/core/AddOnRegister/AddOnRegister.module.css b/src/components/AddOn/core/AddOnRegister/AddOnRegister.module.css new file mode 100644 index 0000000000..c122d386fa --- /dev/null +++ b/src/components/AddOn/core/AddOnRegister/AddOnRegister.module.css @@ -0,0 +1,9 @@ +.modalbtn { + display: flex !important; + margin-left: auto; + align-items: center; +} + +.modalbtn i { + margin-right: 8px; +} diff --git a/src/components/AddOn/core/AddOnRegister/AddOnRegister.test.tsx b/src/components/AddOn/core/AddOnRegister/AddOnRegister.test.tsx new file mode 100644 index 0000000000..dc6a7c2091 --- /dev/null +++ b/src/components/AddOn/core/AddOnRegister/AddOnRegister.test.tsx @@ -0,0 +1,207 @@ +import React, { act } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MockedProvider } from '@apollo/react-testing'; +import { ADD_PLUGIN_MUTATION } from 'GraphQl/Mutations/mutations'; +import AddOnRegister from './AddOnRegister'; +import { + ApolloClient, + ApolloProvider, + InMemoryCache, + ApolloLink, + HttpLink, +} from '@apollo/client'; +import type { NormalizedCacheObject } from '@apollo/client'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import { BrowserRouter } from 'react-router-dom'; +import { BACKEND_URL } from 'Constant/constant'; +import i18nForTest from 'utils/i18nForTest'; +import { I18nextProvider } from 'react-i18next'; +import { toast } from 'react-toastify'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { getItem } = useLocalStorage(); + +const httpLink = new HttpLink({ + uri: BACKEND_URL, + headers: { + authorization: 'Bearer ' + getItem('token') || '', + }, +}); + +const client: ApolloClient = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.from([httpLink]), +}); + +const mocks = [ + { + request: { + query: ADD_PLUGIN_MUTATION, + variables: { + pluginName: 'Test Plugin', + pluginCreatedBy: 'AdminTest Creator', + pluginDesc: 'Test Description', + pluginInstallStatus: false, + installedOrgs: ['id'], + }, + }, + result: { + data: { + createPlugin: { + _id: '1', + pluginName: 'Test Plugin', + pluginCreatedBy: 'AdminTest Creator', + pluginDesc: 'Test Description', + }, + }, + }, + }, +]; + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const pluginData = { + pluginName: 'Test Plugin', + pluginCreatedBy: 'Test Creator', + pluginDesc: 'Test Description', +}; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + }, +})); + +const mockNavigate = jest.fn(); +let mockId: string | undefined = 'id'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: mockId }), + useNavigate: () => mockNavigate, +})); + +describe('Testing AddOnRegister', () => { + const props = { + id: '6234d8bf6ud937ddk70ecc5c9', + }; + + test('should render modal and take info to add plugin for registered organization', async () => { + await act(async () => { + render( + + + + + + + + + , + ); + }); + // Wait for the button to be in the document + await waitFor(() => + expect( + screen.getByRole('button', { name: /Add New/i }), + ).toBeInTheDocument(), + ); + + // Simulate user interactions + userEvent.click(screen.getByRole('button', { name: /Add New/i })); + userEvent.type(screen.getByPlaceholderText(/Ex: Donations/i), 'myplugin'); + userEvent.type( + screen.getByPlaceholderText(/This Plugin enables UI for/i), + 'test description', + ); + userEvent.type( + screen.getByPlaceholderText(/Ex: john Doe/i), + 'test creator', + ); + }); + + test('Expect toast.success to be called on successful plugin addition', async () => { + await act(async () => { + render( + + + + + + + + + , + ); + }); + await waitFor(() => new Promise((resolve) => setTimeout(resolve, 0))); + + userEvent.click(screen.getByRole('button', { name: /Add New/i })); + await wait(100); + expect(screen.getByTestId('addonregisterBtn')).toBeInTheDocument(); + userEvent.type(screen.getByTestId('pluginName'), pluginData.pluginName); + userEvent.type( + screen.getByTestId('pluginCreatedBy'), + pluginData.pluginCreatedBy, + ); + userEvent.type(screen.getByTestId('pluginDesc'), pluginData.pluginDesc); + userEvent.click(screen.getByTestId('addonregisterBtn')); + + await wait(100); + expect(toast.success).toHaveBeenCalledWith('Plugin added Successfully'); + }); + + test('Expect the window to reload after successful plugin addition', async () => { + await act(async () => { + render( + + + + + + + + + , + ); + }); + await waitFor(() => new Promise((resolve) => setTimeout(resolve, 0))); + + userEvent.click(screen.getByRole('button', { name: /Add New/i })); + await wait(100); + expect(screen.getByTestId('addonregisterBtn')).toBeInTheDocument(); + userEvent.type(screen.getByTestId('pluginName'), pluginData.pluginName); + userEvent.type( + screen.getByTestId('pluginCreatedBy'), + pluginData.pluginCreatedBy, + ); + userEvent.type(screen.getByTestId('pluginDesc'), pluginData.pluginDesc); + userEvent.click(screen.getByTestId('addonregisterBtn')); + + await wait(3000); // Waiting for 3 seconds to reload the page as timeout is set to 2 seconds in the component + expect(mockNavigate).toHaveBeenCalledWith(0); + }); + + test('should be redirected to /orglist if orgId is undefined', async () => { + mockId = undefined; + render( + + + + + + + + + , + ); + expect(window.location.pathname).toEqual('/orglist'); + }); +}); diff --git a/src/components/AddOn/core/AddOnRegister/AddOnRegister.tsx b/src/components/AddOn/core/AddOnRegister/AddOnRegister.tsx new file mode 100644 index 0000000000..6023e74ee1 --- /dev/null +++ b/src/components/AddOn/core/AddOnRegister/AddOnRegister.tsx @@ -0,0 +1,202 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import styles from './AddOnRegister.module.css'; +import { Button, Form, Modal } from 'react-bootstrap'; +import { useMutation } from '@apollo/client'; +import { ADD_PLUGIN_MUTATION } from 'GraphQl/Mutations/mutations'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; + +/** + * Interface defining the form state for the `addOnRegister` component. + */ +interface InterfaceFormStateTypes { + pluginName: string; + pluginCreatedBy: string; + pluginDesc: string; + pluginInstallStatus: boolean; + installedOrgs: [string] | []; +} + +interface InterfaceAddOnRegisterProps { + createdBy?: string; +} + +/** + * A React component for registering a new add-on plugin. + * + * This component: + * - Displays a button to open a modal for plugin registration. + * - Contains a form in the modal for entering plugin details. + * - Uses GraphQL mutation to register the plugin. + * - Uses `react-i18next` for localization and `react-toastify` for notifications. + * - Redirects to the organization list page if no `orgId` is found in the URL. + * + * @returns A JSX element containing the button and modal for plugin registration. + */ +function addOnRegister({ + createdBy = 'Admin', +}: InterfaceAddOnRegisterProps): JSX.Element { + // Translation hook for the 'addOnRegister' namespace + const { t } = useTranslation('translation', { keyPrefix: 'addOnRegister' }); + // Translation hook for the 'common' namespace + const { t: tCommon } = useTranslation('common'); + + // Get the organization ID from the URL parameters + const { orgId: currentUrl } = useParams(); + const navigate = useNavigate(); + + // Redirect to the organization list if no organization ID is found + if (!currentUrl) { + return ; + } + + // State to manage the visibility of the modal + const [show, setShow] = useState(false); + + // Functions to show and hide the modal + const handleClose = (): void => setShow(false); + const handleShow = (): void => setShow(true); + + // GraphQL mutation hook for adding a plugin + const [create] = useMutation(ADD_PLUGIN_MUTATION); + + // Initial form state + const [formState, setFormState] = useState({ + pluginName: '', + pluginCreatedBy: createdBy, // Using the default value here + pluginDesc: '', + pluginInstallStatus: false, + installedOrgs: [currentUrl], + }); + + /** + * Handles the registration of the plugin. + * Sends the form data to the GraphQL mutation and displays a success message. + */ + const handleRegister = async (): Promise => { + const { data } = await create({ + variables: { + pluginName: formState.pluginName, + pluginCreatedBy: formState.pluginCreatedBy, + pluginDesc: formState.pluginDesc, + pluginInstallStatus: formState.pluginInstallStatus, + installedOrgs: formState.installedOrgs, + }, + }); + + if (data) { + // Show a success message when the plugin is added + toast.success(tCommon('addedSuccessfully', { item: 'Plugin' }) as string); + // Refresh the page after 2 seconds + setTimeout(() => { + navigate(0); + }, 2000); + } + }; + + return ( + <> + {/* Button to open the modal */} + + + {/* Modal for plugin registration */} + + + {t('addPlugin')} + + +
+ {/* Form group for plugin name */} + + {t('pluginName')} + { + setFormState({ + ...formState, + pluginName: e.target.value, + }); + }} + /> + + + {t('creatorName')} + { + setFormState({ + ...formState, + pluginCreatedBy: e.target.value, + }); + }} + /> + + {/* Form group for plugin description */} + + {t('pluginDesc')} + { + setFormState({ + ...formState, + pluginDesc: e.target.value, + }); + }} + /> + +
+
+ + {/* Button to close the modal */} + + {/* Button to register the plugin */} + + +
+ + ); +} + +// Prop types validation for the component +addOnRegister.propTypes = { + createdBy: PropTypes.string, +}; + +export default addOnRegister; diff --git a/src/components/AddOn/core/AddOnStore/AddOnStore.module.css b/src/components/AddOn/core/AddOnStore/AddOnStore.module.css new file mode 100644 index 0000000000..8a34c03be5 --- /dev/null +++ b/src/components/AddOn/core/AddOnStore/AddOnStore.module.css @@ -0,0 +1,31 @@ +.container { + display: flex; +} + +.logintitle { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 30px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 15%; +} + +.actioninput { + text-decoration: none; + margin-bottom: 50px; + border-color: #e8e5e5; + width: 80%; + border-radius: 7px; + padding-top: 5px; + padding-bottom: 5px; + padding-right: 10px; + padding-left: 10px; + box-shadow: none; +} + +.actionradio input { + width: fit-content; + margin: inherit; +} diff --git a/src/components/AddOn/core/AddOnStore/AddOnStore.test.tsx b/src/components/AddOn/core/AddOnStore/AddOnStore.test.tsx new file mode 100644 index 0000000000..e76e2a7b73 --- /dev/null +++ b/src/components/AddOn/core/AddOnStore/AddOnStore.test.tsx @@ -0,0 +1,394 @@ +import React, { act } from 'react'; +import 'jest-location-mock'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { + ApolloClient, + ApolloProvider, + InMemoryCache, + ApolloLink, + HttpLink, +} from '@apollo/client'; +import type { NormalizedCacheObject } from '@apollo/client'; +import { BrowserRouter } from 'react-router-dom'; +import AddOnStore from './AddOnStore'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import { BACKEND_URL } from 'Constant/constant'; +import i18nForTest from 'utils/i18nForTest'; +import { I18nextProvider } from 'react-i18next'; +import { ORGANIZATIONS_LIST, PLUGIN_GET } from 'GraphQl/Queries/Queries'; +import userEvent from '@testing-library/user-event'; +import useLocalStorage from 'utils/useLocalstorage'; +import { MockedProvider } from '@apollo/react-testing'; + +const { getItem } = useLocalStorage(); + +jest.mock('components/AddOn/support/services/Plugin.helper', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + fetchStore: jest.fn().mockResolvedValue([ + { + _id: '1', + pluginName: 'Plugin 1', + pluginDesc: 'Description 1', + pluginCreatedBy: 'User 1', + pluginInstallStatus: true, + }, + { + _id: '2', + pluginName: 'Plugin 2', + pluginDesc: 'Description 2', + pluginCreatedBy: 'User 2', + pluginInstallStatus: false, + }, + // Add more mock data as needed + ]), + fetchInstalled: jest.fn().mockResolvedValue([ + { + _id: '1', + pluginName: 'Installed Plugin 1', + pluginDesc: 'Installed Description 1', + pluginCreatedBy: 'User 3', + pluginInstallStatus: true, + }, + { + _id: '3', + pluginName: 'Installed Plugin 3', + pluginDesc: 'Installed Description 3', + pluginCreatedBy: 'User 4', + pluginInstallStatus: true, + }, + // Add more mock data as needed + ]), + generateLinks: jest.fn().mockImplementation((plugins) => { + return plugins + .filter((plugin: { enabled: any }) => plugin.enabled) + .map((installedPlugin: { pluginName: any; component: string }) => { + return { + name: installedPlugin.pluginName, + url: `/plugin/${installedPlugin.component.toLowerCase()}`, + }; + }); + }), + })), +})); + +const httpLink = new HttpLink({ + uri: BACKEND_URL, + headers: { + authorization: 'Bearer ' + getItem('token') || '', + }, +}); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const client: ApolloClient = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.from([httpLink]), +}); + +jest.mock('components/AddOn/support/services/Plugin.helper', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + fetchInstalled: jest.fn().mockResolvedValue([]), + fetchStore: jest.fn().mockResolvedValue([]), + })), +})); + +const today = new Date(); +const tomorrow = today; +tomorrow.setDate(today.getDate() + 1); + +const PLUGIN_GET_MOCK = { + request: { + query: PLUGIN_GET, + }, + result: { + data: { + getPlugins: [ + { + _id: '6581be50e88e74003aab436c', + pluginName: 'Plugin 1', + pluginCreatedBy: 'Talawa Team', + pluginDesc: + 'User can share messages with other users in a chat user interface.', + uninstalledOrgs: [ + '62ccfccd3eb7fd2a30f41601', + '62ccfccd3eb7fd2a30f41601', + ], + __typename: 'Plugin', + }, + { + _id: '6581be50e88e74003aab436d', + pluginName: 'Plugin 2', + pluginCreatedBy: 'Talawa Team', + pluginDesc: + 'User can share messages with other users in a chat user interface.', + uninstalledOrgs: ['6537904485008f171cf29924'], + __typename: 'Plugin', + }, + { + _id: '6581be50e88e74003aab436e', + pluginName: 'Plugin 3', + pluginCreatedBy: 'Talawa Team', + pluginDesc: + 'User can share messages with other users in a chat user interface.', + uninstalledOrgs: [ + '62ccfccd3eb7fd2a30f41601', + '62ccfccd3eb7fd2a30f41601', + ], + __typename: 'Plugin', + }, + ], + }, + loading: false, + }, +}; + +const PLUGIN_LOADING_MOCK = { + request: { + query: PLUGIN_GET, + }, + result: { + data: { + getPlugins: [], + }, + loading: true, + }, +}; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'undefined' }), +})); +const ORGANIZATIONS_LIST_MOCK = { + request: { + query: ORGANIZATIONS_LIST, + variables: { + id: 'undefined', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'undefined', + image: '', + creator: { + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + name: 'name', + description: 'description', + userRegistrationRequired: true, + + visibleInSearch: true, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + members: { + _id: 'id', + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + admins: { + _id: 'id', + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + membershipRequests: { + _id: 'id', + user: { + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + }, + blockedUsers: { + _id: 'id', + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + }, + ], + }, + }, +}; + +describe('Testing AddOnStore Component', () => { + test('for the working of the tabs', async () => { + const mocks = [ORGANIZATIONS_LIST_MOCK, PLUGIN_GET_MOCK]; + + render( + + + + + + + + + + + , + ); + + await wait(); + userEvent.click(screen.getByText('Installed')); + + await wait(); + userEvent.click(screen.getByText('Available')); + }); + + test('check the working search bar when on Available tab', async () => { + const mocks = [ORGANIZATIONS_LIST_MOCK, PLUGIN_GET_MOCK]; + + render( + + + + + + + + + + + , + ); + + await wait(); + userEvent.click(screen.getByText('Available')); + + await wait(); + let searchText = ''; + fireEvent.change(screen.getByPlaceholderText('Ex: Donations'), { + target: { value: searchText }, + }); + expect(screen.getAllByText('Plugin 1').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Plugin 2').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('Plugin 3').length).toBeGreaterThanOrEqual(1); + + searchText = 'Plugin 1'; + fireEvent.change(screen.getByPlaceholderText('Ex: Donations'), { + target: { value: searchText }, + }); + const plugin1Elements = screen.queryAllByText('Plugin 1'); + expect(plugin1Elements.length).toBeGreaterThan(1); + + searchText = 'Test Plugin'; + fireEvent.change(screen.getByPlaceholderText('Ex: Donations'), { + target: { value: searchText }, + }); + + const message = screen.getAllByText('Plugin does not exists'); + expect(message.length).toBeGreaterThanOrEqual(1); + }); + + test('check filters enabled and disabled under Installed tab', async () => { + const mocks = [ORGANIZATIONS_LIST_MOCK, PLUGIN_GET_MOCK]; + render( + + + + + + + + + + + , + ); + + await wait(); + userEvent.click(screen.getByText('Installed')); + + expect(screen.getByText('Filters')).toBeInTheDocument(); + expect(screen.getByLabelText('Enabled')).toBeInTheDocument(); + expect(screen.getByLabelText('Disabled')).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('Enabled')); + expect(screen.getByLabelText('Enabled')).toBeChecked(); + fireEvent.click(screen.getByLabelText('Disabled')); + expect(screen.getByLabelText('Disabled')).toBeChecked(); + }); + + test('check the working search bar when on Installed tab', async () => { + const mocks = [ORGANIZATIONS_LIST_MOCK, PLUGIN_GET_MOCK]; + + const { container } = render( + + + + + + + + + + + , + ); + await wait(); + userEvent.click(screen.getByText('Installed')); + + await wait(); + let searchText = ''; + fireEvent.change(screen.getByPlaceholderText('Ex: Donations'), { + target: { value: searchText }, + }); + expect(container).toHaveTextContent('Plugin 1'); + expect(container).toHaveTextContent('Plugin 3'); + + searchText = 'Plugin 1'; + fireEvent.change(screen.getByPlaceholderText('Ex: Donations'), { + target: { value: searchText }, + }); + const plugin1Elements = screen.queryAllByText('Plugin 1'); + expect(plugin1Elements.length).toBeGreaterThan(1); + + searchText = 'Test Plugin'; + fireEvent.change(screen.getByPlaceholderText('Ex: Donations'), { + target: { value: searchText }, + }); + const message = screen.getAllByText('Plugin does not exists'); + expect(message.length).toBeGreaterThanOrEqual(1); + }); + + test('AddOnStore loading test', async () => { + expect(true).toBe(true); + const mocks = [ORGANIZATIONS_LIST_MOCK, PLUGIN_LOADING_MOCK]; + render( + + + + + + + + + + + , + ); + + expect(screen.getByTestId('AddOnEntryStore')).toBeInTheDocument(); + }); +}); diff --git a/src/components/AddOn/core/AddOnStore/AddOnStore.tsx b/src/components/AddOn/core/AddOnStore/AddOnStore.tsx new file mode 100644 index 0000000000..878ad64e31 --- /dev/null +++ b/src/components/AddOn/core/AddOnStore/AddOnStore.tsx @@ -0,0 +1,317 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React, { useState } from 'react'; +// import PropTypes from 'react'; +import styles from './AddOnStore.module.css'; +import AddOnEntry from '../AddOnEntry/AddOnEntry'; +import Action from '../../support/components/Action/Action'; +import { useQuery } from '@apollo/client'; +import { PLUGIN_GET } from 'GraphQl/Queries/Queries'; // GraphQL query for fetching plugins +import { Col, Form, Row, Tab, Tabs } from 'react-bootstrap'; +import PluginHelper from 'components/AddOn/support/services/Plugin.helper'; +import { store } from './../../../../state/store'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; + +/** + * Component for managing and displaying plugins in the store. + * + * This component: + * - Displays a search input and filter options. + * - Uses tabs to switch between available and installed plugins. + * - Fetches plugins from a GraphQL endpoint and filters them based on search criteria. + * - Utilizes Redux store to manage plugin data. + * + * @returns A JSX element containing the UI for the add-on store. + */ +function addOnStore(): JSX.Element { + const { t } = useTranslation('translation', { keyPrefix: 'addOnStore' }); + document.title = t('title'); + + const [isStore, setIsStore] = useState(true); + const [showEnabled, setShowEnabled] = useState(true); + const [searchText, setSearchText] = useState(''); + const [, setDataList] = useState([]); + + // type plugData = { pluginName: String, plug }; + const { data, loading } = useQuery(PLUGIN_GET); + + const { orgId } = useParams(); + + /** + * Fetches store plugins and updates the Redux store with the plugin data. + */ + /* istanbul ignore next */ + const getStorePlugins = async (): Promise => { + let plugins = await new PluginHelper().fetchStore(); + const installIds = (await new PluginHelper().fetchInstalled()).map( + (plugin: any) => plugin.id, + ); + plugins = plugins.map((plugin: any) => { + plugin.installed = installIds.includes(plugin.id); + return plugin; + }); + store.dispatch({ type: 'UPDATE_STORE', payload: plugins }); + }; + + /** + * Sets the list of installed plugins in the component's state. + */ + /* istanbul ignore next */ + const getInstalledPlugins: () => any = () => { + setDataList(data); + }; + + /** + * Updates the currently selected tab and fetches the relevant plugin data. + * + * @param tab - The key of the selected tab (either 'available' or 'installed'). + */ + const updateSelectedTab = (tab: any): void => { + setIsStore(tab === 'available'); + /* istanbul ignore next */ + isStore ? getStorePlugins() : getInstalledPlugins(); + }; + + /** + * Handles changes in the filter options. + * + * @param ev - The event object from the filter change. + */ + const filterChange = (ev: any): void => { + setShowEnabled(ev.target.value === 'enabled'); + }; + + // Show a loader while the data is being fetched + /* istanbul ignore next */ + if (loading) { + return ( + <> +
+ + ); + } + + return ( + <> + + + + setSearchText(e.target.value)} + /> + + {!isStore && ( + +
+
+ + +
+
+
+ )} + + +
+

{t('pHeading')}

+ {searchText ? ( +

+ Search results for {searchText} +

+ ) : null} + + + + {data.getPlugins.filter( + (val: { + _id: string; + pluginName: string | undefined; + pluginDesc: string | undefined; + pluginCreatedBy: string; + pluginInstallStatus: boolean | undefined; + getInstalledPlugins: () => any; + }) => { + if (searchText == '') { + return val; + } else if ( + val.pluginName + ?.toLowerCase() + .includes(searchText.toLowerCase()) + ) { + return val; + } + }, + ).length === 0 ? ( +

{t('pMessage')}

+ ) : ( + data.getPlugins + .filter( + (val: { + _id: string; + pluginName: string | undefined; + pluginDesc: string | undefined; + pluginCreatedBy: string; + pluginInstallStatus: boolean | undefined; + getInstalledPlugins: () => any; + }) => { + if (searchText == '') { + return val; + } else if ( + val.pluginName + ?.toLowerCase() + .includes(searchText.toLowerCase()) + ) { + return val; + } + }, + ) + .map( + ( + plug: { + _id: string; + pluginName: string | undefined; + pluginDesc: string | undefined; + pluginCreatedBy: string; + uninstalledOrgs: string[]; + getInstalledPlugins: () => any; + }, + i: React.Key | null | undefined, + ): JSX.Element => ( + + ), + ) + )} +
+ + {data.getPlugins + .filter( + (plugin: any) => !plugin.uninstalledOrgs.includes(orgId), + ) + .filter( + (val: { + _id: string; + pluginName: string | undefined; + pluginDesc: string | undefined; + pluginCreatedBy: string; + pluginInstallStatus: boolean | undefined; + getInstalledPlugins: () => any; + }) => { + if (searchText == '') { + return val; + } else if ( + val.pluginName + ?.toLowerCase() + .includes(searchText.toLowerCase()) + ) { + return val; + } + }, + ).length === 0 ? ( +

{t('pMessage')}

+ ) : ( + data.getPlugins + .filter( + (plugin: any) => !plugin.uninstalledOrgs.includes(orgId), + ) + .filter( + (val: { + _id: string; + pluginName: string | undefined; + pluginDesc: string | undefined; + pluginCreatedBy: string; + pluginInstallStatus: boolean | undefined; + getInstalledPlugins: () => any; + }) => { + if (searchText == '') { + return val; + } else if ( + val.pluginName + ?.toLowerCase() + .includes(searchText.toLowerCase()) + ) { + return val; + } + }, + ) + .map( + ( + plug: { + _id: string; + pluginName: string | undefined; + pluginDesc: string | undefined; + pluginCreatedBy: string; + uninstalledOrgs: string[]; + pluginInstallStatus: boolean | undefined; + getInstalledPlugins: () => any; + }, + i: React.Key | null | undefined, + ): JSX.Element => ( + + ), + ) + )} +
+
+
+ +
+ + ); +} + +export default addOnStore; diff --git a/src/components/AddOn/support/components/Action/Action.module.css b/src/components/AddOn/support/components/Action/Action.module.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/components/AddOn/support/components/Action/Action.test.tsx b/src/components/AddOn/support/components/Action/Action.test.tsx new file mode 100644 index 0000000000..ce6cd633b9 --- /dev/null +++ b/src/components/AddOn/support/components/Action/Action.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; + +import { store } from 'state/store'; +import Action from './Action'; + +describe('Testing Action Component', () => { + const props = { + children: 'dummy children', + label: 'dummy label', + }; + + test('should render props and text elements test for the page component', () => { + const { getByText } = render( + + + , + ); + + expect(getByText(props.label)).toBeInTheDocument(); + expect(getByText(props.children)).toBeInTheDocument(); + }); +}); diff --git a/src/components/AddOn/support/components/Action/Action.tsx b/src/components/AddOn/support/components/Action/Action.tsx new file mode 100644 index 0000000000..f29d8492d3 --- /dev/null +++ b/src/components/AddOn/support/components/Action/Action.tsx @@ -0,0 +1,40 @@ +import React, { useRef } from 'react'; + +/** + * Props for the `action` component. + */ +interface InterfaceActionProps { + /** + * The child elements to be rendered inside the action component. + */ + children: any; + + /** + * The label to be displayed above the child elements. + */ + label: string; +} + +/** + * A React component that renders a labeled container for embedded actions. + * + * @param props - The properties for the component. + * @returns A JSX element containing the label and child elements. + * + * @example + * + * + * + */ +function action(props: InterfaceActionProps): JSX.Element { + const actionRef = useRef(null); + + return ( +
+
{props.label}
+ {props.children} +
+ ); +} + +export default action; diff --git a/src/components/AddOn/support/components/MainContent/MainContent.module.css b/src/components/AddOn/support/components/MainContent/MainContent.module.css new file mode 100644 index 0000000000..3321cf6fd7 --- /dev/null +++ b/src/components/AddOn/support/components/MainContent/MainContent.module.css @@ -0,0 +1,4 @@ +.maincontainer { + width: 70vw; + margin-right: 2rem; +} diff --git a/src/components/AddOn/support/components/MainContent/MainContent.test.tsx b/src/components/AddOn/support/components/MainContent/MainContent.test.tsx new file mode 100644 index 0000000000..81adbc916e --- /dev/null +++ b/src/components/AddOn/support/components/MainContent/MainContent.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; + +import { store } from 'state/store'; +import MainContent from './MainContent'; + +describe('Testing MainContent component', () => { + const props = { + children: 'This is a dummy text', + }; + + test('should render props and children for the Main Content', () => { + const { getByTestId, getByText } = render( + + + + + , + ); + + expect(getByTestId('mainContentCheck')).toBeInTheDocument(); + expect(getByText(props.children)).toBeInTheDocument(); + }); +}); diff --git a/src/components/AddOn/support/components/MainContent/MainContent.tsx b/src/components/AddOn/support/components/MainContent/MainContent.tsx new file mode 100644 index 0000000000..eddc1b993d --- /dev/null +++ b/src/components/AddOn/support/components/MainContent/MainContent.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import styles from './MainContent.module.css'; + +/** + * Props for the `mainContent` component. + */ +interface InterfaceMainContentProps { + /** + * The child elements to be rendered inside the main content container. + */ + children: any; +} + +/** + * A React component that renders a main content container with additional styles. + * + * @param props - The properties for the component. + * @returns A JSX element containing the main content container with the provided child elements. + * + * @example + * + *

Main content goes here

+ *
+ */ +function mainContent({ children }: InterfaceMainContentProps): JSX.Element { + return ( +
+ {children} +
+ ); +} + +export default mainContent; diff --git a/src/components/AddOn/support/components/SidePanel/SidePanel.module.css b/src/components/AddOn/support/components/SidePanel/SidePanel.module.css new file mode 100644 index 0000000000..f337eeea1b --- /dev/null +++ b/src/components/AddOn/support/components/SidePanel/SidePanel.module.css @@ -0,0 +1,12 @@ +.sidebarcontainer { + width: 30vw; + justify-content: center; + display: flex; + flex-direction: column; + padding: 2rem; + height: fit-content; +} + +.sidebarcollapsed { + display: none; +} diff --git a/src/components/AddOn/support/components/SidePanel/SidePanel.test.tsx b/src/components/AddOn/support/components/SidePanel/SidePanel.test.tsx new file mode 100644 index 0000000000..d929278d0e --- /dev/null +++ b/src/components/AddOn/support/components/SidePanel/SidePanel.test.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import SidePanel from './SidePanel'; +import type { NormalizedCacheObject } from '@apollo/client'; +import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client'; +import { BACKEND_URL } from 'Constant/constant'; + +const client: ApolloClient = new ApolloClient({ + cache: new InMemoryCache(), + uri: BACKEND_URL, +}); + +describe('Testing Contribution Stats', () => { + const props = { + collapse: true, + children: '234', + }; + + test('should render props and text elements test for the SidePanel component', () => { + render( + + + , + ); + expect(screen.getByTestId('SidePanel')).toBeInTheDocument(); + }); +}); diff --git a/src/components/AddOn/support/components/SidePanel/SidePanel.tsx b/src/components/AddOn/support/components/SidePanel/SidePanel.tsx new file mode 100644 index 0000000000..d4c0d65966 --- /dev/null +++ b/src/components/AddOn/support/components/SidePanel/SidePanel.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import styles from './SidePanel.module.css'; + +/** + * Props for the `sidePanel` component. + */ +interface InterfaceSidePanelProps { + /** + * Whether the side panel should be collapsed. + */ + collapse?: boolean; + + /** + * The child elements to be rendered inside the side panel. + */ + children: any; +} + +/** + * A React component that renders a side panel with an optional collapse state. + * + * @param props - The properties for the component. + * @returns A JSX element containing the side panel with the provided child elements. + * + * @example + * + *

Side panel content

+ *
+ */ +function sidePanel({ + collapse, + children, +}: InterfaceSidePanelProps): JSX.Element { + return ( +
+ {children} +
+ ); +} + +export default sidePanel; diff --git a/src/components/AddOn/support/services/Plugin.helper.test.ts b/src/components/AddOn/support/services/Plugin.helper.test.ts new file mode 100644 index 0000000000..e024734247 --- /dev/null +++ b/src/components/AddOn/support/services/Plugin.helper.test.ts @@ -0,0 +1,46 @@ +import PluginHelper from './Plugin.helper'; + +describe('Testing src/components/AddOn/support/services/Plugin.helper.ts', () => { + test('Class should contain the required method definitions', () => { + const pluginHelper = new PluginHelper(); + expect(pluginHelper).toHaveProperty('fetchStore'); + expect(pluginHelper).toHaveProperty('fetchInstalled'); + expect(pluginHelper).toHaveProperty('generateLinks'); + expect(pluginHelper).toHaveProperty('generateLinks'); + }); + test('generateLinks should return proper objects', () => { + const obj = { enabled: true, name: 'demo', component: 'samplecomponent' }; + const objToMatch = { name: 'demo', url: '/plugin/samplecomponent' }; + const pluginHelper = new PluginHelper(); + const val = pluginHelper.generateLinks([obj]); + expect(val).toMatchObject([objToMatch]); + }); + it('fetchStore should return expected JSON', async () => { + const helper = new PluginHelper(); + const spy = jest.spyOn(global, 'fetch').mockImplementation(() => { + const response = new Response(); + response.json = jest + .fn() + .mockReturnValue(Promise.resolve({ data: 'mock data' })); + return Promise.resolve(response); + }); + + const result = await helper.fetchStore(); + expect(result).toEqual({ data: 'mock data' }); + spy.mockRestore(); + }); + it('fetchInstalled() should return expected JSON', async () => { + const pluginHelper = new PluginHelper(); + const mockResponse = [ + { name: 'plugin1', component: 'Component1', enabled: true }, + { name: 'plugin2', component: 'Component2', enabled: false }, + ]; + jest.spyOn(global, 'fetch').mockImplementation(() => { + const response = new Response(); + response.json = jest.fn().mockReturnValue(Promise.resolve(mockResponse)); + return Promise.resolve(response); + }) as jest.Mock; + const result = await pluginHelper.fetchInstalled(); + expect(result).toEqual(mockResponse); + }); +}); diff --git a/src/components/AddOn/support/services/Plugin.helper.ts b/src/components/AddOn/support/services/Plugin.helper.ts new file mode 100644 index 0000000000..1ebefc351f --- /dev/null +++ b/src/components/AddOn/support/services/Plugin.helper.ts @@ -0,0 +1,44 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** + * Helper class for managing plugin-related tasks such as fetching store data, installed plugins, and generating plugin links. + */ +class PluginHelper { + /** + * Fetches the store data from a local server. + * + * @returns A promise that resolves to the store data in JSON format. + */ + fetchStore = async (): Promise => { + const result = await fetch(`http://localhost:${process.env.PORT}/store`); + return await result.json(); + }; + + /** + * Fetches the list of installed plugins from a local server. + * + * @returns A promise that resolves to the installed plugins data in JSON format. + */ + fetchInstalled = async (): Promise => { + const result = await fetch(`http://localhost:3005/installed`); + return await result.json(); + }; + + /** + * Generates an array of links for the enabled plugins. + * + * @param plugins - An array of plugin objects. + * @returns An array of objects containing the name and URL of each enabled plugin. + */ + generateLinks = (plugins: any[]): { name: string; url: string }[] => { + return plugins + .filter((plugin: any) => plugin.enabled) + .map((installedPlugin: any) => { + return { + name: installedPlugin.name, + url: `/plugin/${installedPlugin.component.toLowerCase()}`, + }; + }); + }; +} + +export default PluginHelper; diff --git a/src/components/AddOn/support/services/Render.helper.ts b/src/components/AddOn/support/services/Render.helper.ts new file mode 100644 index 0000000000..21202dc7af --- /dev/null +++ b/src/components/AddOn/support/services/Render.helper.ts @@ -0,0 +1,3 @@ +class RenderHelper {} + +export default RenderHelper; diff --git a/src/components/AddPeopleToTag/AddPeopleToTag.module.css b/src/components/AddPeopleToTag/AddPeopleToTag.module.css new file mode 100644 index 0000000000..5dd04ffed5 --- /dev/null +++ b/src/components/AddPeopleToTag/AddPeopleToTag.module.css @@ -0,0 +1,54 @@ +.errorContainer { + min-height: 100vh; +} + +.errorMessage { + margin-top: 25%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.errorIcon { + transform: scale(1.5); + color: var(--bs-danger); + margin-bottom: 1rem; +} + +.tableHeader { + background-color: var(--bs-primary); + color: var(--bs-white); + font-size: 1rem; +} + +.rowBackground { + background-color: var(--bs-white); + max-height: 120px; +} + +.scrollContainer { + height: 100px; + overflow-y: auto; + margin-bottom: 1rem; +} + +.memberBadge { + display: flex; + align-items: center; + padding: 5px 10px; + border-radius: 12px; + box-shadow: 0 1px 3px var(--bs-gray-400); + max-width: calc(100% - 2rem); +} + +.removeFilterIcon { + cursor: pointer; +} + +.loadingDiv { + min-height: 300px; + display: flex; + justify-content: center; + align-items: center; +} diff --git a/src/components/AddPeopleToTag/AddPeopleToTag.test.tsx b/src/components/AddPeopleToTag/AddPeopleToTag.test.tsx new file mode 100644 index 0000000000..824bc25654 --- /dev/null +++ b/src/components/AddPeopleToTag/AddPeopleToTag.test.tsx @@ -0,0 +1,316 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import type { RenderResult } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + cleanup, + waitFor, +} from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; + +import { store } from 'state/store'; +import userEvent from '@testing-library/user-event'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import { InMemoryCache, type ApolloLink } from '@apollo/client'; +import type { InterfaceAddPeopleToTagProps } from './AddPeopleToTag'; +import AddPeopleToTag from './AddPeopleToTag'; +import i18n from 'utils/i18nForTest'; +import { MOCKS, MOCKS_ERROR } from './AddPeopleToTagsMocks'; +import type { TFunction } from 'i18next'; + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(MOCKS_ERROR, true); + +async function wait(): Promise { + await waitFor(() => { + // The waitFor utility automatically uses optimal timing + return Promise.resolve(); + }); +} + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const translations = { + ...JSON.parse( + JSON.stringify(i18n.getDataByLanguage('en')?.translation.manageTag ?? {}), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const props: InterfaceAddPeopleToTagProps = { + addPeopleToTagModalIsOpen: true, + hideAddPeopleToTagModal: () => {}, + refetchAssignedMembersData: () => {}, + t: ((key: string) => translations[key]) as TFunction< + 'translation', + 'manageTag' + >, + tCommon: ((key: string) => translations[key]) as TFunction< + 'common', + undefined + >, +}; + +const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + getUserTag: { + merge(existing = {}, incoming) { + const merged = { + ...existing, + ...incoming, + usersToAssignTo: { + ...existing.usersToAssignTo, + ...incoming.usersToAssignTo, + edges: [ + ...(existing.usersToAssignTo?.edges || []), + ...(incoming.usersToAssignTo?.edges || []), + ], + }, + }; + + return merged; + }, + }, + }, + }, + }, +}); + +const renderAddPeopleToTagModal = ( + props: InterfaceAddPeopleToTagProps, + link: ApolloLink, +): RenderResult => { + return render( + + + + + + } + /> + + + + + , + ); +}; + +describe('Organisation Tags Page', () => { + beforeEach(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + cache.reset(); + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + test('Component loads correctly', async () => { + const { getByText } = renderAddPeopleToTagModal(props, link); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.addPeople)).toBeInTheDocument(); + }); + }); + + test('Renders error component when when query is unsuccessful', async () => { + const { queryByText } = renderAddPeopleToTagModal(props, link2); + + await wait(); + + await waitFor(() => { + expect(queryByText(translations.addPeople)).not.toBeInTheDocument(); + }); + }); + + test('Selects and deselects members to assign to', async () => { + renderAddPeopleToTagModal(props, link); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('selectMemberBtn')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('selectMemberBtn')[0]); + + await waitFor(() => { + expect(screen.getAllByTestId('selectMemberBtn')[1]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('selectMemberBtn')[1]); + + await waitFor(() => { + expect( + screen.getAllByTestId('clearSelectedMember')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('clearSelectedMember')[0]); + + await waitFor(() => { + expect(screen.getAllByTestId('deselectMemberBtn')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('deselectMemberBtn')[0]); + }); + + test('searchs for tags where the firstName matches the provided firstName search input', async () => { + renderAddPeopleToTagModal(props, link); + + await wait(); + + await waitFor(() => { + expect( + screen.getByPlaceholderText(translations.firstName), + ).toBeInTheDocument(); + }); + const input = screen.getByPlaceholderText(translations.firstName); + fireEvent.change(input, { target: { value: 'usersToAssignTo' } }); + + // should render the two users from the mock data + // where firstName starts with "usersToAssignTo" + await waitFor(() => { + const members = screen.getAllByTestId('memberName'); + expect(members.length).toEqual(2); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('memberName')[0]).toHaveTextContent( + 'usersToAssignTo user1', + ); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('memberName')[1]).toHaveTextContent( + 'usersToAssignTo user2', + ); + }); + }); + + test('searchs for tags where the lastName matches the provided lastName search input', async () => { + renderAddPeopleToTagModal(props, link); + + await wait(); + + await waitFor(() => { + expect( + screen.getByPlaceholderText(translations.lastName), + ).toBeInTheDocument(); + }); + const input = screen.getByPlaceholderText(translations.lastName); + fireEvent.change(input, { target: { value: 'userToAssignTo' } }); + + // should render the two users from the mock data + // where lastName starts with "usersToAssignTo" + await waitFor(() => { + const members = screen.getAllByTestId('memberName'); + expect(members.length).toEqual(2); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('memberName')[0]).toHaveTextContent( + 'first userToAssignTo', + ); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('memberName')[1]).toHaveTextContent( + 'second userToAssignTo', + ); + }); + }); + + test('Renders more members with infinite scroll', async () => { + const { getByText } = renderAddPeopleToTagModal(props, link); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.addPeople)).toBeInTheDocument(); + }); + + // Find the infinite scroll div by test ID or another selector + const addPeopleToTagScrollableDiv = screen.getByTestId( + 'addPeopleToTagScrollableDiv', + ); + + const initialMemberDataLength = screen.getAllByTestId('memberName').length; + + // Set scroll position to the bottom + fireEvent.scroll(addPeopleToTagScrollableDiv, { + target: { scrollY: addPeopleToTagScrollableDiv.scrollHeight }, + }); + + await waitFor(() => { + const finalMemberDataLength = screen.getAllByTestId('memberName').length; + expect(finalMemberDataLength).toBeGreaterThan(initialMemberDataLength); + + expect(getByText(translations.addPeople)).toBeInTheDocument(); + }); + }); + + test('Toasts error when no one is selected while assigning', async () => { + renderAddPeopleToTagModal(props, link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('assignPeopleBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('assignPeopleBtn')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith(translations.noOneSelected); + }); + }); + + test('Assigns tag to multiple people', async () => { + renderAddPeopleToTagModal(props, link); + + await wait(); + + // select members and assign them + await waitFor(() => { + expect(screen.getAllByTestId('selectMemberBtn')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('selectMemberBtn')[0]); + + await waitFor(() => { + expect(screen.getAllByTestId('selectMemberBtn')[1]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('selectMemberBtn')[1]); + + await waitFor(() => { + expect(screen.getAllByTestId('selectMemberBtn')[2]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('selectMemberBtn')[2]); + + userEvent.click(screen.getByTestId('assignPeopleBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + translations.successfullyAssignedToPeople, + ); + }); + }); +}); diff --git a/src/components/AddPeopleToTag/AddPeopleToTag.tsx b/src/components/AddPeopleToTag/AddPeopleToTag.tsx new file mode 100644 index 0000000000..51d21a6a1c --- /dev/null +++ b/src/components/AddPeopleToTag/AddPeopleToTag.tsx @@ -0,0 +1,419 @@ +import { useMutation, useQuery } from '@apollo/client'; +import type { GridCellParams, GridColDef } from '@mui/x-data-grid'; +import { DataGrid } from '@mui/x-data-grid'; +import { USER_TAGS_MEMBERS_TO_ASSIGN_TO } from 'GraphQl/Queries/userTagQueries'; +import type { ChangeEvent } from 'react'; +import React, { useEffect, useState } from 'react'; +import { Modal, Form, Button } from 'react-bootstrap'; +import { useParams } from 'react-router-dom'; +import type { InterfaceQueryUserTagsMembersToAssignTo } from 'utils/interfaces'; +import styles from './AddPeopleToTag.module.css'; +import type { InterfaceTagUsersToAssignToQuery } from 'utils/organizationTagsUtils'; +import { + TAGS_QUERY_DATA_CHUNK_SIZE, + dataGridStyle, +} from 'utils/organizationTagsUtils'; +import { Stack } from '@mui/material'; +import { toast } from 'react-toastify'; +import { ADD_PEOPLE_TO_TAG } from 'GraphQl/Mutations/TagMutations'; +import InfiniteScroll from 'react-infinite-scroll-component'; +import { WarningAmberRounded } from '@mui/icons-material'; +import { useTranslation } from 'react-i18next'; +import InfiniteScrollLoader from 'components/InfiniteScrollLoader/InfiniteScrollLoader'; +import type { TFunction } from 'i18next'; + +/** + * Props for the `AddPeopleToTag` component. + */ +export interface InterfaceAddPeopleToTagProps { + addPeopleToTagModalIsOpen: boolean; + hideAddPeopleToTagModal: () => void; + refetchAssignedMembersData: () => void; + t: TFunction<'translation', 'manageTag'>; + tCommon: TFunction<'common', undefined>; +} + +interface InterfaceMemberData { + _id: string; + firstName: string; + lastName: string; +} + +const AddPeopleToTag: React.FC = ({ + addPeopleToTagModalIsOpen, + hideAddPeopleToTagModal, + refetchAssignedMembersData, + t, + tCommon, +}) => { + const { tagId: currentTagId } = useParams(); + + const { t: tErrors } = useTranslation('error'); + + const [assignToMembers, setAssignToMembers] = useState( + [], + ); + + const [memberToAssignToSearchFirstName, setMemberToAssignToSearchFirstName] = + useState(''); + const [memberToAssignToSearchLastName, setMemberToAssignToSearchLastName] = + useState(''); + + const { + data: userTagsMembersToAssignToData, + loading: userTagsMembersToAssignToLoading, + error: userTagsMembersToAssignToError, + refetch: userTagsMembersToAssignToRefetch, + fetchMore: fetchMoreMembersToAssignTo, + }: InterfaceTagUsersToAssignToQuery = useQuery( + USER_TAGS_MEMBERS_TO_ASSIGN_TO, + { + variables: { + id: currentTagId, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: memberToAssignToSearchFirstName }, + lastName: { starts_with: memberToAssignToSearchLastName }, + }, + }, + skip: !addPeopleToTagModalIsOpen, + }, + ); + + useEffect(() => { + setMemberToAssignToSearchFirstName(''); + setMemberToAssignToSearchLastName(''); + userTagsMembersToAssignToRefetch(); + }, [addPeopleToTagModalIsOpen]); + + const loadMoreMembersToAssignTo = (): void => { + fetchMoreMembersToAssignTo({ + variables: { + first: TAGS_QUERY_DATA_CHUNK_SIZE, + after: + userTagsMembersToAssignToData?.getUsersToAssignTo.usersToAssignTo + .pageInfo.endCursor, // Fetch after the last loaded cursor + }, + updateQuery: ( + prevResult: { + getUsersToAssignTo: InterfaceQueryUserTagsMembersToAssignTo; + }, + { + fetchMoreResult, + }: { + fetchMoreResult: { + getUsersToAssignTo: InterfaceQueryUserTagsMembersToAssignTo; + }; + }, + ) => { + if (!fetchMoreResult) /* istanbul ignore next */ return prevResult; + + return { + getUsersToAssignTo: { + ...fetchMoreResult.getUsersToAssignTo, + usersToAssignTo: { + ...fetchMoreResult.getUsersToAssignTo.usersToAssignTo, + edges: [ + ...prevResult.getUsersToAssignTo.usersToAssignTo.edges, + ...fetchMoreResult.getUsersToAssignTo.usersToAssignTo.edges, + ], + }, + }, + }; + }, + }); + }; + + const userTagMembersToAssignTo = + userTagsMembersToAssignToData?.getUsersToAssignTo.usersToAssignTo.edges.map( + (edge) => edge.node, + ) ?? /* istanbul ignore next */ []; + + const handleAddOrRemoveMember = (member: InterfaceMemberData): void => { + setAssignToMembers((prevMembers) => { + const isAssigned = prevMembers.some((m) => m._id === member._id); + if (isAssigned) { + return prevMembers.filter((m) => m._id !== member._id); + } else { + return [...prevMembers, member]; + } + }); + }; + + const removeMember = (id: string): void => { + setAssignToMembers((prevMembers) => + prevMembers.filter((m) => m._id !== id), + ); + }; + + const [addPeople, { loading: addPeopleToTagLoading }] = + useMutation(ADD_PEOPLE_TO_TAG); + + const addPeopleToCurrentTag = async ( + e: ChangeEvent, + ): Promise => { + e.preventDefault(); + + if (!assignToMembers.length) { + toast.error(t('noOneSelected')); + return; + } + + try { + const { data } = await addPeople({ + variables: { + tagId: currentTagId, + userIds: assignToMembers.map((member) => member._id), + }, + }); + + if (data) { + toast.success(t('successfullyAssignedToPeople')); + refetchAssignedMembersData(); + hideAddPeopleToTagModal(); + setAssignToMembers([]); + } + } catch (error: unknown) /* istanbul ignore next */ { + const errorMessage = + error instanceof Error ? error.message : tErrors('unknownError'); + toast.error(errorMessage); + } + }; + + if (userTagsMembersToAssignToError) { + return ( +
+
+ +
+ {t('errorOccurredWhileLoadingMembers')} +
+ {userTagsMembersToAssignToError.message} +
+
+
+ ); + } + + const columns: GridColDef[] = [ + { + field: 'id', + headerName: '#', + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return
{params.row.id}
; + }, + }, + { + field: 'userName', + headerName: t('userName'), + flex: 2, + minWidth: 100, + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
+ {params.row.firstName + ' ' + params.row.lastName} +
+ ); + }, + }, + { + field: 'actions', + headerName: t('actions'), + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + const isToBeAssigned = assignToMembers.some( + (member) => member._id === params.row._id, + ); + + return ( + + ); + }, + }, + ]; + + return ( + <> + + + {t('addPeople')} + +
+ +
+ {assignToMembers.length === 0 ? ( +
+ {t('noOneSelected')} +
+ ) : ( + assignToMembers.map((member) => ( +
+ {member.firstName} {member.lastName} + removeMember(member._id)} + data-testid="clearSelectedMember" + /> +
+ )) + )} +
+ +
+
+ + + setMemberToAssignToSearchFirstName(e.target.value.trim()) + } + data-testid="searchByFirstName" + autoComplete="off" + /> +
+
+ + + setMemberToAssignToSearchLastName(e.target.value.trim()) + } + data-testid="searchByLastName" + autoComplete="off" + /> +
+
+ + {userTagsMembersToAssignToLoading ? ( +
+ +
+ ) : ( + <> +
+ } + scrollableTarget="addPeopleToTagScrollableDiv" + > + row.id} + slots={{ + noRowsOverlay: /* istanbul ignore next */ () => ( + + {t('noMoreMembersFound')} + + ), + }} + sx={{ + ...dataGridStyle, + '& .MuiDataGrid-topContainer': { + position: 'static', + }, + '& .MuiDataGrid-virtualScrollerContent': { + marginTop: '0', + }, + }} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={userTagMembersToAssignTo?.map( + (membersToAssignTo, index) => ({ + id: index + 1, + ...membersToAssignTo, + }), + )} + columns={columns} + isRowSelectable={() => false} + /> + +
+ + )} +
+ + + + + +
+ + ); +}; + +export default AddPeopleToTag; diff --git a/src/components/AddPeopleToTag/AddPeopleToTagsMocks.ts b/src/components/AddPeopleToTag/AddPeopleToTagsMocks.ts new file mode 100644 index 0000000000..fbaf812186 --- /dev/null +++ b/src/components/AddPeopleToTag/AddPeopleToTagsMocks.ts @@ -0,0 +1,292 @@ +import { ADD_PEOPLE_TO_TAG } from 'GraphQl/Mutations/TagMutations'; +import { USER_TAGS_MEMBERS_TO_ASSIGN_TO } from 'GraphQl/Queries/userTagQueries'; +import { TAGS_QUERY_DATA_CHUNK_SIZE } from 'utils/organizationTagsUtils'; + +export const MOCKS = [ + { + request: { + query: USER_TAGS_MEMBERS_TO_ASSIGN_TO, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: '' }, + lastName: { starts_with: '' }, + }, + }, + }, + result: { + data: { + getUsersToAssignTo: { + name: 'tag1', + usersToAssignTo: { + edges: [ + { + node: { + _id: '1', + firstName: 'member', + lastName: '1', + }, + cursor: '1', + }, + { + node: { + _id: '2', + firstName: 'member', + lastName: '2', + }, + cursor: '2', + }, + { + node: { + _id: '3', + firstName: 'member', + lastName: '3', + }, + cursor: '3', + }, + { + node: { + _id: '4', + firstName: 'member', + lastName: '4', + }, + cursor: '4', + }, + { + node: { + _id: '5', + firstName: 'member', + lastName: '5', + }, + cursor: '5', + }, + { + node: { + _id: '6', + firstName: 'member', + lastName: '6', + }, + cursor: '6', + }, + { + node: { + _id: '7', + firstName: 'member', + lastName: '7', + }, + cursor: '7', + }, + { + node: { + _id: '8', + firstName: 'member', + lastName: '8', + }, + cursor: '8', + }, + { + node: { + _id: '9', + firstName: 'member', + lastName: '9', + }, + cursor: '9', + }, + { + node: { + _id: '10', + firstName: 'member', + lastName: '10', + }, + cursor: '10', + }, + ], + pageInfo: { + startCursor: '1', + endCursor: '10', + hasNextPage: true, + hasPreviousPage: false, + }, + totalCount: 12, + }, + }, + }, + }, + }, + { + request: { + query: USER_TAGS_MEMBERS_TO_ASSIGN_TO, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + after: '10', + where: { + firstName: { starts_with: '' }, + lastName: { starts_with: '' }, + }, + }, + }, + result: { + data: { + getUsersToAssignTo: { + name: 'tag1', + usersToAssignTo: { + edges: [ + { + node: { + _id: '11', + firstName: 'member', + lastName: '11', + }, + cursor: '11', + }, + { + node: { + _id: '12', + firstName: 'member', + lastName: '12', + }, + cursor: '12', + }, + ], + pageInfo: { + startCursor: '11', + endCursor: '12', + hasNextPage: false, + hasPreviousPage: true, + }, + totalCount: 12, + }, + }, + }, + }, + }, + { + request: { + query: USER_TAGS_MEMBERS_TO_ASSIGN_TO, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: 'usersToAssignTo' }, + lastName: { starts_with: '' }, + }, + }, + }, + result: { + data: { + getUsersToAssignTo: { + name: 'tag1', + usersToAssignTo: { + edges: [ + { + node: { + _id: '1', + firstName: 'usersToAssignTo', + lastName: 'user1', + }, + cursor: '1', + }, + { + node: { + _id: '2', + firstName: 'usersToAssignTo', + lastName: 'user2', + }, + cursor: '2', + }, + ], + pageInfo: { + startCursor: '1', + endCursor: '2', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 2, + }, + }, + }, + }, + }, + { + request: { + query: USER_TAGS_MEMBERS_TO_ASSIGN_TO, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: '' }, + lastName: { starts_with: 'userToAssignTo' }, + }, + }, + }, + result: { + data: { + getUsersToAssignTo: { + name: 'tag1', + usersToAssignTo: { + edges: [ + { + node: { + _id: '1', + firstName: 'first', + lastName: 'userToAssignTo', + }, + cursor: '1', + }, + { + node: { + _id: '2', + firstName: 'second', + lastName: 'userToAssignTo', + }, + cursor: '2', + }, + ], + pageInfo: { + startCursor: '1', + endCursor: '2', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 2, + }, + }, + }, + }, + }, + { + request: { + query: ADD_PEOPLE_TO_TAG, + variables: { + tagId: '1', + userIds: ['1', '3', '5'], + }, + }, + result: { + data: { + addPeopleToUserTag: { + _id: '1', + }, + }, + }, + }, +]; + +export const MOCKS_ERROR = [ + { + request: { + query: USER_TAGS_MEMBERS_TO_ASSIGN_TO, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: '' }, + lastName: { starts_with: '' }, + }, + }, + }, + error: new Error('Mock Graphql Error'), + }, +]; diff --git a/src/components/Advertisements/Advertisements.module.css b/src/components/Advertisements/Advertisements.module.css new file mode 100644 index 0000000000..8a34c03be5 --- /dev/null +++ b/src/components/Advertisements/Advertisements.module.css @@ -0,0 +1,31 @@ +.container { + display: flex; +} + +.logintitle { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 30px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 15%; +} + +.actioninput { + text-decoration: none; + margin-bottom: 50px; + border-color: #e8e5e5; + width: 80%; + border-radius: 7px; + padding-top: 5px; + padding-bottom: 5px; + padding-right: 10px; + padding-left: 10px; + box-shadow: none; +} + +.actionradio input { + width: fit-content; + margin: inherit; +} diff --git a/src/components/Advertisements/Advertisements.test.tsx b/src/components/Advertisements/Advertisements.test.tsx new file mode 100644 index 0000000000..c0992a1012 --- /dev/null +++ b/src/components/Advertisements/Advertisements.test.tsx @@ -0,0 +1,730 @@ +import React, { act } from 'react'; +import { + ApolloClient, + ApolloLink, + ApolloProvider, + HttpLink, + InMemoryCache, +} from '@apollo/client'; +import { MockedProvider } from '@apollo/client/testing'; +import { fireEvent, render, screen } from '@testing-library/react'; +import 'jest-location-mock'; + +import type { DocumentNode, NormalizedCacheObject } from '@apollo/client'; +import userEvent from '@testing-library/user-event'; +import { BACKEND_URL } from 'Constant/constant'; +import { ADD_ADVERTISEMENT_MUTATION } from 'GraphQl/Mutations/mutations'; +import { + ORGANIZATIONS_LIST, + ORGANIZATION_ADVERTISEMENT_LIST, + PLUGIN_GET, +} from 'GraphQl/Queries/Queries'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { ToastContainer } from 'react-toastify'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import useLocalStorage from 'utils/useLocalstorage'; +import Advertisement from './Advertisements'; + +const { getItem } = useLocalStorage(); + +const httpLink = new HttpLink({ + uri: BACKEND_URL, + headers: { + authorization: 'Bearer ' + getItem('token') || '', + }, +}); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const client: ApolloClient = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.from([httpLink]), +}); + +jest.mock('components/AddOn/support/services/Plugin.helper', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + fetchInstalled: jest.fn().mockResolvedValue([]), + fetchStore: jest.fn().mockResolvedValue([]), + })), +})); +let mockID: string | undefined = '1'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: mockID }), +})); + +const today = new Date(); +const tomorrow = today; +tomorrow.setDate(today.getDate() + 1); + +const ADVERTISEMENTS_LIST_MOCK: { + request: + | { + query: DocumentNode; + variables: { id: string; first: number; after: null }; + } + | { + query: DocumentNode; + variables: { + id: string; + first: number; + after: null; + before: null; + last: null; + }; + }; + result: + | { + data: { + organizations: { + _id: string; + advertisements: { + edges: { + node: { + _id: string; + name: string; + startDate: string; + endDate: string; + mediaUrl: string; + }; + cursor: string; + }[]; + pageInfo: { + startCursor: string; + endCursor: string; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + totalCount: number; + }; + }[]; + }; + } + | { + data: { + organizations: { + _id: string; + advertisements: { + edges: { + node: { + _id: string; + name: string; + startDate: string; + endDate: string; + mediaUrl: string; + }; + cursor: string; + }[]; + pageInfo: { + startCursor: string; + endCursor: string; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + totalCount: number; + }; + }[]; + }; + }; +}[] = []; + +for (let i = 0; i < 4; i++) { + ADVERTISEMENTS_LIST_MOCK.push({ + request: { + query: ORGANIZATION_ADVERTISEMENT_LIST, + variables: { + id: '1', + first: 6, + after: null, + }, + }, + result: { + data: { + organizations: [ + { + _id: '1', + advertisements: { + edges: [ + { + node: { + _id: '1', + name: 'Advertisement1', + startDate: '2022-01-01', + endDate: '2023-01-01', + mediaUrl: 'http://example1.com', + }, + cursor: 'cursor1', + }, + { + node: { + _id: '2', + name: 'Advertisement2', + startDate: '2024-02-01', + endDate: '2025-02-01', + mediaUrl: 'http://example2.com', + }, + cursor: 'cursor2', + }, + ], + pageInfo: { + startCursor: 'cursor1', + endCursor: 'cursor2', + hasNextPage: true, + hasPreviousPage: false, + }, + totalCount: 2, + }, + }, + ], + }, + }, + }); + ADVERTISEMENTS_LIST_MOCK.push({ + request: { + query: ORGANIZATION_ADVERTISEMENT_LIST, + variables: { + id: '1', + first: 6, + after: null, + before: null, + last: null, + }, + }, + result: { + data: { + organizations: [ + { + _id: '1', + advertisements: { + edges: [ + { + node: { + _id: '1', + name: 'Advertisement1', + startDate: '2022-01-01', + endDate: '2023-01-01', + mediaUrl: 'http://example1.com', + }, + cursor: 'cursor1', + }, + { + node: { + _id: '2', + name: 'Advertisement2', + startDate: '2024-02-01', + endDate: '2025-02-01', + mediaUrl: 'http://example2.com', + }, + cursor: 'cursor2', + }, + ], + pageInfo: { + startCursor: 'cursor1', + endCursor: 'cursor2', + hasNextPage: true, + hasPreviousPage: false, + }, + totalCount: 2, + }, + }, + ], + }, + }, + }); +} + +const PLUGIN_GET_MOCK = { + request: { + query: PLUGIN_GET, + }, + result: { + data: { + getPlugins: [ + { + _id: '6581be50e88e74003aab436c', + pluginName: 'Chats', + pluginCreatedBy: 'Talawa Team', + pluginDesc: + 'User can share messages with other users in a chat user interface.', + uninstalledOrgs: [ + '62ccfccd3eb7fd2a30f41601', + '62ccfccd3eb7fd2a30f41601', + ], + pluginInstallStatus: true, + __typename: 'Plugin', + }, + ], + }, + loading: false, + }, +}; + +const ADD_ADVERTISEMENT_MUTATION_MOCK = { + request: { + query: ADD_ADVERTISEMENT_MUTATION, + variables: { + organizationId: '1', + name: 'Cookie Shop', + file: 'data:image/png;base64,bWVkaWEgY29udGVudA==', + type: 'POPUP', + startDate: '2023-01-01', + endDate: '2023-02-02', + }, + }, + result: { + data: { + createAdvertisement: { + _id: '65844efc814dd4003db811c4', + advertisement: null, + __typename: 'Advertisement', + }, + }, + }, +}; + +const ORGANIZATIONS_LIST_MOCK = { + request: { + query: ORGANIZATIONS_LIST, + variables: { + id: '1', + }, + }, + result: { + data: { + organizations: [ + { + _id: '1', + image: '', + creator: { + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + name: 'name', + description: 'description', + userRegistrationRequired: true, + + visibleInSearch: true, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + members: { + _id: 'id', + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + admins: { + _id: 'id', + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + membershipRequests: { + _id: 'id', + user: { + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + }, + blockedUsers: { + _id: 'id', + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + }, + ], + }, + }, +}; + +describe('Testing Advertisement Component', () => { + test('for creating new Advertisements', async () => { + const mocks = [ + ORGANIZATIONS_LIST_MOCK, + PLUGIN_GET_MOCK, + ADD_ADVERTISEMENT_MUTATION_MOCK, + ...ADVERTISEMENTS_LIST_MOCK, + ]; + + render( + + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByText('Create Advertisement')); + userEvent.type( + screen.getByLabelText('Enter name of Advertisement'), + 'Cookie Shop', + ); + const mediaFile = new File(['media content'], 'test.png', { + type: 'image/png', + }); + + const mediaInput = screen.getByTestId('advertisementMedia'); + fireEvent.change(mediaInput, { + target: { + files: [mediaFile], + }, + }); + const mediaPreview = await screen.findByTestId('mediaPreview'); + expect(mediaPreview).toBeInTheDocument(); + userEvent.selectOptions( + screen.getByLabelText('Select type of Advertisement'), + 'POPUP', + ); + userEvent.type(screen.getByLabelText('Select Start Date'), '2023-01-01'); + userEvent.type(screen.getByLabelText('Select End Date'), '2023-02-02'); + + userEvent.click(screen.getByTestId('addonregister')); + expect( + await screen.findByText('Advertisement created successfully.'), + ).toBeInTheDocument(); + }); + + test('for the working of the tabs', async () => { + const mocks = [ + ORGANIZATIONS_LIST_MOCK, + PLUGIN_GET_MOCK, + ADD_ADVERTISEMENT_MUTATION_MOCK, + ...ADVERTISEMENTS_LIST_MOCK, + ]; + + render( + + + + + + + + + + + , + ); + + await wait(); + userEvent.click(screen.getByText('Active Campaigns')); + + await wait(); + userEvent.click(screen.getByText('Completed Campaigns')); + }); + + test('if the component renders correctly and ads are correctly categorized date wise', async () => { + mockID = '1'; + const mocks = [...ADVERTISEMENTS_LIST_MOCK]; + + render( + + + + + + + + + + + , + ); + + await wait(); + + const date = await screen.findAllByTestId('Ad_end_date'); + const dateString = date[1].innerHTML; + const dateMatch = dateString.match( + /\b(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat)\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+(\d{1,2})\s+(\d{4})\b/, + ); + let dateObject = new Date(); + + if (dateMatch) { + const monthName = dateMatch[1]; + const day = parseInt(dateMatch[2], 10); + const year = parseInt(dateMatch[3], 10); + + const monthIndex = + 'JanFebMarAprMayJunJulAugSepOctNovDec'.indexOf(monthName) / 3; + + dateObject = new Date(year, monthIndex, day); + } + + expect(dateObject.getTime()).toBeLessThan(new Date().getTime()); + }); + + test('delete ad', async () => { + mockID = '1'; + const mocks = [...ADVERTISEMENTS_LIST_MOCK]; + + render( + + + + + + + + + + + , + ); + + await wait(); + + const moreiconbtn = await screen.findAllByTestId('moreiconbtn'); + fireEvent.click(moreiconbtn[1]); + const deleteBtn = await screen.findByTestId('deletebtn'); + expect(deleteBtn).toBeInTheDocument(); + fireEvent.click(deleteBtn); + }); + + test('infinite scroll', async () => { + mockID = '1'; + const mocks = [ + ...ADVERTISEMENTS_LIST_MOCK, + { + request: { + query: ORGANIZATION_ADVERTISEMENT_LIST, + variables: { + id: '1', + first: 2, + after: null, + last: null, + before: null, + }, + }, + result: { + data: { + organizations: [ + { + _id: '1', + advertisements: { + edges: [ + { + node: { + _id: '1', + name: 'Advertisement1', + startDate: '2022-01-01', + endDate: '2023-01-01', + mediaUrl: 'http://example1.com', + }, + cursor: 'cursor1', + }, + { + node: { + _id: '2', + name: 'Advertisement2', + startDate: '2024-02-01', + endDate: '2025-02-01', + mediaUrl: 'http://example2.com', + }, + cursor: 'cursor2', + }, + { + node: { + _id: '3', + name: 'Advertisement1', + startDate: '2022-01-01', + endDate: '2023-01-01', + mediaUrl: 'http://example1.com', + }, + cursor: 'cursor3', + }, + { + node: { + _id: '4', + name: 'Advertisement2', + startDate: '2024-02-01', + endDate: '2025-02-01', + mediaUrl: 'http://example2.com', + }, + cursor: 'cursor4', + }, + { + node: { + _id: '5', + name: 'Advertisement1', + startDate: '2022-01-01', + endDate: '2023-01-01', + mediaUrl: 'http://example1.com', + }, + cursor: 'cursor5', + }, + { + node: { + _id: '6', + name: 'Advertisement2', + startDate: '2024-02-01', + endDate: '2025-02-01', + mediaUrl: 'http://example2.com', + }, + cursor: 'cursor6', + }, + ], + pageInfo: { + startCursor: 'cursor1', + endCursor: 'cursor6', + hasNextPage: true, + hasPreviousPage: false, + }, + totalCount: 8, + }, + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_ADVERTISEMENT_LIST, + variables: { + id: '1', + first: 6, + after: 'cursor6', + last: null, + before: null, + }, + }, + result: { + data: { + organizations: [ + { + _id: '1', + advertisements: { + edges: [ + { + node: { + _id: '7', + name: 'Advertisement7', + startDate: '2022-01-01', + endDate: '2023-01-01', + mediaUrl: 'http://example1.com', + }, + cursor: '5rdiyruyu3hkjkjiwfhwaify', + }, + { + node: { + _id: '8', + name: 'Advertisement8', + startDate: '2024-02-01', + endDate: '2025-02-01', + mediaUrl: 'http://example2.com', + }, + cursor: '5rdiyrhgkjkjjyg3iwfhwaify', + }, + ], + pageInfo: { + startCursor: '5rdiyruyu3hkjkjiwfhwaify', + endCursor: '5rdiyrhgkjkjjyg3iwfhwaify', + hasNextPage: false, + hasPreviousPage: true, + }, + totalCount: 8, + }, + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_ADVERTISEMENT_LIST, + variables: { + id: '1', + first: 6, + after: 'cursor2', + }, + }, + result: { + data: { + organizations: [ + { + _id: '1', + advertisements: { + edges: [ + { + node: { + _id: '7', + name: 'Advertisement7', + startDate: '2022-01-01', + endDate: '2023-01-01', + mediaUrl: 'http://example1.com', + }, + cursor: '5rdiyruyu3hkjkjiwfhwaify', + }, + { + node: { + _id: '8', + name: 'Advertisement8', + startDate: '2024-02-01', + endDate: '2025-02-01', + mediaUrl: 'http://example2.com', + }, + cursor: '5rdiyrhgkjkjjyg3iwfhwaify', + }, + ], + pageInfo: { + startCursor: '5rdiyruyu3hkjkjiwfhwaify', + endCursor: '5rdiyrhgkjkjjyg3iwfhwaify', + hasNextPage: false, + hasPreviousPage: true, + }, + totalCount: 8, + }, + }, + ], + }, + }, + }, + ]; + + render( + + + + + + + + + + + , + ); + let moreiconbtn = await screen.findAllByTestId('moreiconbtn'); + console.log('before scroll', moreiconbtn); + fireEvent.scroll(window, { target: { scrollY: 500 } }); + moreiconbtn = await screen.findAllByTestId('moreiconbtn'); + console.log('after scroll', moreiconbtn); + }); +}); diff --git a/src/components/Advertisements/Advertisements.tsx b/src/components/Advertisements/Advertisements.tsx new file mode 100644 index 0000000000..f20c2a7d8e --- /dev/null +++ b/src/components/Advertisements/Advertisements.tsx @@ -0,0 +1,273 @@ +import React, { useEffect, useState } from 'react'; +import styles from './Advertisements.module.css'; +import { useQuery } from '@apollo/client'; +import { ORGANIZATION_ADVERTISEMENT_LIST } from 'GraphQl/Queries/Queries'; +import { Col, Row, Tab, Tabs } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import AdvertisementEntry from './core/AdvertisementEntry/AdvertisementEntry'; +import AdvertisementRegister from './core/AdvertisementRegister/AdvertisementRegister'; +import { useParams } from 'react-router-dom'; +import type { InterfaceQueryOrganizationAdvertisementListItem } from 'utils/interfaces'; +import InfiniteScroll from 'react-infinite-scroll-component'; + +/** + * The `Advertisements` component displays a list of advertisements for a specific organization. + * It uses a tab-based interface to toggle between active and archived advertisements. + * + * The component utilizes the `useQuery` hook from Apollo Client to fetch advertisements data + * and implements infinite scrolling to load more advertisements as the user scrolls. + * + * @example + * return ( + * + * ) + * + */ + +export default function Advertisements(): JSX.Element { + // Retrieve the organization ID from URL parameters + const { orgId: currentOrgId } = useParams(); + // Translation hook for internationalization + const { t } = useTranslation('translation', { keyPrefix: 'advertisement' }); + const { t: tCommon } = useTranslation('common'); + + // Set the document title based on the translation + document.title = t('title'); + + // State to manage pagination cursor for infinite scrolling + const [after, setAfter] = useState(null); + + // Type definition for an advertisement object + type Ad = { + _id: string; + name: string; + type: 'BANNER' | 'MENU' | 'POPUP'; + mediaUrl: string; + endDate: string; // Assuming it's a string in the format 'yyyy-MM-dd' + startDate: string; // Assuming it's a string in the format 'yyyy-MM-dd' + }; + + // GraphQL query to fetch the list of advertisements + const { + data: orgAdvertisementListData, + refetch, + }: { + data?: { + organizations: InterfaceQueryOrganizationAdvertisementListItem[]; + }; + refetch: () => void; + } = useQuery(ORGANIZATION_ADVERTISEMENT_LIST, { + variables: { + id: currentOrgId, + after: after, + first: 6, + }, + }); + + // State to manage the list of advertisements + const [advertisements, setAdvertisements] = useState( + orgAdvertisementListData?.organizations[0].advertisements?.edges.map( + (edge: { node: Ad }) => edge.node, + ) || [], + ); + + // Effect hook to update advertisements list when data changes or pagination cursor changes + useEffect(() => { + if (orgAdvertisementListData && orgAdvertisementListData.organizations) { + const ads: Ad[] = + orgAdvertisementListData.organizations[0].advertisements?.edges.map( + (edge) => edge.node, + ) || []; + setAdvertisements(after ? [...advertisements, ...ads] : ads); + } + }, [orgAdvertisementListData, after]); + + /** + * Fetches more advertisements for infinite scrolling. + */ + async function loadMoreAdvertisements(): Promise { + await refetch(); + + if (orgAdvertisementListData && orgAdvertisementListData.organizations) { + setAfter( + orgAdvertisementListData?.organizations[0]?.advertisements.pageInfo + .endCursor, + ); + } + } + + return ( + <> + + +
+ {/* Component for registering a new advertisement */} + + + {/* Tabs for active and archived advertisements */} + + {/* Tab for active advertisements */} + + + {/* Skeleton loader while fetching more advertisements */} + {[...Array(6)].map((_, index) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} + + } + hasMore={ + orgAdvertisementListData?.organizations[0].advertisements + .pageInfo.hasNextPage ?? false + } + className={styles.listBox} + data-testid="organizations-list" + endMessage={ + advertisements.filter( + (ad: Ad) => new Date(ad.endDate) > new Date(), + ).length !== 0 && ( +
+
{tCommon('endOfResults')}
+
+ ) + } + > + {advertisements.filter( + (ad: Ad) => new Date(ad.endDate) > new Date(), + ).length === 0 ? ( +

{t('pMessage')}

+ ) : ( + advertisements + .filter((ad: Ad) => new Date(ad.endDate) > new Date()) + .map( + ( + ad: { + _id: string; + name: string | undefined; + type: string | undefined; + mediaUrl: string; + endDate: string; + startDate: string; + }, + i: React.Key | null | undefined, + ): JSX.Element => ( + + ), + ) + )} + + + + {/* Tab for archived advertisements */} + + + {/* Skeleton loader while fetching more advertisements */} + {[...Array(6)].map((_, index) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} + + } + hasMore={ + orgAdvertisementListData?.organizations[0].advertisements + .pageInfo.hasNextPage ?? false + } + className={styles.listBox} + data-testid="organizations-list" + endMessage={ + advertisements.filter( + (ad: Ad) => new Date(ad.endDate) < new Date(), + ).length !== 0 && ( +
+
{tCommon('endOfResults')}
+
+ ) + } + > + {advertisements.filter( + (ad: Ad) => new Date(ad.endDate) < new Date(), + ).length === 0 ? ( +

{t('pMessage')}

+ ) : ( + advertisements + .filter((ad: Ad) => new Date(ad.endDate) < new Date()) + .map( + ( + ad: { + _id: string; + name: string | undefined; + type: string | undefined; + mediaUrl: string; + endDate: string; + startDate: string; + }, + i: React.Key | null | undefined, + ): JSX.Element => ( + + ), + ) + )} + + + +
+ + + + ); +} diff --git a/src/components/Advertisements/core/AdvertisementEntry/AdvertisementEntry.module.css b/src/components/Advertisements/core/AdvertisementEntry/AdvertisementEntry.module.css new file mode 100644 index 0000000000..879d96a0a0 --- /dev/null +++ b/src/components/Advertisements/core/AdvertisementEntry/AdvertisementEntry.module.css @@ -0,0 +1,75 @@ +.entrytoggle { + margin: 24px 24px 0 auto; + width: fit-content; +} + +.entryaction { + display: flex !important; +} + +.entryaction i { + margin-right: 8px; + margin-top: 4px; +} + +.entryaction .spinner-grow { + height: 1rem; + width: 1rem; + margin-right: 8px; +} + +.admedia { + object-fit: cover; + height: 20rem; +} + +.buttons { + display: flex; + justify-content: flex-end; +} + +.dropdownButton { + background-color: transparent; + color: #000; + border: none; + cursor: pointer; + display: flex; + width: 100%; + justify-content: flex-end; + padding: 8px 10px; +} + +.dropdownContainer { + position: relative; + display: inline-block; +} + +.dropdownmenu { + display: none; + position: absolute; + z-index: 1; + background-color: white; + width: 120px; + box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2); + padding: 5px 0; + margin: 0; + list-style-type: none; + right: 0; + top: 100%; +} + +.dropdownmenu li { + cursor: pointer; + padding: 8px 16px; + text-decoration: none; + display: block; + color: #333; +} + +.dropdownmenu li:hover { + background-color: #f1f1f1; +} + +.dropdownContainer:hover .dropdownmenu { + display: block; +} diff --git a/src/components/Advertisements/core/AdvertisementEntry/AdvertisementEntry.test.tsx b/src/components/Advertisements/core/AdvertisementEntry/AdvertisementEntry.test.tsx new file mode 100644 index 0000000000..dbd6f88cc3 --- /dev/null +++ b/src/components/Advertisements/core/AdvertisementEntry/AdvertisementEntry.test.tsx @@ -0,0 +1,637 @@ +import React from 'react'; +import { render, fireEvent, waitFor, screen } from '@testing-library/react'; +import { + ApolloClient, + ApolloProvider, + InMemoryCache, + ApolloLink, + HttpLink, +} from '@apollo/client'; +import type { NormalizedCacheObject } from '@apollo/client'; +import { BrowserRouter } from 'react-router-dom'; +import AdvertisementEntry from './AdvertisementEntry'; +import AdvertisementRegister from '../AdvertisementRegister/AdvertisementRegister'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import { BACKEND_URL } from 'Constant/constant'; +import i18nForTest from 'utils/i18nForTest'; +import { I18nextProvider } from 'react-i18next'; +import dayjs from 'dayjs'; +import useLocalStorage from 'utils/useLocalstorage'; +import { MockedProvider } from '@apollo/client/testing'; +import { ORGANIZATION_ADVERTISEMENT_LIST } from 'GraphQl/Queries/OrganizationQueries'; +import { DELETE_ADVERTISEMENT_BY_ID } from 'GraphQl/Mutations/mutations'; + +const { getItem } = useLocalStorage(); + +const httpLink = new HttpLink({ + uri: BACKEND_URL, + headers: { + authorization: 'Bearer ' + getItem('token') || '', + }, +}); + +const translations = JSON.parse( + JSON.stringify( + i18nForTest.getDataByLanguage('en')?.translation?.advertisement ?? null, + ), +); + +const client: ApolloClient = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.from([httpLink]), +}); + +const mockUseMutation = jest.fn(); +jest.mock('@apollo/client', () => { + const originalModule = jest.requireActual('@apollo/client'); + return { + ...originalModule, + useMutation: () => mockUseMutation(), + }; +}); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: '1' }), +})); + +describe('Testing Advertisement Entry Component', () => { + test('Testing rendering and deleting of advertisement', async () => { + const deleteAdByIdMock = jest.fn(); + mockUseMutation.mockReturnValue([deleteAdByIdMock]); + const { getByTestId, getAllByText } = render( + + + + + + + + + , + ); + + //Testing rendering + expect(getByTestId('AdEntry')).toBeInTheDocument(); + expect(getAllByText('POPUP')[0]).toBeInTheDocument(); + expect(getAllByText('Advert1')[0]).toBeInTheDocument(); + expect(screen.getByTestId('media')).toBeInTheDocument(); + + //Testing successful deletion + fireEvent.click(getByTestId('moreiconbtn')); + fireEvent.click(getByTestId('deletebtn')); + + await waitFor(() => { + expect(screen.getByTestId('delete_title')).toBeInTheDocument(); + expect(screen.getByTestId('delete_body')).toBeInTheDocument(); + }); + + fireEvent.click(getByTestId('delete_yes')); + + await waitFor(() => { + expect(deleteAdByIdMock).toHaveBeenCalledWith({ + variables: { + id: '1', + }, + }); + const deletedMessage = screen.queryByText('Advertisement Deleted'); + expect(deletedMessage).toBeNull(); + }); + + //Testing unsuccessful deletion + deleteAdByIdMock.mockRejectedValueOnce(new Error('Deletion Failed')); + + fireEvent.click(getByTestId('moreiconbtn')); + + fireEvent.click(getByTestId('delete_yes')); + + await waitFor(() => { + expect(deleteAdByIdMock).toHaveBeenCalledWith({ + variables: { + id: '1', + }, + }); + const deletionFailedText = screen.queryByText((content, element) => { + return ( + element?.textContent === 'Deletion Failed' && + element.tagName.toLowerCase() === 'div' + ); + }); + expect(deletionFailedText).toBeNull(); + }); + }); + it('should use default props when none are provided', () => { + render( + , + ): void { + throw new Error('Function not implemented.'); + }} + />, + ); + + //Check if component renders with default ''(empty string) + const elements = screen.getAllByText(''); // This will return an array of matching elements + elements.forEach((element) => expect(element).toBeInTheDocument()); + + // Check that the component renders with default `mediaUrl` (empty string) + const mediaElement = screen.getByTestId('media'); + expect(mediaElement).toHaveAttribute('src', ''); + + // Check that the component renders with default `endDate` + const defaultEndDate = new Date().toDateString(); + expect(screen.getByText(`Ends on ${defaultEndDate}`)).toBeInTheDocument(); + + // Check that the component renders with default `startDate` + const defaultStartDate = new Date().toDateString(); + console.log(screen.getByText); + expect(screen.getByText(`Ends on ${defaultStartDate}`)).toBeInTheDocument(); //fix text "Ends on"? + }); + it('should correctly override default props when values are provided', () => { + const mockName = 'Test Ad'; + const mockType = 'Banner'; + const mockMediaUrl = 'https://example.com/media.png'; + const mockEndDate = new Date(2025, 11, 31); + const mockStartDate = new Date(2024, 0, 1); + const mockOrganizationId = 'org123'; + + const { getByText } = render( + , + ): void { + throw new Error('Function not implemented.'); + }} + />, + ); + + // Check that the component renders with provided values + expect(getByText(mockName)).toBeInTheDocument(); + // Add more checks based on how each prop affects rendering + }); + it('should open and close the dropdown when options button is clicked', () => { + const { getByTestId, queryByText, getAllByText } = render( + + + + + + + + + , + ); + + // Test initial rendering + expect(getByTestId('AdEntry')).toBeInTheDocument(); + expect(getAllByText('POPUP')[0]).toBeInTheDocument(); + expect(getAllByText('Advert1')[0]).toBeInTheDocument(); + + // Test dropdown functionality + const optionsButton = getByTestId('moreiconbtn'); + + // Initially, the dropdown should not be visible + expect(queryByText('Edit')).toBeNull(); + + // Click to open the dropdown + fireEvent.click(optionsButton); + + // After clicking the button, the dropdown should be visible + expect(queryByText('Edit')).toBeInTheDocument(); + + // Click again to close the dropdown + fireEvent.click(optionsButton); + + // After the second click, the dropdown should be hidden again + expect(queryByText('Edit')).toBeNull(); + }); + + test('Updates the advertisement and shows success toast on successful update', async () => { + const updateAdByIdMock = jest.fn().mockResolvedValue({ + data: { + updateAdvertisement: { + advertisement: { + _id: '1', + name: 'Updated Advertisement', + mediaUrl: '', + startDate: dayjs(new Date()).add(1, 'day').format('YYYY-MM-DD'), + endDate: dayjs(new Date()).add(2, 'days').format('YYYY-MM-DD'), + type: 'BANNER', + }, + }, + }, + }); + + mockUseMutation.mockReturnValue([updateAdByIdMock]); + + render( + + + + + + + + + , + ); + + const optionsButton = screen.getByTestId('moreiconbtn'); + fireEvent.click(optionsButton); + fireEvent.click(screen.getByTestId('editBtn')); + + fireEvent.change(screen.getByLabelText('Enter name of Advertisement'), { + target: { value: 'Updated Advertisement' }, + }); + + expect(screen.getByLabelText('Enter name of Advertisement')).toHaveValue( + 'Updated Advertisement', + ); + + fireEvent.change(screen.getByLabelText(translations.Rtype), { + target: { value: 'BANNER' }, + }); + expect(screen.getByLabelText(translations.Rtype)).toHaveValue('BANNER'); + + fireEvent.change(screen.getByLabelText(translations.RstartDate), { + target: { value: dayjs().add(1, 'day').format('YYYY-MM-DD') }, + }); + + fireEvent.change(screen.getByLabelText(translations.RendDate), { + target: { value: dayjs().add(2, 'days').format('YYYY-MM-DD') }, + }); + + fireEvent.click(screen.getByTestId('addonupdate')); + + expect(updateAdByIdMock).toHaveBeenCalledWith({ + variables: { + id: '1', + name: 'Updated Advertisement', + type: 'BANNER', + startDate: dayjs().add(1, 'day').format('YYYY-MM-DD'), + endDate: dayjs().add(2, 'days').format('YYYY-MM-DD'), + }, + }); + }); + + test('Simulating if the mutation doesnt have data variable while updating', async () => { + const updateAdByIdMock = jest.fn().mockResolvedValue({ + updateAdvertisement: { + _id: '1', + name: 'Updated Advertisement', + type: 'BANNER', + }, + }); + + mockUseMutation.mockReturnValue([updateAdByIdMock]); + + render( + + + + + + + + + , + ); + + const optionsButton = screen.getByTestId('moreiconbtn'); + fireEvent.click(optionsButton); + fireEvent.click(screen.getByTestId('editBtn')); + + fireEvent.change(screen.getByLabelText('Enter name of Advertisement'), { + target: { value: 'Updated Advertisement' }, + }); + + expect(screen.getByLabelText('Enter name of Advertisement')).toHaveValue( + 'Updated Advertisement', + ); + + fireEvent.change(screen.getByLabelText(translations.Rtype), { + target: { value: 'BANNER' }, + }); + expect(screen.getByLabelText(translations.Rtype)).toHaveValue('BANNER'); + + fireEvent.click(screen.getByTestId('addonupdate')); + + expect(updateAdByIdMock).toHaveBeenCalledWith({ + variables: { + id: '1', + name: 'Updated Advertisement', + type: 'BANNER', + }, + }); + }); + + test('Simulating if the mutation does not have data variable while registering', async () => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { + reload: jest.fn(), + href: 'https://example.com/page/id=1', + }, + }); + const createAdByIdMock = jest.fn().mockResolvedValue({ + data1: { + createAdvertisement: { + _id: '1', + }, + }, + }); + + mockUseMutation.mockReturnValue([createAdByIdMock]); + + render( + + + + + { + + } + + + + , + ); + + fireEvent.click(screen.getByTestId('createAdvertisement')); + + fireEvent.change(screen.getByLabelText('Enter name of Advertisement'), { + target: { value: 'Updated Advertisement' }, + }); + + expect(screen.getByLabelText('Enter name of Advertisement')).toHaveValue( + 'Updated Advertisement', + ); + + fireEvent.change(screen.getByLabelText(translations.Rtype), { + target: { value: 'BANNER' }, + }); + expect(screen.getByLabelText(translations.Rtype)).toHaveValue('BANNER'); + + fireEvent.change(screen.getByLabelText(translations.RstartDate), { + target: { value: '2023-01-01' }, + }); + expect(screen.getByLabelText(translations.RstartDate)).toHaveValue( + '2023-01-01', + ); + + fireEvent.change(screen.getByLabelText(translations.RendDate), { + target: { value: '2023-02-01' }, + }); + expect(screen.getByLabelText(translations.RendDate)).toHaveValue( + '2023-02-01', + ); + + fireEvent.click(screen.getByTestId('addonregister')); + + expect(createAdByIdMock).toHaveBeenCalledWith({ + variables: { + organizationId: '1', + name: 'Updated Advertisement', + file: '', + type: 'BANNER', + startDate: dayjs(new Date('2023-01-01')).format('YYYY-MM-DD'), + endDate: dayjs(new Date('2023-02-01')).format('YYYY-MM-DD'), + }, + }); + }); + test('delet advertisement', async () => { + const deleteAdByIdMock = jest.fn(); + const mocks = [ + { + request: { + query: ORGANIZATION_ADVERTISEMENT_LIST, + variables: { + id: '1', + first: 2, + after: null, + last: null, + before: null, + }, + }, + result: { + data: { + organizations: [ + { + _id: '1', + advertisements: { + edges: [ + { + node: { + _id: '1', + name: 'Advertisement1', + startDate: '2022-01-01', + endDate: '2023-01-01', + mediaUrl: 'http://example1.com', + }, + cursor: 'cursor1', + }, + { + node: { + _id: '2', + name: 'Advertisement2', + startDate: '2024-02-01', + endDate: '2025-02-01', + mediaUrl: 'http://example2.com', + }, + cursor: 'cursor2', + }, + { + node: { + _id: '3', + name: 'Advertisement1', + startDate: '2022-01-01', + endDate: '2023-01-01', + mediaUrl: 'http://example1.com', + }, + cursor: 'cursor3', + }, + { + node: { + _id: '4', + name: 'Advertisement2', + startDate: '2024-02-01', + endDate: '2025-02-01', + mediaUrl: 'http://example2.com', + }, + cursor: 'cursor4', + }, + { + node: { + _id: '5', + name: 'Advertisement1', + startDate: '2022-01-01', + endDate: '2023-01-01', + mediaUrl: 'http://example1.com', + }, + cursor: 'cursor5', + }, + { + node: { + _id: '6', + name: 'Advertisement2', + startDate: '2024-02-01', + endDate: '2025-02-01', + mediaUrl: 'http://example2.com', + }, + cursor: 'cursor6', + }, + ], + pageInfo: { + startCursor: 'cursor1', + endCursor: 'cursor6', + hasNextPage: true, + hasPreviousPage: false, + }, + totalCount: 8, + }, + }, + ], + }, + }, + }, + { + request: { + query: DELETE_ADVERTISEMENT_BY_ID, + variables: { + id: '1', + }, + }, + result: { + data: { + advertisements: { + _id: null, + }, + }, + }, + }, + ]; + mockUseMutation.mockReturnValue([deleteAdByIdMock]); + const { getByTestId, getAllByText } = render( + + + + + + + + + + + , + ); + + //Testing rendering + expect(getByTestId('AdEntry')).toBeInTheDocument(); + expect(getAllByText('POPUP')[0]).toBeInTheDocument(); + expect(getAllByText('Advert1')[0]).toBeInTheDocument(); + expect(screen.getByTestId('media')).toBeInTheDocument(); + + //Testing successful deletion + fireEvent.click(getByTestId('moreiconbtn')); + fireEvent.click(getByTestId('deletebtn')); + + await waitFor(() => { + expect(screen.getByTestId('delete_title')).toBeInTheDocument(); + expect(screen.getByTestId('delete_body')).toBeInTheDocument(); + }); + + fireEvent.click(getByTestId('delete_yes')); + + await waitFor(() => { + expect(deleteAdByIdMock).toHaveBeenCalledWith({ + variables: { + id: '1', + }, + }); + const deletedMessage = screen.queryByText('Advertisement Deleted'); + expect(deletedMessage).toBeNull(); + }); + + //Testing unsuccessful deletion + deleteAdByIdMock.mockRejectedValueOnce(new Error('Deletion Failed')); + + fireEvent.click(getByTestId('moreiconbtn')); + + fireEvent.click(getByTestId('delete_yes')); + + await waitFor(() => { + expect(deleteAdByIdMock).toHaveBeenCalledWith({ + variables: { + id: '1', + }, + }); + const deletionFailedText = screen.queryByText((content, element) => { + return ( + element?.textContent === 'Deletion Failed' && + element.tagName.toLowerCase() === 'div' + ); + }); + expect(deletionFailedText).toBeNull(); + }); + }); +}); diff --git a/src/components/Advertisements/core/AdvertisementEntry/AdvertisementEntry.tsx b/src/components/Advertisements/core/AdvertisementEntry/AdvertisementEntry.tsx new file mode 100644 index 0000000000..7368ded68e --- /dev/null +++ b/src/components/Advertisements/core/AdvertisementEntry/AdvertisementEntry.tsx @@ -0,0 +1,221 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import styles from './AdvertisementEntry.module.css'; +import { Button, Card, Col, Row, Spinner, Modal } from 'react-bootstrap'; +import { DELETE_ADVERTISEMENT_BY_ID } from 'GraphQl/Mutations/mutations'; +import { useMutation } from '@apollo/client'; +import { useTranslation } from 'react-i18next'; +import { ORGANIZATION_ADVERTISEMENT_LIST } from 'GraphQl/Queries/Queries'; +import AdvertisementRegister from '../AdvertisementRegister/AdvertisementRegister'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import { toast } from 'react-toastify'; + +interface InterfaceAddOnEntryProps { + id: string; + name?: string; + mediaUrl?: string; + type?: string; + organizationId?: string; + startDate?: Date; + endDate?: Date; + setAfter: React.Dispatch>; +} + +/** + * Component for displaying an advertisement entry. + * Allows viewing, editing, and deleting of the advertisement. + * + * @param props - Component properties + * @returns The rendered component + */ +function AdvertisementEntry({ + id, + name = '', + type = '', + mediaUrl = '', + endDate = new Date(), + organizationId = '', + startDate = new Date(), + setAfter, +}: InterfaceAddOnEntryProps): JSX.Element { + const { t } = useTranslation('translation', { keyPrefix: 'advertisement' }); + const { t: tCommon } = useTranslation('common'); + + // State for loading button + const [buttonLoading, setButtonLoading] = useState(false); + // State for dropdown menu visibility + const [dropdown, setDropdown] = useState(false); + // State for delete confirmation modal visibility + const [showDeleteModal, setShowDeleteModal] = useState(false); + + // Mutation hook for deleting an advertisement + const [deleteAdById] = useMutation(DELETE_ADVERTISEMENT_BY_ID, { + refetchQueries: [ + { + query: ORGANIZATION_ADVERTISEMENT_LIST, + variables: { first: 6, after: null, id: organizationId }, + }, + ], + }); + + /** + * Toggles the visibility of the delete confirmation modal. + */ + const toggleShowDeleteModal = (): void => setShowDeleteModal((prev) => !prev); + + /** + * Handles advertisement deletion. + * Displays a success or error message based on the result. + */ + const onDelete = async (): Promise => { + setButtonLoading(true); + try { + await deleteAdById({ + variables: { + id: id.toString(), + }, + }); + toast.success(t('advertisementDeleted') as string); + setButtonLoading(false); + setAfter?.(null); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + setButtonLoading(false); + } + }; + + /** + * Toggles the visibility of the dropdown menu. + */ + const handleOptionsClick = (): void => { + setDropdown(!dropdown); + }; + + return ( + <> + + {Array.from({ length: 1 }).map((_, idx) => ( + + +
+ + {dropdown && ( +
    +
  • + +
  • +
  • + {tCommon('delete')} +
  • +
+ )} +
+ {mediaUrl?.includes('videos') ? ( + + ) : ( + + )} + + {name} + + Ends on {endDate?.toDateString()} + + + {type} + +
+ +
+ + +
+ {t('deleteAdvertisement')} +
+ +
+ + {t('deleteAdvertisementMsg')} + + + + + +
+
+
+ + ))} +
+
+ + ); +} + +AdvertisementEntry.propTypes = { + name: PropTypes.string, + type: PropTypes.string, + organizationId: PropTypes.string, + mediaUrl: PropTypes.string, + endDate: PropTypes.instanceOf(Date), + startDate: PropTypes.instanceOf(Date), +}; + +export default AdvertisementEntry; diff --git a/src/components/Advertisements/core/AdvertisementRegister/AdvertisementRegister.module.css b/src/components/Advertisements/core/AdvertisementRegister/AdvertisementRegister.module.css new file mode 100644 index 0000000000..0c5678676b --- /dev/null +++ b/src/components/Advertisements/core/AdvertisementRegister/AdvertisementRegister.module.css @@ -0,0 +1,56 @@ +.modalbtn { + margin-top: 1rem; + display: flex !important; + margin-left: auto; + align-items: center; +} + +.modalbtn i, +.button i { + margin-right: 8px; +} + +.preview { + display: flex; + position: relative; + width: 100%; + margin-top: 10px; + justify-content: center; +} +.preview img { + width: 400px; + height: auto; +} +.preview video { + width: 400px; + height: auto; +} + +.closeButton { + position: absolute; + top: 0px; + right: 0px; + background: transparent; + transform: scale(1.2); + cursor: pointer; + border: none; + color: #707070; + font-weight: 600; + font-size: 16px; + cursor: pointer; +} + +.button { + min-width: 102px; +} + +.editHeader { + background-color: #31bb6b; + color: white; +} + +.link_check { + display: flex; + justify-content: center; + align-items: flex-start; +} diff --git a/src/components/Advertisements/core/AdvertisementRegister/AdvertisementRegister.test.tsx b/src/components/Advertisements/core/AdvertisementRegister/AdvertisementRegister.test.tsx new file mode 100644 index 0000000000..0646a94819 --- /dev/null +++ b/src/components/Advertisements/core/AdvertisementRegister/AdvertisementRegister.test.tsx @@ -0,0 +1,641 @@ +import React, { act } from 'react'; +import { render, fireEvent, waitFor, screen } from '@testing-library/react'; + +import { + ApolloClient, + ApolloProvider, + InMemoryCache, + ApolloLink, + HttpLink, +} from '@apollo/client'; + +import type { NormalizedCacheObject } from '@apollo/client'; +import { BrowserRouter } from 'react-router-dom'; +import AdvertisementRegister from './AdvertisementRegister'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import { BACKEND_URL } from 'Constant/constant'; +// import i18nForTest from 'utils/i18nForTest'; +import { I18nextProvider } from 'react-i18next'; +import { MockedProvider } from '@apollo/client/testing'; +import i18n from 'utils/i18nForTest'; +import { toast } from 'react-toastify'; +import { ADD_ADVERTISEMENT_MUTATION } from 'GraphQl/Mutations/mutations'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import userEvent from '@testing-library/user-event'; +import useLocalStorage from 'utils/useLocalstorage'; +import { ORGANIZATION_ADVERTISEMENT_LIST } from 'GraphQl/Queries/Queries'; + +const { getItem } = useLocalStorage(); + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +const MOCKS = [ + { + request: { + query: ADD_ADVERTISEMENT_MUTATION, + variables: { + organizationId: '1', + name: 'Ad1', + type: 'BANNER', + startDate: '2023-01-01', + endDate: '2023-02-01', + file: 'data:image/png;base64,bWVkaWEgY29udGVudA==', + }, + }, + result: { + data: { + createAdvertisement: { + _id: '1', + advertisement: null, + __typename: 'Advertisement', + }, + }, + }, + }, + { + request: { + query: ORGANIZATION_ADVERTISEMENT_LIST, + variables: { + id: '1', + first: 6, + after: null, + }, + }, + result: { + data: { + organizations: [ + { + _id: '1', + advertisements: { + edges: [ + { + node: { + _id: '1', + name: 'Advertisement1', + startDate: '2022-01-01', + endDate: '2023-01-01', + mediaUrl: 'http://example1.com', + }, + cursor: '5rdiyr3iwfhwaify', + }, + { + node: { + _id: '2', + name: 'Advertisement2', + startDate: '2024-02-01', + endDate: '2025-02-01', + mediaUrl: 'http://example2.com', + }, + cursor: '5rdiyr3iwfhwaify', + }, + ], + pageInfo: { + startCursor: 'erdftgyhujkerty', + endCursor: 'edrftgyhujikl', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 2, + }, + }, + ], + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +const httpLink = new HttpLink({ + uri: BACKEND_URL, + headers: { + authorization: 'Bearer ' + getItem('token') || '', + }, +}); + +const client: ApolloClient = new ApolloClient({ + cache: new InMemoryCache(), + link: ApolloLink.from([httpLink]), +}); + +const translations = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.advertisement ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: '1' }), +})); +describe('Testing Advertisement Register Component', () => { + test('AdvertismentRegister component loads correctly in register mode', async () => { + const { getByText } = render( + + + + + + + + + , + ); + await waitFor(() => { + expect(getByText(translations.createAdvertisement)).toBeInTheDocument(); + }); + }); + + test('create advertisement', async () => { + jest.useFakeTimers(); + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + + await act(async () => { + render( + + + + + + + + + , + ); + }); + + expect( + screen.getByText(translations.createAdvertisement), + ).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByText(translations.createAdvertisement)); + }); + + expect(screen.queryByText(translations.addNew)).toBeInTheDocument(); + + await act(async () => { + fireEvent.change(screen.getByLabelText(translations.Rname), { + target: { value: 'Ad1' }, + }); + + const mediaFile = new File(['media content'], 'test.png', { + type: 'image/png', + }); + + fireEvent.change(screen.getByLabelText(translations.Rmedia), { + target: { + files: [mediaFile], + }, + }); + }); + + await waitFor(() => { + expect(screen.getByTestId('mediaPreview')).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.change(screen.getByLabelText(translations.Rtype), { + target: { value: 'BANNER' }, + }); + + fireEvent.change(screen.getByLabelText(translations.RstartDate), { + target: { value: '2023-01-01' }, + }); + + fireEvent.change(screen.getByLabelText(translations.RendDate), { + target: { value: '2023-02-01' }, + }); + }); + + expect(screen.getByLabelText(translations.Rname)).toHaveValue('Ad1'); + expect(screen.getByLabelText(translations.Rtype)).toHaveValue('BANNER'); + expect(screen.getByLabelText(translations.RstartDate)).toHaveValue( + '2023-01-01', + ); + expect(screen.getByLabelText(translations.RendDate)).toHaveValue( + '2023-02-01', + ); + + await act(async () => { + fireEvent.click(screen.getByText(translations.register)); + }); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + 'Advertisement created successfully.', + ); + expect(setTimeoutSpy).toHaveBeenCalled(); + }); + jest.useRealTimers(); + }); + + test('update advertisement', async () => { + jest.useFakeTimers(); + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + + await act(async () => { + render( + + + + + + + + + , + ); + }); + + await waitFor(() => { + expect(screen.getByText(translations.edit)).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByText(translations.edit)); + }); + + await act(async () => { + fireEvent.change(screen.getByLabelText(translations.Rname), { + target: { value: 'Ad1' }, + }); + + const mediaFile = new File(['media content'], 'test.png', { + type: 'image/png', + }); + + fireEvent.change(screen.getByLabelText(translations.Rmedia), { + target: { + files: [mediaFile], + }, + }); + }); + + await waitFor(() => { + expect(screen.getByTestId('mediaPreview')).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.change(screen.getByLabelText(translations.Rtype), { + target: { value: 'BANNER' }, + }); + + fireEvent.change(screen.getByLabelText(translations.RstartDate), { + target: { value: '2023-01-01' }, + }); + + fireEvent.change(screen.getByLabelText(translations.RendDate), { + target: { value: '2023-02-01' }, + }); + }); + + expect(screen.getByLabelText(translations.Rname)).toHaveValue('Ad1'); + expect(screen.getByLabelText(translations.Rtype)).toHaveValue('BANNER'); + expect(screen.getByLabelText(translations.RstartDate)).toHaveValue( + '2023-01-01', + ); + expect(screen.getByLabelText(translations.RendDate)).toHaveValue( + '2023-02-01', + ); + + await act(async () => { + fireEvent.click(screen.getByText(translations.saveChanges)); + }); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + 'Advertisement created successfully.', + ); + expect(setTimeoutSpy).toHaveBeenCalled(); + }); + + jest.useRealTimers(); + }); + + test('Logs error to the console and shows error toast when advertisement creation fails', async () => { + jest.useFakeTimers(); + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + const toastErrorSpy = jest.spyOn(toast, 'error'); + + await act(async () => { + render( + + + + + + + + + , + ); + }); + + expect( + screen.getByText(translations.createAdvertisement), + ).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByText(translations.createAdvertisement)); + }); + + expect(screen.queryByText(translations.addNew)).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(screen.getByText(translations.register)); + }); + + await waitFor(() => { + expect(toastErrorSpy).toHaveBeenCalledWith( + `An error occurred. Couldn't create advertisement`, + ); + }); + + expect(setTimeoutSpy).toHaveBeenCalled(); + jest.useRealTimers(); + }); + + test('Throws error when the end date is less than the start date', async () => { + jest.useFakeTimers(); + const setTimeoutSpy = jest.spyOn(global, 'setTimeout'); + const { getByText, queryByText, getByLabelText } = render( + + + + + + + + + , + ); + + expect(getByText(translations.createAdvertisement)).toBeInTheDocument(); + + fireEvent.click(getByText(translations.createAdvertisement)); + expect(queryByText(translations.addNew)).toBeInTheDocument(); + + fireEvent.change(getByLabelText(translations.Rname), { + target: { value: 'Ad1' }, + }); + expect(getByLabelText(translations.Rname)).toHaveValue('Ad1'); + + const mediaFile = new File(['media content'], 'test.png', { + type: 'image/png', + }); + + const mediaInput = getByLabelText(translations.Rmedia); + fireEvent.change(mediaInput, { + target: { + files: [mediaFile], + }, + }); + + const mediaPreview = await screen.findByTestId('mediaPreview'); + expect(mediaPreview).toBeInTheDocument(); + + fireEvent.change(getByLabelText(translations.Rtype), { + target: { value: 'BANNER' }, + }); + expect(getByLabelText(translations.Rtype)).toHaveValue('BANNER'); + + fireEvent.change(getByLabelText(translations.RstartDate), { + target: { value: '2023-01-01' }, + }); + expect(getByLabelText(translations.RstartDate)).toHaveValue('2023-01-01'); + + fireEvent.change(getByLabelText(translations.RendDate), { + target: { value: '2022-02-01' }, + }); + expect(getByLabelText(translations.RendDate)).toHaveValue('2022-02-01'); + + await waitFor(() => { + fireEvent.click(getByText(translations.register)); + }); + expect(toast.error).toHaveBeenCalledWith( + 'End Date should be greater than or equal to Start Date', + ); + expect(setTimeoutSpy).toHaveBeenCalled(); + jest.useRealTimers(); + }); + + test('AdvertismentRegister component loads correctly in edit mode', async () => { + jest.useFakeTimers(); + render( + + + + + + + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('editBtn')).toBeInTheDocument(); + }); + jest.useRealTimers(); + }); + + test('Opens and closes modals on button click', async () => { + jest.useFakeTimers(); + const { getByText, queryByText } = render( + + + + + + + + + , + ); + fireEvent.click(getByText(translations.createAdvertisement)); + await waitFor(() => { + expect(queryByText(translations.addNew)).toBeInTheDocument(); + }); + fireEvent.click(getByText(translations.close)); + await waitFor(() => { + expect(queryByText(translations.close)).not.toBeInTheDocument(); + }); + jest.useRealTimers(); + }); + + test('Throws error when the end date is less than the start date while editing the advertisement', async () => { + jest.useFakeTimers(); + const { getByText, getByLabelText, queryByText } = render( + + + + + { + + } + + + + , + ); + + fireEvent.click(getByText(translations.edit)); + expect(queryByText(translations.editAdvertisement)).toBeInTheDocument(); + fireEvent.change(getByLabelText(translations.Rname), { + target: { value: 'Test Advertisement' }, + }); + expect(getByLabelText(translations.Rname)).toHaveValue( + 'Test Advertisement', + ); + + const mediaFile = new File(['video content'], 'test.mp4', { + type: 'video/mp4', + }); + const mediaInput = screen.getByTestId('advertisementMedia'); + userEvent.upload(mediaInput, mediaFile); + + const mediaPreview = await screen.findByTestId('mediaPreview'); + expect(mediaPreview).toBeInTheDocument(); + + fireEvent.change(getByLabelText(translations.Rtype), { + target: { value: 'BANNER' }, + }); + expect(getByLabelText(translations.Rtype)).toHaveValue('BANNER'); + + fireEvent.change(getByLabelText(translations.RstartDate), { + target: { value: '2023-02-02' }, + }); + expect(getByLabelText(translations.RstartDate)).toHaveValue('2023-02-02'); + + fireEvent.change(getByLabelText(translations.RendDate), { + target: { value: '2023-01-01' }, + }); + expect(getByLabelText(translations.RendDate)).toHaveValue('2023-01-01'); + + fireEvent.click(getByText(translations.saveChanges)); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + 'End Date should be greater than or equal to Start Date', + ); + }); + jest.useRealTimers(); + }); + + test('Media preview renders correctly', async () => { + jest.useFakeTimers(); + render( + + + + + + + + + , + ); + + fireEvent.click(screen.getByText(translations.createAdvertisement)); + await screen.findByText(translations.addNew); + + const mediaFile = new File(['video content'], 'test.mp4', { + type: 'video/mp4', + }); + const mediaInput = screen.getByTestId('advertisementMedia'); + userEvent.upload(mediaInput, mediaFile); + + const mediaPreview = await screen.findByTestId('mediaPreview'); + expect(mediaPreview).toBeInTheDocument(); + + const closeButton = await screen.findByTestId('closePreview'); + fireEvent.click(closeButton); + expect(mediaPreview).not.toBeInTheDocument(); + }); + jest.useRealTimers(); +}); diff --git a/src/components/Advertisements/core/AdvertisementRegister/AdvertisementRegister.tsx b/src/components/Advertisements/core/AdvertisementRegister/AdvertisementRegister.tsx new file mode 100644 index 0000000000..1d5c51ce84 --- /dev/null +++ b/src/components/Advertisements/core/AdvertisementRegister/AdvertisementRegister.tsx @@ -0,0 +1,445 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import styles from './AdvertisementRegister.module.css'; +import { Button, Form, Modal } from 'react-bootstrap'; +import { + ADD_ADVERTISEMENT_MUTATION, + UPDATE_ADVERTISEMENT_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import { useMutation } from '@apollo/client'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import dayjs from 'dayjs'; +import convertToBase64 from 'utils/convertToBase64'; +import { ORGANIZATION_ADVERTISEMENT_LIST } from 'GraphQl/Queries/Queries'; +import { useParams } from 'react-router-dom'; + +/** + * Props for the `advertisementRegister` component. + */ +interface InterfaceAddOnRegisterProps { + id?: string; // Optional organization ID + createdBy?: string; // Optional user who created the advertisement + formStatus?: string; // Determines if the form is in register or edit mode + idEdit?: string; // ID of the advertisement to edit + nameEdit?: string; // Name of the advertisement to edit + typeEdit?: string; // Type of the advertisement to edit + orgIdEdit?: string; // Organization ID associated with the advertisement + advertisementMediaEdit?: string; // Media URL of the advertisement to edit + endDateEdit?: Date; // End date of the advertisement to edit + startDateEdit?: Date; // Start date of the advertisement to edit + setAfter: React.Dispatch>; // Function to update parent state +} + +/** + * State for the advertisement form. + */ +interface InterfaceFormStateTypes { + name: string; // Name of the advertisement + advertisementMedia: string; // Base64-encoded media of the advertisement + type: string; // Type of advertisement (e.g., BANNER, POPUP) + startDate: Date; // Start date of the advertisement + endDate: Date; // End date of the advertisement + organizationId: string | undefined; // Organization ID +} + +/** + * Component for registering or editing an advertisement. + * + * @param props - Contains form status, advertisement details, and a function to update parent state. + * @returns A JSX element that renders a form inside a modal for creating or editing an advertisement. + * + * @example + * ```tsx + * console.log(value)} + * /> + * ``` + */ +function advertisementRegister({ + formStatus = 'register', + idEdit, + nameEdit = '', + typeEdit = 'BANNER', + advertisementMediaEdit = '', + endDateEdit = new Date(), + startDateEdit = new Date(), + setAfter, +}: InterfaceAddOnRegisterProps): JSX.Element { + const { t } = useTranslation('translation', { keyPrefix: 'advertisement' }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + const { orgId: currentOrg } = useParams(); + + const [show, setShow] = useState(false); + const handleClose = (): void => setShow(false); // Closes the modal + const handleShow = (): void => setShow(true); // Shows the modal + + const [create] = useMutation(ADD_ADVERTISEMENT_MUTATION, { + refetchQueries: [ + { + query: ORGANIZATION_ADVERTISEMENT_LIST, + variables: { first: 6, after: null, id: currentOrg }, + }, + ], + }); + + const [updateAdvertisement] = useMutation(UPDATE_ADVERTISEMENT_MUTATION, { + refetchQueries: [ + { + query: ORGANIZATION_ADVERTISEMENT_LIST, + variables: { first: 6, after: null, id: currentOrg }, + }, + ], + }); + + // Initialize form state + const [formState, setFormState] = useState({ + name: '', + advertisementMedia: '', + type: 'BANNER', + startDate: new Date(), + endDate: new Date(), + organizationId: currentOrg, + }); + + // Set form state if editing + useEffect(() => { + if (formStatus === 'edit') { + setFormState((prevState) => ({ + ...prevState, + name: nameEdit || '', + advertisementMedia: advertisementMediaEdit || '', + type: typeEdit || 'BANNER', + startDate: startDateEdit || new Date(), + endDate: endDateEdit || new Date(), + orgId: currentOrg, + })); + } + }, [ + formStatus, + nameEdit, + advertisementMediaEdit, + typeEdit, + startDateEdit, + endDateEdit, + currentOrg, + ]); + /** + * Handles advertisement registration. + * Validates the date range and performs the mutation to create an advertisement. + */ + const handleRegister = async (): Promise => { + try { + console.log('At handle register', formState); + if (formState.endDate < formState.startDate) { + toast.error(t('endDateGreaterOrEqual') as string); + return; + } + const { data } = await create({ + variables: { + organizationId: currentOrg, + name: formState.name as string, + type: formState.type as string, + startDate: dayjs(formState.startDate).format('YYYY-MM-DD'), + endDate: dayjs(formState.endDate).format('YYYY-MM-DD'), + file: formState.advertisementMedia as string, + }, + }); + + if (data) { + toast.success(t('advertisementCreated') as string); + setFormState({ + name: '', + advertisementMedia: '', + type: 'BANNER', + startDate: new Date(), + endDate: new Date(), + organizationId: currentOrg, + }); + } + setAfter(null); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error( + tErrors('errorOccurredCouldntCreate', { + entity: 'advertisement', + }) as string, + ); + console.log('error occured', error.message); + } + } + }; + + /** + * Handles advertisement update. + * Validates the date range and performs the mutation to update the advertisement. + */ + const handleUpdate = async (): Promise => { + try { + const updatedFields: Partial = {}; + + // Only include the fields which are updated + if (formState.name !== nameEdit) { + updatedFields.name = formState.name; + } + if (formState.advertisementMedia !== advertisementMediaEdit) { + updatedFields.advertisementMedia = formState.advertisementMedia; + } + if (formState.type !== typeEdit) { + updatedFields.type = formState.type; + } + if (formState.endDate < formState.startDate) { + toast.error(t('endDateGreaterOrEqual') as string); + return; + } + const startDateFormattedString = dayjs(formState.startDate).format( + 'YYYY-MM-DD', + ); + const endDateFormattedString = dayjs(formState.endDate).format( + 'YYYY-MM-DD', + ); + + const startDateDate = dayjs( + startDateFormattedString, + 'YYYY-MM-DD', + ).toDate(); + const endDateDate = dayjs(endDateFormattedString, 'YYYY-MM-DD').toDate(); + + if (!dayjs(startDateDate).isSame(startDateEdit, 'day')) { + updatedFields.startDate = startDateDate; + } + if (!dayjs(endDateDate).isSame(endDateEdit, 'day')) { + updatedFields.endDate = endDateDate; + } + + console.log('At handle update', updatedFields); + const { data } = await updateAdvertisement({ + variables: { + id: idEdit, + ...(updatedFields.name && { name: updatedFields.name }), + ...(updatedFields.advertisementMedia && { + file: updatedFields.advertisementMedia, + }), + ...(updatedFields.type && { type: updatedFields.type }), + ...(updatedFields.startDate && { + startDate: startDateFormattedString, + }), + ...(updatedFields.endDate && { endDate: endDateFormattedString }), + }, + }); + + if (data) { + toast.success( + tCommon('updatedSuccessfully', { item: 'Advertisement' }) as string, + ); + handleClose(); + setAfter(null); + } + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + return ( + //If register show register button else show edit button + <> + {formStatus === 'register' ? ( + + ) : ( +
+ {tCommon('edit')} +
+ )} + + + {formStatus === 'register' ? ( + {t('addNew')} + ) : ( + {t('editAdvertisement')} + )} + + +
+ + {t('Rname')} + { + setFormState({ + ...formState, + name: e.target.value, + }); + }} + /> + + + + {t('Rmedia')} + + , + ): Promise => { + const target = e.target as HTMLInputElement; + const file = target.files && target.files[0]; + if (file) { + const mediaBase64 = await convertToBase64(file); + setFormState({ + ...formState, + advertisementMedia: mediaBase64, + }); + } + }} + /> + {formState.advertisementMedia && ( +
+ {formState.advertisementMedia.includes('video') ? ( + + ) : ( + + )} + +
+ )} +
+ + {t('Rtype')} + { + setFormState({ + ...formState, + type: e.target.value, + }); + }} + > + + + + + + + {t('RstartDate')} + { + setFormState({ + ...formState, + startDate: new Date(e.target.value), + }); + }} + /> + + + + {t('RendDate')} + { + setFormState({ + ...formState, + endDate: new Date(e.target.value), + }); + }} + /> + +
+
+ + + {formStatus === 'register' ? ( + + ) : ( + + )} + +
+ + ); +} + +advertisementRegister.propTypes = { + name: PropTypes.string, + advertisementMedia: PropTypes.string, + type: PropTypes.string, + startDate: PropTypes.instanceOf(Date), + endDate: PropTypes.instanceOf(Date), + organizationId: PropTypes.string, + formStatus: PropTypes.string, +}; + +export default advertisementRegister; diff --git a/src/components/AgendaCategory/AgendaCategoryContainer.module.css b/src/components/AgendaCategory/AgendaCategoryContainer.module.css new file mode 100644 index 0000000000..7ad16b4c7c --- /dev/null +++ b/src/components/AgendaCategory/AgendaCategoryContainer.module.css @@ -0,0 +1,20 @@ +.createModal { + margin-top: 20vh; + margin-left: 13vw; + max-width: 80vw; +} + +.titlemodal { + color: var(--bs-gray-600); + font-weight: 600; + font-size: 20px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 3px solid var(--bs-primary); + width: 65%; +} + +.agendaCategoryOptionsButton { + width: 24px; + height: 24px; +} diff --git a/src/components/AgendaCategory/AgendaCategoryContainer.test.tsx b/src/components/AgendaCategory/AgendaCategoryContainer.test.tsx new file mode 100644 index 0000000000..d8e27c3cb2 --- /dev/null +++ b/src/components/AgendaCategory/AgendaCategoryContainer.test.tsx @@ -0,0 +1,419 @@ +import React from 'react'; +import { + render, + screen, + waitFor, + act, + waitForElementToBeRemoved, + fireEvent, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import 'jest-localstorage-mock'; +import { MockedProvider } from '@apollo/client/testing'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import i18nForTest from 'utils/i18nForTest'; +import { toast } from 'react-toastify'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +import AgendaCategoryContainer from './AgendaCategoryContainer'; +import { props, props2 } from './AgendaCategoryContainerProps'; +import { MOCKS, MOCKS_ERROR_MUTATIONS } from './AgendaCategoryContainerMocks'; + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(MOCKS_ERROR_MUTATIONS, true); + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +async function wait(ms = 100): Promise { + await act(async () => { + return new Promise((resolve) => setTimeout(resolve, ms)); + }); +} + +const translations = JSON.parse( + JSON.stringify( + i18nForTest.getDataByLanguage('en')?.translation.organizationAgendaCategory, + ), +); + +describe('Testing Agenda Category Component', () => { + const formData = { + name: 'AgendaCategory 1 Edited', + description: 'AgendaCategory 1 Description Edited', + }; + + test('component loads correctly with categories', async () => { + render( + + + + + + + + + , + ); + await wait(); + + await waitFor(() => { + expect( + screen.queryByText(translations.noAgendaCategories), + ).not.toBeInTheDocument(); + }); + }); + + test('component loads correctly with no agenda Categories', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.queryByText(translations.noAgendaCategories), + ).toBeInTheDocument(); + }); + }); + + test('opens and closes the update modal correctly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('editAgendCategoryModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('editAgendCategoryModalBtn')[0]); + + await waitFor(() => { + return expect( + screen.findByTestId('updateAgendaCategoryModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('updateAgendaCategoryModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('updateAgendaCategoryModalCloseBtn'), + ); + }); + + test('opens and closes the preview modal correctly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('previewAgendaCategoryModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('previewAgendaCategoryModalBtn')[0]); + + await waitFor(() => { + return expect( + screen.findByTestId('previewAgendaCategoryModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('previewAgendaCategoryModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('previewAgendaCategoryModalCloseBtn'), + ); + }); + + test('opens and closes the update and delete modals through the preview modal', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('previewAgendaCategoryModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('previewAgendaCategoryModalBtn')[0]); + + await waitFor(() => { + return expect( + screen.findByTestId('previewAgendaCategoryModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByTestId('deleteAgendaCategoryModalBtn'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('deleteAgendaCategoryModalBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('deleteAgendaCategoryCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('deleteAgendaCategoryCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('deleteAgendaCategoryCloseBtn'), + ); + + await waitFor(() => { + expect( + screen.getByTestId('editAgendaCategoryPreviewModalBtn'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('editAgendaCategoryPreviewModalBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('updateAgendaCategoryModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('updateAgendaCategoryModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('updateAgendaCategoryModalCloseBtn'), + ); + }); + + test('updates an agenda category and toasts success', async () => { + render( + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('editAgendCategoryModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('editAgendCategoryModalBtn')[0]); + + const name = screen.getByPlaceholderText(translations.name); + const description = screen.getByPlaceholderText(translations.description); + + fireEvent.change(name, { target: { value: '' } }); + userEvent.type(name, formData.name); + + fireEvent.change(description, { target: { value: '' } }); + userEvent.type(description, formData.description); + + await waitFor(() => { + expect(screen.getByTestId('editAgendaCategoryBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('editAgendaCategoryBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + translations.agendaCategoryUpdated, + ); + }); + }); + + test('toasts error on unsuccessful updation', async () => { + render( + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('editAgendCategoryModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('editAgendCategoryModalBtn')[0]); + + const nameInput = screen.getByLabelText(translations.name); + const descriptionInput = screen.getByLabelText(translations.description); + fireEvent.change(nameInput, { target: { value: '' } }); + fireEvent.change(descriptionInput, { + target: { value: '' }, + }); + userEvent.type(nameInput, formData.name); + userEvent.type(descriptionInput, formData.description); + + await waitFor(() => { + expect(screen.getByTestId('editAgendaCategoryBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('editAgendaCategoryBtn')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + test('deletes the agenda category and toasts success', async () => { + render( + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('previewAgendaCategoryModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('previewAgendaCategoryModalBtn')[0]); + + await waitFor(() => { + return expect( + screen.findByTestId('previewAgendaCategoryModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByTestId('deleteAgendaCategoryModalBtn'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('deleteAgendaCategoryModalBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('deleteAgendaCategoryCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('deleteAgendaCategoryBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + translations.agendaCategoryDeleted, + ); + }); + }); + + test('toasts error on unsuccessful deletion', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('previewAgendaCategoryModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('previewAgendaCategoryModalBtn')[0]); + + await waitFor(() => { + return expect( + screen.findByTestId('previewAgendaCategoryModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByTestId('deleteAgendaCategoryModalBtn'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('deleteAgendaCategoryModalBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('deleteAgendaCategoryCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('deleteAgendaCategoryBtn')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/AgendaCategory/AgendaCategoryContainer.tsx b/src/components/AgendaCategory/AgendaCategoryContainer.tsx new file mode 100644 index 0000000000..7b4c5cf8f4 --- /dev/null +++ b/src/components/AgendaCategory/AgendaCategoryContainer.tsx @@ -0,0 +1,324 @@ +import React, { useState } from 'react'; +import type { ChangeEvent } from 'react'; +import { Button, Col, Row } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { useMutation } from '@apollo/client'; + +import { + DELETE_AGENDA_ITEM_CATEGORY_MUTATION, + UPDATE_AGENDA_ITEM_CATEGORY_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import type { InterfaceAgendaItemCategoryInfo } from 'utils/interfaces'; +import styles from './AgendaCategoryContainer.module.css'; + +import AgendaCategoryDeleteModal from 'components/OrgSettings/AgendaItemCategories/AgendaCategoryDeleteModal'; +import AgendaCategoryPreviewModal from 'components/OrgSettings/AgendaItemCategories/AgendaCategoryPreviewModal'; +import AgendaCategoryUpdateModal from 'components/OrgSettings/AgendaItemCategories/AgendaCategoryUpdateModal'; + +/** + * Component for displaying and managing agenda item categories. + * + * @param props - Contains agenda category data and functions for data management. + * @returns A JSX element that renders agenda item categories with options to preview, edit, and delete. + * + * @example + * ```tsx + * + * ``` + */ +function agendaCategoryContainer({ + agendaCategoryConnection, + agendaCategoryData, + agendaCategoryRefetch, +}: { + agendaCategoryConnection: 'Organization'; + agendaCategoryData: InterfaceAgendaItemCategoryInfo[] | undefined; + agendaCategoryRefetch: () => void; +}): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'organizationAgendaCategory', + }); + const { t: tCommon } = useTranslation('common'); + + // State management for modals and form data + const [ + agendaCategoryPreviewModalIsOpen, + setAgendaCategoryPreviewModalIsOpen, + ] = useState(false); + const [agendaCategoryUpdateModalIsOpen, setAgendaCategoryUpdateModalIsOpen] = + useState(false); + const [agendaCategoryDeleteModalIsOpen, setAgendaCategoryDeleteModalIsOpen] = + useState(false); + + const [agendaCategoryId, setAgendaCategoryId] = useState(''); + + const [formState, setFormState] = useState({ + name: '', + description: '', + createdBy: '', + }); + + /** + * Opens the preview modal and sets the state for the selected agenda category. + * + * @param agendaItemCategory - The agenda category to preview. + */ + const showPreviewModal = ( + agendaItemCategory: InterfaceAgendaItemCategoryInfo, + ): void => { + setAgendaCategoryState(agendaItemCategory); + setAgendaCategoryPreviewModalIsOpen(true); + }; + + /** + * Closes the preview modal. + */ + const hidePreviewModal = (): void => { + setAgendaCategoryPreviewModalIsOpen(false); + }; + + /** + * Toggles the visibility of the update modal. + */ + const showUpdateModal = (): void => { + setAgendaCategoryUpdateModalIsOpen(!agendaCategoryUpdateModalIsOpen); + }; + + /** + * Toggles the visibility of the update modal. + */ + const hideUpdateModal = (): void => { + setAgendaCategoryUpdateModalIsOpen(!agendaCategoryUpdateModalIsOpen); + }; + + /** + * Toggles the visibility of the delete modal. + */ + const toggleDeleteModal = (): void => { + setAgendaCategoryDeleteModalIsOpen(!agendaCategoryDeleteModalIsOpen); + }; + + const [updateAgendaCategory] = useMutation( + UPDATE_AGENDA_ITEM_CATEGORY_MUTATION, + ); + + /** + * Handles the update of an agenda category. + * + * @param event - The form submit event. + */ + const updateAgendaCategoryHandler = async ( + event: ChangeEvent, + ): Promise => { + event.preventDefault(); + try { + await updateAgendaCategory({ + variables: { + updateAgendaCategoryId: agendaCategoryId, + input: { + name: formState.name, + description: formState.description, + }, + }, + }); + + agendaCategoryRefetch(); + hideUpdateModal(); + toast.success(t('agendaCategoryUpdated') as string); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(`Agenda Category Update Failed ${error.message}`); + } + } + }; + + const [deleteAgendaCategory] = useMutation( + DELETE_AGENDA_ITEM_CATEGORY_MUTATION, + ); + + /** + * Handles the deletion of an agenda category. + */ + const deleteAgendaCategoryHandler = async (): Promise => { + try { + await deleteAgendaCategory({ + variables: { + deleteAgendaCategoryId: agendaCategoryId, + }, + }); + agendaCategoryRefetch(); + toggleDeleteModal(); + toast.success(t('agendaCategoryDeleted') as string); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(`Agenda Category Delete Failed, ${error.message}`); + } + } + }; + + /** + * Prepares the form state and shows the update modal for the selected agenda category. + * + * @param agendaItemCategory - The agenda category to edit. + */ + const handleEditClick = ( + agendaItemCategory: InterfaceAgendaItemCategoryInfo, + ): void => { + setAgendaCategoryState(agendaItemCategory); + showUpdateModal(); + }; + + /** + * Updates the form state with the selected agenda category's details. + * + * @param agendaItemCategory - The agenda category details. + */ + const setAgendaCategoryState = ( + agendaItemCategory: InterfaceAgendaItemCategoryInfo, + ): void => { + setFormState({ + ...formState, + name: `${agendaItemCategory.name} `, + description: `${agendaItemCategory.description}`, + createdBy: `${agendaItemCategory.createdBy.firstName} ${agendaItemCategory.createdBy.lastName}`, + }); + setAgendaCategoryId(agendaItemCategory._id); + }; + + return ( + <> +
+
+ + +
{t('name')}
+ + + {t('description')} + + +
{t('createdBy')}
+ + +
{t('options')}
+ +
+
+
+ {agendaCategoryData?.map((agendaCategory, index) => ( +
+ + + {`${agendaCategory.name}`} + + + {agendaCategory.description} + + + {`${agendaCategory.createdBy.firstName} ${agendaCategory.createdBy.lastName}`} + + + +
+ + +
+ +
+ + {index !== agendaCategoryData.length - 1 && ( +
+ )} +
+ ))} + {agendaCategoryData?.length === 0 && ( +
+ {t('noAgendaCategories')} +
+ )} +
+
+ + {/* Preview modal */} + + {/* Update modal */} + + {/* Delete modal */} + + + ); +} + +export default agendaCategoryContainer; diff --git a/src/components/AgendaCategory/AgendaCategoryContainerMocks.ts b/src/components/AgendaCategory/AgendaCategoryContainerMocks.ts new file mode 100644 index 0000000000..f5dee5cffd --- /dev/null +++ b/src/components/AgendaCategory/AgendaCategoryContainerMocks.ts @@ -0,0 +1,104 @@ +import { + UPDATE_AGENDA_ITEM_CATEGORY_MUTATION, + DELETE_AGENDA_ITEM_CATEGORY_MUTATION, +} from 'GraphQl/Mutations/AgendaCategoryMutations'; + +export const MOCKS = [ + { + request: { + query: UPDATE_AGENDA_ITEM_CATEGORY_MUTATION, + variables: { + updateAgendaCategoryId: 'agendaCategory1', + input: { + name: 'AgendaCategory 1 Edited', + description: 'AgendaCategory 1 Description Edited', + }, + }, + }, + result: { + data: { + updateAgendaCategory: { + _id: 'agendaCategory1', + }, + }, + }, + }, + { + request: { + query: UPDATE_AGENDA_ITEM_CATEGORY_MUTATION, + variables: { + updateAgendaCategoryId: 'agendaCategory1', + input: { + name: 'AgendaCategory 1', + description: 'AgendaCategory 1 Description', + }, + }, + }, + result: { + data: { + updateAgendaCategory: { + _id: 'agendaCategory1', + }, + }, + }, + }, + { + request: { + query: UPDATE_AGENDA_ITEM_CATEGORY_MUTATION, + variables: { + updateAgendaCategoryId: 'agendaCategory2', + input: { + name: 'AgendaCategory 2 edited', + description: 'AgendaCategory 2 Description', + }, + }, + }, + result: { + data: { + updateAgendaCategory: { + _id: 'agendaCategory2', + }, + }, + }, + }, + { + request: { + query: DELETE_AGENDA_ITEM_CATEGORY_MUTATION, + variables: { + deleteAgendaCategoryId: 'agendaCategory1', + }, + }, + result: { + data: { + deleteAgendaCategory: { + _id: 'agendaCategory1', + }, + }, + }, + }, +]; + +export const MOCKS_ERROR_MUTATIONS = [ + { + request: { + query: UPDATE_AGENDA_ITEM_CATEGORY_MUTATION, + variables: { + updateAgendaCategoryId: 'agendaCategory1', + input: { + name: 'AgendaCategory 1 Edited', + description: 'AgendaCategory 1 Description Edited', + }, + }, + }, + error: new Error('Mock Graphql Error'), + }, + { + request: { + query: DELETE_AGENDA_ITEM_CATEGORY_MUTATION, + variables: { + deleteAgendaCategoryId: 'agendaCategory1', + }, + }, + error: new Error('Mock Graphql Error'), + }, +]; diff --git a/src/components/AgendaCategory/AgendaCategoryContainerProps.ts b/src/components/AgendaCategory/AgendaCategoryContainerProps.ts new file mode 100644 index 0000000000..5181eec153 --- /dev/null +++ b/src/components/AgendaCategory/AgendaCategoryContainerProps.ts @@ -0,0 +1,34 @@ +type AgendaCategoryConnectionType = 'Organization'; + +export const props = { + agendaCategoryConnection: 'Organization' as AgendaCategoryConnectionType, + agendaCategoryData: [ + { + _id: 'agendaCategory1', + name: 'AgendaCategory 1', + description: 'AgendaCategory 1 Description', + createdBy: { + _id: 'user0', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + { + _id: 'agendaCategory2', + name: 'AgendaCategory 2', + description: 'AgendaCategory 2 Description', + createdBy: { + _id: 'user0', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + ], + agendaCategoryRefetch: jest.fn(), +}; + +export const props2 = { + agendaCategoryConnection: 'Organization' as AgendaCategoryConnectionType, + agendaCategoryData: [], + agendaCategoryRefetch: jest.fn(), +}; diff --git a/src/components/AgendaItems/AgendaItemsContainer.module.css b/src/components/AgendaItems/AgendaItemsContainer.module.css new file mode 100644 index 0000000000..f254e7aad7 --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsContainer.module.css @@ -0,0 +1,230 @@ +.createModal { + margin-top: 20vh; + margin-left: 13vw; + max-width: 80vw; +} + +.titlemodal { + color: var(--bs-gray-600); + font-weight: 600; + font-size: 20px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 3px solid var(--bs-primary); + width: 65%; +} + +.agendaItemsOptionsButton { + width: 24px; + height: 24px; +} + +.agendaItemModal { + max-width: 80vw; + margin-top: 2vh; + margin-left: 13vw; +} + +.iconContainer { + display: flex; + justify-content: flex-end; +} +.icon { + margin: 1px; +} + +.errorIcon { + transform: scale(1.5); + color: var(--bs-danger); + margin-bottom: 1rem; +} + +.greenregbtn { + margin: 1rem 0 0; + margin-top: 15px; + border: 1px solid var(--bs-gray-300); + box-shadow: 0 2px 2px var(--bs-gray-300); + padding: 10px 10px; + border-radius: 5px; + background-color: var(--bs-primary); + width: 100%; + font-size: 16px; + color: var(--bs-white); + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; +} + +.preview { + display: flex; + flex-direction: row; + font-weight: 900; + font-size: 16px; + color: rgb(80, 80, 80); +} + +.view { + margin-left: 2%; + font-weight: 600; + font-size: 16px; + color: var(--bs-gray-600); +} + +.previewFile { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + margin-top: 10px; +} + +.previewFile img, +.previewFile video { + width: 100%; + max-width: 400px; + height: auto; + margin-bottom: 10px; +} + +.attachmentPreview { + position: relative; + width: 100%; +} + +.closeButtonFile { + position: absolute; + top: 10px; + right: 10px; + background: transparent; + transform: scale(1.2); + cursor: pointer; + border: none; + color: #707070; + font-weight: 600; + font-size: 16px; + cursor: pointer; +} + +.tableHeader { + background-color: var(--bs-primary); + color: var(--bs-white); + font-size: 16px; +} + +.noOutline input { + outline: none; +} + +.categoryContainer { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; +} + +.categoryChip { + display: inline-flex; + align-items: center; + background-color: #e0e0e0; + border-radius: 16px; + padding: 0 12px; + font-size: 14px; + height: 32px; + margin: 5px; +} + +.urlListItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: 5px 0; +} + +.urlIcon { + margin-right: 10px; +} + +.deleteButton { + margin-left: auto; + padding: 2px 5px; +} + +.urlListItem a { + text-decoration: none; + color: inherit; +} + +.urlListItem a:hover { + text-decoration: underline; +} + +.agendaItemRow { + border: 1px solid #dee2e6; + border-radius: 4px; + transition: box-shadow 0.2s ease; + background-color: #fff; +} +.agendaItemRow:hover { + background-color: #f0f0f0; +} + +.dragging { + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); + z-index: 1000; + background-color: #f0f0f0; +} + +.droppable { + background-color: #f9f9f9; /* Background color of droppable area */ +} + +.droppableDraggingOver { + background-color: #e6f7ff; /* Background color of droppable area while dragging over */ +} + +.tableHead { + background-color: #31bb6b !important; + color: white; + border-radius: 20px 20px 0px 0px !important; + padding: 20px; +} + +@media (max-width: 768px) { + .createModal, + .agendaItemModal { + margin: 10vh auto; + max-width: 90%; + } + + .titlemodal { + width: 90%; + } + + .greenregbtn { + width: 90%; + } + + /* Add more specific styles for smaller screens as needed */ +} + +@media (max-width: 576px) { + .createModal, + .agendaItemModal { + margin: 5vh auto; + max-width: 95%; + } + + .titlemodal { + width: 100%; + } + + .greenregbtn { + width: 100%; + } + + /* Additional specific styles for even smaller screens */ +} diff --git a/src/components/AgendaItems/AgendaItemsContainer.test.tsx b/src/components/AgendaItems/AgendaItemsContainer.test.tsx new file mode 100644 index 0000000000..8b391a2073 --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsContainer.test.tsx @@ -0,0 +1,428 @@ +import React, { act } from 'react'; +import { + render, + screen, + waitFor, + waitForElementToBeRemoved, + fireEvent, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import 'jest-localstorage-mock'; +import { MockedProvider } from '@apollo/client/testing'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import i18nForTest from 'utils/i18nForTest'; +import { toast } from 'react-toastify'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +import { props, props2 } from './AgendaItemsContainerProps'; +import { MOCKS, MOCKS_ERROR } from './AgendaItemsContainerMocks'; +import AgendaItemsContainer from './AgendaItemsContainer'; + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(MOCKS_ERROR, true); + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +//temporarily fixes react-beautiful-dnd droppable method's depreciation error +//needs to be fixed in React 19 +jest.spyOn(console, 'error').mockImplementation((message) => { + if (message.includes('Support for defaultProps will be removed')) { + return; + } + console.error(message); +}); + +async function wait(ms = 100): Promise { + await act(async () => { + return new Promise((resolve) => setTimeout(resolve, ms)); + }); +} + +const translations = JSON.parse( + JSON.stringify(i18nForTest.getDataByLanguage('en')?.translation.agendaItems), +); + +describe('Testing Agenda Items components', () => { + const formData = { + title: 'AgendaItem 1 Edited', + description: 'AgendaItem 1 Description Edited', + }; + + test('component loads correctly with items', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.queryByText(translations.noAgendaItems), + ).not.toBeInTheDocument(); + }); + }); + + test('component loads correctly with no agenda items', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.queryByText(translations.noAgendaItems), + ).toBeInTheDocument(); + }); + }); + + test('opens and closes the update modal correctly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('editAgendaItemModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('editAgendaItemModalBtn')[0]); + + await waitFor(() => { + return expect( + screen.findByTestId('updateAgendaItemModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('updateAgendaItemModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('updateAgendaItemModalCloseBtn'), + ); + }); + + test('opens and closes the preview modal correctly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('previewAgendaItemModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('previewAgendaItemModalBtn')[0]); + + await waitFor(() => { + return expect( + screen.findByTestId('previewAgendaItemModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('previewAgendaItemModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('previewAgendaItemModalCloseBtn'), + ); + }); + + test('opens and closes the update and delete modals through the preview modal', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('previewAgendaItemModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('previewAgendaItemModalBtn')[0]); + + await waitFor(() => { + return expect( + screen.findByTestId('previewAgendaItemModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByTestId('previewAgendaItemModalDeleteBtn'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('previewAgendaItemModalDeleteBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('deleteAgendaItemCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('deleteAgendaItemCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('deleteAgendaItemCloseBtn'), + ); + + await waitFor(() => { + expect( + screen.getByTestId('previewAgendaItemModalUpdateBtn'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('previewAgendaItemModalUpdateBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('updateAgendaItemModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('updateAgendaItemModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('updateAgendaItemModalCloseBtn'), + ); + }); + + test('updates an agenda Items and toasts success', async () => { + render( + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('editAgendaItemModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('editAgendaItemModalBtn')[0]); + + const title = screen.getByPlaceholderText(translations.enterTitle); + const description = screen.getByPlaceholderText( + translations.enterDescription, + ); + + fireEvent.change(title, { target: { value: '' } }); + userEvent.type(title, formData.title); + + fireEvent.change(description, { target: { value: '' } }); + userEvent.type(description, formData.description); + + await waitFor(() => { + expect(screen.getByTestId('updateAgendaItemBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('updateAgendaItemBtn')); + + await waitFor(() => { + // expect(toast.success).toBeCalledWith(translations.agendaItemUpdated); + }); + }); + + test('toasts error on unsuccessful updation', async () => { + render( + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('editAgendaItemModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('editAgendaItemModalBtn')[0]); + + const titleInput = screen.getByLabelText(translations.title); + const descriptionInput = screen.getByLabelText(translations.description); + fireEvent.change(titleInput, { target: { value: '' } }); + fireEvent.change(descriptionInput, { + target: { value: '' }, + }); + userEvent.type(titleInput, formData.title); + userEvent.type(descriptionInput, formData.description); + + await waitFor(() => { + expect(screen.getByTestId('updateAgendaItemBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('updateAgendaItemBtn')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + test('deletes the agenda item and toasts success', async () => { + render( + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('previewAgendaItemModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('previewAgendaItemModalBtn')[0]); + + await waitFor(() => { + return expect( + screen.findByTestId('previewAgendaItemModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByTestId('previewAgendaItemModalDeleteBtn'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('previewAgendaItemModalDeleteBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('deleteAgendaItemCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('deleteAgendaItemBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + translations.agendaItemDeleted, + ); + }); + }); + + test('toasts error on unsuccessful deletion', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('previewAgendaItemModalBtn')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('previewAgendaItemModalBtn')[0]); + + await waitFor(() => { + return expect( + screen.findByTestId('previewAgendaItemModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + + await waitFor(() => { + expect( + screen.getByTestId('previewAgendaItemModalDeleteBtn'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('previewAgendaItemModalDeleteBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('deleteAgendaItemCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('deleteAgendaItemBtn')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + // write test case for drag and drop line:- 172-202 +}); diff --git a/src/components/AgendaItems/AgendaItemsContainer.tsx b/src/components/AgendaItems/AgendaItemsContainer.tsx new file mode 100644 index 0000000000..4e50cb5fe8 --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsContainer.tsx @@ -0,0 +1,456 @@ +import React, { useState } from 'react'; +import type { ChangeEvent } from 'react'; +import { Button, Col, Row } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { useMutation } from '@apollo/client'; +import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'; +import type { DropResult } from 'react-beautiful-dnd'; + +import { + DELETE_AGENDA_ITEM_MUTATION, + UPDATE_AGENDA_ITEM_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import type { + InterfaceAgendaItemInfo, + InterfaceAgendaItemCategoryInfo, +} from 'utils/interfaces'; +import styles from './AgendaItemsContainer.module.css'; + +import AgendaItemsPreviewModal from 'components/AgendaItems/AgendaItemsPreviewModal'; +import AgendaItemsDeleteModal from 'components/AgendaItems/AgendaItemsDeleteModal'; +import AgendaItemsUpdateModal from 'components/AgendaItems/AgendaItemsUpdateModal'; + +/** + * Component for displaying and managing agenda items. + * Supports drag-and-drop functionality, and includes modals for previewing, + * updating, and deleting agenda items. + * + * @param props - The props for the component. + * @returns JSX.Element + */ +function AgendaItemsContainer({ + agendaItemConnection, + agendaItemData, + agendaItemRefetch, + agendaItemCategories, +}: { + agendaItemConnection: 'Event'; + agendaItemData: InterfaceAgendaItemInfo[] | undefined; + agendaItemRefetch: () => void; + agendaItemCategories: InterfaceAgendaItemCategoryInfo[] | undefined; +}): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'agendaItems', + }); + const { t: tCommon } = useTranslation('common'); + + // State for modals + const [agendaItemPreviewModalIsOpen, setAgendaItemPreviewModalIsOpen] = + useState(false); + const [agendaItemUpdateModalIsOpen, setAgendaItemUpdateModalIsOpen] = + useState(false); + const [agendaItemDeleteModalIsOpen, setAgendaItemDeleteModalIsOpen] = + useState(false); + + // State for current agenda item ID and form data + const [agendaItemId, setAgendaItemId] = useState(''); + + const [formState, setFormState] = useState<{ + agendaItemCategoryIds: string[]; + agendaItemCategoryNames: string[]; + title: string; + description: string; + duration: string; + attachments: string[]; + urls: string[]; + createdBy: { + firstName: string; + lastName: string; + }; + }>({ + agendaItemCategoryIds: [], + agendaItemCategoryNames: [], + title: '', + description: '', + duration: '', + attachments: [], + urls: [], + createdBy: { + firstName: '', + lastName: '', + }, + }); + + /** + * Shows the preview modal with the details of the selected agenda item. + * @param agendaItem - The agenda item to preview. + */ + const showPreviewModal = (agendaItem: InterfaceAgendaItemInfo): void => { + setAgendaItemState(agendaItem); + setAgendaItemPreviewModalIsOpen(true); + }; + + /** + * Hides the preview modal. + */ + const hidePreviewModal = (): void => { + setAgendaItemPreviewModalIsOpen(false); + }; + + /** + * Toggles the visibility of the update modal. + */ + const showUpdateModal = (): void => { + setAgendaItemUpdateModalIsOpen(!agendaItemUpdateModalIsOpen); + }; + + /** + * Toggles the visibility of the update modal. + */ + const hideUpdateModal = (): void => { + setAgendaItemUpdateModalIsOpen(!agendaItemUpdateModalIsOpen); + }; + + /** + * Toggles the visibility of the delete modal. + */ + const toggleDeleteModal = (): void => { + setAgendaItemDeleteModalIsOpen(!agendaItemDeleteModalIsOpen); + }; + + const [updateAgendaItem] = useMutation(UPDATE_AGENDA_ITEM_MUTATION); + + /** + * Handles updating an agenda item. + * @param e - The form submission event. + */ + const updateAgendaItemHandler = async ( + e: ChangeEvent, + ): Promise => { + e.preventDefault(); + try { + await updateAgendaItem({ + variables: { + updateAgendaItemId: agendaItemId, + input: { + title: formState.title, + description: formState.description, + duration: formState.duration, + categories: formState.agendaItemCategoryIds, + attachments: formState.attachments, + urls: formState.urls, + }, + }, + }); + agendaItemRefetch(); + hideUpdateModal(); + toast.success(t('agendaItemUpdated') as string); + } catch (error) { + if (error instanceof Error) { + toast.error(`${error.message}`); + } + } + }; + + const [deleteAgendaItem] = useMutation(DELETE_AGENDA_ITEM_MUTATION); + + /** + * Handles deleting an agenda item. + */ + const deleteAgendaItemHandler = async (): Promise => { + try { + await deleteAgendaItem({ + variables: { + removeAgendaItemId: agendaItemId, + }, + }); + agendaItemRefetch(); + toggleDeleteModal(); + toast.success(t('agendaItemDeleted') as string); + } catch (error) { + if (error instanceof Error) { + toast.error(`${error.message}`); + } + } + }; + + /** + * Handles click event to show the update modal for the selected agenda item. + * @param agendaItem - The agenda item to update. + */ + const handleEditClick = (agendaItem: InterfaceAgendaItemInfo): void => { + setAgendaItemState(agendaItem); + showUpdateModal(); + }; + + /** + * Sets the state for the selected agenda item. + * @param agendaItem - The agenda item to set in the state. + */ + const setAgendaItemState = (agendaItem: InterfaceAgendaItemInfo): void => { + setFormState({ + ...formState, + agendaItemCategoryIds: agendaItem.categories.map( + (category) => category._id, + ), + agendaItemCategoryNames: agendaItem.categories.map( + (category) => category.name, + ), + title: agendaItem.title, + description: agendaItem.description, + duration: agendaItem.duration, + attachments: agendaItem.attachments, + urls: agendaItem.urls, + createdBy: { + firstName: agendaItem.createdBy.firstName, + lastName: agendaItem.createdBy.lastName, + }, + }); + setAgendaItemId(agendaItem._id); + }; + + /** + * Handles the end of a drag-and-drop operation. + * @param result - The result of the drag-and-drop operation. + */ + const onDragEnd = async (result: DropResult): Promise => { + if (!result.destination || !agendaItemData) { + return; + } + + const reorderedAgendaItems = Array.from(agendaItemData); + const [removed] = reorderedAgendaItems.splice(result.source.index, 1); + reorderedAgendaItems.splice(result.destination.index, 0, removed); + + try { + await Promise.all( + reorderedAgendaItems.map(async (item, index) => { + if (item.sequence !== index + 1) { + // Only update if the sequence has changed + await updateAgendaItem({ + variables: { + updateAgendaItemId: item._id, + input: { + sequence: index + 1, // Update sequence based on new index + }, + }, + }); + } + }), + ); + + // After updating all items, refetch data and notify success + agendaItemRefetch(); + } catch (error) { + if (error instanceof Error) { + toast.error(`${error.message}`); + } + } + }; + + return ( + <> +
+
+ + +
{t('sequence')}
+ + + {t('title')} + + + {t('category')} + + + {t('description')} + + +
{t('options')}
+ +
+
+ + + {(provided) => ( +
+ {agendaItemData && + agendaItemData.map((agendaItem, index) => ( + + {(provided, snapshot) => ( +
+ + + + + + {agendaItem.title} + + +
+ {agendaItem.categories.length > 0 ? ( + agendaItem.categories.map((category, idx) => ( + + {category.name} + {idx < agendaItem.categories.length - 1 && + ', '} + + )) + ) : ( + + No Category + + )} +
+ {' '} + + {agendaItem.description} + + +
+ + +
+ +
+
+ )} +
+ ))} + {agendaItemData?.length === 0 && ( +
+ {t('noAgendaItems')} +
+ )} +
+ )} +
+
+
+ {/* Preview modal */} + + {/* Delete modal */} + + {/* Update modal */} + + + ); +} + +export default AgendaItemsContainer; diff --git a/src/components/AgendaItems/AgendaItemsContainerMocks.ts b/src/components/AgendaItems/AgendaItemsContainerMocks.ts new file mode 100644 index 0000000000..11cd8b254e --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsContainerMocks.ts @@ -0,0 +1,119 @@ +import { + UPDATE_AGENDA_ITEM_MUTATION, + DELETE_AGENDA_ITEM_MUTATION, +} from 'GraphQl/Mutations/AgendaItemMutations'; + +export const MOCKS = [ + { + request: { + query: UPDATE_AGENDA_ITEM_MUTATION, + variables: { + updateAgendaItemId: 'agendaItem1', + input: { + title: 'AgendaItem 1 Edited', + description: 'AgendaItem 1 Description Edited', + }, + }, + }, + result: { + data: { + updateAgendaItem: { + _id: 'agendaItem1', + }, + }, + }, + }, + { + request: { + query: UPDATE_AGENDA_ITEM_MUTATION, + variables: { + updateAgendaItemId: 'agendaItem1', + input: { + title: 'AgendaItem 1', + description: 'AgendaItem 1 Description', + }, + }, + }, + result: { + data: { + updateAgendaItem: { + _id: 'agendaItem1', + }, + }, + }, + }, + { + request: { + query: UPDATE_AGENDA_ITEM_MUTATION, + variables: { + updateAgendaItemId: 'agendaItem2', + input: { + title: 'AgendaItem 2 edited', + description: 'AgendaItem 2 Description', + }, + }, + }, + result: { + data: { + updateAgendaItem: { + _id: 'agendaItem2', + }, + }, + }, + }, + { + request: { + query: DELETE_AGENDA_ITEM_MUTATION, + variables: { + removeAgendaItemId: 'agendaItem1', + }, + }, + result: { + data: { + removeAgendaItem: { + _id: 'agendaItem1', + }, + }, + }, + }, + { + request: { + query: DELETE_AGENDA_ITEM_MUTATION, + variables: { + removeAgendaItemId: 'agendaItem2', + }, + }, + result: { + data: { + removeAgendaItem: { + _id: 'agendaItem2', + }, + }, + }, + }, +]; + +export const MOCKS_ERROR = [ + { + request: { + query: UPDATE_AGENDA_ITEM_MUTATION, + variables: { + updateAgendaItemId: 'agendaItem1', + input: { + title: 'AgendaItem 1 Edited', + description: 'AgendaItem 1 Description Edited', + }, + }, + }, + error: new Error('An error occurred'), + }, + { + request: { + query: DELETE_AGENDA_ITEM_MUTATION, + variables: { + removeAgendaItemId: 'agendaItem1', + }, + }, + error: new Error('An error occurred'), + }, +]; diff --git a/src/components/AgendaItems/AgendaItemsContainerProps.ts b/src/components/AgendaItems/AgendaItemsContainerProps.ts new file mode 100644 index 0000000000..d6dcf3feca --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsContainerProps.ts @@ -0,0 +1,101 @@ +type AgendaItemConnectionType = 'Event'; + +export const props = { + agendaItemConnection: 'Event' as AgendaItemConnectionType, + agendaItemData: [ + { + _id: 'agendaItem1', + title: 'AgendaItem 1', + description: 'AgendaItem 1 Description', + duration: '2h', + attachments: ['attachment1'], + createdBy: { + _id: 'user0', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + urls: [], + users: [], + sequence: 1, + categories: [ + { + _id: 'category1', + name: 'Category 1', + }, + ], + organization: { + _id: 'org1', + name: 'Unity Foundation', + }, + relatedEvent: { + _id: 'event1', + title: 'Aerobics for Everyone', + }, + }, + { + _id: 'agendaItem2', + title: 'AgendaItem 2', + description: 'AgendaItem 2 Description', + duration: '1h', + attachments: ['attachment3'], + createdBy: { + _id: 'user1', + firstName: 'Jane', + lastName: 'Doe', + }, + urls: ['http://example.com'], + users: [ + { + _id: 'user2', + firstName: 'John', + lastName: 'Smith', + }, + ], + sequence: 2, + categories: [ + { + _id: 'category2', + name: 'Category 2', + }, + ], + organization: { + _id: 'org2', + name: 'Health Organization', + }, + relatedEvent: { + _id: 'event2', + title: 'Yoga for Beginners', + }, + }, + ], + agendaItemRefetch: jest.fn(), + agendaItemCategories: [ + { + _id: 'agendaCategory1', + name: 'AgendaCategory 1', + description: 'AgendaCategory 1 Description', + createdBy: { + _id: 'user0', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + { + _id: 'agendaCategory2', + name: 'AgendaCategory 2', + description: 'AgendaCategory 2 Description', + createdBy: { + _id: 'user1', + firstName: 'Jane', + lastName: 'Doe', + }, + }, + ], +}; + +export const props2 = { + agendaItemConnection: 'Event' as AgendaItemConnectionType, + agendaItemData: [], + agendaItemRefetch: jest.fn(), + agendaItemCategories: [], +}; diff --git a/src/components/AgendaItems/AgendaItemsCreateModal.test.tsx b/src/components/AgendaItems/AgendaItemsCreateModal.test.tsx new file mode 100644 index 0000000000..5b7339ad67 --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsCreateModal.test.tsx @@ -0,0 +1,368 @@ +import React from 'react'; +import { + render, + screen, + fireEvent, + waitFor, + within, +} from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; + +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +import AgendaItemsCreateModal from './AgendaItemsCreateModal'; +import { toast } from 'react-toastify'; +import convertToBase64 from 'utils/convertToBase64'; + +const mockFormState = { + title: 'Test Title', + description: 'Test Description', + duration: '20', + attachments: ['Test Attachment'], + urls: ['https://example.com'], + agendaItemCategoryIds: ['category'], +}; +const mockHideCreateModal = jest.fn(); +const mockSetFormState = jest.fn(); +const mockCreateAgendaItemHandler = jest.fn(); +const mockT = (key: string): string => key; +const mockAgendaItemCategories = [ + { + _id: '1', + name: 'Test Name', + description: 'Test Description', + createdBy: { + _id: '1', + firstName: 'Test', + lastName: 'User', + }, + }, + { + _id: '2', + name: 'Another Category', + description: 'Another Description', + createdBy: { + _id: '2', + firstName: 'Another', + lastName: 'Creator', + }, + }, + { + _id: '3', + name: 'Third Category', + description: 'Third Description', + createdBy: { + _id: '3', + firstName: 'Third', + lastName: 'User', + }, + }, +]; +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); +jest.mock('utils/convertToBase64'); +const mockedConvertToBase64 = convertToBase64 as jest.MockedFunction< + typeof convertToBase64 +>; + +describe('AgendaItemsCreateModal', () => { + test('renders modal correctly', () => { + render( + + + + + + + + + + + , + ); + + expect(screen.getByText('agendaItemDetails')).toBeInTheDocument(); + expect(screen.getByTestId('createAgendaItemFormBtn')).toBeInTheDocument(); + expect( + screen.getByTestId('createAgendaItemModalCloseBtn'), + ).toBeInTheDocument(); + }); + + test('tests the condition for formState', async () => { + const mockFormState = { + title: 'Test Title', + description: 'Test Description', + duration: '20', + attachments: ['Test Attachment'], + urls: ['https://example.com'], + agendaItemCategoryIds: ['1'], + }; + render( + + + + + + + + + + + , + ); + + fireEvent.change(screen.getByLabelText('title'), { + target: { value: 'New title' }, + }); + + fireEvent.change(screen.getByLabelText('description'), { + target: { value: 'New description' }, + }); + + fireEvent.change(screen.getByLabelText('duration'), { + target: { value: '30' }, + }); + + fireEvent.click(screen.getByTestId('deleteUrl')); + fireEvent.click(screen.getByTestId('deleteAttachment')); + + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + title: 'New title', + }); + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + description: 'New description', + }); + + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + duration: '30', + }); + + await waitFor(() => { + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + urls: [], + }); + }); + + await waitFor(() => { + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + attachments: [], + }); + }); + }); + test('handleAddUrl correctly adds valid URL', async () => { + render( + + + + + + + + + , + ); + + const urlInput = screen.getByTestId('urlInput'); + const linkBtn = screen.getByTestId('linkBtn'); + + fireEvent.change(urlInput, { target: { value: 'https://example.com' } }); + fireEvent.click(linkBtn); + + await waitFor(() => { + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + urls: [...mockFormState.urls, 'https://example.com'], + }); + }); + }); + + test('shows error toast for invalid URL', async () => { + render( + + + + + + + + + + + , + ); + + const urlInput = screen.getByTestId('urlInput'); + const linkBtn = screen.getByTestId('linkBtn'); + + fireEvent.change(urlInput, { target: { value: 'invalid-url' } }); + fireEvent.click(linkBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('invalidUrl'); + }); + }); + + test('shows error toast for file size exceeding limit', async () => { + render( + + + + + + + + + + + , + ); + + const fileInput = screen.getByTestId('attachment'); + const largeFile = new File( + ['a'.repeat(11 * 1024 * 1024)], + 'large-file.jpg', + ); // 11 MB file + + Object.defineProperty(fileInput, 'files', { + value: [largeFile], + }); + + fireEvent.change(fileInput); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('fileSizeExceedsLimit'); + }); + }); + + test('adds files correctly when within size limit', async () => { + mockedConvertToBase64.mockResolvedValue('base64-file'); + + render( + + + + + + + + + + + , + ); + + const fileInput = screen.getByTestId('attachment'); + const smallFile = new File(['small-file-content'], 'small-file.jpg'); // Small file + + Object.defineProperty(fileInput, 'files', { + value: [smallFile], + }); + + fireEvent.change(fileInput); + + await waitFor(() => { + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + attachments: [...mockFormState.attachments, 'base64-file'], + }); + }); + }); + test('renders autocomplete and selects categories correctly', async () => { + render( + + + + + + + + + + + , + ); + + const autocomplete = screen.getByTestId('categorySelect'); + expect(autocomplete).toBeInTheDocument(); + + const input = within(autocomplete).getByRole('combobox'); + fireEvent.mouseDown(input); + + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(mockAgendaItemCategories.length); + + fireEvent.click(options[0]); + fireEvent.click(options[1]); + }); +}); diff --git a/src/components/AgendaItems/AgendaItemsCreateModal.tsx b/src/components/AgendaItems/AgendaItemsCreateModal.tsx new file mode 100644 index 0000000000..a37ab329a2 --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsCreateModal.tsx @@ -0,0 +1,330 @@ +import React, { useState, useEffect } from 'react'; +import { Modal, Form, Button, Row, Col } from 'react-bootstrap'; +import { Autocomplete, TextField } from '@mui/material'; + +import { FaLink, FaTrash } from 'react-icons/fa'; +import { toast } from 'react-toastify'; +import styles from './AgendaItemsContainer.module.css'; +import type { ChangeEvent } from 'react'; +import type { InterfaceAgendaItemCategoryInfo } from 'utils/interfaces'; +import convertToBase64 from 'utils/convertToBase64'; + +interface InterfaceFormStateType { + agendaItemCategoryIds: string[]; + title: string; + description: string; + duration: string; + attachments: string[]; + urls: string[]; +} + +interface InterfaceAgendaItemsCreateModalProps { + agendaItemCreateModalIsOpen: boolean; + hideCreateModal: () => void; + formState: InterfaceFormStateType; + setFormState: (state: React.SetStateAction) => void; + createAgendaItemHandler: (e: ChangeEvent) => Promise; + t: (key: string) => string; + agendaItemCategories: InterfaceAgendaItemCategoryInfo[] | undefined; +} + +/** + * Component for creating a new agenda item. + * Displays a modal form where users can input details for a new agenda item, including title, description, duration, categories, URLs, and attachments. + * + * @param agendaItemCreateModalIsOpen - Boolean flag indicating if the modal is open. + * @param hideCreateModal - Function to close the modal. + * @param formState - Current state of the form fields. + * @param setFormState - Function to update the form state. + * @param createAgendaItemHandler - Function to handle form submission. + * @param t - Function for translating text based on keys. + * @param agendaItemCategories - List of agenda item categories for selection. + */ +const AgendaItemsCreateModal: React.FC< + InterfaceAgendaItemsCreateModalProps +> = ({ + agendaItemCreateModalIsOpen, + hideCreateModal, + formState, + setFormState, + createAgendaItemHandler, + t, + agendaItemCategories, +}) => { + const [newUrl, setNewUrl] = useState(''); + + useEffect(() => { + // Ensure URLs and attachments do not have empty or invalid entries + setFormState((prevState) => ({ + ...prevState, + urls: prevState.urls.filter((url) => url.trim() !== ''), + attachments: prevState.attachments.filter((att) => att !== ''), + })); + }, []); + + /** + * Validates if a given URL is in a correct format. + * + * @param url - URL string to validate. + * @returns True if the URL is valid, false otherwise. + */ + const isValidUrl = (url: string): boolean => { + // Regular expression for basic URL validation + const urlRegex = /^(ftp|http|https):\/\/[^ "]+$/; + return urlRegex.test(url); + }; + + /** + * Handles adding a new URL to the form state. + * + * Checks if the URL is valid before adding it. + */ + const handleAddUrl = (): void => { + if (newUrl.trim() !== '' && isValidUrl(newUrl.trim())) { + setFormState({ + ...formState, + urls: [...formState.urls.filter((url) => url.trim() !== ''), newUrl], + }); + setNewUrl(''); + } else { + toast.error(t('invalidUrl')); + } + }; + + /** + * Handles removing a URL from the form state. + * + * @param url - URL to remove. + */ + const handleRemoveUrl = (url: string): void => { + setFormState({ + ...formState, + urls: formState.urls.filter((item) => item !== url), + }); + }; + + /** + * Handles file selection and converts files to base64 before updating the form state. + * + * @param e - File input change event. + */ + const handleFileChange = async ( + e: React.ChangeEvent, + ): Promise => { + const target = e.target as HTMLInputElement; + if (target.files) { + const files = Array.from(target.files); + let totalSize = 0; + files.forEach((file) => { + totalSize += file.size; + }); + if (totalSize > 10 * 1024 * 1024) { + toast.error(t('fileSizeExceedsLimit')); + return; + } + const base64Files = await Promise.all( + files.map(async (file) => await convertToBase64(file)), + ); + setFormState({ + ...formState, + attachments: [...formState.attachments, ...base64Files], + }); + } + }; + + /** + * Handles removing an attachment from the form state. + * + * @param attachment - Attachment to remove. + */ + const handleRemoveAttachment = (attachment: string): void => { + setFormState({ + ...formState, + attachments: formState.attachments.filter((item) => item !== attachment), + }); + }; + + return ( + + +

{t('agendaItemDetails')}

+ +
+ +
+ + + formState.agendaItemCategoryIds.includes(category._id), + ) || [] + } + filterSelectedOptions={true} + getOptionLabel={( + category: InterfaceAgendaItemCategoryInfo, + ): string => category.name} + onChange={(_, newCategories): void => { + setFormState({ + ...formState, + agendaItemCategoryIds: newCategories.map( + (category) => category._id, + ), + }); + }} + renderInput={(params) => ( + + )} + /> + + + + + {t('title')} + + setFormState({ ...formState, title: e.target.value }) + } + /> + + + + + {t('duration')} + + setFormState({ ...formState, duration: e.target.value }) + } + /> + + + + + {t('description')} + + setFormState({ ...formState, description: e.target.value }) + } + /> + + + + {t('url')} +
+ setNewUrl(e.target.value)} + /> + +
+ + {formState.urls.map((url, index) => ( +
  • + + + {url.length > 50 ? url.substring(0, 50) + '...' : url} + + +
  • + ))} +
    + + {t('attachments')} + + {t('attachmentLimit')} + + {formState.attachments && ( +
    + {formState.attachments.map((attachment, index) => ( +
    + {attachment.includes('video') ? ( + + ) : ( + Attachment preview + )} + +
    + ))} +
    + )} + + +
    +
    + ); +}; + +export default AgendaItemsCreateModal; diff --git a/src/components/AgendaItems/AgendaItemsDeleteModal.tsx b/src/components/AgendaItems/AgendaItemsDeleteModal.tsx new file mode 100644 index 0000000000..cba3b29446 --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsDeleteModal.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { Modal, Button } from 'react-bootstrap'; +import styles from './AgendaItemsContainer.module.css'; + +interface InterfaceAgendaItemsDeleteModalProps { + agendaItemDeleteModalIsOpen: boolean; + toggleDeleteModal: () => void; + deleteAgendaItemHandler: () => Promise; + t: (key: string) => string; + tCommon: (key: string) => string; +} + +/** + * Modal component for confirming the deletion of an agenda item. + * Displays a confirmation dialog when a user attempts to delete an agenda item. + * + * @param agendaItemDeleteModalIsOpen - Boolean flag indicating if the modal is open. + * @param toggleDeleteModal - Function to toggle the visibility of the modal. + * @param deleteAgendaItemHandler - Function to handle the deletion of the agenda item. + * @param t - Function for translating text based on keys. + * @param tCommon - Function for translating common text keys. + */ +const AgendaItemsDeleteModal: React.FC< + InterfaceAgendaItemsDeleteModalProps +> = ({ + agendaItemDeleteModalIsOpen, + toggleDeleteModal, + deleteAgendaItemHandler, + t, + tCommon, +}) => { + return ( + + + + {t('deleteAgendaItem')} + + + +

    {t('deleteAgendaItemMsg')}

    +
    + + + + +
    + ); +}; + +export default AgendaItemsDeleteModal; diff --git a/src/components/AgendaItems/AgendaItemsPreviewModal.test.tsx b/src/components/AgendaItems/AgendaItemsPreviewModal.test.tsx new file mode 100644 index 0000000000..0a7b4646ba --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsPreviewModal.test.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; + +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +import AgendaItemsPreviewModal from './AgendaItemsPreviewModal'; + +const mockFormState = { + title: 'Test Title', + description: 'Test Description', + duration: '20', + attachments: [ + 'https://example.com/video.mp4', + 'https://example.com/image.jpg', + ], + urls: [ + 'https://example.com', + 'https://thisisaverylongurlthatexceedsfiftycharacters.com/very/long/path', + ], + agendaItemCategoryIds: ['category'], + agendaItemCategoryNames: ['category'], + createdBy: { + firstName: 'Test', + lastName: 'User', + }, +}; + +const mockT = (key: string): string => key; + +describe('AgendaItemsPreviewModal', () => { + test('check url and attachment links', () => { + render( + + + + + + + + + + + , + ); + + expect(screen.getByText('Test Title')).toBeInTheDocument(); + expect(screen.getByText('Test Description')).toBeInTheDocument(); + expect(screen.getByText('20')).toBeInTheDocument(); + expect(screen.getByText('https://example.com')).toBeInTheDocument(); + + // Check attachments + const videoLink = screen.getByText( + (content, element) => + element?.tagName.toLowerCase() === 'a' && + (element as HTMLAnchorElement)?.href === + 'https://example.com/video.mp4', + ); + expect(videoLink).toBeInTheDocument(); + + const imageLink = screen.getByText( + (content, element) => + element?.tagName.toLowerCase() === 'a' && + (element as HTMLAnchorElement)?.href === + 'https://example.com/image.jpg', + ); + expect(imageLink).toBeInTheDocument(); + + // Check URLs + const shortUrlLink = screen.getByText( + (content, element) => + element?.tagName.toLowerCase() === 'a' && + (element as HTMLAnchorElement)?.href === 'https://example.com/', + ); + expect(shortUrlLink).toBeInTheDocument(); + expect(shortUrlLink).toHaveTextContent('https://example.com'); + + const longUrlLink = screen.getByText( + (content, element) => + element?.tagName.toLowerCase() === 'a' && + (element as HTMLAnchorElement)?.href === + 'https://thisisaverylongurlthatexceedsfiftycharacters.com/very/long/path', + ); + expect(longUrlLink).toBeInTheDocument(); + expect(longUrlLink).toHaveTextContent( + 'https://thisisaverylongurlthatexceedsfiftycharacte...', + ); + }); +}); diff --git a/src/components/AgendaItems/AgendaItemsPreviewModal.tsx b/src/components/AgendaItems/AgendaItemsPreviewModal.tsx new file mode 100644 index 0000000000..299165656c --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsPreviewModal.tsx @@ -0,0 +1,173 @@ +import React from 'react'; +import { Modal, Form, Button } from 'react-bootstrap'; +import styles from './AgendaItemsContainer.module.css'; +import { FaLink } from 'react-icons/fa'; + +interface InterfaceFormStateType { + agendaItemCategoryIds: string[]; + agendaItemCategoryNames: string[]; + title: string; + description: string; + duration: string; + attachments: string[]; + urls: string[]; + createdBy: { + firstName: string; + lastName: string; + }; +} + +interface InterfaceAgendaItemsPreviewModalProps { + agendaItemPreviewModalIsOpen: boolean; + hidePreviewModal: () => void; + showUpdateModal: () => void; + toggleDeleteModal: () => void; + formState: InterfaceFormStateType; + t: (key: string) => string; +} + +/** + * Modal component for previewing details of an agenda item. + * Displays the details of the selected agenda item, including its categories, title, description, duration, creator, URLs, and attachments. + * Also provides options to update or delete the agenda item. + * + * @param agendaItemPreviewModalIsOpen - Boolean flag indicating if the preview modal is open. + * @param hidePreviewModal - Function to hide the preview modal. + * @param showUpdateModal - Function to show the update modal. + * @param toggleDeleteModal - Function to toggle the delete modal. + * @param formState - The current state of the form containing agenda item details. + * @param t - Function for translating text based on keys. + */ +const AgendaItemsPreviewModal: React.FC< + InterfaceAgendaItemsPreviewModalProps +> = ({ + agendaItemPreviewModalIsOpen, + hidePreviewModal, + showUpdateModal, + toggleDeleteModal, + formState, + t, +}) => { + /** + * Renders the attachments preview. + * + * @returns JSX elements for each attachment, displaying videos and images. + */ + const renderAttachments = (): JSX.Element[] => { + return formState.attachments.map((attachment, index) => ( +
    + {attachment.includes('video') ? ( + + + + ) : ( + + Attachment preview + + )} +
    + )); + }; + + /** + * Renders the URLs list. + * + * @returns JSX elements for each URL, displaying clickable links. + */ + const renderUrls = (): JSX.Element[] => { + return formState.urls.map((url, index) => ( +
  • + + + {url.length > 50 ? `${url.substring(0, 50)}...` : url} + +
  • + )); + }; + + return ( + + +

    {t('agendaItemDetails')}

    + +
    + +
    +
    +
    +

    {t('category')}

    + + {formState.agendaItemCategoryNames.join(', ')} + +
    +
    +

    {t('title')}

    + {formState.title} +
    +
    +

    {t('description')}

    + {formState.description} +
    +
    +

    {t('duration')}

    + {formState.duration} +
    +
    +

    {t('createdBy')}

    + + {`${formState.createdBy.firstName} ${formState.createdBy.lastName}`} + +
    +
    +

    {t('urls')}

    + {renderUrls()} +
    +
    +

    {t('attachments')}

    + {renderAttachments()} +
    +
    +
    + + +
    +
    +
    +
    + ); +}; + +export default AgendaItemsPreviewModal; diff --git a/src/components/AgendaItems/AgendaItemsUpdateModal.test.tsx b/src/components/AgendaItems/AgendaItemsUpdateModal.test.tsx new file mode 100644 index 0000000000..0f11ea31b2 --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsUpdateModal.test.tsx @@ -0,0 +1,369 @@ +import React from 'react'; +import { + render, + screen, + fireEvent, + waitFor, + within, +} from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; + +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +import AgendaItemsUpdateModal from './AgendaItemsUpdateModal'; +import { toast } from 'react-toastify'; +import convertToBase64 from 'utils/convertToBase64'; + +const mockFormState = { + title: 'Test Title', + description: 'Test Description', + duration: '20', + attachments: ['Test Attachment'], + urls: ['https://example.com'], + agendaItemCategoryIds: ['category'], + agendaItemCategoryNames: ['category'], + createdBy: { + firstName: 'Test', + lastName: 'User', + }, +}; + +const mockAgendaItemCategories = [ + { + _id: '1', + name: 'Test Name', + description: 'Test Description', + createdBy: { + _id: '1', + firstName: 'Test', + lastName: 'User', + }, + }, + { + _id: '2', + name: 'Another Category', + description: 'Another Description', + createdBy: { + _id: '2', + firstName: 'Another', + lastName: 'Creator', + }, + }, + { + _id: '3', + name: 'Third Category', + description: 'Third Description', + createdBy: { + _id: '3', + firstName: 'Third', + lastName: 'User', + }, + }, +]; + +const mockHideUpdateModal = jest.fn(); +const mockSetFormState = jest.fn(); +const mockUpdateAgendaItemHandler = jest.fn(); +const mockT = (key: string): string => key; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); +jest.mock('utils/convertToBase64'); +const mockedConvertToBase64 = convertToBase64 as jest.MockedFunction< + typeof convertToBase64 +>; + +describe('AgendaItemsUpdateModal', () => { + test('renders modal correctly', () => { + render( + + + + + + + + + + + , + ); + + expect(screen.getByText('updateAgendaItem')).toBeInTheDocument(); + expect(screen.getByTestId('updateAgendaItemBtn')).toBeInTheDocument(); + expect( + screen.getByTestId('updateAgendaItemModalCloseBtn'), + ).toBeInTheDocument(); + }); + + test('tests the condition for formState.title and formState.description', async () => { + render( + + + + + + + + + + + , + ); + + fireEvent.change(screen.getByLabelText('title'), { + target: { value: 'New title' }, + }); + + fireEvent.change(screen.getByLabelText('description'), { + target: { value: 'New description' }, + }); + + fireEvent.change(screen.getByLabelText('duration'), { + target: { value: '30' }, + }); + + fireEvent.click(screen.getByTestId('deleteUrl')); + fireEvent.click(screen.getByTestId('deleteAttachment')); + + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + title: 'New title', + }); + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + description: 'New description', + }); + + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + duration: '30', + }); + + await waitFor(() => { + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + urls: [], + }); + }); + + await waitFor(() => { + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + attachments: [], + }); + }); + }); + + test('handleAddUrl correctly adds valid URL', async () => { + render( + + + + + + + + + , + ); + + const urlInput = screen.getByTestId('urlInput'); + const linkBtn = screen.getByTestId('linkBtn'); + + fireEvent.change(urlInput, { target: { value: 'https://example.com' } }); + fireEvent.click(linkBtn); + + await waitFor(() => { + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + urls: [...mockFormState.urls, 'https://example.com'], + }); + }); + }); + + test('shows error toast for invalid URL', async () => { + render( + + + + + + + + + + + , + ); + + const urlInput = screen.getByTestId('urlInput'); + const linkBtn = screen.getByTestId('linkBtn'); + + fireEvent.change(urlInput, { target: { value: 'invalid-url' } }); + fireEvent.click(linkBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('invalidUrl'); + }); + }); + + test('shows error toast for file size exceeding limit', async () => { + render( + + + + + + + + + + + , + ); + + const fileInput = screen.getByTestId('attachment'); + const largeFile = new File( + ['a'.repeat(11 * 1024 * 1024)], + 'large-file.jpg', + ); // 11 MB file + + Object.defineProperty(fileInput, 'files', { + value: [largeFile], + }); + + fireEvent.change(fileInput); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('fileSizeExceedsLimit'); + }); + }); + + test('adds files correctly when within size limit', async () => { + mockedConvertToBase64.mockResolvedValue('base64-file'); + + render( + + + + + + + + + + + , + ); + + const fileInput = screen.getByTestId('attachment'); + const smallFile = new File(['small-file-content'], 'small-file.jpg'); // Small file + + Object.defineProperty(fileInput, 'files', { + value: [smallFile], + }); + + fireEvent.change(fileInput); + + await waitFor(() => { + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + attachments: [...mockFormState.attachments, 'base64-file'], + }); + }); + }); + test('renders autocomplete and selects categories correctly', async () => { + render( + + + + + + + + + + + , + ); + + const autocomplete = screen.getByTestId('categorySelect'); + expect(autocomplete).toBeInTheDocument(); + + const input = within(autocomplete).getByRole('combobox'); + fireEvent.mouseDown(input); + + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(mockAgendaItemCategories.length); + + fireEvent.click(options[0]); + fireEvent.click(options[1]); + }); +}); diff --git a/src/components/AgendaItems/AgendaItemsUpdateModal.tsx b/src/components/AgendaItems/AgendaItemsUpdateModal.tsx new file mode 100644 index 0000000000..7761c912ca --- /dev/null +++ b/src/components/AgendaItems/AgendaItemsUpdateModal.tsx @@ -0,0 +1,334 @@ +import React, { useState, useEffect } from 'react'; +import { Modal, Form, Button, Row, Col } from 'react-bootstrap'; +import type { ChangeEvent } from 'react'; +import { Autocomplete, TextField } from '@mui/material'; +import { FaLink, FaTrash } from 'react-icons/fa'; +import { toast } from 'react-toastify'; +import convertToBase64 from 'utils/convertToBase64'; + +import styles from './AgendaItemsContainer.module.css'; +import type { InterfaceAgendaItemCategoryInfo } from 'utils/interfaces'; + +interface InterfaceFormStateType { + agendaItemCategoryIds: string[]; + agendaItemCategoryNames: string[]; + title: string; + description: string; + duration: string; + attachments: string[]; + urls: string[]; + createdBy: { + firstName: string; + lastName: string; + }; +} + +interface InterfaceAgendaItemsUpdateModalProps { + agendaItemUpdateModalIsOpen: boolean; + hideUpdateModal: () => void; + formState: InterfaceFormStateType; + setFormState: (state: React.SetStateAction) => void; + updateAgendaItemHandler: (e: ChangeEvent) => Promise; + t: (key: string) => string; + agendaItemCategories: InterfaceAgendaItemCategoryInfo[] | undefined; +} + +/** + * Modal component for updating details of an agenda item. + * Provides a form to update the agenda item's title, description, duration, categories, URLs, and attachments. + * Also includes functionality to add, remove URLs and attachments. + * + * @param agendaItemUpdateModalIsOpen - Boolean flag indicating if the update modal is open. + * @param hideUpdateModal - Function to hide the update modal. + * @param formState - The current state of the form containing agenda item details. + * @param setFormState - Function to update the form state. + * @param updateAgendaItemHandler - Handler function for submitting the form. + * @param t - Function for translating text based on keys. + * @param agendaItemCategories - List of agenda item categories for selection. + */ +const AgendaItemsUpdateModal: React.FC< + InterfaceAgendaItemsUpdateModalProps +> = ({ + agendaItemUpdateModalIsOpen, + hideUpdateModal, + formState, + setFormState, + updateAgendaItemHandler, + t, + agendaItemCategories, +}) => { + const [newUrl, setNewUrl] = useState(''); + + useEffect(() => { + setFormState((prevState) => ({ + ...prevState, + urls: prevState.urls.filter((url) => url.trim() !== ''), + attachments: prevState.attachments.filter((att) => att !== ''), + })); + }, []); + + /** + * Validates if a given URL is in a correct format. + * + * @param url - The URL to validate. + * @returns True if the URL is valid, false otherwise. + */ + const isValidUrl = (url: string): boolean => { + // Regular expression for basic URL validation + const urlRegex = /^(ftp|http|https):\/\/[^ "]+$/; + return urlRegex.test(url); + }; + + /** + * Handles adding a new URL to the form state. + * Displays an error toast if the URL is invalid. + */ + const handleAddUrl = (): void => { + if (newUrl.trim() !== '' && isValidUrl(newUrl.trim())) { + setFormState({ + ...formState, + urls: [...formState.urls.filter((url) => url.trim() !== ''), newUrl], + }); + setNewUrl(''); + } else { + toast.error(t('invalidUrl')); + } + }; + + /** + * Handles removing a URL from the form state. + * + * @param url - The URL to remove. + */ + const handleRemoveUrl = (url: string): void => { + setFormState({ + ...formState, + urls: formState.urls.filter((item) => item !== url), + }); + }; + + /** + * Handles file input change event. + * Converts selected files to base64 format and updates the form state. + * Displays an error toast if the total file size exceeds the limit. + * + * @param e - The change event for file input. + */ + const handleFileChange = async ( + e: React.ChangeEvent, + ): Promise => { + const target = e.target as HTMLInputElement; + if (target.files) { + const files = Array.from(target.files); + let totalSize = 0; + files.forEach((file) => { + totalSize += file.size; + }); + if (totalSize > 10 * 1024 * 1024) { + toast.error(t('fileSizeExceedsLimit')); + return; + } + const base64Files = await Promise.all( + files.map(async (file) => await convertToBase64(file)), + ); + setFormState({ + ...formState, + attachments: [...formState.attachments, ...base64Files], + }); + } + }; + + /** + * Handles removing an attachment from the form state. + * + * @param attachment - The attachment to remove. + */ + const handleRemoveAttachment = (attachment: string): void => { + setFormState({ + ...formState, + attachments: formState.attachments.filter((item) => item !== attachment), + }); + }; + + return ( + + +

    {t('updateAgendaItem')}

    + +
    + +
    + + + formState.agendaItemCategoryIds.includes(category._id), + ) || [] + } + filterSelectedOptions={true} + getOptionLabel={( + category: InterfaceAgendaItemCategoryInfo, + ): string => category.name} + onChange={(_, newCategories): void => { + setFormState({ + ...formState, + agendaItemCategoryIds: newCategories.map( + (category) => category._id, + ), + }); + }} + renderInput={(params) => ( + + )} + /> + + + + + + {t('title')} + + setFormState({ ...formState, title: e.target.value }) + } + /> + + + + + {t('duration')} + + setFormState({ ...formState, duration: e.target.value }) + } + /> + + + + + + {t('description')} + + setFormState({ ...formState, description: e.target.value }) + } + /> + + + + {t('url')} +
    + setNewUrl(e.target.value)} + /> + +
    + + {formState.urls.map((url, index) => ( +
  • + + + {url.length > 50 ? url.substring(0, 50) + '...' : url} + + +
  • + ))} +
    + + {t('attachments')} + + {t('attachmentLimit')} + + {formState.attachments && ( +
    + {formState.attachments.map((attachment, index) => ( +
    + {attachment.includes('video') ? ( + + ) : ( + Attachment preview + )} + +
    + ))} +
    + )} + + +
    +
    + ); +}; + +export default AgendaItemsUpdateModal; diff --git a/src/components/Avatar/Avatar.module.css b/src/components/Avatar/Avatar.module.css new file mode 100644 index 0000000000..13fe1bbbc6 --- /dev/null +++ b/src/components/Avatar/Avatar.module.css @@ -0,0 +1,14 @@ +.imageContainer { + width: fit-content; + height: fit-content; + display: flex; + align-items: center; + justify-content: center; + border-radius: 100%; +} +.imageContainer img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 100%; +} diff --git a/src/components/Avatar/Avatar.test.tsx b/src/components/Avatar/Avatar.test.tsx new file mode 100644 index 0000000000..d178c48a4a --- /dev/null +++ b/src/components/Avatar/Avatar.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import Avatar from './Avatar'; +import { BrowserRouter } from 'react-router-dom'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; + +describe('Avatar component', () => { + test('renders with name and alt attribute', () => { + const testName = 'John Doe'; + const testAlt = 'Test Alt Text'; + const testSize = 64; + + const { getByAltText } = render( + + + + + , + ); + const avatarElement = getByAltText(testAlt); + + expect(avatarElement).toBeInTheDocument(); + expect(avatarElement.getAttribute('src')).toBeDefined(); + }); + + test('renders with custom style and data-testid', () => { + const testName = 'Jane Doe'; + const testStyle = 'custom-avatar-style'; + const testDataTestId = 'custom-avatar-test-id'; + + const { getByAltText } = render( + + + + + , + ); + const avatarElement = getByAltText('Dummy Avatar'); + + expect(avatarElement).toBeInTheDocument(); + expect(avatarElement.getAttribute('src')).toBeDefined(); + expect(avatarElement.getAttribute('class')).toContain(testStyle); + expect(avatarElement.getAttribute('data-testid')).toBe(testDataTestId); + }); +}); diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx new file mode 100644 index 0000000000..3e2f818e3f --- /dev/null +++ b/src/components/Avatar/Avatar.tsx @@ -0,0 +1,61 @@ +import React, { useMemo } from 'react'; +import { createAvatar } from '@dicebear/core'; +import { initials } from '@dicebear/collection'; +import styles from 'components/Avatar/Avatar.module.css'; + +interface InterfaceAvatarProps { + name: string; + alt?: string; + size?: number; + containerStyle?: string; + avatarStyle?: string; + dataTestId?: string; + radius?: number; +} + +/** + * A component that generates and displays an avatar based on the provided name. + * The avatar is generated using the DiceBear library with the initials style. + * + * @param name - The name used to generate the avatar. + * @param alt - Alternative text for the avatar image. + * @param size - Size of the avatar image. + * @param avatarStyle - Custom CSS class for the avatar image. + * @param dataTestId - Data-testid attribute for testing purposes. + * @param radius - Radius of the avatar corners. + * + * @returns JSX.Element - The rendered avatar image component. + */ +const Avatar = ({ + name, + alt = 'Dummy Avatar', + size, + avatarStyle, + containerStyle, + dataTestId, + radius, +}: InterfaceAvatarProps): JSX.Element => { + // Memoize the avatar creation to avoid unnecessary recalculations + const avatar = useMemo(() => { + return createAvatar(initials, { + size: size || 128, + seed: name, + radius: radius || 0, + }).toDataUri(); + }, [name, size]); + + const svg = avatar?.toString(); + + return ( +
    + {alt} +
    + ); +}; + +export default Avatar; diff --git a/src/components/ChangeLanguageDropdown/ChangeLanguageDropDown.tsx b/src/components/ChangeLanguageDropdown/ChangeLanguageDropDown.tsx new file mode 100644 index 0000000000..ac758be867 --- /dev/null +++ b/src/components/ChangeLanguageDropdown/ChangeLanguageDropDown.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { Dropdown } from 'react-bootstrap'; +import i18next from 'i18next'; +import { languages } from 'utils/languages'; +import cookies from 'js-cookie'; +import { UPDATE_USER_MUTATION } from 'GraphQl/Mutations/mutations'; +import { useMutation } from '@apollo/client'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { getItem } = useLocalStorage(); + +interface InterfaceChangeLanguageDropDownProps { + parentContainerStyle?: string; + btnStyle?: string; + btnTextStyle?: string; +} + +/** + * A dropdown component that allows users to change the application's language. + * It updates the user's language preference in the backend and stores the selection in cookies. + * + * @param props - The properties for customizing the dropdown component. + * @param parentContainerStyle - Custom style for the dropdown container. + * @param btnStyle - Custom style for the dropdown button. + * @param btnTextStyle - Custom style for the button text. + * + * @returns JSX.Element - The rendered dropdown component for changing languages. + */ +const ChangeLanguageDropDown = ( + props: InterfaceChangeLanguageDropDownProps, +): JSX.Element => { + const currentLanguageCode = cookies.get('i18next') || 'en'; + const userId = getItem('userId'); + const [updateUser] = useMutation(UPDATE_USER_MUTATION); + + /** + * Changes the application's language and updates the user's language preference. + * + * @param languageCode - The code of the language to switch to. + */ + const changeLanguage = async (languageCode: string): Promise => { + if (userId) { + try { + await updateUser({ + variables: { + appLanguageCode: languageCode, + }, + }); + await i18next.changeLanguage(languageCode); + cookies.set('i18next', languageCode); + } catch (error) { + console.log('Error in changing language', error); + } + } + }; + + return ( + + + {languages.map((language, index: number) => ( + + {currentLanguageCode === language.code ? ( + + + {language.name} + + ) : null} + + ))} + + + {languages.map((language, index: number) => ( + => changeLanguage(language.code)} + disabled={currentLanguageCode === language.code} + data-testid={`change-language-btn-${language.code}`} + > + + {language.name} + + ))} + + + ); +}; + +export default ChangeLanguageDropDown; diff --git a/src/components/ChangeLanguageDropdown/ChangeLanguageDropdown.test.tsx b/src/components/ChangeLanguageDropdown/ChangeLanguageDropdown.test.tsx new file mode 100644 index 0000000000..dc14f6ce17 --- /dev/null +++ b/src/components/ChangeLanguageDropdown/ChangeLanguageDropdown.test.tsx @@ -0,0 +1,158 @@ +import React, { act } from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { BrowserRouter } from 'react-router-dom'; +import i18nForTest from 'utils/i18nForTest'; +import { languages } from 'utils/languages'; +import ChangeLanguageDropDown from './ChangeLanguageDropDown'; +import cookies from 'js-cookie'; +import { MockedProvider } from '@apollo/react-testing'; +import { UPDATE_USER_MUTATION } from 'GraphQl/Mutations/mutations'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import useLocalStorage from 'utils/useLocalstorage'; +// import { Provider } from 'react-redux'; +// import { store } from 'state/store'; +const { setItem } = useLocalStorage(); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const MOCKS = [ + { + request: { + query: UPDATE_USER_MUTATION, + variables: { + appLanguageCode: 'fr', + }, + }, + result: { + data: { + updateUserProfile: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: UPDATE_USER_MUTATION, + variables: { + appLanguageCode: 'hi', + }, + }, + error: new Error('An error occurred'), + }, +]; + +const link = new StaticMockLink(MOCKS, true); +describe('Testing Change Language Dropdown', () => { + test('Component Should be rendered properly', async () => { + const { getByTestId } = render( + + + + + + + , + ); + + expect(getByTestId('language-dropdown-container')).toBeInTheDocument(); + expect(getByTestId('language-dropdown-btn')).toBeInTheDocument(); + expect(getByTestId('dropdown-btn-0')).toBeInTheDocument(); + + getByTestId('language-dropdown-container').className.includes(''); + getByTestId('language-dropdown-btn').className.includes(''); + getByTestId('dropdown-btn-0').className.includes(''); + + userEvent.click(getByTestId('dropdown-btn-0')); + await wait(); + + languages.map((language) => { + expect( + getByTestId(`change-language-btn-${language.code}`), + ).toBeInTheDocument(); + }); + }); + + test('Component Should accept props properly', async () => { + const props = { + parentContainerStyle: 'parentContainerStyle', + btnStyle: 'btnStyle', + btnTextStyle: 'btnTextStyle', + }; + const { getByTestId } = render( + + + + + + + , + ); + getByTestId('language-dropdown-container').className.includes( + props.parentContainerStyle, + ); + getByTestId('language-dropdown-btn').className.includes(props.btnStyle); + getByTestId('dropdown-btn-0').className.includes(props.btnTextStyle); + }); + + test('Testing when language cookie is not set', async () => { + Object.defineProperty(window.document, 'cookie', { + writable: true, + value: 'i18next=', + }); + + render( + + + + + , + ); + + await wait(); + expect(cookies.get('i18next')).toBe(''); + }); + + test('Testing change language functionality', async () => { + Object.defineProperty(window.document, 'cookie', { + writable: true, + value: 'i18next=sp', + }); + setItem('userId', '1'); + + const { getByTestId } = render( + + + + + , + ); + + userEvent.click(getByTestId('language-dropdown-btn')); + await wait(); + const changeLanguageBtn = getByTestId(`change-language-btn-fr`); + await wait(); + expect(changeLanguageBtn).toBeInTheDocument(); + await wait(); + userEvent.click(changeLanguageBtn); + await wait(); + expect(cookies.get('i18next')).toBe('fr'); + await wait(); + userEvent.click(getByTestId('language-dropdown-btn')); + await wait(); + const changeLanguageBtnHi = getByTestId(`change-language-btn-hi`); + await wait(); + expect(changeLanguageBtnHi).toBeInTheDocument(); + await wait(); + userEvent.click(changeLanguageBtnHi); + await wait(); + }); +}); diff --git a/src/components/CheckIn/CheckInModal.module.css b/src/components/CheckIn/CheckInModal.module.css new file mode 100644 index 0000000000..0f78d81c01 --- /dev/null +++ b/src/components/CheckIn/CheckInModal.module.css @@ -0,0 +1,43 @@ +.loader, +.loader:after { + border-radius: 50%; + width: 10em; + height: 10em; +} +.loader { + margin: 60px auto; + margin-top: 35vh !important; + font-size: 10px; + position: relative; + text-indent: -9999em; + border-top: 1.1em solid rgba(255, 255, 255, 0.2); + border-right: 1.1em solid rgba(255, 255, 255, 0.2); + border-bottom: 1.1em solid rgba(255, 255, 255, 0.2); + border-left: 1.1em solid #febc59; + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); + -webkit-animation: load8 1.1s infinite linear; + animation: load8 1.1s infinite linear; +} + +@-webkit-keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} diff --git a/src/components/CheckIn/CheckInModal.test.tsx b/src/components/CheckIn/CheckInModal.test.tsx new file mode 100644 index 0000000000..1660c7c4bb --- /dev/null +++ b/src/components/CheckIn/CheckInModal.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { CheckInModal } from './CheckInModal'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; +import { ToastContainer } from 'react-toastify'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { checkInQueryMock } from './mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +const link = new StaticMockLink(checkInQueryMock, true); + +describe('Testing Check In Attendees Modal', () => { + const props = { + show: true, + eventId: 'event123', + handleClose: jest.fn(), + }; + + test('The modal should be rendered, and all the fetched users should be shown properly and user filtering should work', async () => { + const { queryByText, queryByLabelText } = render( + + + + + + + + + + + + , + ); + + await waitFor(() => + expect(queryByText('Event Check In Management')).toBeInTheDocument(), + ); + + await waitFor(() => expect(queryByText('John Doe')).toBeInTheDocument()); + await waitFor(() => expect(queryByText('John2 Doe2')).toBeInTheDocument()); + + // Tetst filtering of users + fireEvent.change(queryByLabelText('Search Attendees') as Element, { + target: { value: 'John Doe' }, + }); + + await waitFor(() => expect(queryByText('John Doe')).toBeInTheDocument()); + await waitFor(() => + expect(queryByText('John2 Doe2')).not.toBeInTheDocument(), + ); + }); +}); diff --git a/src/components/CheckIn/CheckInModal.tsx b/src/components/CheckIn/CheckInModal.tsx new file mode 100644 index 0000000000..8c75bab598 --- /dev/null +++ b/src/components/CheckIn/CheckInModal.tsx @@ -0,0 +1,147 @@ +import React, { useState, useEffect } from 'react'; +import { Modal } from 'react-bootstrap'; +import { useQuery } from '@apollo/client'; +import { EVENT_CHECKINS } from 'GraphQl/Queries/Queries'; +import styles from 'components/CheckIn/CheckInModal.module.css'; +import { TableRow } from './TableRow'; +import type { + InterfaceAttendeeCheckIn, + InterfaceModalProp, + InterfaceTableData, +} from './types'; +import type { GridColDef, GridRowHeightReturnValue } from '@mui/x-data-grid'; +import { DataGrid } from '@mui/x-data-grid'; +import TextField from '@mui/material/TextField'; + +/** + * Modal component for managing event check-ins. Displays a list of attendees + * and their check-in statuses, allowing for filtering by user name. + * + * @param show - Boolean indicating whether the modal is visible. + * @param eventId - ID of the event whose check-ins are to be displayed. + * @param handleClose - Function to call when the modal is closed. + * + * @returns JSX.Element - The rendered modal component. + */ +export const CheckInModal = ({ + show, + eventId, + handleClose, +}: InterfaceModalProp): JSX.Element => { + // State to hold the data for the table + const [tableData, setTableData] = useState([]); + + // State for search filter input + const [userFilterQuery, setUserFilterQuery] = useState(''); + + // State for filter model used in DataGrid + const [filterQueryModel, setFilterQueryModel] = useState({ + items: [{ field: 'userName', operator: 'contains', value: '' }], + }); + + // Query to get check-in data from the server + const { + data: checkInData, + loading: checkInLoading, + refetch: checkInRefetch, + } = useQuery(EVENT_CHECKINS, { + variables: { id: eventId }, + }); + + // Effect runs whenever checkInData, eventId, or checkInLoading changes + useEffect(() => { + checkInRefetch(); // Refetch data when component mounts or updates + if (checkInLoading) { + setTableData([]); // Clear table data while loading + } else { + // Map the check-in data to table rows + setTableData( + checkInData.event.attendeesCheckInStatus.map( + (checkIn: InterfaceAttendeeCheckIn) => ({ + userName: `${checkIn.user.firstName} ${checkIn.user.lastName}`, + id: checkIn._id, + checkInData: { + id: checkIn._id, + name: `${checkIn.user.firstName} ${checkIn.user.lastName}`, + userId: checkIn.user._id, + checkIn: checkIn.checkIn, + eventId, + }, + }), + ), + ); + } + }, [checkInData, eventId, checkInLoading]); + + // Define columns for the DataGrid + const columns: GridColDef[] = [ + { field: 'userName', headerName: 'User', width: 300 }, // Column for user names + { + field: 'checkInData', + headerName: 'Check In Status', + width: 400, + renderCell: (props) => ( + // Render a custom row component for check-in status + + ), + }, + ]; + + // Show a loading indicator while data is loading + if (checkInLoading) { + return ( + <> +
    + + ); + } + + return ( + <> + + + + Event Check In Management + + + +
    + { + setUserFilterQuery(e.target.value); + setFilterQueryModel({ + items: [ + { + field: 'userName', + operator: 'contains', + value: e.target.value, + }, + ], + }); + }} + fullWidth + /> +
    +
    + 'auto'} + columns={columns} + filterModel={filterQueryModel} + /> +
    +
    +
    + + ); +}; diff --git a/src/components/CheckIn/CheckInWrapper.module.css b/src/components/CheckIn/CheckInWrapper.module.css new file mode 100644 index 0000000000..f5f42546c3 --- /dev/null +++ b/src/components/CheckIn/CheckInWrapper.module.css @@ -0,0 +1,13 @@ +button .iconWrapper { + width: 32px; + padding-right: 4px; + margin-right: 4px; + transform: translateY(4px); +} + +button .iconWrapperSm { + width: 32px; + display: flex; + justify-content: center; + align-items: center; +} diff --git a/src/components/CheckIn/CheckInWrapper.test.tsx b/src/components/CheckIn/CheckInWrapper.test.tsx new file mode 100644 index 0000000000..81f53d0043 --- /dev/null +++ b/src/components/CheckIn/CheckInWrapper.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { CheckInWrapper } from './CheckInWrapper'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; +import { ToastContainer } from 'react-toastify'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { checkInQueryMock } from './mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +const link = new StaticMockLink(checkInQueryMock, true); + +describe('Testing CheckIn Wrapper', () => { + const props = { + eventId: 'event123', + }; + + test('The button to open and close the modal should work properly', async () => { + render( + + + + + + + + + + + + , + ); + + // Open the modal + fireEvent.click(screen.getByLabelText('checkInRegistrants') as Element); + + await waitFor(() => + expect(screen.queryByTestId('modal-title')).toBeInTheDocument(), + ); + + // Close the modal + const closebtn = screen.getByLabelText('Close'); + + fireEvent.click(closebtn as Element); + + await waitFor(() => + expect(screen.queryByTestId('modal-title')).not.toBeInTheDocument(), + ); + }); +}); diff --git a/src/components/CheckIn/CheckInWrapper.tsx b/src/components/CheckIn/CheckInWrapper.tsx new file mode 100644 index 0000000000..859ae1f869 --- /dev/null +++ b/src/components/CheckIn/CheckInWrapper.tsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react'; +import { CheckInModal } from './CheckInModal'; +import { Button } from 'react-bootstrap'; +import IconComponent from 'components/IconComponent/IconComponent'; +import styles from './CheckInWrapper.module.css'; + +type PropType = { + eventId: string; +}; + +/** + * Wrapper component that displays a button to open the CheckInModal. + * + * @param eventId - The ID of the event for which check-in management is being handled. + * + * @returns JSX.Element - The rendered CheckInWrapper component. + */ +export const CheckInWrapper = ({ eventId }: PropType): JSX.Element => { + const [showModal, setShowModal] = useState(false); + + return ( + <> + + {showModal && ( + setShowModal(false)} + eventId={eventId} + /> + )} + + ); +}; diff --git a/src/components/CheckIn/TableRow.test.tsx b/src/components/CheckIn/TableRow.test.tsx new file mode 100644 index 0000000000..1a08f40a3d --- /dev/null +++ b/src/components/CheckIn/TableRow.test.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { TableRow } from './TableRow'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; +import { ToastContainer } from 'react-toastify'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { MockedProvider } from '@apollo/react-testing'; +import { checkInMutationSuccess, checkInMutationUnsuccess } from './mocks'; + +describe('Testing Table Row for CheckIn Table', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('If the user is not checked in, button to check in should be displayed, and the user should be able to check in successfully', async () => { + const props = { + data: { + id: `123`, + name: `John Doe`, + userId: `user123`, + checkIn: null, + eventId: `event123`, + }, + refetch: jest.fn(), + }; + + const { findByText } = render( + + + + + + + + + + + + , + ); + + expect(await findByText('Check In')).toBeInTheDocument(); + + fireEvent.click(await findByText('Check In')); + + expect(await findByText('Checked in successfully')).toBeInTheDocument(); + }); + + test('If the user is checked in, the option to download tag should be shown', async () => { + const props = { + data: { + id: '123', + name: 'John Doe', + userId: 'user123', + checkIn: { + _id: '123', + time: '12:00:00', + }, + eventId: 'event123', + }, + refetch: jest.fn(), + }; + + const { findByText } = render( + + + + + + + + + + + + , + ); + + global.URL.createObjectURL = jest.fn(() => 'mockURL'); + global.window.open = jest.fn(); + + expect(await findByText('Checked In')).toBeInTheDocument(); + expect(await findByText('Download Tag')).toBeInTheDocument(); + + fireEvent.click(await findByText('Download Tag')); + + expect(await findByText('Generating pdf...')).toBeInTheDocument(); + expect(await findByText('PDF generated successfully!')).toBeInTheDocument(); + + // Cleanup mocks + jest.clearAllMocks(); + }); + + test('Upon failing of check in mutation, the appropriate error message should be shown', async () => { + const props = { + data: { + id: `123`, + name: `John Doe`, + userId: `user123`, + checkIn: null, + eventId: `event123`, + }, + refetch: jest.fn(), + }; + + const { findByText } = render( + + + + + + + + + + + + , + ); + + expect(await findByText('Check In')).toBeInTheDocument(); + + fireEvent.click(await findByText('Check In')); + + expect(await findByText('Error checking in')).toBeInTheDocument(); + expect(await findByText('Oops')).toBeInTheDocument(); + }); + + test('If PDF generation fails, the error message should be shown', async () => { + const props = { + data: { + id: `123`, + name: '', + userId: `user123`, + checkIn: { + _id: '123', + time: '12:00:00', + }, + eventId: `event123`, + }, + refetch: jest.fn(), + }; + + const { findByText } = render( + + + + + + + + + + + + , + ); + + // Mocking the PDF generation function to throw an error + global.URL.createObjectURL = jest.fn(() => 'mockURL'); + global.window.open = jest.fn(); + + fireEvent.click(await findByText('Download Tag')); + + expect( + await findByText('Error generating pdf: Invalid or empty name provided'), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/CheckIn/TableRow.tsx b/src/components/CheckIn/TableRow.tsx new file mode 100644 index 0000000000..b2bd6e11cb --- /dev/null +++ b/src/components/CheckIn/TableRow.tsx @@ -0,0 +1,113 @@ +import React from 'react'; +import type { InterfaceTableCheckIn } from './types'; +import Button from '@mui/material/Button'; +import { useMutation } from '@apollo/client'; +import { MARK_CHECKIN } from 'GraphQl/Mutations/mutations'; +import { toast } from 'react-toastify'; +import { generate } from '@pdfme/generator'; +import { tagTemplate } from './tagTemplate'; +import { useTranslation } from 'react-i18next'; +/** + * Component that represents a single row in the check-in table. + * Allows users to mark themselves as checked in and download a tag if they are already checked in. + * + * @param data - The data for the current row, including user and event information. + * @param refetch - Function to refetch the check-in data after marking a check-in. + * + * @returns JSX.Element - The rendered TableRow component. + */ +export const TableRow = ({ + data, + refetch, +}: { + data: InterfaceTableCheckIn; + refetch: () => void; +}): JSX.Element => { + const [checkInMutation] = useMutation(MARK_CHECKIN); + const { t } = useTranslation('translation', { keyPrefix: 'checkIn' }); + + /** + * Marks the user as checked in for the event. + * Displays success or error messages based on the result of the mutation. + */ + const markCheckIn = (): void => { + // as we do not want to clutter the UI currently with the same (only provide the most basic of operations) + checkInMutation({ + variables: { + userId: data.userId, + eventId: data.eventId, + }, + }) + .then(() => { + toast.success(t('checkedInSuccessfully') as string); + refetch(); + }) + .catch((err) => { + toast.error(t('errorCheckingIn') as string); + toast.error(err.message); + }); + }; + /** + * Triggers a notification while generating and downloading a PDF tag. + * + * @returns A promise that resolves when the PDF is generated and opened. + */ + const notify = (): Promise => + toast.promise(generateTag, { + pending: 'Generating pdf...', + success: 'PDF generated successfully!', + error: 'Error generating pdf!', + }); + + /** + * Generates a PDF tag based on the provided data and opens it in a new tab. + * + * @returns A promise that resolves when the PDF is successfully generated and opened. + */ + const generateTag = async (): Promise => { + try { + const inputs = []; + if (typeof data.name !== 'string' || !data.name.trim()) { + throw new Error('Invalid or empty name provided'); + } + inputs.push({ name: data.name.trim() }); + const pdf = await generate({ template: tagTemplate, inputs }); + // istanbul ignore next + const blob = new Blob([pdf.buffer], { type: 'application/pdf' }); + // istanbul ignore next + const url = URL.createObjectURL(blob); + // istanbul ignore next + window.open(url); + // istanbul ignore next + toast.success('PDF generated successfully!'); + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Error generating pdf: ${errorMessage}`); + } + }; + + return ( + <> + {data.checkIn !== null ? ( +
    + + +
    + ) : ( + + )} + + ); +}; diff --git a/src/components/CheckIn/mocks.ts b/src/components/CheckIn/mocks.ts new file mode 100644 index 0000000000..a4e78aa2fc --- /dev/null +++ b/src/components/CheckIn/mocks.ts @@ -0,0 +1,76 @@ +import { EVENT_CHECKINS } from 'GraphQl/Queries/Queries'; +import { MARK_CHECKIN } from 'GraphQl/Mutations/mutations'; +import type { InterfaceAttendeeQueryResponse } from './types'; + +const checkInQueryData: InterfaceAttendeeQueryResponse = { + event: { + _id: 'event123', + attendeesCheckInStatus: [ + { + _id: 'eventAttendee1', + user: { + _id: 'user1', + firstName: 'John', + lastName: 'Doe', + }, + checkIn: null, + }, + { + _id: 'eventAttendee2', + user: { + _id: 'user2', + firstName: 'John2', + lastName: 'Doe2', + }, + checkIn: { + _id: 'checkin1', + time: '08:00:00', + }, + }, + ], + }, +}; + +export const checkInQueryMock = [ + { + request: { + query: EVENT_CHECKINS, + variables: { id: 'event123' }, + }, + result: { + data: checkInQueryData, + }, + }, +]; + +export const checkInMutationSuccess = [ + { + request: { + query: MARK_CHECKIN, + variables: { + userId: 'user123', + eventId: 'event123', + }, + }, + result: { + data: { + checkIn: { + _id: '123', + }, + }, + }, + }, +]; + +export const checkInMutationUnsuccess = [ + { + request: { + query: MARK_CHECKIN, + variables: { + userId: 'user123', + eventId: 'event123', + }, + }, + error: new Error('Oops'), + }, +]; diff --git a/src/components/CheckIn/tagTemplate.ts b/src/components/CheckIn/tagTemplate.ts new file mode 100644 index 0000000000..acd35fca0d --- /dev/null +++ b/src/components/CheckIn/tagTemplate.ts @@ -0,0 +1,22 @@ +import { Template } from '@pdfme/common'; + +export const tagTemplate: Template = { + schemas: [ + { + name: { + type: 'text', + position: { x: 14.91, y: 27.03 }, + width: 58.55, + height: 5.67, + alignment: 'center', + fontSize: 16, + characterSpacing: 0, + lineHeight: 1, + fontName: 'Roboto', + fontColor: '#08780b', + }, + }, + ], + basePdf: + 'data:application/pdf;base64,JVBERi0xLjQKJfbk/N8KMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZwovVmVyc2lvbiAvMS40Ci9QYWdlcyAyIDAgUgovU3RydWN0VHJlZVJvb3QgMyAwIFIKL01hcmtJbmZvIDQgMCBSCi9MYW5nIChlbikKL1ZpZXdlclByZWZlcmVuY2VzIDUgMCBSCj4+CmVuZG9iago2IDAgb2JqCjw8Ci9DcmVhdG9yIChDYW52YSkKL1Byb2R1Y2VyIChDYW52YSkKL0NyZWF0aW9uRGF0ZSAoRDoyMDIzMDYyMDA3MjgxMyswMCcwMCcpCi9Nb2REYXRlIChEOjIwMjMwNjIwMDcyODEzKzAwJzAwJykKL0tleXdvcmRzIChEQUZjMjhYSXViTSxCQUUycS01WEdhaykKL0F1dGhvciAoRXNoYWFuIEFnZ2Fyd2FsKQovVGl0bGUgKEJsYW5rIE5hbWUgVGFnIGluIEVtZXJhbGQgTWludCBHcmVlbiBBc3BpcmF0aW9uYWwgRWxlZ2FuY2UgU3R5bGUpCj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9QYWdlcwovS2lkcyBbNyAwIFJdCi9Db3VudCAxCj4+CmVuZG9iagozIDAgb2JqCjw8Ci9UeXBlIC9TdHJ1Y3RUcmVlUm9vdAovUGFyZW50VHJlZSA4IDAgUgovUGFyZW50VHJlZU5leHRLZXkgMQovSyBbOSAwIFJdCi9JRFRyZWUgMTAgMCBSCj4+CmVuZG9iago0IDAgb2JqCjw8Ci9NYXJrZWQgdHJ1ZQovU3VzcGVjdHMgZmFsc2UKPj4KZW5kb2JqCjUgMCBvYmoKPDwKL0Rpc3BsYXlEb2NUaXRsZSB0cnVlCj4+CmVuZG9iago3IDAgb2JqCjw8Ci9UeXBlIC9QYWdlCi9SZXNvdXJjZXMgMTEgMCBSCi9NZWRpYUJveCBbMC4wIDcuOTIwMDAyNSAyNTIuMCAxNTEuOTJdCi9Db250ZW50cyAxMiAwIFIKL1N0cnVjdFBhcmVudHMgMAovUGFyZW50IDIgMCBSCi9UYWJzIC9TCi9CbGVlZEJveCBbMC4wIDcuOTIwMDAyNSAyNTIuMCAxNTEuOTJdCi9UcmltQm94IFswLjAgNy45MjAwMDI1IDI1Mi4wIDE1MS45Ml0KL0Nyb3BCb3ggWzAuMCA3LjkyMDAwMjUgMjUyLjAgMTUxLjkyXQovUm90YXRlIDAKL0Fubm90cyBbXQo+PgplbmRvYmoKOCAwIG9iago8PAovTGltaXRzIFswIDBdCi9OdW1zIFswIFsxMyAwIFIgMTQgMCBSIDE1IDAgUiAxNiAwIFIgMTcgMCBSIDE4IDAgUiAxOSAwIFJdCl0KPj4KZW5kb2JqCjkgMCBvYmoKPDwKL1R5cGUgL1N0cnVjdEVsZW0KL1MgL0RvY3VtZW50Ci9MYW5nIChlbikKL1AgMyAwIFIKL0sgWzIwIDAgUl0KL0lEIChub2RlMDAwMDE3MzgpCj4+CmVuZG9iagoxMCAwIG9iago8PAovTmFtZXMgWyhub2RlMDAwMDE3MzgpIDkgMCBSIChub2RlMDAwMDE3MzkpIDEzIDAgUiAobm9kZTAwMDAxNzQwKSAyMCAwIFIgKG5vZGUwMDAwMTc0MSkgMjEgMCBSIChub2RlMDAwMDE3NDIpIDIyIDAgUgoobm9kZTAwMDAxNzQzKSAyMyAwIFIgKG5vZGUwMDAwMTc0NCkgMjQgMCBSIChub2RlMDAwMDE3NDUpIDI1IDAgUiAobm9kZTAwMDAxNzQ2KSAyNiAwIFIgKG5vZGUwMDAwMTc0NykgMjcgMCBSCihub2RlMDAwMDE3NjEpIDI4IDAgUiAobm9kZTAwMDAxNzYyKSAyOSAwIFIgKG5vZGUwMDAwMTc2MykgMzAgMCBSIChub2RlMDAwMDE3NjQpIDMxIDAgUiAobm9kZTAwMDAxNzY1KSAzMiAwIFIKKG5vZGUwMDAwMTc2NikgMzMgMCBSIChub2RlMDAwMDE3NjcpIDE0IDAgUiAobm9kZTAwMDAxNzY4KSAzNCAwIFIgKG5vZGUwMDAwMTc2OSkgMzUgMCBSIChub2RlMDAwMDE3NzApIDM2IDAgUgoobm9kZTAwMDAxNzcxKSAxNSAwIFIgKG5vZGUwMDAwMTc3MikgMzcgMCBSIChub2RlMDAwMDE3NzMpIDM4IDAgUiAobm9kZTAwMDAxNzc0KSAzOSAwIFIgKG5vZGUwMDAwMTc3NSkgMTYgMCBSCihub2RlMDAwMDE3NzYpIDE3IDAgUiAobm9kZTAwMDAxNzc3KSA0MCAwIFIgKG5vZGUwMDAwMTc3OCkgMTggMCBSIChub2RlMDAwMDE3NzkpIDQxIDAgUiAobm9kZTAwMDAxNzgwKSA0MiAwIFIKKG5vZGUwMDAwMTc4MSkgNDMgMCBSIChub2RlMDAwMDE3ODIpIDQ0IDAgUiAobm9kZTAwMDAxNzgzKSAxOSAwIFJdCj4+CmVuZG9iagoxMSAwIG9iago8PAovUHJvY1NldCBbL1BERiAvVGV4dCAvSW1hZ2VCIC9JbWFnZUMgL0ltYWdlSV0KL0V4dEdTdGF0ZSA0NSAwIFIKL1hPYmplY3QgPDwKL1g1IDQ2IDAgUgo+PgovRm9udCA0NyAwIFIKPj4KZW5kb2JqCjEyIDAgb2JqCjw8Ci9MZW5ndGggOTc4Ci9GaWx0ZXIgL0ZsYXRlRGVjb2RlCj4+CnN0cmVhbQ0KeJytVk2PGzcMvc+v0LlAtCQl6gNYLODYmyCHAE1roL27SYDC2yDJ/wfyJM2MNFnvJpuuDdsyKZGPj0/SWHG5vgzh/cIOf1nZZsY4mdPd9HkqE5iUTHBsRc2X99Nfv5n/4HGW8b/4+whL2JT3H69NG3z5OF29dubj1xrJuWCEpET5ML3De0xA9AzROf+P6JY4B2N9/ZYMoMh03/iErJ005L0H5gEbO7HBc0AqCWw5M5uYyboCVbK2wTI5kI0hbCbfTSk7m1KKrhvPU6ZsvXDI3ZhFUJ8r86KzMTlKoy1l5ApsTlM3qkRMVEklYrd6h6nJmZ5EVSElwsSOpttOI/JuPk/sFYJ0muNgHihZM422FdNpw96C/7yxrpUOqVZGLvF5qkx/nsSmRS3z8FKripBS0YnNkj2+CZupCOmecSskSVyk8JPyjQTcg4RZyYpn55zxDmx4CqFpmlyGcMWpsaySm6a/N1YovkGxCgXhTLDJowPl73n683mkHXywpI68AfUM3OyxgbwNKaKEPIYM0VuOaTP1bsoo04vqaIUOA4SiScNgzTFZVcJMBR3klcJggwbEReWimG4VipbR2FBiditUHtnD2vOAOesoFNuKqNtOA/puPU9BE+SiRS2rtVPS8wy2FdFpIK+jPw/WXmfP0/m4xOfDwr7UqF8VNgNnSKOug2JjlkXYbFReUAGF0nwGw2F7zzRHvamkjqksXCIItn7KSSq8OenV7+b6+urt/s0Bi25uXh7209Xfag6fptu3+6fdDJtzA4LwPuhTt1XF9PI4wuIF1quInWtzyvXKPX7ADVcrLUcB2UDZlY0h5ng3XYOm/Y05/jsxFJdy9KFMPv5jrnEGH6oHLU7OMyQwO8SHxaGCDSarI3J1iM05Ek6TxUHK84oEghmofrjitiWP6AhWxNXB2hyF9dtjpf0ev9BZUhSD01jYeoer1P2S0rYEy0gwLgxpDzgjwUWYM6+H2HgVHKEx8VoylFsdCoyRUpBema8OZ5VY3Eo3025eoLh4ci21OUJzBBu4PF7p9xyhpxEezT9OvvT0EVodo33kcQV4bDEBxPgMqnUjqbIh03tLsV2JNJMKMnjn24cTCn2FX27jubS5gm0W/3CWFMuzRb0r5iwQ5SrMi9H04WiitkGWdX9p6WwRdIVaglf4YdfGeljgV3u17XfVz7v9pcawxbNJTOKkZh3+as4Ww1ILqsJRS/roE+62rPBIK5Kdj42lE3zbIBb45QOotRvu0Mrb9/Jq6fDVXzd3aym7rFnKTocWcz93N9NMzUwb5RJ3S8e76RuIroTkDQplbmRzdHJlYW0KZW5kb2JqCjEzIDAgb2JqCjw8Ci9UeXBlIC9TdHJ1Y3RFbGVtCi9TIC9GaWd1cmUKL1AgMzAgMCBSCi9LIFs0OCAwIFJdCi9JRCAobm9kZTAwMDAxNzM5KQo+PgplbmRvYmoKMTQgMCBvYmoKPDwKL1R5cGUgL1N0cnVjdEVsZW0KL1MgL05vblN0cnVjdAovUCAzMyAwIFIKL0sgWzQ5IDAgUl0KL0lEIChub2RlMDAwMDE3NjcpCj4+CmVuZG9iagoxNSAwIG9iago8PAovVHlwZSAvU3RydWN0RWxlbQovUyAvTm9uU3RydWN0Ci9QIDM2IDAgUgovSyBbNTAgMCBSXQovSUQgKG5vZGUwMDAwMTc3MSkKPj4KZW5kb2JqCjE2IDAgb2JqCjw8Ci9UeXBlIC9TdHJ1Y3RFbGVtCi9TIC9Ob25TdHJ1Y3QKL1AgMzkgMCBSCi9LIFs1MSAwIFJdCi9JRCAobm9kZTAwMDAxNzc1KQo+PgplbmRvYmoKMTcgMCBvYmoKPDwKL1R5cGUgL1N0cnVjdEVsZW0KL1MgL05vblN0cnVjdAovUCAzOSAwIFIKL0sgWzUyIDAgUl0KL0lEIChub2RlMDAwMDE3NzYpCj4+CmVuZG9iagoxOCAwIG9iago8PAovVHlwZSAvU3RydWN0RWxlbQovUyAvTm9uU3RydWN0Ci9QIDQwIDAgUgovSyBbNTMgMCBSXQovSUQgKG5vZGUwMDAwMTc3OCkKPj4KZW5kb2JqCjE5IDAgb2JqCjw8Ci9UeXBlIC9TdHJ1Y3RFbGVtCi9TIC9Ob25TdHJ1Y3QKL1AgNDQgMCBSCi9LIFs1NCAwIFJdCi9JRCAobm9kZTAwMDAxNzgzKQo+PgplbmRvYmoKMjAgMCBvYmoKPDwKL1R5cGUgL1N0cnVjdEVsZW0KL1MgL0RpdgovUCA5IDAgUgovSyBbMjEgMCBSXQovSUQgKG5vZGUwMDAwMTc0MCkKPj4KZW5kb2JqCjIxIDAgb2JqCjw8Ci9UeXBlIC9TdHJ1Y3RFbGVtCi9TIC9EaXYKL1AgMjAgMCBSCi9LIFsyMiAwIFJdCi9JRCAobm9kZTAwMDAxNzQxKQo+PgplbmRvYmoKMjIgMCBvYmoKPDwKL1R5cGUgL1N0cnVjdEVsZW0KL1MgL0RpdgovUCAyMSAwIFIKL0sgWzIzIDAgUl0KL0lEIChub2RlMDAwMDE3NDIpCj4+CmVuZG9iagoyMyAwIG9iago8PAovVHlwZSAvU3RydWN0RWxlbQovUyAvRGl2Ci9QIDIyIDAgUgovSyBbMjQgMCBSXQovSUQgKG5vZGUwMDAwMTc0MykKPj4KZW5kb2JqCjI0IDAgb2JqCjw8Ci9UeXBlIC9TdHJ1Y3RFbGVtCi9TIC9EaXYKL1AgMjMgMCBSCi9LIFsyNSAwIFJdCi9JRCAobm9kZTAwMDAxNzQ0KQo+PgplbmRvYmoKMjUgMCBvYmoKPDwKL1R5cGUgL1N0cnVjdEVsZW0KL1MgL0RpdgovUCAyNCAwIFIKL0sgWzI2IDAgUl0KL0lEIChub2RlMDAwMDE3NDUpCj4+CmVuZG9iagoyNiAwIG9iago8PAovVHlwZSAvU3RydWN0RWxlbQovUyAvRGl2Ci9QIDI1IDAgUgovSyBbMjcgMCBSXQovSUQgKG5vZGUwMDAwMTc0NikKPj4KZW5kb2JqCjI3IDAgb2JqCjw8Ci9UeXBlIC9TdHJ1Y3RFbGVtCi9TIC9EaXYKL1AgMjYgMCBSCi9LIFsyOCAwIFIgMzEgMCBSIDM0IDAgUiAzNyAwIFIgNDEgMCBSXQovSUQgKG5vZGUwMDAwMTc0NykKPj4KZW5kb2JqCjI4IDAgb2JqCjw8Ci9UeXBlIC9TdHJ1Y3RFbGVtCi9TIC9EaXYKL1AgMjcgMCBSCi9LIFsyOSAwIFJdCi9JRCAobm9kZTAwMDAxNzYxKQo+PgplbmRvYmoKMjkgMCBvYmoKPDwKL1R5cGUgL1N0cnVjdEVsZW0KL1MgL0RpdgovUCAyOCAwIFIKL0sgWzMwIDAgUl0KL0lEIChub2RlMDAwMDE3NjIpCj4+CmVuZG9iagozMCAwIG9iago8PAovVHlwZSAvU3RydWN0RWxlbQovUyAvRGl2Ci9QIDI5IDAgUgovSyBbMTMgMCBSXQovSUQgKG5vZGUwMDAwMTc2MykKPj4KZW5kb2JqCjMxIDAgb2JqCjw8Ci9UeXBlIC9TdHJ1Y3RFbGVtCi9TIC9EaXYKL1AgMjcgMCBSCi9LIFszMiAwIFJdCi9JRCAobm9kZTAwMDAxNzY0KQo+PgplbmRvYmoKMzIgMCBvYmoKPDwKL1R5cGUgL1N0cnVjdEVsZW0KL1MgL0RpdgovUCAzMSAwIFIKL0sgWzMzIDAgUl0KL0lEIChub2RlMDAwMDE3NjUpCj4+CmVuZG9iagozMyAwIG9iago8PAovVHlwZSAvU3RydWN0RWxlbQovUyAvUAovUCAzMiAwIFIKL0sgWzE0IDAgUl0KL0lEIChub2RlMDAwMDE3NjYpCj4+CmVuZG9iagozNCAwIG9iago8PAovVHlwZSAvU3RydWN0RWxlbQovUyAvRGl2Ci9QIDI3IDAgUgovSyBbMzUgMCBSXQovSUQgKG5vZGUwMDAwMTc2OCkKPj4KZW5kb2JqCjM1IDAgb2JqCjw8Ci9UeXBlIC9TdHJ1Y3RFbGVtCi9TIC9EaXYKL1AgMzQgMCBSCi9LIFszNiAwIFJdCi9JRCAobm9kZTAwMDAxNzY5KQo+PgplbmRvYmoKMzYgMCBvYmoKPDwKL1R5cGUgL1N0cnVjdEVsZW0KL1MgL1AKL1AgMzUgMCBSCi9LIFsxNSAwIFJdCi9JRCAobm9kZTAwMDAxNzcwKQo+PgplbmRvYmoKMzcgMCBvYmoKPDwKL1R5cGUgL1N0cnVjdEVsZW0KL1MgL0RpdgovUCAyNyAwIFIKL0sgWzM4IDAgUl0KL0lEIChub2RlMDAwMDE3NzIpCj4+CmVuZG9iagozOCAwIG9iago8PAovVHlwZSAvU3RydWN0RWxlbQovUyAvRGl2Ci9QIDM3IDAgUgovSyBbMzkgMCBSIDQwIDAgUl0KL0lEIChub2RlMDAwMDE3NzMpCj4+CmVuZG9iagozOSAwIG9iago8PAovVHlwZSAvU3RydWN0RWxlbQovUyAvUAovUCAzOCAwIFIKL0sgWzE2IDAgUiAxNyAwIFJdCi9JRCAobm9kZTAwMDAxNzc0KQo+PgplbmRvYmoKNDAgMCBvYmoKPDwKL1R5cGUgL1N0cnVjdEVsZW0KL1MgL1AKL1AgMzggMCBSCi9LIFsxOCAwIFJdCi9JRCAobm9kZTAwMDAxNzc3KQo+PgplbmRvYmoKNDEgMCBvYmoKPDwKL1R5cGUgL1N0cnVjdEVsZW0KL1MgL0RpdgovUCAyNyAwIFIKL0sgWzQyIDAgUl0KL0lEIChub2RlMDAwMDE3NzkpCj4+CmVuZG9iago0MiAwIG9iago8PAovVHlwZSAvU3RydWN0RWxlbQovUyAvRGl2Ci9QIDQxIDAgUgovSyBbNDMgMCBSXQovSUQgKG5vZGUwMDAwMTc4MCkKPj4KZW5kb2JqCjQzIDAgb2JqCjw8Ci9UeXBlIC9TdHJ1Y3RFbGVtCi9TIC9EaXYKL1AgNDIgMCBSCi9LIFs0NCAwIFJdCi9JRCAobm9kZTAwMDAxNzgxKQo+PgplbmRvYmoKNDQgMCBvYmoKPDwKL1R5cGUgL1N0cnVjdEVsZW0KL1MgL1AKL1AgNDMgMCBSCi9LIFsxOSAwIFJdCi9JRCAobm9kZTAwMDAxNzgyKQo+PgplbmRvYmoKNDUgMCBvYmoKPDwKL0czIDU1IDAgUgovRzQgNTYgMCBSCj4+CmVuZG9iago0NiAwIG9iago8PAovTGVuZ3RoIDMwMTg4Ci9UeXBlIC9YT2JqZWN0Ci9TdWJ0eXBlIC9JbWFnZQovV2lkdGggMzQ3Ci9IZWlnaHQgMjMzCi9Db2xvclNwYWNlIC9EZXZpY2VSR0IKL1NNYXNrIDU3IDAgUgovQml0c1BlckNvbXBvbmVudCA4Ci9GaWx0ZXIgL0ZsYXRlRGVjb2RlCj4+CnN0cmVhbQ0KeJzsfQl4FNeVbgmpN6GuajDYY1tIIpPJm8dbJhkyb5LJTIaZJCaYRRvCwTEOjmNi42DAZjGLpNbGamOMgVgJGGMMxo3QvqKltQsBXuKQxFlmPEPGkziLd0BSd6vfuefcul29SOqWhBa7zldff6VWdS237vnv2Y8k6aSTTjrppJNOOumkk0466aSTTjrppJNOOumkk0466aSTTjrppJNOOumkk0466aSTTjrppJNOOumkk0466aSTTp9Y8sI2T/KulrzFkrdL8l6SvM2St0ryOvCzGb+B79+UvG9JXu94365OOul0Ywi42/uO5H0RMcEueZ3I+7Q51E1848Tjf4ew8I6ODDrp9Ikixt20XWbM7noqpq/U5GoyulqNrgajq9roLjW6qozueqO7xeRymnpeiWEHX8Lj6VeO8X4GnXTSaZSI0MDzG6P7kqmvxfJeia2n2NpXJ/fUTu2ps/bWxPXWwKe1p8rqqlP6auT3q5XfOqTeCwb3a0bPRQMTHi7rcoJOOn1CqP/fTe7XpvbWTXO1Wl1Oub9N6b+g9F9S+i+qn2Lngq3/kq2/W3E1w2b1tFh7GhV3t4VByhUdFnTSaRITCQZuQIMm2dMoA5szfm9VPM2Kp0XxtA68tSj9zUp/u+xlEKG4Wmye8xYmJ8AJ3x7vp9JJJ52GRbSgey6Y+hoVb6sMUOB2Iho4ZU9zOJviaVI8nSA82NxVcn9nDLMqvDneT6WTTjoNi7ydzBjoaTN5Wm2w0LtBSAA2B5WhWRly8ziVfic7mO20yG62Y2Tehzd0rUEnnSYlkTOxv8rEVICLCkGBh/E7Y3MPlxbwM2BzqgcLQGiyepqM3B2pA4JOOk1C4oBQbfK2MlOAp0nmS3+YEoIfIMTpgKCTTpOaSGXwtpu87QgIPgkhHEDg6OFpZsYHTyPs6CqDTjpNYmKOQq/U/4aJWRQv+Hg8HEDoV82PbL9N9oDK0GqiwGaddNJpMhKJ9/1vmdwtSv/5yADBI+SEFuZ/dDWC7mCi3AeddNJpMhK5HfsvmNxNtv7OSFUG1fbYIgMgMJWhycQUEB0QdNJpchLjX7vUX2nyAhR0RGhUVCUETytICIqn0drfpEsIOuk0ickHCCj2e5rURT98QGhGQAAwAZWh3Ui5kDrppNNkJKEyuJqm9bfKPAIBDYaRAUKn0tcgM7ejLiHopNOkJWFUZF4Gpw8NIpUQvOcVb1Ocp9ZIdVR00kmnyUi8msEFs9dpY+EELb74wwgAoU3p71Lc9db+ciNFOumkk06TkRgg2BEQmqZzQAjbrugHCN2K+5zcr0sIOuk0mcknIbTI/hJCJF6GNsXbrXgb5P5G3Yagk06TmHjZtLdUlaE1cgkBAYFlStbH9bfqXgaddJrExCun/ZvJ23gTS3hsJc9jJIDQJHswD8JdbfXogKCTTpOZOCD8yvRRXRyTENowUwlDESKUEGyuBpnnMuiAoJNOk5Oo6JmnxOQttjIoaCcbghyB25EkhIuKq07xtOmAoNMkI2ZUPyR5a/VyoIy82diF4Uist3o6Uxk6IjcqIiB4LyneBmu/LiHoNKnIbpecdsQEKgd65dPeUMBL2w7F65jpBwgRSggMEKrlfqee/qzTZCIABNiuPGfoqzG5f2pkmEAdyuo/3bBQfou37hZWej2ShEc/G8JFpa9S8TSYWIGUtvF+Hp10Goq8KBj85ED0mw7LB0WyqyXuWvX0vs5Y9wUjb1VWi+3JPpWw4C23eStuo4DDYbodL9l6q6a56xAQnOP9PDrpNBQxu4Fdcr9kuFZtcXUrni7F3a30OpXrTpuryeJujiHdgZUU+/TZFrxnzaz2civWSGkapoTQW2F11eqAoNMNI+BK5zzpcoZUu17qvF9yZLBP2Idv4PsIedZ7AuuNlxi9Ttn7uq2/nenLzMvWzVjgWp2lp8ncWxfDbQvvsm4jnx5Y8J7CUqutsg8QIpUQLio9JYqrxqgDgk6jTMCGl+dIV+LZ5kW1H5Z2QAPavGgFoGMOrWYb/BnOWcvYRO2vNbGcvg7F3SwzaxgLxUFLWhdIC/Ifz8VeazP3UrcR76eoejCDwWNSf7uVAULjMCMVr5Va+ioMDBBqx/t5dPokERm+Ge9LMU1fNJ9bbC2+V675lnImTa5dbi1ZGVeTHOf4mqlhLjsG0GCeUxpKXGCsfQ4XQYfR7aTwG3UyEyw0M2nZ3YGwcDb2Wqe5902DryHyJx0WGCBUS5FWYg8AhKtnY3tLsOtr2Xg/j06fDEIoiP3tLUrrV5Tuv5Pr/kU6Nm/G0W/En1yUWJw66/TChNKM20+n3vbcHbfsveOW08nTX7x7ak6miiCDnpiaBaCs0e+09rdQ1j9Xlj3UfqgFZ3i34mpVepvl3xfHfdwd2/OWUQsLn1Rk8J5GTGiz+QAhIhsCGh8+LLa6ivX0Z51GiViIALMM3NL6pcSz82eVpyaWpc4qTU0oSU4sT02qTEusTEuqTE8sT0soSUkoTYH/JlakxjuSb87fZnl0L1coBiAqFMY2Z5IvEs8ZBAsUstuCybytcm+78k7D1Gvdlt6fmPp+GfMJhgXvYclbKHk7sHlT+BKCaNTSong7FW/9NE8lGmGOjPfz6PQJILQGxFUsSHrpm0nV6bMbliXVpCdWpAEaJJSlJpSmJpbSZ0oCg4I0+C87pm7prOLFclHaEIBAaOBlQXRuvgLK/aJDmTMAFpAp2lCJaFGutVqvVVh6z1tc/6YqEZ+scCb2RKfY+Hg6ZB8gNIdRIMXJG0HCiHnbZW+FKobpNgSdRk6184GjlfI7QQAAfmcgALIBsD+AQBmAgNgQE1BIgAOSapfCAfLJ5QwNjtw/0Lm1gOCb8MT7TtFwxCczwKe7Gc1rHYqnQ3G3KT0t1utV5r43zK7/MnzCwpk4ygEgtCkRAoKipkGxvOnLDvVUl8f7kXT6BFDnl0BfUM59fVYJcj1jfPwsSyHxQN3ovyn0L9AjZpWlysfuYxrHvvUDndsPEJwaCSFAMHaqqrFaQIwpEQALXQowi7tdBmmhp1rpbTe7umIonOkdp/RWt+SczI42zsUOf5VhKEDwa+yIlVU+wSqVTuNAAAggIZz7WlLxksSKVGYrQEMBSQgcHEBrYPsp6r9SEqvTAR+UiADBX0IIYSjDZoW+JmUUrg+/6lQ8rYrrPAtn+qDC1FNvcmE4E6DBK07pslNyTE5pQUgI3nZbBIDQrKgF29mRbvh81eh16ICg0yjRkSUMEF5ckliyGAEhlVsO6TNgI0AA5aImPZEBwjKmMuy7Z6Bz+wDBKZHZcMicPnUF1MACOuhZgnCr7OqUe5vkD2pN7zab3m1nsABoUPuS5DwVZljEBCIOCKzxawRGxQAJwQ079XrrZ51Gj/bNl+zzlKMLE4qSmS2xUgWEsoEBoTSF2RCYyrAMfjsYIPi8DPOwcqCs0X/DUpNFLWIGCy0Y4thO4UzW90tMvz9n+l0TUyIADS5PtsRqXom91ejtikRC0AJCKwCCVe8Fr9NoEgKCfGThrKL0xMqhAKFMBYS6pfGlqfILKxgrHlo90LnFRO1voN7lspB4hzSdaSe/sK6LcCYP6BGd8vUm69ullv8os/zHWZ/YPFlYg4/MFRPgW3+3iFSMEBBgQJpNei94nUaNVJUh4cwiBAQWbzCEyoCAwLwMZ1ewMgeOgQEB5vwvMZeh0+RuUnihMCd3MkaACaFggZ3hvOJpswIs/PHsjGutsb1v+EU5TnAGEYDg7bBpchnCBAQ8slVxN9n0XvA6jSZhIIFyJjUJcKAqLbEqdTBA8EkIGYmlqUpNGgtqcg4YhwDkbUADQqMJhFtmHiT3otMXkDB8WCD3ZTMur23Kdaf8h6qpPW/Euv7LNClggXdzu2TyACB0hRupqCpTiKjtICEong69XJJOo0cICPKZtFlnkhOq0gEThrQhwGfSuYxZpclKzXwEhHmDnJ4BwiUGCK4mW/8lHpfYL2Ah7LphPl4IgAVyRmBTM3er7bpz2tVzM3p+OrX3v9Tg505pYob18n6vNSZvq6ZAypDWFfHgLQQIMIx662edRo8IEF5Ove1McmJ1elLV4CpDKnoZUpPqMxLKlsi1YQACBhF56ox9LWg6a1fcFKvMZ7if5TASw4J6vBoR7W7EkmJdirtN/rh52rv1067/NLbvpzzInyVdVk4sWOApHsdNzO3Yyc2JQ0sIzbIPEDoUF+zU6W2bdBo9IkBwpCS9dGdidRpsLFJxAEBIUN2OSQ0ZCRVpyrlvMDRo+8ogp+f86JDcFyy9jYrnVYWp/60YctAk+9Y7WhnDExU0m8odKrO4McrR26H0tSu9rdPer1auV1t6qwxUlIl9dk+UugFcQqg2AYIxp6oKCINjgp+EAIDQZPVU6plNOo0ecQkhZVbRnUk16bBxCSG0ypCqAsKyhPJk5dwd0qW5gwOCRDMf9WXXr83Xm2RXl+w5zwyMzMZIoUfq0h+RYWFA80ITwkInO7+708YSq2usvfUWj9PAbsOpbuPNQdyGcBFVhtawHbJaQOhUXI2yu8KkA4JOo0ZcQki9HYQEAIRqFRBKgy2KfoDAjIphSAhEwr53/Wemt0psrH5al83TqVZPapQDzAJCNh4JLLD9DmZvdHUprma512l1tVtcXQZetO0lnls0XuTrCN9iFdrTkGYEX2BGC6vE2Ntk85wzUWlKncaZqNQYC9Vbz3zxAyf9TWhSbQjxjuSkmqUaQBhCQgARIhyjoiCOCZek668YP74w9Q/OmVebbH0XERa6MBAxABacIp45Qljwj3JkP29j52clF9qUDxusvU6Lq8XADHH28ewTQdf1dJtcTiEeDN2rxQcIVImxAVQwEztV5zg8gk5+RAVCqMI4sNXl1dxONLmIAOF0SuLphUl1SxNVQEgYREIoTUmqB0BYooRhVBTE0OBtrJqIsNBTavrorPzWsaRrTVbXBZu7g8MCRtqosgFxSiSeCA0s+BZcBgtUtK2TfJTW9+uUvhqzu0yt5XiZx1CNJVFHeAYIjYomXmsIrcFnVGzF1s+N1n4dEMadEAri3r51Wt18W3Ga8vx98okVlvP3ISDMC6OS0EQiBATrqZT4k4sAEJKqlw6qMqhGxfplCRWpSu03wwcEIg4Lbbxpy7UGyzvOWIdD+lN9nKtbBljwnkdPRKMaY9CsMTyGLTAIngoRztTCFlbMo7Rec1r6zptdDTE84WJs4xa4hHDe5G2y8o7wYaQzaFUG3gu+xURlq3UaN3r7VmB528Uvfq5iMYvuq0iLL02+pfVuufFu0+t3qF15wqkxNgHoCKurHHfsrpufW5hUm5FYszSheEAJIUGoDAAIZelK7YJIAYHI19YN83/fPhH9X2eM16tir7bG9jKrAqtC7O1A86CTxyD1DwcWfNVXNJoIQg1WZ+rvsPW2Wa+2x7razO5XY8Y4nIn3e/13U18DcLcWEAYznqjWElQZLiieevhTlxDGm978nHRprq39H+JLUma3fCupKj3p3FJWWKwsVTqwwFb9j9affJajAQjVYRcoHh/at55VTPrhvbeevJM9xaCA4AtdBkAoSZdrIpYQtMRl9cu8d0PfcUPvWXNPeez7lcrVVpbbCBMe/XHcARECFsIIXRAA4v9zLDfUirDQqbja5WudFtebJvfvVFioxbu6kaZ7upAbAMFpI40mLEBo1gDCRcXdYPXogDDu1PCvwAVy1YKEUqwdhCWGYCcRtsrUpMr0mYfm2dq/YLn0N7xAMWDCsZUTFBbQBiLvfyD++KKk+owkFRBCqwxq6HLiuYxZ5YuVyoUMDQD0RkY8I1Ldek6brnbEveVM+rhN7muVPZdYSB4lL3DfgQYWtGVVwuAjDbtRgaYmrF183ubpYLnV19rMrl+Y3a8YeBtKCme6MXELQkLodU5jEgI8aVM4NgQNIFxS3OdkT53uZRhvKsOEoNJkpm5Xp6uaNcJC3VJWK6A6HZSI6S8usB3PiH0hnXEN8F35IunU8gkHCwQI+x5IOnanFhBCSwgCEOAxy1MUHAc2GqNEHBMO8Z6w19+I7btgudZmdYESccnGEp9bQ8FCGN66AFVCiyQsnKkJw5mYJ1R2nZd7GhV3ucXjMPBYpjbJ2zH6sMDjELqYDaG/RXSEH8Lf6g8INned4inT4xDGmyghqIhVIGSAQNXGqOwYC/MDaYFhAqjks84uAWlBKULDwqW5wHqxFzJmXp4zgWwLBAhPPnBb4eKkcxlDuR199RASy9KUs+mDF1kdHvFApivcwuDujulrt3zslHtZMVJYylU9wuempPjnCGAhhB4BbIjVmVgUcZfMSi7UWV0NFlcjOiidmBMxqlGO5GXwAiBUogek3WcwjEBCqLN6akx66PI4kwoIiSgh+AqLkc0NYKEYa5jXsFwhUMwTziy8zb44tmIxzIA52AlFevf/MHyYCISAEPfEqoTDCxPrMhKrBgtMStBUTEpggDBE1eWREC+zjD5KYEP3azF9nZZrTda+TtnTbaPQAp7CwEFAE7oQZlpEMCyo4Uwsoum84uq0fVQb19dqdnWqUY5vjlqzOZIQvB1mVke9VdMRPiJAqJc9jXpy03iTkBBKUgkQqFA5igopgncYLFSkAosB+8CSGu9IltYskLsWSd4MhgZvpkpvrRy2RW40nwUA4XjqjKNLQEJIDFNCqE6fVbZErl/OOz/eGOI+SgELlyT3pRhXi6WvydbbZvN0y9RIvb+Zkv5Uy0CkudU+cNDAQjPK8B2sB6Wr3fpRQ5yr3eLuNIhmc6PzdF7J8xuLt3q6hxIew8iADgAEV4PiadPTn8ebCBDOgoSQKiQEXoOUqpdrYQGUiMpU1u6kbmlCzdLEM0tmtC6K+9lSJi8CGrzyBdYtcRyjHFF/sVZ/hakM9aAy8GzHhKBcBqqzymGhKj0eAKEbQy+uDFhkdVSIw8LbvDYjq67QZnDVxYKm34OWQIpCBHZ2+7KltD6FMGFB5ElpYKGFRT5jOJPN1a70NFg9l7EGy3ujAAscEH5tvnpuhqfN5gOEsCUE76s2b51aIEUHhHEkFRASKqjcKO9iEGB588FCCbJSBcICrMJVabMAFpozYkv+gbdTJKvCuBgW8LrmVxM+e2DBbMCrQdKfS3n5ZXjYhIq0xOK75NYHGSBcHgszKWOft3DzqumTPzP0XbZ8XDW1t1NmegQFNXFY8GUO8ijoiCwMTk00FMFCKwZSorTAete+PoWElpE+EQDCr4ze2ljWADq8GikBRsW+aviJUa+YNM6k9TLUaGwIWkxQo3xFvRGSH5gdEmChfllCeWpiMbPPxxb+nencV8YtnElc0T4vAcBtyJqKHPpSZpWkKGcxxMIxdn4THrrQiRsylOuioafJ8ufq6VfbWQ84TxfAgs3XONK32gpYCMOv5wwqwwI4Q+FMryjuBplJ6YVMbR+JbZ8HPPzK4C2bynSfrrDqrPoCk1oU70XFWx/bT0VW20ZvlD9lNIVRNGxB30cDwf+GPgXGIShVCxJL0N6uBYQAUYF3N1DdEFytYH8mVaUngrRQnppQjOFMFf8U9+pfcd68nMFqFY6Ng1IDQayEWsXAZdiFZoTqw2fKM2xnM3gex5iTX1ShXeppMv+70wL3cq1tGsj2LL36vMJtjKLgGPGRMDUMvRAHwUITxQfKPedkz1nDCJ19/OZ/afRWx3nbrQwQwqizKlyTHBDq4rydWO1Bb9s0LIqKYvxuNptjjOZog9lgNBvMZoMB9i0GE6NoQ8zQZwEWvjRXKU5NAr5uWObrgsp9Df4MJdqi8eZHXGygXyXVYDgTRjnesvcOm/NvLb/8EpviYxbOpKZoWU/PZ2HYZQIQBsxswt4NqbPLlistmLgB+s44kbAqUGrSv70e89tXjH1vxr7fpoAS0d+FxUub1TJrzYGhC2EYFvinXwYBsGGDMvJwIA4Ir5u8Fdb+TllTZzU8lYHlMti8VVbv61gs7p3RG9ZPE0VNmQLsH2MyASAYTLDDMIF9mhhEwE6MJSZqSCEB44vidn09sSidAQLjo5QgWFBje/yaH6X6jqE/CRZqmXk/qZLlREw/sOCmohVxFSsonGla4apbb2g4E5cQ7ErVfUkVqQkaSSDY58g7PCIgxDsybL/43xMkXyMgDcHVbehxWv7cGtfXinmU57FHTKsfLPT7DAVDw4K24TKVJeEBwyMofs7vtjXW62BZXSwNMxwbglZlOG/z1imeV4zkotVpeBTN2d8EsGAQn0aTgcAhxgSqwxCnAPacx9yF049+IaF4yeymZUz+r2B90gNgwQ8TtOZ6rXkBYAGDA5MwDGB21dJZRSmfKfz6zBNLb345ZS6GM8m1829UOBNxtCNDqV8+C/u6DigkiCCEClaI9bPV357++t9OEEAg8oom0Rhh2Ndk6Gm2vN+i9LbYPN1Us90HC8JsiOAwpJlR8ZV/bFXcjTYGCKPhgvSeUbwlNp6XHRYg+FQG0DKuNVpcTqOe7TgSMhrNKB6YDCgkcBxAQGDfG8zB5oXQlM14YVbnVz5zOjWhMpUFLVdhI/UKxuB+0kKgG0IVGPiyq+rmxUwax4QIjHJ0LPpMYUa8Y+msM0t5ONM7fyeNOizA2Sr/gZVEOJnM+r0KvWDgVEfWu6E0+baKxX/R9Y8TChCIGHdUYhqCgzkC+s4Ze+stV9tjsfCCIso09QtnRBj1FvxqLLTILvjt+dHpqOgtnuZ1TPOGLyEIAymTVeSPquNcTTogjIhizKQsmDga8I0rEQazYWgJgcgr3fr2rbCCJ1Z/M74sefbZNJClGSyABlGdzrqmamAhMdi2wGEhlfQIn3mhmC3BTIOArS4joTgVWG96953MsMBhYfgJhqHpBDZvenlhQgkZDNVezwGwIGynAAhlICEsmHFiwCZu406MR+qxdzya/lznYq43mf+7wvpxi811UQZY8Hbxoo5D2vH6hetBbbEKmCDEg5ECwsszvGUzvB1yJIDADZ5w/yBdeH5q0lWGYVPUlOgYISGoaICWBBPZE4xG45ToMBwNSIAGt3Z8GdgTVvCZpffMrFg+u2wJW/FB+AdYgLWewUJyor+0kBhCWkj1My+UYDgT67SYhkmUGbB2w1XMv7yDhzMBJrBKLKNkWHAwhFGKvjmrlEVcD6QyqCJNMrV+BkVm+o+/Nzo3cMNI1HwmaeH6ecPVdgtLr262ui8png5rvy8kKTwDI+oO3lbFW/3ZUQIE2fvCLcz+eSG8Xi0+z6nsbVa8dbdM8H40E5wAD2KMlhiuL2hMB6rKEM0AITwJgcg5b2blnTLl/Tntcafvn3l6OeuCVJ7CygtUpTHLALMtJPvceX65DymDwgKch+kgLO2oKn12aTILIjr/VTIDotVRkkaOCljTQK6Zn1icOgggJGoBoTT55hfvmXCZmwMQs/79BA2AWH7heuvUq07r78struY47IYWaVcIBITLoyQhnGDeCta8KUxA0IRTepqt3mr1NnS347AImJ1kAxUKTCQhxKg70SZTVESAQARoULlaOrGe/PJxr35hxsW/jXd8Ob6CBSokVS+lCiqUC5mgcn0CKeYqFISwN6p+TBbFBLBQv4xFOZanSBkZsfWLzTV3MjRwjjicqe0rDBDqvhFPHeFFElNooyJJCKBTJA/3emNEAa6HP3fEvNdg7OuO/dipsHzqizx9IFxfg9MfEEZLQnhW8h6TvOFLCCIwu0VxOxVfHQkdEIZFHBCMZEPQmBFIZQCZwWweDiAQUfBA7XxiT/nnn5lW87+BZ3FVTUtAmwBz4pekiAilxDLh6RNRTEJUUBMKSlVYKEtNqmJRjqBNJFWz1GPbi/OsVZ9Tw5nm8MILkVL130uX5soV/zqrbAlLuygfIDapVJUQakAVSoZ7G+Yo3XgKgILrb5n/6LQ47VJP+7TeVsVzAaGgRVMSIfxuCLg0u52yttn0iO7zJSy16udlGLLfK7dtssaOp1Tbpg4IwyIGCGbB/kFGRbMZAAHUihFdwz8I2bT3priDSTcd/7vbSpNnYWukJAxQ9IkKIlBBa3gMggUezoSHsQYKzEyRDiBzywt33HT+7+Lems1LQDvnRSoqTHPMlQrn3lI0LxE9CAMHK6qAgLaRWwq2T7SqkTywuVat1uiV+n4bc/0Ny7v10z5qU3pbZE83VkGhgmxqTbawko61bscWlkrZ32UUze5HdMNeqooQUQNo0QteLYYwGW0IMTESCwyOnhI13PV3NGhKTEx0MCAYNTKDwTJSQBBE7JLN96f+9MvTm++8+dSSpCqWzpCI6QwJ5WmJpQGhCxr9PXQ4kxrlWMuqM4HMMKsy7TOOjBmVC6e2f4mLCpGEE3+mcO7cwrlJJ+YlnMWasUNJCKxqXHnq9KP3s6fKzh6dsRoZ8dSnX6qpT3ap76Kx5w3L1bLYD1usfSzHQfZ2YXEkWuU1AcmqpS4cfYFxoheb2nvaMQ7hF6MBCBkgIUzznidPaFh2DB4OATv1ppHj0rgQk8PNzGRnkSwxUYbxggWQEIwWCzcgqM5HNV4RvzTERk0ZJUAQBEzjoMYu9tiXvnfTyeXAv2zBrUqbfS5DLMpqDzU1OEHVIPzCAEp9Fj8BC0lYnSm+PCXh5MJpjfN40ZKwMeEzJ/+eAcLL/5pUvARONaSEwEq+lKcozz6IcDfOgMCToz9Uw5gdkqsmpqfU8kGtfK3V5upkhde8uPiKmgl+XafDKJugDWCmcCDWUREkBLRVjvTmvWwUPR2a5KYwJYQ2BDcnAsJrkw8QgGIMJlqLDcB4kjlmyjjAAgMEozFGmBONYgc/DSbJaJSGbUMYhOB9vTVPcvJsoJkn7rnl+ZXxjozZuOAmkZuyMo07HLVpEVpYID2izN+8QDVRa1j8Q1LDsttPL7a8eDfvJxWePWFaOTMq3lLyjYSzzGCIcUehEh5FU4ZzGbPL05Ti9PHKbCIKLJ/ildw/iemtN79XO9XVZuvrUtytQeVTtFWVwvQpaEsqYbSz9xWbu0HubzeSejLSR/BK/S1GT2e46c++XK12AASrp2USpzrGaE36bN/EpIWxhQWW0chjkFQhAWWDGNXSKMEWZqTiMIisjsiqc7BU0fTi74L+PuvskqRaVlWJuSmx/7IPFijKUeOJCG1eoOBnVkc9nZd6hu3U8rDuqnwR8zKULI7ngBBaQvA1ZTjHHKnK8/exS+y7sdVRQhJTxb4jeUt8BdZcP4/pvWS5Vg/agdyHEUdYkUxt5SagIOyqCKF7OjThOt4pe502rvuPrLQpr7N6yeShom0RuB3Z8UxCaJvExRCMRnNMgKcPdHmLBWDBMMUYPSawAICAqY4m/2BFDhHwKUlmKWq0VYYAsttnHlodu2cj7My9tAomltz0beDiBAYLzEfJYAG7MGuVCE2S9cBxC4AJjctmnVkkn7ibOQ5e+lZY93NyuVS4Sn45PaF4cRLVSBlcQqjPSAAJ4SWUQ47cqPppAxEzEdyPmIC6M4OCFssHNXJvu+y5YGPVk0QJVuKgCAuk+IqraOqvsnbS7QwN3N3KtTrZc9nEUyxHFohxGc0d71SbWMWkNrWVWzhehmYm/DDJp3USAwKPDTaafII6Z0lQ6o1WyWqIMt7oe5gSExMjAMGoCVb0AUKsJI2JxGK333rqsbjyLQgLc2FmmC98dUb1v9xeDPL/sgRYqavSqSi6WtMsVIpBqS/SOAGLMrHijWdBYFgCEkhiSXihAiogxJ9ZTFJKyKJJCVpAqESV4YZVWA1JIgKHzIZ/+HF030XLh+dsve1WgAIP9ZdvpnVWVGOOoNMT/cTHcc28/hKTN84r7k6l77xytT62z2niNswRV2AGQLAjIHhbmZHQJ5aECQiNVgYIzskNCDFGnlXEJQRt9rHROE2aFg0LdFTUDboHAASDxaIigJ+jgaQXPOpGXT0EMVhYHvfGIqpZBJ+W179wU/WihKK0xOqlSRj/zFZtNdDR55oMZlVWqQCrQJcs+dyp5LmFqz7/XHiAgCqDUrw4oShZCwiBZRU1jR2TQGUoGSNACAwq6Lb89pQZ+Oijljh3i9VzkZnXAvo1eLRMHYnNsF9TMcndpHZYBijoUj5umQrP+scz2MNllDrDXkaV4Z0LJm8LiyvQ3nCwMKOt2cL2QVxxWie1ysCrEGiseWrWoYkXJcAQYoAFCQsX3AhYiAJAiI0lYYC7GIzBgDDmxBwQSdJbNh5L4JXiXv3bmxq/fnPZktm1rHfk7PoMzqeh8qkTtYBQlT6rNPWvixbMLZw71/H1sK5OoctVdySdWZRUl87CG0JVYvdJCA3LEqpuuIQQUPyEBRW8Zbx6Me73dbdcb7X1AWtcULztLL4odP/HoUqlCaYL0eXNyasNeLqUvnaltysO7ue5LYauk1P4XY1SyDa3IVwwuZttvvYxquWTB0s089JPQvHp9wGC7OlEQHh1dO5njEm145mEbKAJHjZplYhoLFMgMf6dEhU9qrAQHS0ZjVqVwQ8QTOMECERezptS/depRsGMkoWzyhcmViyZXZEOzM4CmHnxpcDcZBUQUrFYQfJfV/0LqCFz68Nq/TATL3pr5R23nV2SyPIvREf4AVSGhmWJ5WmzEBDibwAgcL3gCg/acTuj+/7D2Psz84cN1p52ua/Z6qFWsGr64TBawYaukNas9oHFCmzurmk9F62uN6Y2vGB87uloFvM12h5/0n08lSAhyNoC8oPfOQ+H6MadTqzTMgkBgVvzzGROxE9EBmJDkhBImxDqfDRGCklxUpQhevjhxAEUAhBMPs8jsyGMH7GEiDulE/eQO2/quXm3nFkY7/jy7MpkWLVZeECZWLhDRgiogFCS8j9q5gMgfL75q+Fcdo5z3jznvL+suCP+pWRmH/ABQggJYRYCwqzKpf+rJD3DkfGlUQUEJo0f4151FlFwdkpfueF6Rew1p+XjNsWNZj1KQHBzXtZ0bgofCoJ7v6qd4gFqPN3K9Talt326+2Ks+1WT+8+s/StAgf0GFLmkNIT+QkNfg0yeEXezWh62KdQmule3KZ5Xld6GaZ5zMUxC2DPKNzY2BPo7MGM0ZRrSxm2MPtlArNrcD0gVjQAWLJYog2EUYAHOYDJF+xsV1fRn9udoPGjkRIJB7XxMmZw39WTGTSeWxR+Zn1CRPhsdkcCnvnghTbRSSJUhoTj1czXJcxwZn28Oy4bAAaH8joSTC9mFBgUEsiEkVqf9r5KlowgIvpaOqob+UUM07L9bF3e9U+ZQ0EF9mlSNQM0CjqSro4oAamASQUE/9Um5IF9vUa7AFWtj3eVGbbbCDQr7IUDwHIxhO53Y3r2TVXtj1lF0l1APKd5Jqg3vswX3X1WuN1tFXJN31Q25vbGgKVMAFkwgAMQYYzQWRYPP0iiKm6k+CC0smM1TYgzhVjQKSRgwiVCgjUZQ91kiw9jGSlH2NEABYsLUigU3lS6+/UXqK53BEh9q07Eys9aoGKwv+ACB9VQqTv6rsoy5l1b9n6q7w7kFrjKUfyP+1BItIAxUQg3jEAB2RkdloAKhvNEh8uDv66OddunDWqvrguzuwH6LHWji0zZu1kYbRgAFfl3b2A6ty91KX6vt7erYj1vN1zuwPZMdEyIuj0UEILtWkcFTZb7WYL3WYu1pll1diuei0ndB6T2PWzfb91y0uWG/RelplT9okvt+bfa8F81GLHsi5ZMMi6YgLDDuNpi47qBGJqixxCYhMPAUAw4LFtgAT9jPw6mXHkRReF1NRRQ/3SEm0mIIIyFgw450qW0R1VeZUXnnjKoFs04vSChPo5ZqrDVkGUKBqiCEcjv6lH3udqxbmng2OTK3I8KRXLwg4cUF2EdmAAlBvQcWqViWOnKjIg8rels1GnilPzSaCwulq43WXoz/ISggadkn3kfaqS3YckgnbGc2SWCxvg7lvXNTr7Wae0QDx2LJe2rULIdhDQXaTl0dhr7XjB9dtLzqiPtjjfU/HbG/Pi3D9quX5CtFyp8qrD89NRUO/vmPjH0XDJMxf2EImhINWoAUGxttMBiNfrVKYoy+GEKNR4BbGGB/itGIeBJ2uTOVoqawSKgYEYlk8gcEgynqxoUpCiJbAUkFl1bNrEmNL1+cVLQYGJ8WaBAMMEs62Rd0VBpsPQhqG1ecnFiWltR0V7xj0Ywz86XCuTOPh+dlwOousmNR/MlFQ0sICAgJFcm2EQQm+YUaFrLP939uvuyYc80p93QwmyEJzIFQEJ4bcXAoYCFGWFMRVuHeTuX9RqWn2dLXpkLBaLd4DndAnNhx/hIHxo9+FfNui+H9WuO7tcY/1ZnerTW9X2t6r9z4k93RXI66NGo9ZyccRXODYTRK7CTMq0yqyvMmkxYfaHGHDWAhNjYWYCGCq7EoRIvBoLUhmAQgRN9oQKD0ZNVWEFefPLM2jVVdYwpCOoYNo1QQCAWBreISAqCghDWWBWVhNiuslDaz6avsKl2fA0wI665woZdPpSS+sDjRDxAGSG6qWzq7Kl05PpzQZa72Onyhhh++brhUIb3vtPa1sUoFnjZZQEFAo8bwbQX9wVDQrEJBO0CBrbfL9pEzrqfC4mowcM/mOEGBb2SAx1+XvL9Th4ju6pK6OXnqFgOEo1gu8pPdzDEqirohxMSYozUFzVQ7g1/YQIxPVOCwIFmtYWYkRUUZYqVYg3oSjUCCfg3jCKqjDE5e1auIYQZTW746ozxldgmroQr8xQKVy9MSygKruAfXZfVrDKc2k02qwjyIyvTZdWmfKfy69TWszPxWUrj3Rq0qTybfDipDgFGRF2FIETnaBAhwOeVHD0Vqec/Olhyqpe7qCcNHZ8y/rp7+ETA+izoWrRN8VYxUT1xE7gNuXtAUP5E9FHjcqXg6ZNd5W0+bta/b4u6KEVYLEg8mAnE0IKNKG4s4orgjtl8reU+g6XVyFK4bFYqizkpGo9FXBplzrp9z0F+DMJO7MCqMQKaoaIM0darG7SiiILjhYpRtCP7FUsxFX48rnj+jZlG8g/kHkzAKUe2FNEjldl4SQS28lsIPACjAKqwMEKrSbzu1eEbXXOtvEiKuqIaAYD2ZnHTsTupFK3IoQkoIIMnEl6fIkQOChBO+97+NH/3U8ssK6/VWhVcKalEDb3yWw+F5ElUQUFUMpnFQtGGb7OqwXW9R+qqnultNXEqBzwtjZDmMlALiMz+BFoOwCYUFbnWkVdvXVsmHBmY/wwJ8WgxDd1yS0MsQF6f1YmhTHUfTywBcpimnpvzir6dX/5Nkn5dUifXVa7GDG2f8wJZPfjwY1F+eL9YVqbPr0PZYlfrZ6gW3tvyz/PO/Hma72M4vwd1aWu/8i+fQqFjJBZWBNhYMWbFUPhtu+jNN5uefMFT/yHTxdOyfW6Zfbba62hQvJiDwHs2+pJ4IcpF8UNAcAAVqiNEFJnW4OuRrLZa+V2Ndv4h1Nxu4B+HKxIICr+gyc0UNygrYqBjUEdRuJtKdjxlFRcdEYfGiGBZKZOF1kk08hkFT28QUweJOigmTQMxaM6YqexhH5NOUJC6rg2pw4h5KQ57a/qUZXV9MOLkw8RzLCUJ5Oz1Ry18qp/unOYeAAl7FvSyV2QqwksmMsiU31S6Ydu6OETWP5s3o7TfVf4tlYVejo7OcGTa1Gyk1eN2UhKI0uWYBV4KGHBKczz2vG/7zpNLTgVLBBRuvO8T1gsCui2GZC/wNBb6OzyRvdCu9bXJvh3Lt/PS+16b2/cLU9x8xIvB4okndTFwRIKBqVR2l0f/dZfzP9tj/bjf+ui1aJFzzA66MNPl68hLry2wwGgxmjUfALOIWRFhj+Op/tCiRZDT7ZAw4c3TYbZuCiaAANtVWMPVH37vp5LeYYl6eloSpi0wwKPOVQPHFD/jZCkJBQUkKcSUZHOC/SSUp8uuf96ujOOwlA354hdk54167M75oUWIVq/o+u2EZsyfUZWD9lqWsDnxDxmfqM5i5ozTt5rZ/iv3t7eFclDHgO3ye97XJ/a9SdVBVHtDkFYbvRBgk2pDtoyfxY6f1zXOxVy9Z3L82TnDBmw1RJWfzvl/F9Lxm+lk5s5O/dUxq3z2Dtt/uY+/5d+XGay+bei/EcNyonCimj7GjKIkUAYPBQB2XuItBWwMN2TkiQMB+rzwIQZPZxMBheIBw66q5t56cK5IRLHXfuIkF7Sxh62zNUibY1y3FWojJiZqKqaEs+X6KA9kMORQAY9ayiKB4R8a02m/ya1EFxZHH1IIwg3LC1LpvzDizNKFoYcLpxYmnk28/vfi2U4tuP7VwVtGShJcXzzq7GLSe2M47wscf5k1zSp4ug7vJ5n3V5g7owhyelWAwcwHaDHm0YQfLUP64SfmPOuX9S+arr8WIxKgJiANEPFrbKfV1GXs6LR+ckz9okD9qlK+1WXs6bB+3Kh+3sQ1EnZ525cNztndr5T9VWK93xPZRlVfnpwgTyMAYw6IDfNnKfrVNfBELppiYCBZ3o9kXlSREBQqRitiGULhq2irm7p97aRVs07v+5abqhfFFrH8TkwcqEBCCoaA0NRAN/AMRmeWwGKGgMpVKJgIUzC5bcvvpu2c4lknYAVZ67fNcIBkVEk4Q57wZ5QtmVS6OL02dWZJqPZ1ifTllJmzFi2Wn2joq7Osy8/glydNscjVit7IIfQeBQQVOf8thI2JLO8YVXFCuN8nvVCsfNcZeazHyRirvYOGCCYsGqgrjKre8X830qb5u2dXGDClerLLo7WYFIb3YqZZKx7tbWS52T5fybs20PqdFSBefbCJ/QbTRIhKgeByjNgfBKAwIuGMwhONlIIoxmUJKCMZIIxVhYT2+AjBhbuGqvyxc8lf1y28/m0aGAtbxpDotoZQav6rFTEIEFfgaKfqHGKUmUo0U1hI6NfFsWnxJ+qyz6VR4Da4o3Yge8XDCYyspRgIuNOeyWqwVtzn4fUTNJdmEfwUlhEaT28kiD0W1gUgTEPqdmoAEKlbQjCdsAyayXW+RP3DK1xstvU4Dr6BymasqE5aE6OJusrhaprOwTGpE24qhmE0o+QQlN1HaBYuxvAiDYHXXmz/ZugMxNRPpKarZPwFKKyHw4AT60xxZUpKmQoumbRNWaIkYEBwZN5+8e5ZjOVvNqRULlkvlfO2reKZpxeIvFfhBAYYkMSsBOSWr0gAKZhQtm/HyXXMofOjI/TMPrb6xPdSOrbzNkfEXlal/UbvE1PotqfYe2ExnU01lS0wvfEH60V9JYRdaFlYyj8PohkneruoI4fVN02KCHxQ0YUlSFm0o97Rb/3BOvt5lYdG8lxAKankMzwQnGhxPufF6jc3dImOQNlV+UyWokOPQjI8Po3Ge5Ttcq7N6qjG26pUJjX4RU1RMFFYy5IHK3GZo0oQR+ho081Ai1VFIlRMiqqZiMMb6AYIIaTCG7XZkSnS26ecPTW/ZmFCMQQXYZ423QisRMQNcC/CVSBVSgX+NRCqUivFFaUk1GUllqX9ZfMfM09+KO/4dJg+w0OKMmTe6QpEmemoeJWCyftN2LiTQnxJWlQ9TXxCmPEnC1S3c0iXq5Bd+B+yXhIsmxhcpbtCpO+X368zXy8y99QZuZKvCEL4JDwVE7J4PRWMNdhsoU+5GbTVFmUtEvvQNRYyDQEgvpX01WLjf4e3xfqRRoSnRUdFGSbKqTZnNIlhIxCIaNPqC3wExGI8UeW2laJPFIFId1URL2EwmE8t+GpKEEu31Midg010sAaE8JTHIkxhY4CgYBAgcSpKZD4L599MTqtISTi6c2T3X+urf8PrJhx9iiskYVC+kVlNnvnjrc3fcVpR+W8VCpXGZUrdcqfuWXLoENtvBf7b++PMRWBQvq52JnPNczVZWJYwnLIeWEPwMBWpYQr8IMaLahu2gQct/rrNcP2jqy1ajDTt5rcXJQrwSQo7B2xqLNdV9TRk8KhRobSzaTjGi1RSr8HbB5qqXPS0xFOE8eSkg+kjrTFQFAJPIjNboC4yLmW0B2TYKwCQq4pxHtTUDnMdo4D0a8E+TWZLlqHAyI7AFfOxPvpVQkTzbeRfvphQUaqhRCgK1A020YQpPbKxg3888lHHz83cr1TycicU13WgFgQhlgNjuv1fq/qdkl0BPSUSTJgg/CY7kBJZ4lTyrNDmhePH/KFsyo3ZBbP03wrkrHnvvZQzr67ruDCEhaHqlcSgQ0YZuJ3oQLjAo6OuyfVQd13PZ3FceQx2wvft4IYXJRRSB3F9qcrdZmfrj11RuCD+Lz1HbyrozuBrjPO1GliP25qTUGoCFKYGIxycLU4DRV6UkoK6aQZUTosmbEB0DaBK+CTGAGBBFR/vCGqdO5TcWExNFfRmGpOKvSJfmKiXfSCzGEsQlQVDg1+c9yFDAO7CkMEUDzY+zK5KTjs27qW2Btewhv7iCsXm/cLnKOwEQbilJSypPY4pPVXrSuWWsmOq5DN/WsIz5O2CrTIGbjN2/bsjQZd6YGNOcfZ1MB1WQ1TmvxhW0ygAFvW2Kq8P2ntPW67C4ynnmL6sDMG/ChRiFSczQ4WC2Vlb25JIQD8L0vcpqaDf2gG6R+1osIiVqEhHjYh5UYIn2K2koSh9oDAW++GSNYzHaMBbpyUPSS/8q2ecpZxYkYt8lTYdWf6mA91gJijakuAKWRIxexWMrp7+yiGIFJaf9htTqGoTIs+CcZzu7MOHlJUl1GcwuSu0jsY1UQinbEtl+Kk+kqlsKzzX9ue/xvjMD6zIDAIJPQvBVLtKkJfpBQYf8YZO13jHtvaOxrlNGXiBokrY31RAvr9pt9uBjqibTcKs/cQ0CBgrVqHcd00RI9mQhnsxoRvncz2UgnAU+m6GvMjMmRMdgoaTRhYIpqLNEYfJ1VPSUKEOUFH4W9RFWQEA5lZyAZc8TS9S2CP7lCwIjk0tSyOTIpAJ0Q8xxZNxU/T3kmQzJO398FjxUSSzHl848NI+JAVXprKFbYM1GTdBUCWo6LLw5Le70t3hthwHIDxCcGkDwgwKhI6hQ0KawcsfnGRR0OuSflFp+VopBOBKO0+QvECQJF8NPYnnuVRgdqENoDTBcHYq3e5q3fJrrJLaBPjLeDxY2AftFo6pu0HoKhIQQkH4oqrKz4kijDAXR0TGG6BgLohPcUjSWeYyxxEjTpChDeBYJyhc+y8IMGCCotkEhGPg1VBLRhhXYgbEGjk+Od2TMbMAV9tIq6d2HJO9qNtfHhY7cz1Idn7+LJWBWpmtLLnPxpkykP4uyiqwWa2JZmuL4F8za+MJA59baENxNWpHA31zgRBs7VQvsUFzdyseN1t8Ux/2pwvxBJXMmwjhdXo01VcZyZG48eVrgqdFvItAyXD+sLACh/6LSW2H1lCMglI33I0VCIoNAE22odmTQdF8VKY0IBcOpkzYIgVQAFzLyVClfpQV0Z1hYgHQ4VkoChCKQDRggiE7NmpaLwpMIUMC18oTajFklKXOcGX9R9SgwCYsrgPndtohhwjhSJwME5dzdidT0Qe0Fn1CWGignECzQTmXarPLU6UX/LIFE45w5EKPyyGGsm8rYvwXbIzbLflCAjZY8naxKUt8F5VqD/Mfa2I/qzVcbDVRo8XKh5Dw1plrUmJGn2QobxmtFVgFGKyEwxapqmrsKu8tNHpVBwvhAtewAlwqEeCCqoIgoo2G4D8IhkFOA69U0SV/0sugdYwvnLCogJBAgCDahNu4ECxRtSHEFdRkJmI5080srbj6xkqINYy9snElphuNIlNbktSvFd4uu9KGysAUmpBL6kYo0vXpBbPX/5fkUIU8PaHAOrWcVRrczjrcvdPqggDFCF8YVdMtXW5U/VViu1Zl762N4iJGDyxifVPIBgmgaNTxAKJvmrjNPurwG0exVLW5g1kgIaEMwq3EFN4wAENTAJxMXDGiHCy2WaNAjhsQioTKU+SQEFRYoKkk1FFQtjS9OTTi5cIYjI+7Fu1mIkd0u71t/w6MNwyQKPOj6spSRAeoMNZ4eKOVKZGJyiagi7TPli+TGr7HR6PzSgFdoUEOXm1EjCIg27FRcnfL1NvnjqqnX2829HTG8UNikCjEaNjE0gK1DuFfCVhm0gAAqQ+U0V5V58kkIwmbIw4E0NZFimUXvBrVvCyD/pgwmgVFcYUG35hCnwKqkSilWPlRtCAkcClIw+yA9sWJpQnHqjKNLpv/4O1OPfofnDe3aLD3x2ISAAiIEhKnNf/XZAwtm16Vjy+nkxGBlQSMhCNcqHJxUkW4rShu89jKrYHxJ6q82MmXhFZsbK6SxNRFDjHo7p/2uOu76T82unxq47+CVT0W2Djcqvh7rblF8cQjDkxAu2noqZHeZcdLZEIxCGDCqeQqwxcZKLFhoStSUMWqxKsyV2mAnjUnTMjQgNPwrq1tevYCxRu1S5pUjgxvZDCtYPM8tL9wxrWRB7Ol07kYEkeDYygkEBUQECBcTk47NS6TSjoNLCL5CDfCw6QmOZOXMUICA9cz7Wwxe51TWb+VVNKq3W3s75fdKbNfeiO19W40rmGAljG4o0ZN6us2s8Up3pFVkZZ/bsV3xdsves7G9pdGTy8sAZNA0RIg2mqU4I9VRv6E6Qojb0ICAgWdTmgRKRFvCyIAuTmWBSaWps8pTk+qXJZSRdz6D9V6sTJtbOPf2Y2nWgw9QPVUefjzRoIAIAcH4i1ukwrmsVlJNmk9CCGlDEA6UUszlLFoo16DnsXPAYuyiE1PvK7F/qJA/7LB90CT/1hH/cafc+xvTBC9dcuOIxVo7pP52k6fNCjJ/mI3ghYvBI9rWs+RH21vHVOPt5FEZoqJZc0emKRiM0tSpUUaDFD2mOCDIyMOVTaLskrbQitEURsIjph4rRRkgM7N27dgNOak8Ff4z3bFkRsV9HAEoP2giz3MGCMywqXTPT6rGRvAigiJkEILWqFiWfNupxRbnP7KJeGX+YBehEmpvx1x7xXT9p6aPz0/9+Jexn1ooIGKlEQEQSkyeVhsLTGoSneiHDknyuW7xG3cTiFsG7tCZREZFVQzASonjGW1owMwm4ezwdWwxoucRhIQhbw/5Pbb+jptb/vH2H92Z6Fg0oyI59oUFPBBn7KMNR0LoZZAvLEwo5y4SFRMCFYcErduxgkUj3PQcuh0ZqoRbWVFbwuhTiAOCeC7DWaO3DZO21LrxohXF4JjgEw+6AEzi+puxKfwbn+ohHR6BeqJCgdnXaVottwKbOfzCy6SAN3zFUjePZwe/s3oiVvAcnGrXs9zqsu/cfnYJq+9UqRESNBaDBIqvUGOzMbw5WXmB1ZGWSsKqnsQQ4K2JXsJozIhrUo3R3goWnsSyOBvVIpNqsrMv81GTAulXOw7tD95zACkG6v2kU6REKVHMm2DwpUuIhMpoozk6nPRnIvK/izpCr3whzDpCE4vQATr1xWXxxxfxxo5nk0NbFDUx2JjBkRbb/g32yMcm4VNPACIZ6eKLBnc7qxHd30olEfyMh0GfaugyhnB4LyiuJlt/UywTD96cVPrChCKWxcDCk6J55jVPhcb6aWGjARFF8gMUjGJtwzEmNblp+lP/NLuSmUaTWKhVMqv8VozJTXxLmVWMgZcVrNzrrLJUXOQk6Z2ZkxIGJwCRAtW0n4mjPc44z0WM02iU3Wq1NFZASS2AAF/S9yywEzvV9rN0D5vXEc+Dwz8Z1VHGj6IoCTo62vc5ts6OCURY78V85LOwm1SzjEVXVrHoSpbrVLdU3eDPZSwtujotqWrpbRWLLW//zdglaH9CifTLxsemOO3StQ6bG4M2Yd0HZmcs38qym/tb8LNVIRDov6B4OxR3p+zpsr1bOM1VFCsa0umk06gR+R8b5yhnv8p6ylSmsrTHihRWvqmClY1lhV4r0pOqMpJKkyX7vKldmtYwOg2XeDaoXXq/Ofq9C6Y/V8f1dLD+171tcl8z2zwtstupuJtlV7PcC9+0Ktc65Q9bFXbkaxbXW0ZRt1knnUaTfF2f7HHtaUrZN6TCuTOOfiXpWArgA2zTX1wQv+/LM0q/GVv/FWPD/8N5PLE9qpOERJwG69LyX4arrxr/u1D6sMH685PmK5Vx1xrld6qtH9TL79RM/cVLtg8rrL9/4ZaPz8X2XDDozhqdbiyR9xDDEiS73XggedqRf779+fkJJxbd9uKdtx79ulK/jE1f4WfUl6VRooCQjA87Y/6YZXj3qPE3zxreKjNeKTde7TR++Evjh2eN7z0be+2QhccgXdbRQKcbT2rzqRBxFNSi5fIcyalrCqNPfrBAoOvFWFe7//fZvFuTjsc66fQpIWL8y7A52MaFAccnpFqUTjrppJNOOumkk0466aSTTjrppJNOOumkk0466aSTTjrppJNOOumkk0466aSTTjrppJNOOumkk0466aSTTjrppJNOOumkk0466aSTTjrppJNOOumkk0466aSTTmNAdqTNmzdnZ2fvQoKd48ePO51Or17m8sZQRkbG/fffv2/fPhp82Dl06JDD4Rjv+9Lp004wCcW0XLVqVSES7MCfgAZXrlwZHBPoh+uR5s+fP2+e3udoCILhAjSQcOTtGoI/Dx8+/MQTT9gnSyNdnT6J5ECCSbhhw4bMzMwcpG3bth08eBC+9yKF/CH85LHHHtuyZQvNZ5jkNJNXrFgBkDK2DzGZCDCTRmzNI4/Yc3O3ZWZuz8zMzc3dvXs3jBv9a7zvUadPIxGz19bV2XNygJ0BDUBryMrK2r59O+xs3Ljxx0eOACZcvnw5GBbgT1AoSKiAz3Xr1uXl5cGXsA+z+tKlS7quEUwwyHPmzIEdGDQY57Vr127avn3j1q2PP/44jDmNIehrAMg6LOg09kQmguePH7fn5RUUFOzYuXMnTMfdu+FjJ37m5OYyUfbMGTgMYEH7W4KIg4cObd6yZeXKlSAqrFmzBub5gQMHjh07Nohc8Wmm1atXAybce++9MKowtgU7duxEi81O3PLz8+E1wOeWrVtJ+hrv+9Xp00WkKRw6fBgm505k/9y8PLGBNAuwkJOff+LFF+HIzs5O+hUtXmfOnHnhxAkm6RYU5Iif5OTAJyxwJ06cgAP0ZU5LgJAAlTQmuTjU2gGHQcvLzweIyLTbNz/+OEDrI488Mt63rNOni7gxC40GAAhZdnu2xsYFMu0OQIn8/EM//CHZGfx+hbN67xNPwDSGM4gNvnwCvszL215QoAOClrKzswETGAjv20djle0/4DB2MJgACOsffXQV0njfsk6fLvIBQl4e6AvZuO/b7Hb4MgAQtFZxAJB84noxt/FXDCLs9jUHDmzDHR0TiGgAMxyOHYcO2cVQqzvZCAgwnoDDOiDoNC4kAMGuBQTxZShAAC3YjhELAgd8x6u/pbnNtGD0oK0+dGi8H3RCELlsHAgI2aEAARQHAARdQtBpvCg0IIhZGgoQQO7NyMiAT/hv/o4d2oN9sJCTwySH/Pwtu3bBgrh+377xftAJQWRohWE8OCQgPPaYDgg6jT0NAxBo5+DBg1w10OgXAXM7Nzf3u+vXw3fz779/vB90QpAOCDpNcBoGIFBMwvPPP79161YfDvirDDS9t2zZAuqwhHE44/2gE4J0QNBpgtMwAAGmNOkLD61ebc/N3b1nT47WYJ7D3Ax79u6Fz5dVlXm8n3KikA4IOk1wGgYgSOqKv3LlSjiAohco/EDEIezYsSMvL6+5uVkPT9JSBEZFHRB0Gg8aHiAQUajz2rVrQXeAOZyDgUzwzbZt2+6//374PIPxjTogCBJux8d1QJjk5A1FIY+E1RMkaqZow3LgcFzOziZWykYa9XtgAjxtly9T2CHMt3lOpxQeG44EEOinwPgbNm5cl529eufO9fv2bd6xYwsS/TfMR8vGUQp4rsv4RCMZOtJuiMQb8WJ0kNPpFJmGsDMqWd502stI/EJ4LfgTbgAmBkAo/HnjAIEuQU/E00/oHvBPuo0RTsKA59W+JroiXUK8TQdOzjBn40BX8XuJKtEbHBXOGgZRjJkfG/rfA7wFuEP60o5ZwwQZXkz2oW/gX3PmzInUwqZ99pCAsAL4FHeEeU9CfAAacp6PBBC0PAXzEOZ5hiaZl7hs8EfTDlrIR9MOHRxGuVThjBhc/QgS/TbgjRSeOqUdLvrvlSefhI2m3JCXCDmM9IK018pbsUI8CBy2Z8+egydOwGGP79gxWoAgHoHmlfaJgE499phXHVsxkhlII7H0ap9X+5qA7sNwa+1LhD/ur609hLFYw76KpH2J8Fzl5QHTIx6JEsfGgGDJKyoqampqamxshE/Yh28kzSwV90YGt2eeeQb+Ow855cknnzx0+LBdDVKV1OzXIS/qVVcc8exlZWXsHpqa4PsXT56kS9BVth07BleBSzx08ODqgwfFBCAAH+gSIwEEejWZ+/atfOSRH2zf/kB+/g/27t2Ql7dp06Z9+/apPBGaED8Yz9JNFhYWwoRpbm6uqakR85tuD77Pysr64Q9/6Id4Az/O6tWrCQdordyGlAVr1pkzJ+Gl4HDR+MM7opdy8NChcrgNVccffMQGHEOkZwsLX3Y4zhQVlT35JGM6vNUDeKFV3/8+TW/4fvv27SMHBLs/LV+58r5Vqx5++OGcnJxn4IRwgJplXfPMM3Ddl19+mRZTMYag2UVq8vX6C1elpaWnT5+GdwRz79jzz9Mj05xvRnqmsbGgtlZb9iHMCwFrz58/n34ibvoAspUdXyK9QcKZ+9GvTSUmaO2O6KHCJLuKunfccQdl+MLUglcJn3bM+d26bRstRnTbP/rRjw7/8Ie0nwdvFqiggMxrsJuJkXsPPvQQ3Hw4gyNkMDbNnn2W/P50D6CnZ+XksMw4ODOcv6BgZ0FBLv4J38N/6cy79+yhCjyDgPNIAAFGPufwYXgNGzZsYIrD9u0bcHDgT3g1hw8fHkhCILYl+bmysvKHOGi5ubnMQZGfrx23HEz5saMHE54CRniQcRNPCmfeuWvXvqee2rR585o1a7bhC8uhESsooC0fz89GLCuLXs3RDRvq9+yprKoafMT8CGDn8mVY/uwYxZ2F92kvKICHwYcoyMdrwaPZMYR7586dcC9wS4899pjdPiJA0EIBXHdbTs4q3M8loqxJ9Ulhg8eHqUvHw0A/jOGm8KTHjx8Pv3KFdk5m5+ZmYQENO+a85O3YAZeFt0bvLh//JEZYv349jAY8u12tCjX42Aq+g/e46fHH7XgedhUWX5+XQ3NDfYMiTxwOnp+Ssnrz5sFXopEQXei73/0uPNGuPXvgPkR6GrBJAb7co0ePCiaFHXhw+JedHcKO4Rv+kP+qoAB4YRu+mkHqZYl1Cl7WU089RZfgA56fD/dAQe++S+BVWHAgev0KMHmZBpbga6BrDQ8Q6H3dd999NMnzEI7orogT+Nt/6qmBLA9A+/fvpzJNcOfwAxrbAv+HYoFPNOD44HRX69avJ6E35IhRSQc4ENgc5gycMA/PDD9kM0k7YnR+dIsU4D3AT3ZgMnI4iA00B5QdXBFgtDcCo8Gjw4PgUOzQXAuukosTAx4B7m3Ltm1bRyYhwPcEKQzN7PZNOPGA5RjjIx4EPCkNrx2zKdn7gtmYmblr505Yx2GCAbCHqZvQLAJZq/Do0a3w0lXepJysgLGFK2bDmOMLhVsCqISBpdWTloOBLkRyzbJly+5dufJxu72AxhDvP2DOF4gJT1MoNxd+uf/554fUVYdHm3FiPJ6Ts2X79r379tELZagLo7prFwzFdqTHMVOV3jhNLbhDGGS2ZKifQLnIwvmIbFuBj/bsGWQlIrEH5E8YtxycyXBlOnk+rjjk9KdViV8Fv6FJRcfn4fyE26MXQSHHARcaHiCQkPPoo4/CVfbs3cvWQno0dQe+zEX+pesGv26YyTBDGBTAjMKcX1pQstVn4U8EoiIlBePYws1sz8rasHEjxU6L+xGq5XPPPw/ozTI34WXBQKkjlqMdMbiEeC/4mHRYHi3qO3fCGQjeB9GyvaoRiY0DLgo7xIMAI/i/GiZK4aSlmbNVZDgOCxDsWCcB/vX4li2wQOch7+fipQvEk4rphzt2NUSE7pBzUEGBkHUJXgbhBYGQBbt3F2BG/I7du+luaRkKeHE0G0VCN3wSzMJSuB7xnDJigi9EY84YCtcWwOc8VVykIQqcHmpZCfb64NHgMA0N8kTDoEf2719VWLgRFzu4MbifLHVSCX7Znp29ceNGMZ3smsEP2LLxBdEQ5WGc/y5cxAey+wHLbN26NQ9ZhkY+W53DIc+vDSGm4eLxADjNs7EgUvBbGB4gHMEySiC6+JKm1RvLxkm4E2v+wCepkwGP9v3vfx+AlOs7iBtZATzi/zh2fHA7RkrDw8Bvt+Tnww3sOn6cTkhoUFJWtgVmLCJAHuIh3Uz4I0bnJygjZhxIIRX2w4fXrMnFaxFfCJAJ+fZJSoHNjiIiyS0RAYJ4iczVm5lZgFIBv7S6IgzymGIkc1GNBSZl0wxhYXAOIhjfkpm5avNmWqZJXB/sxeEViV9yNKvh3Llz77nnnpAz365W4cPzsbHKU6e9feCrZGsm/E6Uz28QIGwuLARA2Lp3LwwaqAyUvkePmYOB+rCzAdd68Ub8Bl99BX7PgpSHyz0NV0iV52tf+xqBJCkg2QNMs2Dwyfa/B/gdrY8gVd6DVXpGBRBoH8QbuEOCtYB7ACjIw7cDhwlAsOOaC4oGfJ+DhhW7yiaBUwgZOeTQ7d6zB3ipYN8+eDVPPvecpKLBuYYGtlziEgwjxn8+AFMM9CfZ3OAkOeqdkA0t4O0QysEn6EQ5GglEy9qBN6+ZukzpAylCPHjYgAB/rlixgmnWGzdu27wZRp5UngEfNuDBg8aZjAygm2dr5mowI5AsBJ8gbQoZOPCiSFkDIHC2mgC7AxEMpgEMYMDYCkHFjtU5aFQzgx5NiHl2YWzUvD42G3NySCUH7WwYXD8I7cLiw7l798KSRIBAV6dHg5FhEL1rFwP8gBEI+jN4bhOGwFU2btpElme6KE3voqKiHeiZgqsEsIyYbH7Mqz2//z78vACVHQlxPkAMHiEgkBgQ/PbhSxihw4cPi18J/YiAFAYtW/uuxZ37a/r5lDylEQ5pUYOXAq9mF1rDtOIBvZdgkPEbE3UF8WNJ/xHbgahChtxgR+ScOXPgfa1Zs8ZOOVyoI4RYCAQvBDwpjkK2fwWJIQGBSUS7dsE3a9euZWrg7t0kNAZOg4DnDZp42suRUJSHFm9isWAtCZ4UhHwSh/IQDTgEBZ1QKPhcbxJPrV6dRAVmUgOlcs8eO2qUdoQgO/IvPB2Z4wjVQ9w2Mg4V+qML0TItBkHYf+BUox6cQNXIQR0GuNECgrhPGsyAFyHi+UO8I827gE+m+hUUbH78ca2JjGRU7anE8XwqwcqLoyF0VWIisuNpz0/7WSIn0W5fvHix5J9q5LuxGwwIZMfIQ7mInTOId7IRJWg/F+UHkkhz8HlpnHMwdYLZtPPyGCDs2iUAoam5mdCDL+4aZOY2HzQ6kWyZg4O/A0fM752qn2ThBLH82WefDVjIaPRA6N20aVM+6q2ZAW9fO4Zq1qe4mewcf+YNW0I4fvw4DCBofyAIseqLgAZajlMfJFtVTHaqdRpp9LJDPimOMJle4bSUiablI2F6evKppzLxtfoeULPD7WYUvo7eIv5l0FvOQgEMDtuyZcsmKqmhAoKgPC3GaqYHWc9oepDlNlud/GJRBpyki5Kx9IYAwhNPBAKCRgbI1twwDQJs8NTbYdKSxyHoFYg/yeYA8w04S2j39BQAksxJpBUJxIPDqKoDtXHjxkc3b358O1wti2Y+2TMDLsqWPLQ7Pfzww3b/VcD3Wm8wICxfvpzwPxtvhvOshhdIeSevJbV4gB2QITOxPjmVfISZAG8cHpZWEzhGUtX5g4cOZeJsEX4oO/kvCGTs9ky0ZMIP123cCGPL0AYkjQFGzK4+AgkJWkcJadMPrF6dQ3qfBn79ABzN4wAYZNbj6q3mbfrmg2bcBgIEOBYGk2Hg7t3ZGoQPYEz+okkbhetqFgWyq/hNQu28RYKhpvgN8bDkVoDTbkMzmlZr1s75XPTeAkLSPcOLIxenT5nSXDETbxJUFbjQ0qVLtfFUcM8kHgQsvtmqeYfeIE0PJgTAHzg9+PIKgwwqQy7Ij3tpxMYIEIK4xq7qRyTnwDRet27dNpz9QqoJ/mEWspsd0YxOQteFYYHxhOG1o+vcruIMHQNj8MQTTzy5bx98nnrppRdPnXr6wIEtW7eC3pRFBU6D7jMbL5QbytgyNoAg3MqkJNKM1V6lAJVZZgTAJeZRlQBG7r///oceeghGIwflrny0jYO4DmcDhVpA6GYsY04eN3peWhZhTOC0e2HEnnwSPk+eOgUbSH0gdcMiRTZev/VIffwdQuKy21euXEkPTtJC4fPPryKb7c6dWQFDTXyNt2FHZzFI+HAhclvQ/WtZOBwJAS5ai6EO8MbJNZMj1CJ/vub1WnNz4dVQQiVsMD3sGs71SSma9YIWXxhkbbo6TMvDGFBHwmeeWitPe8Pk1bXj2vQwEixt8OIo8ioLV7FgGCHxD44Rb5Bqz5JNgLRp7bsgiRHGcN++fQDRcJ+b1qx5dO3au5Ytu+vb3/72PfcwUyQ8GnqN4Xh4vzQ/xwgQgiQ9/qIRqR588MG77rrrm9/85ne+853HccqFxgTNrCNtVCzcpDXQygiiGk0hWv6ANWDkRRieNnCU+aEef5yE4eyg2SJUm+eee05rxhwbQKCQs7vvvpvxID5IwEiSp57e43HVd6BFLRgNeDpgzB9s2rRl+3YY5AA9C+YIzC4YaoBK8bDMMbdxI3BE8IjRn8yESHqxeDvqDucd/ELwCBnGD/3oRyS0+InEGqVgB54Qrg6aMqxWMIfh5rciAS8XhJQbBwYEh8iRRCgIEEvEJ81AAL6KigpvUGA2q5afmUmYYNeKZ/5M+sADD4ipCPgD8z8fjt+2jXTzAFU0H9FgE1JAZAjJUfesWMHq9AZNSJpX8NvVKLICp9ANwygRN/mOVPEKZhSwCR0WwOnM8/Lww/uffhqekWFadjaPlxjtvPvBJAT/mUPwtT0zkyJyxX3Cfw8cOJCjzhB70FsQpgbxE0ld8shTn4nXgdGA5xW2R5CXKFCfMlbgexAbYPEFZF6PFYpyApY8vFA2NmMKUIrHBhBglYc7f2TtWlgUKJgk+PZy0T4AA75//36HJtuIDKF0m089/fQBeOnbtgW8KWLzw88+uxllTuA7GHCaw/RGHGpqA40Y7ACuEs6QJJYrzP7qqxHjACBsVzuvUZg6PFc+uTMEDmg+SbvPxllN0ZiinQ2ubGvIeZQdMAgDAwJ5NIA94bkoviLL/zVlq+Y+upwddRyHJnWL9rdu374N2VM7e8V5SCODK9LIAJ06dYo9LEgayJLBiyBx7tNPPw2fMKQBgEA71HQmWAWgy8FfMCVAPKDJT4DgezT1YDIkrtiwgZ4FDlu0aBGdfw4Sadzw7C+99NK2oOkxWjS4DUGgAcyNbXjPZOaC5/rSl74UHx8PyyKwDNwkC3tGOSGAcegkJAB8b8UKeNKH775bXB3m6tHnnnvm4MFs9bXSxBDpG5TaBghA7AbSGoXpciEh4FZxg6n48ssv29FyTicZG0CgO38WeBlGTAsI6vH5GEkFepbQarzYwkB0ObSrbmv6b8CbIkB4yeEA/TQzi5FdjYnSBsWRVxGmLozYaiRAUZhdeRiw4Sf8I8HLhfGkuEeyVxCGEIIVBA1XttptYQPqsHY1tA/uHC5E54HLwWuiCDduhhpKQqBHKHjsMXguwYYBkxBm1+atWx+Fk6uVrrXjY8eYarjudrQ10ZTL9mc6EjzgMFC+4DuCU+FcCFjLslAXBmyHYSEH4qFjxzYXFtKWvX9/+g9+8CD6Jojrd2Lv4ACBxI7a3NGjRx1qUMdjmZlsTPwlBG5AQLfUnn37iCvhoQC4RPwtTQ+BgaMMBCoNbkPwaQrw3vfvh8/y8vKAsHCyAzPdFuO1tJNNnIesZHtycwtXrTpcUBAwyelYigAHAgah1syk1dJ/M1QqxMjGXLS/+c0ZdRWmKerVVDEaG0BwqGlx+9av95ng/FccHpsBWo/dDuIQ9ZyF/68rLSUDOy21gyQ82jVEIwbjD2wICyvsLF++XFKTT2m4aIehEJp//ZyVSHBL8O4yUfATOUHwsthChoXrA8xlgo+yBjHXoJ4COBNsuxjEqMhuW5IKtIZBMYw0D9EeS8f/vwULggfnxRdftGNykJ2mh4g90Ly4HMy5oMlDUc2g7DCf++7duQGGKUIh1R4CUFOIQTvUJ3gVvjj4pNB08hMF3DZ9AtRQ2tqp4mIW85OXR36lAB6xI9LCs7OIoN27t2zdSrzv8O+NGzzmo0uDA0IWBcXBC1q5cjumVAS7OWg1X7p0Kf0khBUCeScL2Arn237NGURaGc1e0TdcWCDFs999zz333X//GiTQW3O10qzmWiSWUzDAGAMCPEtdRwcc9dQzz2wTBg3/FSoH5fadu3eTsA1/wmInYSIknKG2tjbcVG6V30FCI9mJRozS02jE7rn33tU/+AGsvKDFwCIVGhBAeMPodOBQEnJo6MjC5rOL+gMCPRpJsMGB4iDUrV27FrTI3egsCBDhQgICnSTz4YfhPik/IuA+KYaHW6Ht9vT09JCDQ6MH0EoqWyBvqpZnYEbKQIHntSP6kRFAO3X5Iq7CEUsmBX0kP3/LE0/Qlrlr18Zdu7bB6KGXgRJ8QkhTOFaU8frD55+Hz63orQhph6QdcpYBRO/C5LV9KDDYVfwfdaNBAA1uVMxSjf/AhvvQIT7Q/cDMBEmMchmCPVzChRrSc6qNmWevCW2M+cKtbLcztXTLlrVIsNjBzWTjpPW7kEZCCEDRsQEE+MmiFStgCfjWffdlorM4O8iuRTdCo0SARm6RzUePPolIKJTxwd+adsQyMBOTWbxxUd6Iti/4/sENGzYiwXD9AAAhpMqAjLYFVlVsgQ0Esw4+d2IrK66J+z+1MGaShhsc5EM3BiehiIhwAIEszJQ1QwmMgWIqNoeFp4M5Ro64gYYFRq+oqChHBa5gxwoFnBMgkA+Rkh1ytV5OjQGBeTazsrZha2DgkeBtm6q+BS8Bdk2gAil3dtRrgNn94kOCpgdFleRipqodPctkoBOy6ODTYyQUjoQAGywHK/fs8WIlnJDnoag2wsngWUfeHMo4CwAEkbMDhz362GNwD3Y17oLQgF5NXj4n2glh/dZICOMFCHNVKxCwVY5qOQmWISkPiAIOYZ6zvg/43skLbx849UPyL8oBJ2D56dgylUIOBIxrR4wGzS+nIAgQnlZN1iSh0aP5PbVmhsO/Hn74YUkVDoNnAvk7fEbmoQCBZGMQ3YFTaFi0QESSFUuY2rr10UcftaMJdKD57Ouwoy5DAVen+MAjmKtFc4NnTPvfp/Z5qS8w+xx4o+CZAOnLrrrCyey2Z88eii3ZAjoantmXMZSjKpgCFtCmsRNDy3LQiAcSF8hdEwcQ9lRWSlgtKuR5KIQmfwC5lHr5ERQLeKdZfRkn/5njxxnGosMoD1OidiKn0LCE3gLQQAMIAX3GxwwQJKxpA6/+gQce2JqV5bNah5xvxMaoZO1Qg8PtWHFCsHzAIGvRwHHmzN69eyllAC7ERkxNfgx3xEIBgl1Du4Jlflph8/Ie/sEP7KH0BUmVIR2RVEyiy4nI7cAgEzs3b8LOI+vWkWo50HymmENaSnZqu3Rp3hrcP3X6FnPDz00s3pe6GIWz+X4eMLVQ46YQVtBQKEZlbV7eRjWgOlsMd5CokE0RaGqgO6VGAqqAeHPjav6HAwhwMwQI3oEB4a677oIXMZCEwP3dmjo2kjq9K06dss+bl4eTZAdO6UDeUV+NdrMHXWKCAILI1CvEOifEoVkhX7rmuejPnZgGC5erxKEO1h1ItG5sbATsZdFNNGLa2LyIRmxwQID70QKCelpiKLv/CGuJzCkZkVRd5vMfg3xCYBcSPOx6u/0ujM8ZpM4AjAzDoi1b7KEkHIoghUlCtbLp/tfv25eP8QkBKCRuOMwtkNTLwcmfQg2FLAmrDx2Cwdm4axeIQ6QfZfn/yg9VNHIyT/1G8elujatudCl8QKBZOpAou2zZMkCtfLII+fOyHSUE0nDtGgkBdugbQgM6xm9k1KHwg+5gztL8Oe6AIGm8h8dfeIHFJKjB2DmUaRg0ONrRJk0fTkt1k6gmgHacOXeo2fEDjZh2RoUYsfAkhJAqA3tqoW6HAgQuwAwLEHKCR0a9EDzsZrRiDQ4I5PzdiYkkPglHHYFsNeyZamXbKaNwzZoCbfhxUOyE767C3nLUCHOmF6jKC8UaARqsRsstBevmY0aGTy4K+bJUolkHCAODtnz58jCrFEZEoyUhrFixYs2aNXkDqwzZaqrXI4884lCjj+DPPDSviRT7bP9pIBJJgiU0u90fDSYMIEia0kagC2zC97hDzSD2Za4FzFXN48CZWT0ENKCRtEmnnTt3rh0zi0Mkn6pnyMWabGJaDjZigwNCqKe2hyEhjAQQAnlB8wk/2bp164r77htSZeBPESAhaN4aDAyFK9tFEAIlLvmPT7aYgRhiGtFGBax279kDMwSElm/fc4+WecUYr0ZTTBZqlyS6aHPEtNNDO1soGV8UixtdTAjfhnDfE09IAxsVAQ3gmAF9W2heI0A4cOAAMcsja9fateH0/tyRq1YVo7TBTIqVoZmDu34JuRMMECRtKjQmNLESkRibtAPnHlmkA8sFqJ8U+0Fl3h3ojiT8XIrR7D4/TtCIsX73ZKXUFNuhUCUy1gXLJ6GNipix4ntqcRVMmhaTNuRUFCpD+GXY+WtFo71dpG75Dwi8o43bt69DC+pDW7cONJ+pztV6tD3u0NoQ1FES9lK7alSkUmD2IJWBDNcsOjcrC7ZtIOKGt21HlwQbc5zACxcuDL5Pu4buvfdeitfNx9DTHCxO6HNKhoLHgdJ2Rk5huh03bdqUhhWYgwGBVoSDBw+SYusLVtTeP4ZgkYIAl4PjT58+TZWOcjHeOHBuUzA81szZsGHDgjVrnlq3jo7fu2cPAK9dG5g0IQFB+9LhkXdjMS5yo9txegunkt/rph2su8h83xiOsgMVEGBSlsBIcf5B85yqeDExbOtWnnS/fTt8Se/3mWeeobp/IbE6ABAc6HaksjAhvAxo5YBFLS0tLaRpi7xIME/WUMJR2ICQpRoVg2OZKA90S2bm47m5qwoLSXcISeT4IDNOsJfBrqo8O1HuIkmGXZcgTnOr3JyIQ82KOmZnb9m2bcOmTesefXToDRMVN23evD0z8/sPPjjQrdpVqyy8IJgJe/fuZXXFMcyS0tZ8Jscg6U7r3KdXNiiXR0BDSghUHfS7mzZJGve3lkhfBr6gFS0wq87OhU/hZQAWgyuSjuznrBdzG50sWbjPEkUffJCKhwvmYvv4TieyhCAuTYGXdOc70CsNrApzOweDgnwOVu0ObnDyzTt2ZGBDCgfmQ9HS5rPZatAgHxN7gQW2q/VXxXCRVDyI8DaI2zFgHhKbUFmhB3GqhwxM+ue77wa2/d4jj4QpIdDLBdSieNeASEUBCIwxyX2/f3/I0aabESldOWKt0ZyKCat5eT9Gt2NRURHV7KJyoIGwnMPrzHzve9+jwVwVIdGvBvcILFq0CNRtOpKFuT71FIzARszqzUcxQGvtzFbHMFuNzX7yyScdoXLlhk1DSAiU2pyfD5D7OLJzSECg7wu0ISX+L4K0nq3bt1O6Iuxvx4hu7r31f/tUhVibmUus7cWIPtjfvXevX6ToBAMEr9qFR3xDsEBH0jx5aPVqeLmZKAkEuN0F14D4CKydceQIYMJ9J0/uxtjaEOFwmJWThawN6hiFdosR82JlqvABgdrQrMbCC4EZ3Oqv8lFIWLtunQNTurSTwatmMUvYMYEMGtqhCwkI5D0BaRCWDFYcM1QAFSvehWxLCcshEZhaMMDq7BsoQer0gJ3teXmHMf2NCkHAVTMx8TlYVWFSHEbaE9Tcc8894aPBI488sllTHUU7RAHR6XQMJUfTDF+bm7s+Jwd+LuoQBsMyTwfAXLlRrIowRPqzyi9U298epK3Qs3znO99h+/4JR9p6PiyDJj9/C0IKTGyR8xWcpkohNDAxdqBQR0WoKMxeZJTbMcZ+oFyGiQAIXrV5UEBlHhAVYLkRt5SHWbfcX+DPNSxWbc+ezZmZD2P0/hoc/OyAcDhNBg1AAV0ahgg4a/78+dlqr7FDmDDrZ+/VnCEAEKhB3gM4LWmR9R2s7jCbp2r8IYlCPCOxNpxnw8MPU0BmgDtvIECA38Jtw7qzQy2/EyAFMSEBi0XvVkt5B89kMhXCGag+iT3ILEAhzaCEHsbkAppOmSB4YMWSABsLT27asQMump6ebh8g7iKYKHeVJOeBgknsqivKq0nSJ2Fy6+HDGbhwABesW7eOxL+AtCkhMpHWuWtgHSpSCifbkUw6sMQ8+NBDJP+I905mK7h5Cs8O5FAVZllRKZRwampqvCIrXJ1vAhBIzIALbcVS4ZTrJG5VuPiJtSeshNDY2AjP+0Ncg0KOOYwhyckOLKuyQ63pEQgIu3Zt2Lp1zbp1qzCnmB4iWL8WUlYeZjeLi4qVaOOmTStXrgxTQoCfwVReqQJvYLClupNH6c94S4Q8QiiCb44ePUrxtyGLyIVMbiJjYD7alLRPp2UB+hVPtTt0CEQRsk7DdeHnFOcJZ4CplR+QbYo7rFA2Rv1JmiQaO6oqoO2S9yEAbMmgIfq/wPFXrlxxaKrukzRIQ0ddBUVxJIea7Bbw9gF8nnvuORBOfvzjH4t3JPo5Smh+eaakZD82l8kNzndQ5/kujGSgZdo+enbFcAqk0DcFWDceMIFqgIj7/z5278pDV0vIOl1UJDYfDVxaQKDn8gMEtaYcSQjwHUgIlHwqKomRPj5QCaBxBAS6CIVVs/xBHEmQ+r74xS/SCWFwao8cOZKdDRvFstqxTtEOrYSgjgOFa4IuuX7DBmAZGHCQAexqiJf2BeVpogLsmMJMvAn8QkPNKpCr0cvB5p1AQNAMVwgzghoWQoVK4OX9WMU9+ITJ+cCqVauxz5rf5fzF3ZCAQJaxPMxgylOrVQdIJnmoRlFmIg24mITEgE8//TRBSp5/YoLYpywkQjLiRBoi9nco5yyVj4azbdy4kcpriytmaxqhSpp1f8WKFd974IEfYCTnCy+88POf/1wcQwbPe++9F2QhuMk1a9YceOYZgAXxIAAL8NZgKC5cuMAtPxpHpHbuCZVhrAHBHxOoTJwdRYLTp0/v379/ldpsIk9UtAiaA8Tjz+GiU15eDiMAvLAdXYd5GnePmKsUewBswrVaJBgfGIFHH32U4rt4TI52wow3IFCCKtbK3UUGakrQg5tfsmTJd7/7XXrFDixmQnf02GOPbQtQGdShyMERAMFgGw4UCYfZWL0zgFOyNQlHRBnqJZ599lm6fz5iQSw2CCCwF4QvIhhD6ACW1ABSB1qSnzl4kLCdlapAq4jWaxaQAx4SEMhFm7djx8NbtpCNPfgxs1WlHj5grh45evT0yy/D+g7D8tijj/KVgvxcQfOQ/GVwWiqhRmcVWgPMZF4GOXgCa4r7AZyXlJTA8rR27doHH3yQfg5z8v+3d229UR1JeH4PPKJ9QULaN14s5WUDQiaYW1g2sTdcYhvDnDk947ksAzYwHoyNbTARl4C0SZTwAgYpL0j7wAP/aLa6vlPlPufMzcbGklOfrIlj5pzurq6uvlV9RY8jygB3bfAnrHGAHlJwEmANHCc6dCIEH+5KZqHdpob8T4yA2rr5u3dpy1DN7KHUIHC0F1R9yL3MDhoE7Rqsb8HEC5JAXISFiV3CmuMujEZNRxJEksSoRJwh1MMukE9QjXlyuTt3/I3n9DQVhL1qhRU7KU4ULKzqHhoE0gqwxyBwNdlnsbWscJIv6rVWozHn3BzrLWzpbHiNGLSCpFoslUja9M4W39+Ba7fRgztORxAJlirgHQbqdb945n+qBPyEmeb3Mgg3ikXPescukfmTB8frOiQ4q/ERE1QCHIybAs+t23sZBCe4zg4nXbcb2ljk+nTM5ICrqypf4iMQxuUeDBf/d5jWI8OmRcqJjUZoAMOjLWTGwX6EupLWdUrQAZpcUNrWJAETVWNufh45DdfX1zsc7UWg5fQ0Z4FxWHLz+YDneahW6T3t+/dX1tbqnLKQ6ka7xLKeOedEQV3TYt68QreY0503COlxnfzC23xwpCNJDdyrNr8WTAfoBWjLOkeCoxdILKQDJCgy1A0NsA0+I54HYRZKzG+jKZBqgUdZ9op2rw0CGQFcKIPgwoHxGCk/eMdUY7ZAJF2tcE7GarjxCaWNI6MbN2h+oXe+efOG3r+yulrOUCPq9McXDTXOGYQx4n1iJRNrSmLphnc1CAVlXb5wIfGKzK8Q+LMkiT6h2Anpqz8b2pxBNj1y+64QtJs8ZfSlS8huFlZYX1KSZQA8MGfZk9DxJmXzxCO3tKhzggMq69atW500vV5yktBuF5V7LSclf5UDnUR6Jr6Lh0ImWbR4UaQjAs6HVDlqDi0hCuxT/Yop+kmfoQmaXAB2rMbL74ipsyt8/aSU7BlrgJmUhskSD15Yj103CEE1ssFcksnRBakDU0ZMvoblQTk9Qg8dOkSfZGMda76niOmmbDCziWHnz5rk10tqleO8+lyDkD4e3KpB0OBuh9lchFaSpIcJJ4a8AWcyqeleutuP4mp1mlfjpL2fPn2CY5KfDXleTrkiyFOzclCAgEdITK/eYicslOnm9zII9Dk6OuqTSMItpGsvMzS9WirPEf+CxUk5fU7YxyAgdoMGUTJfZ3I/BaXHkjEQM+ymQ3i5HKfr6a0lz8W0cx9nImscBegQcGITSAEQJBJn0q8EjcXI1QWDJtzZPMZ0iQGBefRkVlevFiQzYLPZTAyCTKNYWieWhFPHqg5UtftCa8AzMmiLsO+mveqXMAgqeTEILsxlk/nJ6AlPjlVZ7ahOaqGYgMi+RXJInrIqwe9KIe6C5HclIcNPjevAIOD6SdVb31nuEeMPd9yMQYBJz4bJBAYBpFtKfotLZyTlyXw/lsR8WtU4nT1Tv1xC+oBG43IUzfD5AzahqBXmqSyhsaoK/ss2FqfTpeDvtbzE3KZBaOUofJG56cfJSayEQe2Vf7yXPoB+n2rrD/DZS1NXKb0MQiHIdDbLi0N0fRQWFMgQv8bpiNH8F6p81kGd8iMfzyJPXGYU4HQRPgBVJFzu/U7NO+yCdLcpSyuzoe+FiYkr4+MFuUahhTG8CzICjNPqoUYvo+ElXjz4FMyl0g8cCd4rn+wuGQRYV79057AsOId0NwXpv8xqsuNAabVQJ5ewiw8eOOGoj3I3YqrM4fWTk+TsRdbPcM7FDwxCJ8+pyCYhtUII+i6/Qgjj5vJXnLHkZrqfDgtS8jFs27NldZUYXsjAXOMd9pjP1jsmCUk1tQjundjXd7fPQbRsIjH+DrZ4/gwq5C/lHxw+LOQMAvjZHKcsr0gGmeQ4LhBCvjlJLjMOypuamqK2NCQD10CDUJDreGp7cX4e3la10EWtlwnKCFOyr0Ke3hpMTqrLUH4UdCTYvME3O7Oym4vYkXjIQrXomH0bbjabVHdaCU9yzI5mfCYLGecYnrPq0e3vkcRAeRZNSYW2g8eJAwyCdC5VgDAxMVESygKXCbEXPYwlj97mZrl3/AWOXjc2NpDZ3C+BeH+RxON0fTmL0TvY1Ouga0sUXlASt2cMzyzrshN3jnT0Osm5QQ2vVuckaB1PIbIe/tg+t1rwFFpKf/RkXHxfhlB3GAd/3S99FxJBZCaROF0N7CCQWgj2B+MiFJc3ocvLl9mygQ4llr1bSouCN+MuoMYphJIpXiYjfMe78VQqc9KKsI/0qOrixYu4y6sFKZiT4vQzbAh/k3Rm5vr1iIsoM52abqBAmHP5ypWuI5Ra/dWff9LK/tzqqvfHRqeLWchYvHyTsajGjRjhknPT7NzVyxqokqD7bszPI5lOMqnJNWWvpUjyd4kjgxXC9o0aDj4r6P/IyIhjCjVPP8tBviFBSq8WoYOxOcIk+0++qTya5oTfKQwV/sycirSljbgrlfBtU6t5x4rNFLauJNJILndcjyUNNJx6nH4H+6hnGmQ9RzXw5rJQzGEBSQKhVR/uXOh/aVTWJbsfdu7gwO/k8zLQC7mz6nw1qD81TCKVyr8vXXJBggxw7zhZV2SeShpbqdxkr2w89UrCutEuBNWCPlG5jBL9Qbt4M6jseQRaMOO+FddwGXFhFpvh8/9ZGemQTNgX5ZCUjxcANE9VeRNND3iJaVt410NfQORp3v2PNg4Q3eP1dS9wHptojvYRCkUzcQNVZDN78uTJ02fOXCsW68x/1Qi6iX7obfSvvQbpATLmfAfn7YZjJwHufUheGeHCJif6I3Wr8Ykcveo8+8nkmSXy8AdB7Lx9lQnVE09L9CCTpKV6MND8quRmxakmZiU/lXiP2lkdAqqHS0tLOPXFStIrYUY9pFFhi3Dj//r16+WPH+E/NmBsbwtDhj/T2g8rFu+Kr5k9JeI+sW1M34f2Pnr0qL81KAQaDjNIApyFJx5mPd0O8PDxZ1Ms5KdPn3748KHDmSDK7MPsJCqtKjHF79+/zziFqr31MVNgPJZL9gonPKWv/ePYsbDCWI+BxM+Pbp7r4SgCbbkRTs1p911q/n9/+YVqiMxrFUlE6Jzk/pAppgyd0ag6PibqRa6rZf36228vXrzwOZJYMtpflaA79HB1YWEB/mB+McZxIk741cu12rUoOsWe513ZNvAq7O/i339Xbdf6qxpD+ZFpkazHS0l5TKMet6soFJ9l1u0CnzB3H6es8Ais+PWPP56/fEkaqHe4uirKCDN5ebmMLEtwfhuohyGUvYQ2SseYBAx9XU6v57VcjTGPYhCeeZ9GknOr1aJZ6d27d3l5QpgfP37EOhbGLNWEoBOTPuX0Qxc49QkUmzaQhb7s3NvG8AQp58+fpyb867vvzpw9i4SqNDyv8bW3d+ycmChHES1mzqyvx+yHOQx7cKhvNIjAIaOeujigc+y6sNBuLy0vP+KpH/KkauPUHbX1s0+jgQyYvcqKeZOITZB2brvdjjkXUs+n2LEwfKohCwacc+afQiWRly1mh38sNkhiNIqRdwmk4npShyQUA+XmAh/4UGL09+koIoFc5/Bn7+7Saj18+BCX4B2OOVpdW4uCg0Gq/8PV1Z9fvYo412EfqAaeXl8vC1/BFNtJKvEauz1c4IBrWAO0Ai79Bw8epLFFyjODxbMsiclQDLw9DxtLRp4mmpWVFWogyQoaEgk8VQIv8BxTsNLihCx5OOP0LyhTqJMtPy107927R/YZpLLeT4zdHpB07xorJ30HnC1Ii3P27NnR0VEXeFHmhamgryFZZIFDtpELz6sHqwqpPfQ/ZubnI0eOFIRuouubdwTDGwRkj0K0UYFpXhYXF++1WvTzoN1++dNPjvuowMqzpTrjy9p3x48fHxsbo30rFUGzG3XHxsZGJx0P0knn9QNeDUpqk/maon9tuz7Vv6zEjItvP/1OK72fGTROqZfRrmfPnkHBoIFYGg2Um0pAJXbixImx06fJUE9OTVEHURHqtxxG0HSV2LA9xQbh71Li2uPHi0tLt27fnpqeps/WwsKypNLoGtQDe5jB4ELTwwctooF/9OjRU6dOkYZgpFDpz54/pyUZ3ICdZBj//IGDl+CdNB5p1KPvqNBiFFEFinx5AU8DLRqRTQOps0M3sMOHD5OVIyNAMxpESi16+/YtaciWxPX52AKFGtOw00IIlj+UlXYWCQLhctuoiTY8zJ2tL8/nM8po1zwjv/XOFEFfwDfDQdE/PUr+qWHKAiCornYsb9Y+X2JfB8leh5EY4oOGHzX6rEYBaIlqnfINOXDgAA1hZOTcdnvzLQpfFVpIqtJOee51ghgovDPfZbD8yDm41aLxVOblmU7ctnpsD9smWVVZDRxQ24AKajdevieA6BCdtxvtIolh0K2urn4BiaH3qawvVmIGqiEQZhgtuHvAEk4XhxqVvyPl4lUq0i/Tojx2inXZYDDsA+wU67LBYNgH2JJB6JO5yWAw7AP8hw1CZQiDMMYrhK/NIBgM+xfx3bsXlpejRqM/DTsMQsEMgsGwrzHNbvMzzFrmo/gljDTx62OeyVk2CH9rNv194i6npzcYDHsIXKD4xDRkEJrNCjKtS7Jyx4FyNd4yHPjmG/9AjobdYDDsG8AgnPv22ymm7k8FoXAcis8LVqmMnjy51zU1GAy7DvWSqt+8ubK2FjEvB4II6Bf6vOxc4/btc5xNwGAw/BWgeRa+Hx+fYUSlkqfFaDbzXDoGg2F/g6zB3NxcQZLhqvs0EimaQTAY/oKgvcPIyMg0w3F+qMXFxSdPnpg1MBgMBoPBYDAYDAaDwWAwGAwGg8FgMBgMBoPBYDAYDAaDwWAwGAwGg8FgMBgMW8L/AUtJlu4NCmVuZHN0cmVhbQplbmRvYmoKNDcgMCBvYmoKPDwKL0Y3IDU4IDAgUgo+PgplbmRvYmoKNDggMCBvYmoKPDwKL1R5cGUgL01DUgovUGcgNyAwIFIKL01DSUQgMAo+PgplbmRvYmoKNDkgMCBvYmoKPDwKL1R5cGUgL01DUgovUGcgNyAwIFIKL01DSUQgMQo+PgplbmRvYmoKNTAgMCBvYmoKPDwKL1R5cGUgL01DUgovUGcgNyAwIFIKL01DSUQgMgo+PgplbmRvYmoKNTEgMCBvYmoKPDwKL1R5cGUgL01DUgovUGcgNyAwIFIKL01DSUQgMwo+PgplbmRvYmoKNTIgMCBvYmoKPDwKL1R5cGUgL01DUgovUGcgNyAwIFIKL01DSUQgNAo+PgplbmRvYmoKNTMgMCBvYmoKPDwKL1R5cGUgL01DUgovUGcgNyAwIFIKL01DSUQgNQo+PgplbmRvYmoKNTQgMCBvYmoKPDwKL1R5cGUgL01DUgovUGcgNyAwIFIKL01DSUQgNgo+PgplbmRvYmoKNTUgMCBvYmoKPDwKL2NhIDEKL0JNIC9Ob3JtYWwKPj4KZW5kb2JqCjU2IDAgb2JqCjw8Ci9DQSAxCi9jYSAxCi9MQyAwCi9MSiAwCi9MVyAxCi9NTCA0Ci9TQSB0cnVlCi9CTSAvTm9ybWFsCj4+CmVuZG9iago1NyAwIG9iago8PAovTGVuZ3RoIDEyMjAwCi9UeXBlIC9YT2JqZWN0Ci9TdWJ0eXBlIC9JbWFnZQovV2lkdGggMzQ3Ci9IZWlnaHQgMjMzCi9Db2xvclNwYWNlIC9EZXZpY2VHcmF5Ci9CaXRzUGVyQ29tcG9uZW50IDgKL0ZpbHRlciAvRmxhdGVEZWNvZGUKPj4Kc3RyZWFtDQp4nO1dB3wUxfef3b27NBJChxBKQBBpCkFAEQvyExUQgZ+CiqiIPyuKgAUbSLUgCIpgQ7GAIKIiKCgiWCgqTRAQCL1JCyWX3G2b/7yZ2bvdvZJLLgH+YR98ILnb3Zn57ps377158x5CDjnkkEMOOeSQQw455JBDDjnkkEMOOXTOkyC63G63x+Mh/7rEs92bMkSSx2X+VXB7pLPVlTJGgGN6wzYdu/To3r1Lh0vrJ/HPHIqfmo5euCFn3+FjJ06cOPbvvu3r5j5dHzlSoSRo4EkNW0k7NBhJwtnu1/93ElBLjFVV1YJEfsO+25D7bHft/ztJaCD2Y93Ms7qu+/AU5Cr8ZoeikYSGYBnQNBHWZfw2cjkSIT6S0IMArYVrAdpJDrTxkoTuDQOtgsc5AiFeElEfrITh2jHOMhYviejWsNAOd6CNl0TULQy0Cn4Wec521/6/k4iux2oYaIc4XBsviagD1ojyZYFWU/FAB9p4SUDtAFpsh/ZhB9p4SUCtsaZjG9dq2v8cWRsvCeiScNAq/Rxo4yURNQFJa4NW993pQBsvCaiBEgKtquf1dmRtvCSgugW6ZocWn+zhQBsvCahmXhhoj3d1oI2XBFT1OA6F9sj1DrTxkoAqHbaZYwDtwQ4OtPGSgNL2hYF2X/vzBVpRKr0d1nI7wkC7q+15Ai3gKpVWaEDKP2Ggzck+X1zhDa7MRMhdOuAmb7B5FQHarc3OC2hFlPm7b/nTdREqlYChpNUh0Cp4c6PzAloPeo7wEV77fC3yc8nL3KTlYaDdUL8MQSuIoiRJohi6j+pCc7Hf78d4w7AMIhZKeqM1cVkYaNfVKivQCuYwILs2IKFfQKmXFYy3j6yBSjocK2GRbUsXoF1dowxF1CVmNLioaeOGtVIQKJsmIuy8Dvx+WFc0jPc+X9X2fbzkmRcG2lWVyga0Aqrx7NKdJ7zefK/31J5fX2lm+VYUE7eCegTgAsL7h1ZDJYmu+/Mw0C4vXyagFVHd37GZ8ruZxyUKydup5gnDp+Due6ImMHMJNe/6xBr0pQO0vySX2PPPJkmoD85XCGiUNNWHfw0HrY5pOBbWVB3vHlynpIwIwTXNFk8H0C71lAloRXQ3lnXMYtrI0DT8i3mpCnAtHTVcocGC9mT9kjEiBOktG9fCXvmPRMLH/+yzThLqjWWNsSX8VfH3NmhzgqYoA1clqtimZxuQNShuFUkQJ4RAK+NFZSN0WUI9KbTGyDT8rVUgsGXMNHTys+LDeOOLF8RvoQnC2DBc+03ZCFR0oa4WaFX8lVlfF0XXRqsDhbG3TDh381gQC3FNXRENDwPtnLICbSesWKD93AwtWU9Wh0ZhUHBljHPGEPM3nskromfCQPtZ2TDGJHStplqg/cQyMAkts4dlcamgKyoxIkaDhVZscEU0OAy0H5UNd62ErvapqhnaaRZoXehzrOm2HW0DXPJO8MFh1UFmFq91AQ0IgVbF75UVaNvnaRZorWc03GgEqFtwQThwNQLugWczimtEiOg+qzVGufatsgLt5bm6BdoJNoHQwgvrHEMyVORSI2L/0NrFMyJEdFcYrp1QNoJnJNTmSNCvB9C+bF1EJDTKjzW/GgZcysoEXIVYaEOJtuAqMrgiug2g1a3QvlJWoG110Arti7bpKKC+v8hY9TFwrdiajIhtz11YdCNCAK3axrUaHlVWoL1kjxXaZ+2qj4jS757nw1o0cMGI2PRCo6IaEQK6Ccxs3Qrti2UF2mY5QcaBYLbBdmgFwsXluk33GuDa0WXgygTcLaMaFs2IgIj7UGifOReghY2X+J4goYs2W6F9JFRhFwm4ie0mnsDhFzSThbZ9bFZRjAiIuFfs0GpDzgVoKcVlakqowV9maDXtvnC2ELxB6aJRhzC5gLpu7aGbzIggatrulzJjNyIg4t4Ora48evahFVDl+fNvRHGBK6F6qy3Qqn3Cm5kCTI8aT+0gV6m6HglcMCIOjM2I1YgQ0KV2aDXd/+DZh9aNniTjXHp9Qhy2pojqrLRCe2tECx7eYIUHNxL0VC0iuGBEHBwR404ERNyrmg3agnvPPrQu9AkVfos7lQvdio2RRFTz56DWDsZQt2jOEdJI4p2/5RGVQNVDpK4BLlnsDr1Qm0mR6CSgpqodWpzX5+xDK6EF5CWrRMB9e2MaQbo44Iqo+hIrtDdE3/ODNar7vGNkRVPCcq5hROx/nhgRUiF6roAuLFBVG7Snep0L0P4IkS26ImP85U3liJZUdHBFVHmhBVr5msK2U8Houv6jAxj75VBwjW0eoi3sfJboue6o4Iqo3mnNDm3uzecKtDA2PwH3i1uSkegpqswVUYVvTNDqWG5X6E61AHi1m5CDsU/WQxXdoBGR82KT6BaagGqbPBgc2mOdz757xoCWcomC5a9uT0BSEXslotQvzFyL/a1iCAIQ3KSZi4dvjMq5AO720Y2jhTMJKPPfEGiPdDx3oOVTUMP5i+4gYBXJjhdQ8mcBaOEt+ZvHpMsJLtJM/QF/EASVSJyrU859pWHkkBABZey1BH0BtIeuPBeg/YFtrnBwCQN5l/bmKmiMJKCET7DPJBD8F8WoJgvgRax++8+Q80jnm+1W3V9ny8CeMYmRwBVQjR0h0B44o0HhgigA2bRXCX3DD2bzSQlO64JfewhFMCIEwfVBkGvJH18RMphBuEDajYvUoBVhA5cbEW9GwkpA1baEQLu3xZndG4MgTfurd6GPuUAIcAko7MqvXWI3IsgLe9cELdEqaxfFuAO7wHXFnDxuAIdyLigweH+DCPJbQFXWh0C7u+kZhNZ9zbB3Pvn04zceudAys9zoRewPDIiDSwOzfro+hfFUoUSwmRzwRsPATlYrYrQctJL94XGIq9HsKxoTuVrB/RHUKWKq/2lx2EIPdjY8Y9F0AupvtHz4MkuQAGpxiqwiAWcU31Kh5tD3MRoRBNqJFq49VrHIgYgwQRpP2kPEghwKLt03eD2iplpxeQi0OVlnDlppKs6TCfl9stXfJqD7thEBYBoQl3jggpp/c/kYnKdkyRsHrB/g2kNpxYjxBKsra+Q62G/QQrfQILYhkgKW/pNlcwx6sLXmmYNWeIk0T9vNt2UKEFDDF9YS5dJvB5caEV/3IuAWZkRIaAz2BaDV8d7kYnUS9OmMQUsJuD4N2wSughdH9OGWX2iDVsVbqpxBaEeCegSUf5dtZpEB1XmU6D9yKLg+BWsL7irUQnNBbJCJa3ckFLObEulZ5b5faxjbxa2Cf3VHEvypX4dw7ab0M5ZlVUTPsYVGx3m97GqMiwyoWp8FVjlnGBEqln+42xXdiHBBbFAAWsIzxR8WcG6FrpP3WiOZ4KHLEyK5GFPm2HbLVbwh5YyF14roSQPa02EyBYCcS79hFvM3YT2oLoARoeGCn/sK0XynLvREYHsKeOaveLrqIS+67Z/YaroStJZ7InFtkslg4dCuTzhj4bUCetSA9uSN4ZRv8IsmtX7fS13UXBzw9QyMCN/K26TIRoQLDbBAuzqejiLUcd5hv10iROPaxGkh0P4hnkFoHzCgzY3guYCOi43fOAZdM4LjDXCJJiavuNkTaU/FTZ6umATCiuL2kjzddcMiL4fTKmt/kSKh5ZkaAu2KOCOXhdh3BUTUz4D2WOTj7PC4WqP3Y7a7YgKXGRG/dY1gRLjRvYEoUIB2abGGA6pt+Vt+05hVFqIhfBspYlZwTQyBdln8QeGxBklBvkwO7ZE2UWxA4JtqT29WIDyTAYuN/1TCustuKh9OLLjRnRZoFxVdraVsktFvDaYBYOH02ukRnQjSqyHQLo4zcjnr1pqxnrSAfJky3RfBh6N7LgTyZflHfs0nmm4wGEMPGBHf96wQqjG6Ue/Azh9A+01RoRVg1+GCx/+G/bLwrlsNj45kjQnCqBBov43LhSAI4/AfD9WOLY5HQDczY5CYSo0L0aZFMoSkvl+dIosXDd3kk5OGtxAj4ru2Ife70H9VNXDGBqLdi7SG0B2Hi4cTo9Cv2N0zhg9BPdk7IrTohRBov4rPO1PuS/KY1QNjOq4tohvZGq7hgxcUaqiI5IHumz44BuDqAecCs9D8+LcQLciFuvs1E7Qzi+T4AmCvmLDb3JgNWKzm4+3pkRQEAT0VAu3s+Ny1qfOxl3DRmqcyC7fzRXStAe3+2jHYgCJ5oNRuwiFDLJikgvrvdfZ+u1EXr26C9oPYrUwqCjp9cIABGy4uQaebH8d6RwltGBQC7SdxQruAoAXHtdcPrUz00qjiTURXMvVIw/tiO3MN4KKLR+4hUkA1GImOVJMH2KemG91wEpugnRoztLB43TTnCIiC0C0cY5dBxXmTW0SeCCLRqn2WAFuNvNy4BAKB1s89nH8PLR/dcS2gtnQNJ63uqRzr7gossvUGb8U0HWQAXBUPC7GUUcdjZmhfjxFa8IHfuuQUvL0Qjg0oJhr2TmkmRemziO7HQRMD7lPJy42La1P4NirVVrY+mRotSEpA2WwN1/DO8jFLQrrbU63/xiA/UQ3z1VBor/7XcPQDtC/HAi10NuXu1X62LxYWWOo1Pj31oui7SSK6h3owglUZVPxGfNC63sT5GhsO/JczKD3yroCAmrPzRyreXqQz13T7seturJmgnRgKbbv9ZmhHFB6GAF2o9MgWHDb2ywRs7jsXokK26UR0OwHCdDfpYnxHGUTUgSzhKtsgoBtaOQOqRjIiRNRIpuqRhje5inCMiLoW2r+TaxIIMN9DoG2z2wztM4VAS99X5lO7KFOEBRZYj6wih99uggpNnSCiS1UiqwkRDZD8S5Yf7Y74uFZAjaZuD24QgBdl68O1whsRIqrv1Ri0f9k3dSMTOMQqdp2Pmf82AO1LdpZwoewcM7SDo0IrEhkuNh1xMLhDHkaPVYig2D+1aWwxzOITp7CZTr4d+xgjPBGhhqOIaajwHQ84abFxQFY4I0JAdVj0job/iHWjFgIwMvsuAUPBWMaYIHs2lGubbzFD+0gUaMGr7mo/4UhArQu1vFjQzK43WsQeeX/Z2HkrNuTs2rUrZ+PKb1+7MbabohE45WsP/DkILnRp/ZAwx7UFVPsIhzZGpxD4x+sPXAGbD4Hhsw1Jb/9Qrm28MQitjO+LCC0A6+46jQiYsGcaTMCOu6QIh3HguioXZrdt26ZVo2piydTIcxH+qdF3obH7gnU4abFuaB17kJSAMtlCo+GlsTQMz200fI0BrMlk8OH9re1cK6EGa41NVeDavpFCBkinPL3nnDSAteJqWF5k7u19pWXRTjm5TQap6CmhwBm643HTbI3vvrDj2n89n2HVxARUcxeFVsU/FK5Ng4xtPukfy0avseeA8fSQB0go6w8ztLeGXdJBT06+d4mXWMuRgYVNjf0jmzJxVBQSJBcUJXW5SjIxJoCbfNX7hF2ZY08H82nTsErm8QmoxlYO7YLCuBZEcevpBwDYIMfSgYM2tGNA+ZAlW0KZKwJFv0gz3cJAC+pW2qNr/Ny6s8pY3Yj3Iuva4RENUWHG5ZkjUJHczSeepE5k6DRoZB+lmgYooGobuUD4MjrXwj1XzTnFogcNtxfzhpOH7ny8chhlSELVlxFoOan4hhBoYQpVfmYLhDwZTnYzsPQ32sCR4Vm2hGxnm6gKXnvU3kBX/fiw2fknoKrskIyGZ0fzhIPp2WmhbLaQuP4Ov297PC2sySehKsuwD14rgYfIjOvCcG2tMXup2aoHNbkgsDoH9ugoojwWe5ubhgsW895CHkyU0KGbfXRji8xduZMF2sosekeNkoQBpFSF7suwOWiQS0BwnvzzRGoE9Z28kQ+oasYyJvnq2S4TUIVxxwNTKoway3JOHBidiYqZW00QJa5Skp9Kg+fB89l3F6R1AAOiq4k9BVRxGfU4QBKG8Ge6ILgr8+7fuSJv7IyxpYWobX8NTo+8X05UhBm7juYTc8h3Yt/v/w15trSYLQQBN48NWPDf7YSjYsVkWLp0CYmpqalJ8FO8xzdDCI5qJrR+eg/lObLidLFAm/49hzZsEgYaoH3BgDXsUIwlyoOqHOueqMg8YREIfGRd+g98YvCDt17qsjOeC3VTQiMQTQ2Qte2fV4iMLe5+Ful7g+sHvPTujBkz3n91cOdGqGQjQKkyfv27p3iHNdnb0aIKpM3n0IZ4APixgubD/mbHCozVm0EM4/59ULXCDCNTdE1IoI0bva6r4YBlDRDVZsOI+nGktiVW/MsbTQ/eNrl5CWILjuvEnl+c4pFoWPfi/dkWaMt9waF9OQRaALatcRjGyrIw7hUP14hl3C4Po1DWc6GvYCaFAxaDr+DvYXGl+xLQ5VvhQT5OZJLt6FZSZ5sA2OR7fvByK4fN4ZEey3KZPINCq4WkgYAX0OH9fdZTRnpg3CvvrxFnmjOB5q8NK2RV0s9NQxrElQFQQHX3YK+sGc/XNbkAH7q8RPgWtrfTHlthKOMsYmB2pySryEuajgt0jYD+fAjXdpl71HrwkK308Jjf76oa8348GEKS5ApZnwVRWIOtJz1ZA+Cr2/ZYVrx5K4WJmMbbGJ5w0FHAMIpbT6DK+HMbVboEY7p7gfGX1yTYtRj3ZMzcxY9ZoRUECPRS7Gos9a6vu7NCrAd3hQg/w6+i629TdFxAjyUd3T6gVvzreY1T4OQ3TTcQe0c7xXsEB0ZR26SMQ3+1r6+UQnbKBNTkq51HTxzd+pY1GN6FuuoBR38wiA7ez5o7yhUh+5bY8uHxH8/5bMozXVLt3whJ2wLQBoAl/+x8tEpxM1CZno66B5Lj8ncHEjF0A69oBLkbmkzIDSjj4JjPn98eRdC7PVWrV7JziBu9hm2BHPR8k7q2b2LsaqaE2i4NjG3Pg9bbzElWMQ/PI2y2nSjKxc6bZm56VJjqz378SZwSIaHNlHxqO1HXBlkTTizoGGlrl9gEcGzMJt1daKZxuIkzFDi3/H/2SywCP7lQu4NENMMGigoi+wXLOwlCa5h25JctTxayBx0rCegzcI7YsJXx97HvroYhqcPHCvUlGm7OY192jHxkRrL9b/y2gC8xfNx+FfuX9/MURQKKqNw8nBcYGGHJlpGyLsObI3Ni7dCKxTcQ7PRTSB1dgHZlLJEsEUhENwc84ESd1fGRWddHPeCe2aFbj67tbOnJJbSYiyoGrIyVJf2TqGUXM0noqtOyGtitJrPoLcv5KUPW6lwtXDGkaryZVc20OkwuTBn/1SiOvQbPO/iUAayKc6f9h20ORKJef5wgFx+Z19oyUfjxZ7a2QFmKhXeXQ0LRzu1DsXu/aftMw6ssvmLRtYEdVqWm3fLHYrJAYqf19gyu1B2/OZ4iOCzEQ6dz2Pt2BwFJ0U62t1KoNCQLvyUuLXhonzLUd33Siz5uEY0wBV0BtHssB8ck9CtWuQXyy30xB67GSuvDcC2BNh5jlwUm6XCMY/IVLrrxFJEENBKfVsGZKvssaVkMrqXBs9/2qFCMmUre6SRz0BX54YhF0EGhFhWD+rLqzurFSZsYncJz7aZ4TulSaEl//W9nuwpdFBK+YNsARKEcbYV2EfQMmOpH0EiLkXxGQAnvmbmWQHvUIujc6BmaOuiP26sUO3VQFFoVdhlbG+m4dCyUOh8XED32gxahBkIoJS3mm1cqnmRWpl1oNjPFlnaJ9bSznQSU+L4N2mOW8GgRVd9NTLvbowROFZ8EtDDsMvZLPEVwkmZg/eTHTVFMjvmkH1nAvT3UzE00bqJo/NLZXWw1U0Dut2wC4Wg9y7gE1GDQjUklo8faSULv2fVayrXfJBbfzhPRnSc+bB5rfxPnM5vFntpVRI12n1jSrSiZJUJIQC9bljEdHwwXaFo6B7kkNNBeE4taY1Pj0pulmJ0nRAGaiL1UHsi+R2y+zIaXxTlRRTI6fyBMFDSEDSEujGIlEIut8TYUWnPoMmxpPxSXD0GI3RsnoE5gX/iJSbDPlipAKDxLWSEkoc7Yr5lMBmvxgNKm5LWBGAijBzLe1iC+LhSFE1zP/0tf6abb7eJditfiFFHN35nLlBIxubufQWhF1Avny9hMxDIZcebS+hD02vZ7+rknbos1mVFRyIX6YFzg88uKTGaGgueWxnoVmYQJWPP5FZXFQSh+H8YfJ53BLgSyS5UKPwl37Q1y7dTKJVsGrxASkWvQQaqyqyr1POPcYXG5vYpMgsslSVLp1LKFvfJ+UxauWv3H0k+fbhOyW17KRFBs/NDnm0/ShSxv+7wnGpeWOnI2CHRId2p6hQppRH094+MCh3pypTpNW7dr27xu5ZSS8LCfSxRQ38KVey11ska8lh2WPUdIEESxtALqHHLIIYcccsghhxxyyCGHHHLIIYcccsghhxxyyCGHHHLIobAkQhocd0mdgykSQQ4eV0lHh587ZMQ4Wk6uiYFsGKVJxuG1kjuFc46Rp9mV13a4vL7pExeMVYh2jKdECNKLXtSyxYXppdzOWSIBdfx12/5DB/dsmp1hxBGR/xIrV0kuZvqXmElCLees37wjZ9OaSTXLUABRgEQ02wh/6xWICu391fqtWzcsuKd0m3ahV42m+5/94pYlTwRa3QehfX58B4NWRP15lKr2eGkykyCh17FXUVXZhx8om9B+Rk+UYxXfxrlW3IplVdNUGe8tblGrWIhAO57WPNRUfF/ZhHYWO7hiQCuiel4jJ4K/USmyLYF2ggHt/84LaCXUKt/IiugPrWpRcnQ+QtvSyw7QYOyPkqo/bjr/oBVQtVP0PKmuqqerlKL+df5BSz5ZRI8/+WU8tzQ12/MQWgE1XsaUr4VZDrRxUCi0hKpd0//xgf2uqFiqLZ+X0IrBL0uRzlloLelvBRq7DxH8ka8l38Eldn9WWK4V4fiTK+Q4LX9OhIYE1gsEjUT0mtFr6KWgi0SDlvYVLhUiDYs3yq+KdHhB4E1GRMd+vfl/Og6BeT1DxyTwIzGC4Re1VoEIB23wlZkfxG4TXcyxazvdYpSXFI0XEjYXNesKO7AqSlGgFW1Pi/Cu+GUSH1mYI+DsCoE/J4YE2QJyV6+TVSfDTX6CmzOufHj05InD7squaD/ECHkWUblal942ZMwbb417cUD3i6sLyJr+IQzXSlVrZWXVqmo2F6BTVVv0evLlyW+MGtC5USWLtKAJq9LqtL3jyZfefOvVYQ93bVIVhSmKQPpS6ZI+z70+ecwDrUlPE4KGrhVacOMmZ17W5+mXJr/16nP3dbwgPWxWQBhbSt1r739xwpTXXvhfh6ykkBOccLIzvVHnx0a98dbLT9xySXUphmIvFV9eu/vg7vWT68C5+6YvGklblaX9ylkeT56V3OqhWTmmE8GrX+ngMl0TTkPIGL98+4EDOStezzSj0uq5FYHDxad+eqappZW0NgPn7g62kr9yRLuQtyyh6o8sVfgVS+8qj9DEsNCS2zJu+dDU5yPfPt4EhYgM0mq122ceCFy1/5NbK1kZl/xct/+C48YFytrxVxV2etaDeigsieZ9BLqBe2jSVvIHKiJ+19ySGVzqOfMoPeTt9/ngL1RC8L1rKj0YCi1Ux+bW2HPBha3SyMOQC5Umh/VDAplDA4LyI+nOuVB8RvFDJ8hfyER68nWrE5ZIkBtXsfSy5BLS03ktiUDwhULrRin3rgw+zQdZivG+l2rZsCUg3bmcpmr2sT6RqxZ3QZbRo7vWQa423mlIM/lOuejQulGPo7KsyF78EKr/OSSc5UlbNX8B3nN5YEQCqjFDIR/6lWBSV1X2KXhLMGtcKLQSGqHkQ/0jrzqKXyag+j9jPdAMeYo/H5/IMri84df05VlbUfGfDU3TjyD7gIwL/IGe+vH+DiPDcK0HNZlHTGyf+WlwRnzttZZZIKLyb+vYR6sVsqsUnx/Lz7lN+o3nTfIgv2p0WoOhR65fyKHtfpIm/sU3V/0dKywDEkvZqufj3aa88p8Su4o3rvN0/QSFPLwl0+hBOGhfwCqdQHiY8QYqrMIFChsA8CQ1g48ahblTFmCfMQA9UMNC8eJVpixfEroL4wLduIQA6sNHNvGklSZoPejyjYRhTYlO6I9yAT5hTSdfYRG/TKdvlT6yAOORgTPXLjTJuEKR/TK9xC+PKQzaHiexBgmMJy+j2Z0objxXYT6eHxhPFa9CAdEIj6uqKvN0ytiL3zCuCQftcPaJgl80TN93ABW4iKW+VYF/T3aneBDjTWOwk+dDnkrZqM5YgEcGWEhCrU9DXieWUFOjHClDyVla3yAIrRtlb8N+nqqdPFCWVd7nfJx7WRAWQfwG++jdqspeOc3LIiu4P2/TjfpAbmTduIImcQybIT0MtDrkgYaWYcC6aiSk9+NbjZl6AU3GotpYAHKzK434y40BWgm1zGfvjbSz4bsFP+8DHLVTBrStaSFONVifTtd4csbj1XgrAkr9DufzRKOM6TWazlnXLdBKqOpvRu5A43maZmC7sqrBkRIayy8jsG1bNH+Vj3OWH+c2ptiKYuJaNgzynD0/zF/8j0YLgMQGLeCk0VsZ4/PqPypeb4wny0fL1eD9nw28qf3lHe+Z5cds1H48hL/cGKB1ozdYAk8FL2ydkpSUUrn9WLJ+53Xm0GZjheK6e/ojna9o1/H+eZi3ohIWcvFWemIf6yHMr9xjGqvBybL/BKF1M1WXAeJbOmHo8A+3YyPVfj4ezKGVUNs8NlwV//yf1OTklKzhXpausQC/SUfhQbeept1QcE6v8klJyeWbDVxJsBgbk0Awcjb7v+nRqN4Vk70Gtrp2Hb8/FdLMnJ7/3/LGndccYR1Q8HdRZK0VWqI0/Umzp8t4dVXjQan35GypyZ2QWadBIfi8c4rxZc98ymnkkTOMgSTT9GI0z6L80RXVqlz66inMK2qYoHWhqw5zVtPxF03onYl991Dugf7s5ZvOLrSIvWw//tDI+tT5NHu+WlAfLvKg12j+RlU7fZXRL6nDohNdojv3TdCS9g7dygeUxxP7BgqFCOhtfGTmpYgaS2ASutG9DFoN70yMFVoRVdlLofXhQYbGQN5LYobRnZSZ+OC0poFWiGo5mKVpU/FqbiqiFhxIDRf0Z3c1X8FqJZugJS/xTc7cMn4VGSZzs+08/asf30nnmhtdeZyOX8Y/p/IuedAwNjQZP0guIubE50ROwT1z0wRuipF/gpp6YdCSB23PRpIkCJIHDeCpPDW8mdnLAqo08HLIGx/UgZIO8BvzanIruVBoJZS1n97kw48EBZV5sa496BJz8kkRZZ7WWUHZfelM8KHnONPqeDRKJP0hbzntW5pl0wSthJpuZdf58RcegTeRiLp6GRvJ+Es3g3EKq5Qi4+uNjghC+UNcAHwtUhwXcGhnpwRSdRUeEBSUtSo+2MoYruD+y8ghdrxpkO1tEVWL2TVYb8KEbSzQZjJo/XhJOgpUsrA6EaytVFjJMm7hI7V4K0uMJWV7CncLeFDaYipXzdDewThZxbmdUGA3OYGgpLK7/61EASrHsgD78a81Te6UWbza154UagR/TaFV8MErkNsdVIhigpY8RjsdLHAjokfZK9d1by+OtxAo3kcmF03mM4unVMQtY+VaAZIlU1ZX8KcXQM0Re//EYCsCrciQ/i3NqKvjozwNZ4WjmEvfUYH+elCdtQwMDi25mSVpJQ3NNxVOdKHevB6PhtuSDrtRh4N8Gr2ZmJgAsYBujzvJ9TyfGWoDcLKgaayCuIzX/AeqX8a2fxqA1ofnJJpeW3PMChNo2rOWnH6i2+Ny8cnwrgFtdqxcS4a2kNdXUfCGZxrD/AjXT4mMj7eS8hWH9thFTFq3MhK8qy2DjJMAKRhNJoOAqv7EKmNDze7gAIhRedxYWO4hz/Ogh1UoSkaMjrvNHehuzIyrSRseNFRTuRg5PBlWsigpvsNC+2maCdoqO+jDSc/eDApF7m1EYmqVatVqVPmUtQ9cGyu0bvSAyjVVouVvnXF7WmiWXKMVKa1q9epVshZxgcCSrEroFmNG7zGxAkTP+C3QNtjL53Rud6v6uc5YoqCetBuNo4u/puCx13S/iVO3G8awRlTck1zkQhcfwBrPBooPLXqsbky5iE3QzipnstPTlrARgdJj9Ay+zew+fPr3qzdv3bZt+7ZTPA16EbiWSIStRop2sO7y1r1Uzya1oJV6vUZ9sngNtLJtu5drhgxaF0vSyWp1Bp3BdmhF1MKoTL37Ekv+djTPUKzfJpLWhWZgVv9I9x7PNejEcb7Yqbg3NOpG07HCFGLI86zkfHRlDMVfTdDONEOb9Bmbh2qwbqOA0u774ZDXYo+xP7FDS/65AVM+Mcpp4MMTq5raJepI1UeXHcnHtlaC0I5kT5Txh8FXErKBI6KrwWyHrvyTaUZBQu8bPfqM7it8y8SoNQ+w0bDK4gJFVH0HXcioyQwehLx5rQpD1gztZ2ZoPe+wVQCqjXLF1nVPDjMWIRcekF50roWLHjCqPIIZC4JuT0+TJEp67AD1k9haCUI73oD29ajQdjUUhL8rWqGdYPRoLmySJC02VCHNQizmRwnEqmTv51Y+XAtmqX9EYQnzIkHresOA9hvOtWkfs+It5DMoj64oSrGgJW30yIEM7Zoe7OcjvA0BVf+GelxYK5Q0G7RTDGhfiwptN6aNqXhDecEC7StGj74ApS9tqQGt0Z6ZVHyNMbQLf9KgZglnCSjo82Eh25uRoE2YZgiEL5h1455FyzjpVNoUXyDQDzOm7APHM3drkcvlHtyHkL4Is7pxiq2VILSTDGjfiQrtTQa0m6tZuXay0aMZsE1GrGbFcCyEoZ+S+H3EKBu8QYVa6xo2luHxMfoQbLK23DwDWlptVEJDqNkIXcLH/lq24PMZn32aw4tOFBFa6FCzcWugLA7XMGS8oRJVe9EYyFnLWjm8bun8z2fMnLUPaxauHRvkumjQXmcIhJwG1mXsU+P+N8gyJroXMq7V8KLXp06x0FtvTXk+GE0liqjSoB9zeSF7eot8VVRsTdB+nmqCtuIGQ/maQPQ6ooz9q3OPwZFJnbOYdfN20fVa3k0y+Iy7Z+3FXK/SFe1J2kqDApn7qva/3Kk2m3BfGnotV76eNNr4PTgKAu0kG7StWd1qDR9sb1a+BLSMaQgyfhpJ5L4vySugdu5NYfExLbDkKUldJ/+F2XQwLUOFQzs9VQiuJg1l5tvUlEFk0EQdZXVVNf3gDYgdJHJLHxh6bVGh5dVf2ow6yGqXkMn1NR3nk+xlaTinDaL7224hfb4FWsgYzPXaoxWCA3cTk8EK7UXHdaZ85d9jMRmS9xsOu/9Sa+xtplb58SCU6HHbyFoDBaqIogYP/wnaLXUC76kbTQULQOvHy8oFZpiI+rIB6ZiYv24yoE+pE4P0oD9yeaAUFvlsWnG5ln4FNm475iAgX/9Ba4V8ZxhB3VGCW6LxFOWs0IqoPVvWyFu/NrgrJ7g+ZWUmAyZDrfXMVlXxRBPXSqi9j00UXb+QXOWBHSadctbrMUQWiGCG1X6PyRoNH+8azRke9HzJxCYPuHOEZcbCeaQOGQ4tUgmSS8utFdyZLRa0pjgdlwtdvoe2Qyv3uJG4GTNO25cQuCz1Gwu0AsrIM9T5dwIaNywFVI0NQltuFpsRCv7NpH15yCrG7ayNKfT325lnzY+XRzm6Y/IfSW6U/h17jbplQkSClknyIchwD3cOOBWhYBJ58lruZfu7hnGnC71XPK4NgitJLlbTVMFbmpOuJO7irfwRlGGJX1mgJe2uYX3T8eHaXOUW0d2YiwnD8yWip8GpAJ/o/VBiYLT1DjB54MfjoB8u1DiHvU6/pbI1HY5oVlyDP7rRQ9RfoesF/WOAljE4HkpHLqDMHYF9kzHkEtJ3Vv9T0f/l75YwSvN9xdEQLmoSRFcSE2dxaP9qQNpJ2MnmBt7Je0wavjJXN2sIpJGJgU2GOSAK4FH3aWzRNTsVO/oVvl5B6RpazU+CRVFlJaXwFYieFUQ/BLxa1Ym8Z85puBgFDHAB1bg0wWgKbnmUVuPQ8KlesUDLsZ1el/TLfekGXi+MvN2LoQEJLWGfyLAPSYh8dtleDn8RoBVR+Z148oUwsUXyEBG13csFwk+p8Iz1xhLVAblE2kqnXGMPJABtRw4j+ftBFSiCVPNNTLfkrVxbaRGv9OLHv13ER1v1I8wsFR9exCpxetBAVuCTYLsww8yfSU2fX9YLfhFgpVkI9Vh5hXhDIOBDraOpCAGuZUY9Pv7+oKdmKUbZRIX7QNzoXfa2Vbwrm95Xrt/pQJW62KF1o3bHiAE+5aoq9G27WyxlHAj6PwRUzDGWsQ0X0FYqDJKx1RojlLbRcMFreNe4Bx995zDthWZxhZMxP4T5hr6CD4+5sn6deq2HbMfMOUimf2feZ6HC9sAM3fHQBZUSJcmTUjnr2qE/kbe1pBwdRPkV5MXN71mT7tiJmZPYg4m4TogWsWgWCGAEsZAJlZdl9FN/JVzVS+WbT/jI6M6XdXz4R/C4aiwUoCjQ/ieXRjv8Oemxvn0GfngcbAM6tbqR7yR0f2Cu733mxsuvG7gcsxgJM7Qi+l9wr5yZawrlCmwJ8RBR+mJcwK4D8/T4btg3ZcgSu2Sqm3OnCz1mOGjAGlo8bfxrU2f+cghQyNdXlqeDqPI7hhLYO6Y/1a/PA+O3ci+YQkztaLauWSDAmCGiy89+18kIpiM+R5J3Gu9WpwEVcLFhlkSDdpgN2o7HCX/5DStWY1Pbj+eD11ZANY8pqjFK2opfZ+CboBVQxVUGtlj1+WnsGeH73HwL15J/rz7J1WYyKkBP8Sks3sOLVwfLW7nQAh4wglU5EJok+2XyxlZxaFeRYcuBAECF+9L314q6iWPi2lO0KjV7Mg+E2JQRCDG4mxr30L5PVhW/XyejyWd1YoLQzjagvT0817pQu38pc8kQjuf3saJePryvGfPmokHYyycxtOKTwUzyylaBIKFrvLoPBypeAzMW4G+GwMaOOXrGA/FLPsxjD1RFYd5srObjbc1M/n2U+SeLnoHveEAdnb2y/msaHVb530CWk1dDY/KYz4EgfU90n63JZJg9gnIJ5j0mPdiVbQqnewf7OPJQDRjCw5YOwCxOrYWxNzYD84OkvQxon7fEfJFZ+i8u0Pjb47PZj49cx0YqoMS52GtpRcVzn2YRHMEqeeQ9y1T94UGQUC9xUeqNeQCtZjr+7EZ35VGT30waufGXJuaZLKIGS1kZVuuFSh6eBVobsXC/xl7V4Dn2Lv0YP1OIN9xk6H6UcG8eVpmzT1NId1dZTtElvQn+KJVGhGngmJpd6TKcB6FUuLXBtfNwvkI+KMB3GdCOpZ8o+fgl7vh8jO4vQG0Z8B6qEE75W3bQ215xBm2FCiQNvnxX6olPy7JfP9Ys4JYU0O0nwQdFr6KPGJGG+nv9PsWfj02VqyR02TIaoMWa0zUaRFYwKt0qIyVU9aUCAqWsUO8wLXpDK9kfuYoOzIWu2A28pLJ62BqdAPv7FLala4J2dhq69IfgW8sdXtV8s4DE2zaZXuqWvinoSj4dGxhcO9oQKO3ZrRK607j+bq4PC+1nerGZNg2oHHz9Ikq4b6fpy9X/TUS92Y+Hq5vscNR6vumqJR3Jh1cfoz/Lt5jmuogq9ltlZcbcqdmCXWUizbd5N9fGtf5fhjQMtFf/5X2WL4+Ob1RoVLjZqUg0veQus/fKuq4c/H6gfc+K/FKpx/RNp8lb9e2Y1asSzJT/DH91/KtPtw1ck9jpyZfHvzb6oUuCu9PtHhv72mtjB14RHI07q9/0dcdAgfUd+HncdRWt8fYIVbtt5lY4NZ2/9eNuRNYJSV1Hjhv/6uAWVtvI1WHKRtIX7N38XqdEGnFx6VPjxo97/hrb8QqUetkL3+2AXuvy4dXT7syUwkQQgOpeu/fUlQd8VM7nbvnulV4NkszNoYpdJ/x6wA87D8fWvn9HLVR44RarKxy4J7lRdnaDNP5AC1ENN6N5qxZ1odXiltigt0kVG7XMviSLqu3WB9Hf3JkXt7qkdkKYPpifImY0a9WitjvKVcbDEzMaZ2e3qJ8uokgXM0MrtU5zct0F/OSF6SUxI61cvUuyWzasGLwhKtl3GQKPCxt4EyhKIxinWURI2mO6QBJt55HYJ5ZzAeZzO+GqFIZvxd4fU4EcfjVrO+Rx5ticKMV0BPOtQsiF5q9jK60YZgMn9LG2JkqkmH305wgxZVmK7Sp2pRjLYS8h+nU0wC/m2Rppb8yhuMmBttTIgbbUyIG21MiBttTI2MCxxyE4FDd5UM9cTSW2c4EDbQkTgZZ5mhQ814G2RElEGXMPHD15Knd/Tl8H2ZIlAmeTG2/pffNVpZsd5rykQIhkbIcfHCoCCS63x+MpnerYDjnkkEMOOeSQQw455JBDDjnkkEMOOXTm6P8Af29k4A0KZW5kc3RyZWFtCmVuZG9iago1OCAwIG9iago8PAovVHlwZSAvRm9udAovU3VidHlwZSAvVHlwZTAKL0Jhc2VGb250IC9BQUFBQUErQ3JpbXNvblByby1SZWd1bGFyCi9FbmNvZGluZyAvSWRlbnRpdHktSAovRGVzY2VuZGFudEZvbnRzIFs1OSAwIFJdCi9Ub1VuaWNvZGUgNjAgMCBSCj4+CmVuZG9iago1OSAwIG9iago8PAovVHlwZSAvRm9udAovRm9udERlc2NyaXB0b3IgNjEgMCBSCi9CYXNlRm9udCAvQUFBQUFBK0NyaW1zb25Qcm8tUmVndWxhcgovU3VidHlwZSAvQ0lERm9udFR5cGUyCi9DSURUb0dJRE1hcCAvSWRlbnRpdHkKL0NJRFN5c3RlbUluZm8gNjIgMCBSCi9XIFswIFs1MDAgNTY4LjM1OTM4XQogMzAgWzU4OS44NDM3NV0KIDY5IFs2MzIuODEyNV0KIDc2IFs2NTYuMjVdCiA4MSBbMzAyLjczNDM4XQoyMTUgWzkxOC45NDUzMV0KIDIzNyBbNDYyLjg5MDYzXQogMjY1IFs1MTMuNjcxODggNDE1LjAzOTA2XQogMjczIFs1MjYuMzY3MTldCiAyODAgWzQzOS40NTMxM10KMzA1IFs0ODMuMzk4NDRdCiAzMTcgWzI2MS43MTg3NV0KIDM0MCBbMjYyLjY5NTMxXQogMzQ5IFs4MDMuNzEwOTQgMCA1MzcuMTA5MzhdCiAzNjIgWzQ5Ni4wOTM3NV0KMzk3IFs1MjQuNDE0MDYgMCAwIDM1NS40Njg3NV0KIDQyMCBbMzMxLjA1NDY5XQogNDI4IFs1MzAuMjczNDRdCiA0NTIgWzczNS4zNTE1Nl0KIDQ1OCBbNDYwLjkzNzVdCjU4MiBbMjU5Ljc2NTYzXQogNjI1IFsxODcuNV0KXQovRFcgMAo+PgplbmRvYmoKNjAgMCBvYmoKPDwKL0xlbmd0aCAzNTMKL0ZpbHRlciAvRmxhdGVEZWNvZGUKPj4Kc3RyZWFtDQp4nF2S3WqEMBCF732KXLYXi0lWzS6IILoLXvSH2j6Aq+NWqDFE98K3b5zJbqEBhY85ZziZSVhUZaWHhYXvdmprWFg/6M7CPN1sC+wC10EHQrJuaBdP+G/HxgShM9frvMBY6X4K0pSx8MNV58Wu7Cnvpgs8B+Gb7cAO+sqevoracX0z5gdG0AvjQZaxDnrX6aUxr80ILETbrupcfVjWnfP8KT5XA0wiC0rTTh3MpmnBNvoKQcrdyVh6dicLQHf/6nJPtkvffjcW5cLJOY9EtpE4Ee2RophIERVEB6TY+45IpUKKSXkqkRLsKQQpk4joQBQj7X1NEXkf9hRxRFQQ+VpJdCY6ISU50RnpQErFkY6cSCLl1FNRlpxupChL4WuUpaCeCrPIKEGSeCOpKLXkOGI/S3Gf7H0Tgh83meA+nfRqqm+72d7QY/HtzVq3c3xouOxtzYOGx1s0k9lc2/cL9Ma5Qw0KZW5kc3RyZWFtCmVuZG9iago2MSAwIG9iago8PAovVHlwZSAvRm9udERlc2NyaXB0b3IKL0ZvbnROYW1lIC9BQUFBQUErQ3JpbXNvblByby1SZWd1bGFyCi9GbGFncyA0Ci9Bc2NlbnQgODk2LjQ4NDM4Ci9EZXNjZW50IC0yMTQuODQzNzUKL1N0ZW1WIDEzNy42OTUzMTMKL0NhcEhlaWdodCA1NzMuMjQyMTkKL0l0YWxpY0FuZ2xlIDAKL0ZvbnRCQm94IFstMTA0LjQ5MjE4OCAtMjc2LjM2NzE5IDExMzEuODM1OTQgOTYwLjkzNzVdCi9Gb250RmlsZTIgNjMgMCBSCj4+CmVuZG9iago2MiAwIG9iago8PAovUmVnaXN0cnkgKEFkb2JlKQovT3JkZXJpbmcgKElkZW50aXR5KQovU3VwcGxlbWVudCAwCj4+CmVuZG9iago2MyAwIG9iago8PAovTGVuZ3RoIDMyMTEKL0xlbmd0aDEgODkxMgovRmlsdGVyIC9GbGF0ZURlY29kZQo+PgpzdHJlYW0NCnic7VkJcFvVFX2LLMmLFmuXJWvX/5atxdbiL9uybFleEid27NhxSBonEcGJHbwRO4RAoeyknQbaAdLSdKBQ2oEWGIaBNNAMk7KUgbKUbmGAaadTSqdrhjIBOk2s3vclr8RAKKFlyLv5+W/799xz3333vy8jjBBSoCsRRT1r+kLhjT1Tv0IIPw+9W/v6U/1v3P+8Edr7oN26bSwzqXlK81eEiB/ah3ZkpibhrobrDbjkO0b3bleMlP4CIcPXECpJDg9lLrC8XHEAnu+B8dph6JA9IimDNnveMzw2fUmMUClCxZvgCo9ObMtUBrkXECooQUjaPZa5ZJIKksdh7t/gcoxnxoaCuv5ewAc8wk1OTE1n70Bh0FfCxid3DU0OS796HdRfh0t2GGeve1CyH4YRzmaRCu6IcpLVSIbGUQFrLSgS8ADNVbPfZjpPU9jzB7IemPzd7K2nfie5YYkORKbEHoyuufaZf3VvUSVOyKlEHHml8Mil4v0W/vfZW2f+KLmBvgJNKSJzujGSiy2d+L8HkT07hqeZupHpzCgugnEJ+FpExBSDh8FeaOHCWduQhLD+ArDjW2Q7tLtyd7wd+GTfz2W+tE+syKCjyIH25vjRf2DegfDN4sR7ySrmXdE7RERlNorYcJegu+BuBcsoKkFOlEJptBKtR0NoBxpBk2gX2o32oL3ZrKiDjXbAaEYcHYXR6dnR7NHsazDnSrieQywGbXB14V7AsYpGlok4SNSDEJf3+EWkHbWh1agX/R0X4iJcik24Hw/iLXgn3o/vxveJ04rRV5jFEuarh9Bf8nWMDICUqxOkRD/N1ylqRtfn6xJA2pqvF4AN7fm6FFbIlauDI0pAU65O5/XAyhTBSK7Oagg8Mw3cR4H7NrQWTaAxaI2L3hoBjwzDaBp8MgL9UzA6jnqgNYECMJf5czc8mYGeAWjtghkj4hwHqkFBVA0SXvK0I/+8Y8nz8/MbgM8EWgG9Dcsgt6FLwKpdMGP1nI2LtZ0eMw3XJNorjuWecgBeNWDHodYPPUNwX87e3H0nzNkmPpkCtGl4ZkLk7UAVosZpQJhC9SgEwuKJzdiNzgd220TfhkR242J/BkamQN849J6OqS+/Feo+tkx8ovLIQsHWOVlxTubk8jOQlz6eEC3pWCRfXyRPn6nQ6mUkQbedgdy4VCQEZPVp5bJPRJ5jUiD7iBJcIlNnKPcskNc/nyKVn5PPsNjFw1IETidLzohnv5BNKPppY37UQvahyLJj34E395KCa+F9fa6cK0sKvR++4s5Cwa/CSfhTLESJCL537vT5/nELnHWXKfgJVHl2rFqAcRy+6c5kPnwJseus2KIQf2v4wEIFZGZ3wiHTR9Z7cnn/f9qFmJFxto5Hc1w+qYL7kOYDse0f7t//ppC3kOR0/fiV7MmziXuu/H8VXATf5Z+VcvR/bcDnqBxH7NAM52XSTgaRlr15pnhBK2iNMjcv8DwVqExmlPExgyESrhX0HOd2SSmpebH+1TB9jjQ2FI8Zh4tjjcSqbakOpTWutN2W4pRE6j51TMlxylvsWK2Y+YPtViX3aAwTgmMYs99do9l3STPpRy6E2lwcF4smSSRsMAKSiCDV63KAglEmleLJ9TcN+Ndd2u3b5JEFbNpk+aotgYahRnvM5A9a3YOF6evOb5pYV61WfVmyRaFIjabWXZxQKjcXXFFqAawIYF0AWKEPwZLFohzPCxGjTKeDPiMAX7zx5oHAusvWVA66pP5ybbOlKxNsHE5Z2ywmQlrJk8RPMD9WmL4W8AcA/xrJBmUJw9+TUOviI86woriCKyngLO7qsrJyRNlpm0yRVciCBNSGUIYXkiQGuHlgJdEza8A257IjdNFa4NtII6EYU7gdo0kiliQ99ROog6sx1H89P6NL2y0IqzSG9nj9Ck4pcSarj4STTspWijqT4SPVSaeENQo8Sf+RQJN36QhWN7BFbAB17Pds8CiWi2xY1Cxjbw3gipY0kgWWLI8B8SjLnqL3gN5ipjlF3VRUqXVrtXM18U7xnx6qejlMnqSRu/V33PtSmPyMNt6lu72DHqaRY/jwTBspA+2KmVX4e6dOzNUfZoin3mSR35B9Dz+NDyE/rAdaL0YHBB2jAb6VyaRitAgQLTIICKMegtHt4vmIVMrDCsRkYvA8VZF0V0W+aQvbzRpCPGFbtWBZuRETSptrlCa5vbBY5WwsMPo0kyZ9QO4IW5z+0kjE7DPp7ZVyvanUZTdIuprv0XgLS+waAOC93ga9YcQmLdOZfWAjARvfJIeQAwUQ2lMbjYKbgyQGfjYqicxYWwvRbNDrmGmcAGbVirbrDYY1zO+dZL+WrRk1dfrDfdH0ZGvVugMNXZisidvq9PqwxRUNW+9zd6+MFRU6fMrK/vFe7Kqs+UKqY6xRZ9/Q19Nlsb6tLIXV9oEdd4KvnHOemkUGYJ43cqI3WGjC1nk3vjnesD3lT9h1mlKDJuKp6KzubeQEs8U6II9k0qnhpMGjU+tLNJGiQmdvc8+A1W6MRtiaVGRPQAwfQh5UAzsWspDIbG7fAvcQYRD63JqIKyGuF6DjL6kwLW92122MeJp6qiwNOhZ63eRmWBlcgJv/bY2UaevLmzrWFCtSuNJVO9gYO6/JpVRp6ptqCpVOnzI53fRmZKVK7eTMjmMu62Bnz3rgXgnc78A/YvlqlrsRqOudsQhLG2IamSf/Xv3mWvjXfd7MC28VR4LWqKO32SOUmYF6NNPSMVyH8dBmjV+hr2typiMlazdZHCJ3CXJmrfi3+BE4HddBflgporGQNM76FsBCZGGf0ZhbfLp4SViA5gyDOV6IlT9vGq1NcE5Pc11zb3XdhSpPNBpUaDVm41VtKxLb47G+YPXaSKQnFOr1+6pjVb6ogPV7+NvfSIRd9Ta5Llxe4Y/7XU5JoYS3WqI6nUJfpNRShSweDXZW4a1ca2V1Z1VFR8DfUVmRCNelm6LR9LPyCjXKZsVvkxPkoJTDSajJqDe7ESHxb0HQjx9m5++5/MHzMZGZSE1mIWnIHBKavlFFKSVp4lVKdH6+pbJVo3R6lfZExRXBhFzpZdrYSfptiJuyeW1z2yQXKzIrSREMu4J0kpu0rEKhw6ssDqwWLvf2rIjIi2ALVPVtX3FnwxYl00rY9wAth6iPoJ5ZvYKY5cDMuc1nFLW7xVE+nzxyBObzOOvj8/NzYw6SBFOAHmm+vrSA2ls87pYqTGmCPkkFZp3Fb7YJekog3+6kEDTEFjC664xa9xp/ZWsFJUniUckMlZ5uvZ8vAX+oazHv4robd3rqilUej0pu4izjVVGTzXEgON8z5o+aXG5vdaBide1F4kzmPfh2IyrgGV7AcjFJ/QKSsUUk7TkiBVTk4W6t8KZ8lCbps6Qeuu1BkzMOLJIUrDVVurqcLWzx1DHMubxd9ROe+qJZ00YDMaPDdiAUZ0YRMeP8EmxSofLZd/eiAJ+PcAPGqUxtw1AiuSka31LjCls76mtT7qQ8PpRqvSAubGtp2pEI+VaGBi7csCo+UM1W1pj14OOg3ct+u1vPC9GFpCOL0iqf46kFSEivs/m1XKRN8rStaV5YV5PYkTJF9BSvJSpfwo2xq55zxAza2OseVYHJF/e+a2sSyQser7C1MT3SWChfxcUUCg5L+c4obKFy6z9V7G/OEHd4A+wNfpn1yKdd2Coyo42wSKNJFjPMGqWE2hqtRrvTrHGqNBqXipmJ10O0FJgDjm5nStw8/bjeZzIYy9RKaYnfPmiOBYoV7O2rAa9vIAVwGoR9OSTyjwnumBCRyXIZjsYierxB2+JJrKHtV2kJDviMvPqZHyed3s7O4/rdU+9Ew1ol9oTDbFdCNpcCDzj5DMELdDadL9zqebWx2W0llepYsOFnmq5m2bzRnd5ISes+lsLrA5a4Hsis3A+cCtrVToeiCfucfX0DlloL86u9nRtPxqyuaUMVr2QsrVVV1g4hDbwk2ZPkLfwARBOcKtryJwlZ/iThnGsbKX7vtgg5TKLT5j1RcpCEfjjz+F0h8iiJfNF2reUbEnjHQLjO/BzjGrFy1XxHfXLmELyvT4pZ/AEkY389X5/XDEdqKstxZ2cMmSAA9GsPhugTJLnvRRr6QY+hxmFymMyDwYniq5NkHmcb/H/w+0HOpFWVya3h32gYMIvg9uw76CnyGCpi0bKHcrmYBZjFW2WXkyhNarVJSZxajbW01KrRlKs9h/BjZkdpqcM8035UbS3VWNRqi6bMzXLBceLFI+QgnL9QSjQ3F284UObxlJW5XMTrMpvcbpMZXofoP5F+JXQNCmVuZHN0cmVhbQplbmRvYmoKeHJlZgowIDY0CjAwMDAwMDAwMDAgNjU1MzUgZg0KMDAwMDAwMDAxNSAwMDAwMCBuDQowMDAwMDAwNDE5IDAwMDAwIG4NCjAwMDAwMDA0NzYgMDAwMDAgbg0KMDAwMDAwMDU4NCAwMDAwMCBuDQowMDAwMDAwNjM0IDAwMDAwIG4NCjAwMDAwMDAxNTIgMDAwMDAgbg0KMDAwMDAwMDY3NyAwMDAwMCBuDQowMDAwMDAwOTYwIDAwMDAwIG4NCjAwMDAwMDEwNTcgMDAwMDAgbg0KMDAwMDAwMTE2MCAwMDAwMCBuDQowMDAwMDAxOTE2IDAwMDAwIG4NCjAwMDAwMDIwNDEgMDAwMDAgbg0KMDAwMDAwMzA5NCAwMDAwMCBuDQowMDAwMDAzMTg2IDAwMDAwIG4NCjAwMDAwMDMyODEgMDAwMDAgbg0KMDAwMDAwMzM3NiAwMDAwMCBuDQowMDAwMDAzNDcxIDAwMDAwIG4NCjAwMDAwMDM1NjYgMDAwMDAgbg0KMDAwMDAwMzY2MSAwMDAwMCBuDQowMDAwMDAzNzU2IDAwMDAwIG4NCjAwMDAwMDM4NDQgMDAwMDAgbg0KMDAwMDAwMzkzMyAwMDAwMCBuDQowMDAwMDA0MDIyIDAwMDAwIG4NCjAwMDAwMDQxMTEgMDAwMDAgbg0KMDAwMDAwNDIwMCAwMDAwMCBuDQowMDAwMDA0Mjg5IDAwMDAwIG4NCjAwMDAwMDQzNzggMDAwMDAgbg0KMDAwMDAwNDQ5NSAwMDAwMCBuDQowMDAwMDA0NTg0IDAwMDAwIG4NCjAwMDAwMDQ2NzMgMDAwMDAgbg0KMDAwMDAwNDc2MiAwMDAwMCBuDQowMDAwMDA0ODUxIDAwMDAwIG4NCjAwMDAwMDQ5NDAgMDAwMDAgbg0KMDAwMDAwNTAyNyAwMDAwMCBuDQowMDAwMDA1MTE2IDAwMDAwIG4NCjAwMDAwMDUyMDUgMDAwMDAgbg0KMDAwMDAwNTI5MiAwMDAwMCBuDQowMDAwMDA1MzgxIDAwMDAwIG4NCjAwMDAwMDU0NzcgMDAwMDAgbg0KMDAwMDAwNTU3MSAwMDAwMCBuDQowMDAwMDA1NjU4IDAwMDAwIG4NCjAwMDAwMDU3NDcgMDAwMDAgbg0KMDAwMDAwNTgzNiAwMDAwMCBuDQowMDAwMDA1OTI1IDAwMDAwIG4NCjAwMDAwMDYwMTIgMDAwMDAgbg0KMDAwMDAwNjA1NiAwMDAwMCBuDQowMDAwMDM2NDMyIDAwMDAwIG4NCjAwMDAwMzY0NjUgMDAwMDAgbg0KMDAwMDAzNjUxNiAwMDAwMCBuDQowMDAwMDM2NTY3IDAwMDAwIG4NCjAwMDAwMzY2MTggMDAwMDAgbg0KMDAwMDAzNjY2OSAwMDAwMCBuDQowMDAwMDM2NzIwIDAwMDAwIG4NCjAwMDAwMzY3NzEgMDAwMDAgbg0KMDAwMDAzNjgyMiAwMDAwMCBuDQowMDAwMDM2ODYyIDAwMDAwIG4NCjAwMDAwMzY5NDEgMDAwMDAgbg0KMDAwMDA0OTMxNiAwMDAwMCBuDQowMDAwMDQ5NDY5IDAwMDAwIG4NCjAwMDAwNTAwMzcgMDAwMDAgbg0KMDAwMDA1MDQ2NSAwMDAwMCBuDQowMDAwMDUwNzIwIDAwMDAwIG4NCjAwMDAwNTA3OTUgMDAwMDAgbg0KdHJhaWxlcgo8PAovUm9vdCAxIDAgUgovSW5mbyA2IDAgUgovSUQgWzwwQ0M3NzdBRDZENEFBMThFRkFGQ0ExODQ3QjI1QkMxQz4gPDBDQzc3N0FENkQ0QUExOEVGQUZDQTE4NDdCMjVCQzFDPl0KL1NpemUgNjQKPj4Kc3RhcnR4cmVmCjU0MDk2CiUlRU9GCg==', +}; \ No newline at end of file diff --git a/src/components/CheckIn/types.ts b/src/components/CheckIn/types.ts new file mode 100644 index 0000000000..2fcb2d63cf --- /dev/null +++ b/src/components/CheckIn/types.ts @@ -0,0 +1,44 @@ +export interface InterfaceUser { + _id: string; + firstName: string; + lastName: string; +} + +export interface InterfaceAttendeeCheckIn { + _id: string; + user: InterfaceUser; + checkIn: null | { + _id: string; + time: string; + }; +} + +export interface InterfaceAttendeeQueryResponse { + event: { + _id: string; + attendeesCheckInStatus: InterfaceAttendeeCheckIn[]; + }; +} + +export interface InterfaceModalProp { + show: boolean; + eventId: string; + handleClose: () => void; +} + +export interface InterfaceTableCheckIn { + id: string; + name: string; + userId: string; + checkIn: null | { + _id: string; + time: string; + }; + eventId: string; +} + +export interface InterfaceTableData { + userName: string; + id: string; + checkInData: InterfaceTableCheckIn; +} diff --git a/src/components/CollapsibleDropdown/CollapsibleDropdown.module.css b/src/components/CollapsibleDropdown/CollapsibleDropdown.module.css new file mode 100644 index 0000000000..4337742ecc --- /dev/null +++ b/src/components/CollapsibleDropdown/CollapsibleDropdown.module.css @@ -0,0 +1,14 @@ +.iconWrapper { + width: 36px; +} + +.collapseBtn { + height: 48px; +} + +.iconWrapperSm { + width: 36px; + display: flex; + justify-content: center; + align-items: center; +} diff --git a/src/components/CollapsibleDropdown/CollapsibleDropdown.test.tsx b/src/components/CollapsibleDropdown/CollapsibleDropdown.test.tsx new file mode 100644 index 0000000000..efee248ffb --- /dev/null +++ b/src/components/CollapsibleDropdown/CollapsibleDropdown.test.tsx @@ -0,0 +1,110 @@ +import React, { act } from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; + +import CollapsibleDropdown from './CollapsibleDropdown'; +import type { InterfaceCollapsibleDropdown } from './CollapsibleDropdown'; +import { store } from 'state/store'; +import { Provider } from 'react-redux'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: () => ({ + pathname: '/orgstore', + state: {}, + key: '', + search: '', + hash: '', + }), +})); + +const props: InterfaceCollapsibleDropdown = { + showDropdown: true, + setShowDropdown: jest.fn(), + target: { + name: 'DropDown Category', + url: undefined, + subTargets: [ + { + name: 'SubCategory 1', + url: '/sub-category-1', + icon: 'fa fa-home', + }, + { + name: 'SubCategory 2', + url: '/sub-category-2', + icon: 'fa fa-home', + }, + ], + }, +}; + +describe('Testing CollapsibleDropdown component', () => { + test('Component should be rendered properly', () => { + render( + + + + + + + , + ); + expect(screen.getByText('DropDown Category')).toBeInTheDocument(); + expect(screen.getByText('SubCategory 1')).toBeInTheDocument(); + expect(screen.getByText('SubCategory 2')).toBeInTheDocument(); + }); + + test('Dropdown should be rendered and functioning correctly', () => { + render( + + + + + + + , + ); + const parentDropdownBtn = screen.getByTestId('collapsible-dropdown'); + const activeDropdownBtn = screen.getByText('SubCategory 1'); + const nonActiveDropdownBtn = screen.getByText('SubCategory 2'); + + // Check if dropdown is rendered with correct classes + act(() => { + fireEvent.click(activeDropdownBtn); + }); + expect(parentDropdownBtn).toBeInTheDocument(); + expect(parentDropdownBtn).toHaveClass('text-white'); + expect(parentDropdownBtn).toHaveClass('btn-success'); + + // Check if active dropdown is rendered with correct classes + expect(activeDropdownBtn).toBeInTheDocument(); + expect(activeDropdownBtn).toHaveClass('text-white'); + expect(activeDropdownBtn).toHaveClass('btn-success'); + + // Check if inactive dropdown is rendered with correct classes + expect(nonActiveDropdownBtn).toBeInTheDocument(); + expect(nonActiveDropdownBtn).toHaveClass('text-secondary'); + expect(nonActiveDropdownBtn).toHaveClass('btn-light'); + + // Check if dropdown is collapsed after clicking on it + act(() => { + fireEvent.click(parentDropdownBtn); + }); + expect(props.setShowDropdown).toHaveBeenCalledWith(false); + + // Check if dropdown is expanded after clicking on it again + act(() => { + fireEvent.click(parentDropdownBtn); + }); + expect(props.setShowDropdown).toHaveBeenCalledWith(true); + + // Click on non-active dropdown button and check if it navigates to the correct URL + act(() => { + fireEvent.click(nonActiveDropdownBtn); + }); + expect(window.location.pathname).toBe('/sub-category-2'); + }); +}); diff --git a/src/components/CollapsibleDropdown/CollapsibleDropdown.tsx b/src/components/CollapsibleDropdown/CollapsibleDropdown.tsx new file mode 100644 index 0000000000..2991c2ade5 --- /dev/null +++ b/src/components/CollapsibleDropdown/CollapsibleDropdown.tsx @@ -0,0 +1,106 @@ +import React, { useEffect } from 'react'; +import { Button, Collapse } from 'react-bootstrap'; +import type { TargetsType } from 'state/reducers/routesReducer'; +import styles from './CollapsibleDropdown.module.css'; +import IconComponent from 'components/IconComponent/IconComponent'; +import { NavLink, useLocation, useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +export interface InterfaceCollapsibleDropdown { + showDropdown: boolean; + target: TargetsType; + setShowDropdown: React.Dispatch>; +} + +/** + * A collapsible dropdown component that toggles visibility of sub-targets. + * + * @param showDropdown - Boolean indicating whether the dropdown is visible or not. + * @param target - Object containing the target information, including the name and sub-targets. + * @param setShowDropdown - Function to toggle the visibility of the dropdown. + * + * @returns JSX.Element - The rendered CollapsibleDropdown component. + */ +const collapsibleDropdown = ({ + target, + showDropdown, + setShowDropdown, +}: InterfaceCollapsibleDropdown): JSX.Element => { + const { t: tCommon } = useTranslation('common'); + const { name, subTargets } = target; + const navigate = useNavigate(); + const location = useLocation(); + useEffect(() => { + // Show dropdown if the current path includes 'orgstore', otherwise hide it. + if (location.pathname.includes('orgstore')) { + setShowDropdown(true); + } else { + setShowDropdown(false); + } + }, [location.pathname]); + + return ( + <> + + +
    + {subTargets && + subTargets.map(({ name, icon: stringIcon, url }, index) => { + return ( + + {({ isActive }) => ( + + )} + + ); + })} +
    +
    + + ); +}; + +export default collapsibleDropdown; diff --git a/src/components/ContriStats/ContriStats.module.css b/src/components/ContriStats/ContriStats.module.css new file mode 100644 index 0000000000..7d6c83ea8e --- /dev/null +++ b/src/components/ContriStats/ContriStats.module.css @@ -0,0 +1,7 @@ +.fonts { + color: #707070; +} + +.fonts > span { + font-weight: 600; +} diff --git a/src/components/ContriStats/ContriStats.test.tsx b/src/components/ContriStats/ContriStats.test.tsx new file mode 100644 index 0000000000..8edb853684 --- /dev/null +++ b/src/components/ContriStats/ContriStats.test.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import ContriStats from './ContriStats'; +import { I18nextProvider } from 'react-i18next'; +import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client'; +import type { NormalizedCacheObject } from '@apollo/client'; +import i18nForTest from 'utils/i18nForTest'; +import { BACKEND_URL } from 'Constant/constant'; + +const client: ApolloClient = new ApolloClient({ + cache: new InMemoryCache(), + uri: BACKEND_URL, +}); + +describe('Testing Contribution Stats', () => { + const props = { + id: '234', + recentAmount: '200', + highestAmount: '500', + totalAmount: '1000', + }; + + test('should render props and text elements test for the page component', () => { + render( + + + + + , + ); + expect(screen.getByText('Recent Contribution: $')).toBeInTheDocument(); + expect(screen.getByText('Highest Contribution: $')).toBeInTheDocument(); + expect(screen.getByText('Total Contribution: $')).toBeInTheDocument(); + expect(screen.getByText('200')).toBeInTheDocument(); + expect(screen.getByText('500')).toBeInTheDocument(); + expect(screen.getByText('1000')).toBeInTheDocument(); + }); +}); diff --git a/src/components/ContriStats/ContriStats.tsx b/src/components/ContriStats/ContriStats.tsx new file mode 100644 index 0000000000..a2db307d91 --- /dev/null +++ b/src/components/ContriStats/ContriStats.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import styles from './ContriStats.module.css'; + +interface InterfaceContriStatsProps { + id: string; + recentAmount: string; + highestAmount: string; + totalAmount: string; +} + +/** + * A component that displays contribution statistics. + * + * @param props - The properties passed to the component, including `recentAmount`, `highestAmount`, and `totalAmount`. + * + * @returns JSX.Element - The rendered component displaying the contribution stats. + */ +function ContriStats(props: InterfaceContriStatsProps): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'contriStats', + }); + + return ( + <> +

    + {t('recentContribution')}: $ {props.recentAmount} +

    +

    + {t('highestContribution')}: $ {props.highestAmount} +

    +

    + {t('totalContribution')}: $ {props.totalAmount} +

    + + ); +} +export default ContriStats; diff --git a/src/components/CurrentHourIndicator/CurrentHourIndicator.module.css b/src/components/CurrentHourIndicator/CurrentHourIndicator.module.css new file mode 100644 index 0000000000..d2c250035a --- /dev/null +++ b/src/components/CurrentHourIndicator/CurrentHourIndicator.module.css @@ -0,0 +1,19 @@ +.round { + background-color: red; + border-radius: 100%; + width: 15px; + height: 15px; +} +.line { + width: 100%; + height: 1px; + background-color: red; + margin: auto; +} +.container { + position: relative; + display: flex; + flex-direction: row; + top: -8px; + left: -9px; +} diff --git a/src/components/CurrentHourIndicator/CurrentHourIndicator.test.tsx b/src/components/CurrentHourIndicator/CurrentHourIndicator.test.tsx new file mode 100644 index 0000000000..084817dc0d --- /dev/null +++ b/src/components/CurrentHourIndicator/CurrentHourIndicator.test.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import CurrentHourIndicator from './CurrentHourIndicator'; + +describe('Testing Current Hour Indicator', () => { + test('Component Should be rendered properly', async () => { + const { getByTestId } = render(); + expect(getByTestId('container')).toBeInTheDocument(); + }); +}); diff --git a/src/components/CurrentHourIndicator/CurrentHourIndicator.tsx b/src/components/CurrentHourIndicator/CurrentHourIndicator.tsx new file mode 100644 index 0000000000..98e02bbf5f --- /dev/null +++ b/src/components/CurrentHourIndicator/CurrentHourIndicator.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import styles from './CurrentHourIndicator.module.css'; + +/** + * A component that displays an indicator for the current hour. + * + * @returns JSX.Element - The rendered component showing the current hour indicator. + */ +const CurrentHourIndicator = (): JSX.Element => { + return ( +
    +
    +
    +
    + ); +}; + +export default CurrentHourIndicator; diff --git a/src/components/DynamicDropDown/DynamicDropDown.module.css b/src/components/DynamicDropDown/DynamicDropDown.module.css new file mode 100644 index 0000000000..3cd40fe35d --- /dev/null +++ b/src/components/DynamicDropDown/DynamicDropDown.module.css @@ -0,0 +1,13 @@ +.dropwdownToggle { + background-color: #f1f3f6; + color: black; + width: 100%; + border: none; + padding: 0.5rem; + text-align: left; + display: flex; + align-items: center; + justify-content: space-between; + min-width: 8rem; + outline: 1px solid var(--bs-gray-400); +} diff --git a/src/components/DynamicDropDown/DynamicDropDown.test.tsx b/src/components/DynamicDropDown/DynamicDropDown.test.tsx new file mode 100644 index 0000000000..dac98ca9e6 --- /dev/null +++ b/src/components/DynamicDropDown/DynamicDropDown.test.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { + render, + screen, + act, + waitFor, + fireEvent, +} from '@testing-library/react'; +import DynamicDropDown from './DynamicDropDown'; +import { BrowserRouter } from 'react-router-dom'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; +import userEvent from '@testing-library/user-event'; + +describe('DynamicDropDown component', () => { + test('renders and handles selection correctly', async () => { + const formData = { fieldName: 'value2' }; + const setFormData = jest.fn(); + + render( + + + + + , + ); + + // Verify that the dropdown container is rendered + const containerElement = screen.getByTestId('fieldname-dropdown-container'); + expect(containerElement).toBeInTheDocument(); + + // Verify that the dropdown button displays the correct initial label + const dropdownButton = screen.getByTestId('fieldname-dropdown-btn'); + expect(dropdownButton).toHaveTextContent('Label 2'); + + // Open the dropdown menu + await act(async () => { + userEvent.click(dropdownButton); + }); + + // Select the first option in the dropdown + const optionElement = screen.getByTestId('change-fieldname-btn-TEST'); + await act(async () => { + userEvent.click(optionElement); + }); + + // Verify that the setFormData function was called with the correct arguments + expect(setFormData).toHaveBeenCalledWith({ fieldName: 'TEST' }); + + // Verify that the dropdown button displays the updated label + await waitFor(() => { + expect(dropdownButton).toHaveTextContent('Label 2'); + }); + }); + test('calls custom handleChange function when provided', async () => { + const formData = { fieldName: 'value1' }; + const setFormData = jest.fn(); + const customHandleChange = jest.fn(); + + render( + + + + + , + ); + + const dropdownButton = screen.getByTestId('fieldname-dropdown-btn'); + await act(async () => { + userEvent.click(dropdownButton); + }); + + const optionElement = screen.getByTestId('change-fieldname-btn-value2'); + await act(async () => { + userEvent.click(optionElement); + }); + + expect(customHandleChange).toHaveBeenCalledTimes(1); + expect(customHandleChange).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ + name: 'fieldName', + value: 'value2', + }), + }), + ); + expect(setFormData).not.toHaveBeenCalled(); + }); + test('handles keyboard navigation correctly', async () => { + const formData = { fieldName: 'value1' }; + const setFormData = jest.fn(); + + render( + + + + + , + ); + + // Open dropdown + const dropdownButton = screen.getByTestId('fieldname-dropdown-btn'); + await act(async () => { + userEvent.click(dropdownButton); + }); + + // Get dropdown menu + const dropdownMenu = screen.getByTestId('fieldname-dropdown-menu'); + + // Simulate Enter key press + await act(async () => { + fireEvent.keyDown(dropdownMenu, { key: 'Enter' }); + }); + + // Simulate Space key press + await act(async () => { + fireEvent.keyDown(dropdownMenu, { key: ' ' }); + }); + + // Verify the dropdown menu behavior + const option = screen.getByTestId('change-fieldname-btn-value2'); + expect(option).toBeInTheDocument(); + }); +}); diff --git a/src/components/DynamicDropDown/DynamicDropDown.tsx b/src/components/DynamicDropDown/DynamicDropDown.tsx new file mode 100644 index 0000000000..05cd064ac2 --- /dev/null +++ b/src/components/DynamicDropDown/DynamicDropDown.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { Dropdown } from 'react-bootstrap'; +import styles from './DynamicDropDown.module.css'; + +/** + * Props for the DynamicDropDown component. + */ +interface InterfaceChangeDropDownProps { + parentContainerStyle?: string; + btnStyle?: string; + btnTextStyle?: string; + setFormState: React.Dispatch>; + formState: T; + fieldOptions: { value: string; label: string }[]; + fieldName: string; + handleChange?: (e: React.ChangeEvent) => void; +} + +/** + * A dynamic dropdown component that allows users to select an option. + * + * This component renders a dropdown with a toggle button. Clicking the button + * opens a menu with options. When an option is selected, it updates the form state. + * + * @param parentContainerStyle - Optional CSS class for styling the container. + * @param btnStyle - Optional CSS class for styling the dropdown button. + * @param setFormState - Function to update the form state with the selected option. + * @param formState - Current state of the form, used to determine the selected value. + * @param fieldOptions - Options to display in the dropdown. Each option has a value and a label. + * @param fieldName - The name of the field, used for labeling and key identification. + * @param handleChange - Optional callback function when selection changes + * @returns JSX.Element - The rendered dropdown component. + */ +const DynamicDropDown = >({ + parentContainerStyle = '', + btnStyle = '', + setFormState, + formState, + fieldOptions, + fieldName, + handleChange, +}: InterfaceChangeDropDownProps): JSX.Element => { + const handleFieldChange = (value: string): void => { + if (handleChange) { + const event = { + target: { + name: fieldName, + value: value, + }, + } as React.ChangeEvent; + handleChange(event); + } else { + setFormState({ ...formState, [fieldName]: value }); + } + }; + + const getLabel = (value: string): string => { + const selectedOption = fieldOptions.find( + (option) => option.value === value, + ); + return selectedOption ? selectedOption.label : 'None'; + }; + + return ( + + + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + const focused = document.activeElement; + if (focused instanceof HTMLElement) { + focused.click(); + } + } + }} + > + {fieldOptions.map((option, index: number) => ( + handleFieldChange(option.value)} + data-testid={`change-${fieldName.toLowerCase()}-btn-${option.value}`} + role="option" + aria-selected={option.value === formState[fieldName]} + > + {option.label} + + ))} + + + ); +}; + +export default DynamicDropDown; diff --git a/src/components/EditCustomFieldDropDown/EditCustomFieldDropDown.test.tsx b/src/components/EditCustomFieldDropDown/EditCustomFieldDropDown.test.tsx new file mode 100644 index 0000000000..19d2249a43 --- /dev/null +++ b/src/components/EditCustomFieldDropDown/EditCustomFieldDropDown.test.tsx @@ -0,0 +1,64 @@ +import type { Dispatch, SetStateAction } from 'react'; +import React, { act } from 'react'; +import { render } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import EditOrgCustomFieldDropDown from './EditCustomFieldDropDown'; +import userEvent from '@testing-library/user-event'; +import availableFieldTypes from 'utils/fieldTypes'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; +import type { InterfaceCustomFieldData } from 'utils/interfaces'; + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Testing Custom Field Dropdown', () => { + test('Component Should be rendered properly', async () => { + const customFieldData = { + type: 'Number', + name: 'Age', + }; + + const setCustomFieldData: Dispatch< + SetStateAction + > = (val) => { + { + val; + } + }; + const props = { + customFieldData: customFieldData as InterfaceCustomFieldData, + setCustomFieldData: setCustomFieldData, + parentContainerStyle: 'parentContainerStyle', + btnStyle: 'btnStyle', + btnTextStyle: 'btnTextStyle', + }; + + const { getByTestId, getByText } = render( + + + + + , + ); + + expect(getByText('Number')).toBeInTheDocument(); + + act(() => { + userEvent.click(getByTestId('toggleBtn')); + }); + + await wait(); + + availableFieldTypes.forEach(async (_, index) => { + act(() => { + userEvent.click(getByTestId(`dropdown-btn-${index}`)); + }); + }); + }); +}); diff --git a/src/components/EditCustomFieldDropDown/EditCustomFieldDropDown.tsx b/src/components/EditCustomFieldDropDown/EditCustomFieldDropDown.tsx new file mode 100644 index 0000000000..2350d2c9c4 --- /dev/null +++ b/src/components/EditCustomFieldDropDown/EditCustomFieldDropDown.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import type { SetStateAction, Dispatch } from 'react'; +import { Dropdown } from 'react-bootstrap'; +import availableFieldTypes from 'utils/fieldTypes'; +import { useTranslation } from 'react-i18next'; +import type { InterfaceCustomFieldData } from 'utils/interfaces'; + +/** + * Props for the EditOrgCustomFieldDropDown component. + */ +interface InterfaceEditCustomFieldDropDownProps { + customFieldData: InterfaceCustomFieldData; + setCustomFieldData: Dispatch>; + parentContainerStyle?: string; + btnStyle?: string; + btnTextStyle?: string; +} + +/** + * A dropdown component for editing custom field types. + * + * This component displays a dropdown menu that allows users to select a custom field type. + * It shows the current type of the field and provides a list of available types to choose from. + * When a new type is selected, it updates the custom field data. + * + * @param customFieldData - The current data of the custom field being edited. + * @param setCustomFieldData - Function to update the custom field data with the new type. + * @param parentContainerStyle - Optional CSS class to style the container of the dropdown. + * @param btnStyle - Optional CSS class to style the dropdown button. + * @param btnTextStyle - Optional CSS class to style the text inside the button. + * @returns JSX.Element - The rendered dropdown component. + */ +const EditOrgCustomFieldDropDown = ({ + customFieldData, + setCustomFieldData, + parentContainerStyle, + btnStyle, +}: InterfaceEditCustomFieldDropDownProps): JSX.Element => { + const { t } = useTranslation('translation', { + keyPrefix: 'orgProfileField', + }); + const { t: tCommon } = useTranslation('common'); + + return ( + + + {customFieldData.type ? t(customFieldData.type) : tCommon('none')} + + + {availableFieldTypes.map((customFieldType, index: number) => ( + { + setCustomFieldData({ + ...customFieldData, + type: customFieldType, + }); + }} + disabled={customFieldData.type === customFieldType} + > + {t(customFieldType)} + + ))} + + + ); +}; + +export default EditOrgCustomFieldDropDown; diff --git a/src/components/EventCalendar/EventCalendar.module.css b/src/components/EventCalendar/EventCalendar.module.css new file mode 100644 index 0000000000..67ecd2dec6 --- /dev/null +++ b/src/components/EventCalendar/EventCalendar.module.css @@ -0,0 +1,342 @@ +.calendar { + font-family: sans-serif; + font-size: 1.2rem; + margin-bottom: 20px; + background: rgb(255, 255, 255); + border-radius: 10px; + padding: 5px; +} +.calendar__header { + display: flex; + margin-bottom: 2rem; + align-items: center; + margin: 0px 10px 0px 10px; +} +.input { + flex: 1; + position: relative; +} +.calendar__header_month { + margin: 0.5rem; + color: #707070; + font-weight: 800; + font-size: 55px; + display: flex; + gap: 23px; + flex-direction: row; +} +.calendar__header_month div { + font-weight: 400; + color: black; + font-family: Outfit; +} +.space { + flex: 1; + display: flex; + align-items: center; + justify-content: space-around; +} +.button { + border-radius: 100px; + color: #707070; + background-color: rgba(194, 247, 182, 0); + font-weight: bold; + border: 0px; + font-size: 20px; +} + +.calendar__weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + background-color: black; + font-family: Outfit; + height: 60px; +} +.calendar__scroll { + height: 80vh; + padding: 10px; +} +.weekday { + display: flex; + justify-content: center; + align-items: center; + background-color: white; + font-weight: 600; +} +.calendar__days { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + grid-template-rows: repeat(6, 1fr); +} +.calendar_hour_text_container { + display: flex; + flex-direction: row; + align-items: flex-end; + border-right: 1px solid #8d8d8d55; + width: 40px; +} +.calendar_hour_text { + top: -10px; + left: -5px; + position: relative; + color: #707070; + font-size: 12px; +} +.calendar_timezone_text { + top: -10px; + left: -11px; + position: relative; + color: #707070; + font-size: 9px; +} +.calendar_hour_block { + display: flex; + flex-direction: row; + border-bottom: 1px solid #8d8d8d55; + position: relative; + height: 50px; + border-bottom-right-radius: 5px; +} +.event_list_parent { + position: relative; + width: 100%; +} +.event_list_parent_current { + background-color: #def6e1; + position: relative; + width: 100%; +} +.dummyWidth { + width: 1px; +} +.day { + background-color: #ffffff; + padding-left: 0.3rem; + padding-right: 0.3rem; + border-radius: 10px; + margin: 5px; + background-color: white; + border: 1px solid #8d8d8d55; + color: #4b4b4b; + font-weight: 600; + height: 9rem; + position: relative; +} +.day_weekends { + background-color: rgba(49, 187, 107, 0.2); +} +.day__outside { + background-color: white; + color: #89898996; +} +.day__selected { + background-color: #007bff; + color: #707070; +} +.day__today { + background-color: #def6e1; + font-weight: 700; + text-decoration: underline; + color: #31bb6b; +} +.day__events { + background-color: white; +} +.btn__today { + transition: ease-in all 200ms; + font-family: Arial; + color: #ffffff; + font-size: 18px; + padding: 10px 20px 10px 20px; + text-decoration: none; + margin-left: 20px; + border: none; +} +.btnsContainer { + display: flex; + margin: 2.5rem 0 2.5rem 0; +} + +.btnsContainer .btnsBlock { + display: flex; +} + +.btnsContainer .btnsBlock button { + margin-left: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.dropdown { + border-color: #31bb6b; + background-color: white; + color: #31bb6b; + box-shadow: 0px 2px 1px rgba(49, 187, 107, 0.5); /* Added blur effect */ +} + +.btnsContainer .input { + flex: 1; + position: relative; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); +} + +.btnsContainer .input button { + width: 52px; +} +.searchBtn { + margin-bottom: 10px; +} + +.inputField { + margin-top: 10px; + margin-bottom: 10px; + background-color: white; + box-shadow: 0 1px 1px #31bb6b; +} +.inputField > button { + padding-top: 10px; + padding-bottom: 10px; +} +.btn__more { + border: 0px; + font-size: 14px; + background-color: initial; + color: #262727; + font-weight: 600; + transition: all 200ms; + position: relative; + display: block; + margin: 2px; +} +.btn__more:hover { + color: #454645; +} + +.expand_event_list { + display: block; +} +.list_container { + padding: 5px; + width: fit-content; + display: flex; + flex-direction: row; +} +.event_list_hour { + display: flex; + flex-direction: row; +} +.expand_list_container { + width: 200px; + max-height: 250px; + z-index: 10; + position: absolute; + left: auto; + right: auto; + overflow: auto; + padding: 10px 4px 0px 4px; + background-color: rgb(241, 241, 241); + border: 1px solid rgb(201, 201, 201); + border-radius: 5px; + margin: 5px; +} +.flex_grow { + flex-grow: 1; +} + +@media only screen and (max-width: 700px) { + .event_list { + display: none; + } + .expand_list_container { + width: 150px; + padding: 4px 4px 0px 4px; + } + .day { + height: 5rem; + } +} + +@media only screen and (max-width: 500px) { + .btn__more { + font-size: 12px; + } +} + +.calendar__infocards { + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 10px; + margin-top: 35px; +} +.card__holidays { + background-color: rgba(246, 242, 229, 1); + display: flex; + flex-direction: column; + width: 50%; + font-family: Outfit; + border-radius: 20px; + padding: 30px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} +.month__holidays { + display: flex; + flex-direction: column; +} +.holiday__data { + display: flex; + flex-direction: row; + gap: 10px; +} +.holiday__date { + font-size: 20px; + color: rgba(234, 183, 86, 1); + font-weight: 700; +} +.holiday__name { + font-size: 20px; + margin-left: 35px; +} +.card__events { + background-color: rgba(244, 244, 244, 1); + display: flex; + flex-direction: column; + width: 50%; + font-family: Outfit; + border-radius: 20px; + padding: 30px; +} +.innercard__events { + display: flex; + flex-direction: row; + align-items: center; + gap: 1rem; +} +.innercard__events p { + margin-bottom: 0; +} +.orgEvent__color { + height: 15px; + width: 40px; + background-color: rgba(82, 172, 255, 0.5); + border-radius: 10px; +} +.holidays__color { + height: 15px; + width: 40px; + background: rgba(0, 0, 0, 0.15); + border-radius: 10px; +} + +.userEvents__color { + height: 15px; + width: 40px; + background: rgba(146, 200, 141, 0.5); + border-radius: 10px; +} diff --git a/src/components/EventCalendar/EventCalendar.test.tsx b/src/components/EventCalendar/EventCalendar.test.tsx new file mode 100644 index 0000000000..8e2395968a --- /dev/null +++ b/src/components/EventCalendar/EventCalendar.test.tsx @@ -0,0 +1,440 @@ +import Calendar from './EventCalendar'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import React from 'react'; +import { ViewType } from 'screens/OrganizationEvents/OrganizationEvents'; + +import { + DELETE_EVENT_MUTATION, + UPDATE_EVENT_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { weekdays, months } from './constants'; +import { BrowserRouter as Router } from 'react-router-dom'; + +const eventData = [ + { + _id: '1', + title: 'Event 1', + description: 'This is event 1', + startDate: '2022-05-01', + endDate: '2022-05-01', + location: 'New York', + startTime: '10:00', + endTime: '12:00', + allDay: false, + recurring: false, + recurrenceRule: null, + isRecurringEventException: false, + isPublic: true, + isRegisterable: true, + viewType: ViewType.DAY, + }, + { + _id: '2', + title: 'Event 2', + description: 'This is event 2', + startDate: '2022-05-03', + endDate: '2022-05-03', + location: 'Los Angeles', + startTime: '14:00', + endTime: '16:00', + allDay: false, + recurring: false, + recurrenceRule: null, + isRecurringEventException: false, + isPublic: true, + isRegisterable: true, + }, +]; + +const MOCKS = [ + { + request: { + query: DELETE_EVENT_MUTATION, + variable: { id: '123' }, + }, + result: { + data: { + removeEvent: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: UPDATE_EVENT_MUTATION, + variable: { + id: '123', + title: 'Updated title', + description: 'This is a new update', + isPublic: true, + recurring: false, + isRegisterable: true, + allDay: false, + location: 'New Delhi', + startTime: '02:00', + endTime: '07:00', + }, + }, + result: { + data: { + updateEvent: { + _id: '1', + }, + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 200): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Calendar', () => { + it('renders weekdays', () => { + render(); + + weekdays.forEach((weekday) => { + expect(screen.getByText(weekday)).toBeInTheDocument(); + }); + }); + it('should initialize currentMonth and currentYear with the current date', () => { + const today = new Date(); + const { getByTestId } = render(); + + const currentMonth = getByTestId('current-date'); + const currentYear = getByTestId('current-date'); + + expect(currentMonth).toHaveTextContent( + today.toLocaleString('default', { month: 'long' }), + ); + expect(currentYear).toHaveTextContent(today.getFullYear().toString()); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render the current month and year', () => { + const { getByTestId } = render(); + + // Find the element by its data-testid attribute + const currentDateElement = getByTestId('current-date'); + + // Assert that the text content of the element matches the current month and year + const currentMonth = new Date().toLocaleString('default', { + month: 'long', + }); + const currentYear = new Date().getFullYear(); + const expectedText = ` ${currentYear} ${currentMonth}`; + expect(currentDateElement.textContent).toContain(expectedText); + }); + + it('Should show prev and next month on clicking < & > buttons', () => { + //testing previous month button + render( + + + + + , + ); + const prevButton = screen.getByTestId('prevmonthordate'); + fireEvent.click(prevButton); + //testing next month button + const nextButton = screen.getByTestId('nextmonthordate'); + fireEvent.click(nextButton); + //Testing year change + for (let index = 0; index < 13; index++) { + fireEvent.click(nextButton); + } + for (let index = 0; index < 13; index++) { + fireEvent.click(prevButton); + } + }); + it('Should show prev and next year on clicking < & > buttons when in year view', async () => { + //testing previous month button + render( + + + + + , + ); + await wait(); + const prevButtons = screen.getAllByTestId('prevYear'); + prevButtons.forEach((button) => { + fireEvent.click(button); + }); + await wait(); + //testing next year button + const nextButton = screen.getAllByTestId('prevYear'); + nextButton.forEach((button) => { + fireEvent.click(button); + }); + }); + it('Should show prev and next date on clicking < & > buttons in the day view', async () => { + render( + + + + + + + , + ); + //testing previous date button + const prevButton = screen.getByTestId('prevmonthordate'); + fireEvent.click(prevButton); + //testing next date button + const nextButton = screen.getByTestId('nextmonthordate'); + fireEvent.click(nextButton); + //Testing year change and month change + for (let index = 0; index < 366; index++) { + fireEvent.click(prevButton); + } + for (let index = 0; index < 732; index++) { + fireEvent.click(nextButton); + } + }); + it('Should render eventlistcard of current day event', () => { + const currentDayEventMock = [ + { + _id: '0', + title: 'demo', + description: 'agrsg', + startDate: new Date().toISOString().split('T')[0], + endDate: new Date().toISOString().split('T')[0], + location: 'delhi', + startTime: '10:00', + endTime: '12:00', + allDay: false, + recurring: false, + recurrenceRule: null, + isRecurringEventException: false, + isPublic: true, + isRegisterable: true, + }, + ]; + render( + + + + + + + , + , + ); + }); + it('Test for superadmin case', () => { + render( + + + + + + + , + , + ); + }); + it('Today Cell is having correct styles', () => { + render( + + + + + + + , + , + ); + // const todayDate = new Date().getDate(); + // const todayElement = screen.getByText(todayDate.toString()); + // expect(todayElement).toHaveClass(styles.day__today); + }); + it('Today button should show today cell', () => { + render( + + + + + + + , + , + ); + //Changing the month + const prevButton = screen.getByTestId('prevmonthordate'); + fireEvent.click(prevButton); + + // Clicking today button + const todayButton = screen.getByTestId('today'); + fireEvent.click(todayButton); + // const todayCell = screen.getByText(new Date().getDate().toString()); + // expect(todayCell).toHaveClass(styles.day__today); + }); + it('Should handle window resize in day view', async () => { + const date = new Date().toISOString().split('T')[0]; + const multipleEventData = [ + { + _id: '1', + title: 'Event 1', + description: 'This is event 1', + startDate: date, + endDate: date, + location: 'Los Angeles', + startTime: null, + endTime: null, + allDay: true, + recurring: false, + recurrenceRule: null, + isRecurringEventException: false, + isPublic: true, + isRegisterable: true, + }, + { + _id: '2', + title: 'Event 2', + description: 'This is event 2', + startDate: date, + endDate: date, + location: 'Los Angeles', + startTime: null, + endTime: null, + allDay: true, + recurring: false, + recurrenceRule: null, + isRecurringEventException: false, + isPublic: true, + isRegisterable: true, + }, + { + _id: '3', + title: 'Event 3', + description: 'This is event 3', + startDate: date, + endDate: date, + location: 'Los Angeles', + startTime: '14:00', + endTime: '16:00', + allDay: false, + recurring: false, + recurrenceRule: null, + isRecurringEventException: false, + isPublic: true, + isRegisterable: true, + }, + { + _id: '4', + title: 'Event 4', + description: 'This is event 4', + startDate: date, + endDate: date, + location: 'Los Angeles', + startTime: '14:00', + endTime: '16:00', + allDay: false, + recurring: false, + recurrenceRule: null, + isRecurringEventException: false, + isPublic: true, + isRegisterable: true, + }, + { + _id: '5', + title: 'Event 5', + description: 'This is event 5', + startDate: date, + endDate: date, + location: 'Los Angeles', + startTime: '17:00', + endTime: '19:00', + allDay: false, + recurring: false, + recurrenceRule: null, + isRecurringEventException: false, + isPublic: true, + isRegisterable: true, + }, + ]; + render( + + + + + + + , + ); + + // Simulate window resize and check if components respond correctly + await act(async () => { + window.innerWidth = 500; // Set the window width to <= 700 + window.dispatchEvent(new Event('resize')); + }); + + // Check for "View all" button if there are more than 2 events + const viewAllButton = await screen.findAllByTestId('more'); + console.log('hi', viewAllButton); // This will show the buttons found in the test + expect(viewAllButton.length).toBeGreaterThan(0); + + // Simulate clicking the "View all" button to expand the list + fireEvent.click(viewAllButton[0]); + + const event5 = screen.queryByText('Event 5'); + expect(event5).toBeNull(); + + const viewLessButtons = screen.getAllByText('View less'); + expect(viewLessButtons.length).toBeGreaterThan(0); + + // Simulate clicking "View less" to collapse the list + fireEvent.click(viewLessButtons[0]); + const viewAllButtons = screen.getAllByText('View all'); + expect(viewAllButtons.length).toBeGreaterThan(0); + + // Reset the window size to avoid side effects for other tests + await act(async () => { + window.innerWidth = 1024; + window.dispatchEvent(new Event('resize')); + }); + }); + test('Handles window resize', () => { + render( + + + + + + + , + , + ); + + act(() => { + window.dispatchEvent(new Event('resize')); + }); + }); + it('renders year view', async () => { + render(); + + await wait(); + months.forEach((month) => { + const elements = screen.getAllByText(month); + elements.forEach((element) => { + expect(element).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/src/components/EventCalendar/EventCalendar.tsx b/src/components/EventCalendar/EventCalendar.tsx new file mode 100644 index 0000000000..592456f295 --- /dev/null +++ b/src/components/EventCalendar/EventCalendar.tsx @@ -0,0 +1,657 @@ +import EventListCard from 'components/EventListCard/EventListCard'; +import dayjs from 'dayjs'; +import Button from 'react-bootstrap/Button'; +import React, { useState, useEffect } from 'react'; +import styles from './EventCalendar.module.css'; +import { ChevronLeft, ChevronRight } from '@mui/icons-material'; +import CurrentHourIndicator from 'components/CurrentHourIndicator/CurrentHourIndicator'; +import { ViewType } from 'screens/OrganizationEvents/OrganizationEvents'; +import HolidayCard from '../HolidayCards/HolidayCard'; +import { holidays, hours, months, weekdays } from './constants'; +import type { InterfaceRecurrenceRule } from 'utils/recurrenceUtils'; +import YearlyEventCalender from './YearlyEventCalender'; + +interface InterfaceEventListCardProps { + userRole?: string; + key?: string; + _id: string; + location: string; + title: string; + description: string; + startDate: string; + endDate: string; + startTime: string | null; + endTime: string | null; + allDay: boolean; + recurring: boolean; + recurrenceRule: InterfaceRecurrenceRule | null; + isRecurringEventException: boolean; + isPublic: boolean; + isRegisterable: boolean; + attendees?: { + _id: string; + }[]; + creator?: { + firstName: string; + lastName: string; + _id: string; + }; +} + +interface InterfaceCalendarProps { + eventData: InterfaceEventListCardProps[]; + refetchEvents?: () => void; + orgData?: InterfaceIOrgList; + userRole?: string; + userId?: string; + viewType?: ViewType; +} + +enum Role { + USER = 'USER', + SUPERADMIN = 'SUPERADMIN', + ADMIN = 'ADMIN', +} + +interface InterfaceIOrgList { + admins: { _id: string }[]; +} + +const Calendar: React.FC = ({ + eventData, + refetchEvents, + orgData, + userRole, + userId, + viewType, +}) => { + const [selectedDate] = useState(null); + const today = new Date(); + const [currentDate, setCurrentDate] = useState(today.getDate()); + const [currentMonth, setCurrentMonth] = useState(today.getMonth()); + const [currentYear, setCurrentYear] = useState(today.getFullYear()); + const [events, setEvents] = useState( + null, + ); + const [expanded, setExpanded] = useState(-1); + const [windowWidth, setWindowWidth] = useState(window.screen.width); + + useEffect(() => { + function handleResize(): void { + setWindowWidth(window.screen.width); + } + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + const filterData = ( + eventData: InterfaceEventListCardProps[], + orgData?: InterfaceIOrgList, + userRole?: string, + userId?: string, + ): InterfaceEventListCardProps[] => { + const data: InterfaceEventListCardProps[] = []; + if (userRole === Role.SUPERADMIN) return eventData; + // Hard to test all the cases + /* istanbul ignore next */ + if (userRole === Role.ADMIN) { + eventData?.forEach((event) => { + if (event.isPublic) data.push(event); + if (!event.isPublic) { + const filteredOrg: boolean | undefined = orgData?.admins?.some( + (data) => data._id === userId, + ); + + if (filteredOrg) { + data.push(event); + } + } + }); + } else { + eventData?.forEach((event) => { + if (event.isPublic) data.push(event); + const userAttending = event.attendees?.some( + (data) => data._id === userId, + ); + if (userAttending) { + data.push(event); + } + }); + } + return data; + }; + + useEffect(() => { + const data = filterData(eventData, orgData, userRole, userId); + setEvents(data); + }, [eventData, orgData, userRole, userId]); + + /** + * Moves the calendar view to the previous month. + */ + const handlePrevMonth = (): void => { + /*istanbul ignore next*/ + if (currentMonth === 0) { + setCurrentMonth(11); + setCurrentYear(currentYear - 1); + } else { + setCurrentMonth(currentMonth - 1); + } + }; + + /** + * Moves the calendar view to the next month. + */ + const handleNextMonth = (): void => { + /*istanbul ignore next*/ + if (currentMonth === 11) { + setCurrentMonth(0); + setCurrentYear(currentYear + 1); + } else { + setCurrentMonth(currentMonth + 1); + } + }; + + /** + * Moves the calendar view to the previous date. + */ + const handlePrevDate = (): void => { + /*istanbul ignore next*/ + if (currentDate > 1) { + setCurrentDate(currentDate - 1); + } else { + if (currentMonth > 0) { + const lastDayOfPrevMonth = new Date( + currentYear, + currentMonth, + 0, + ).getDate(); + setCurrentDate(lastDayOfPrevMonth); + setCurrentMonth(currentMonth - 1); + } else { + setCurrentDate(31); + setCurrentMonth(11); + setCurrentYear(currentYear - 1); + } + } + }; + /*istanbul ignore next*/ + const handleNextDate = (): void => { + /*istanbul ignore next*/ + const lastDayOfCurrentMonth = new Date( + currentYear, + currentMonth - 1, + 0, + ).getDate(); + /*istanbul ignore next*/ + if (currentDate < lastDayOfCurrentMonth) { + setCurrentDate(currentDate + 1); + } else { + if (currentMonth < 12) { + setCurrentDate(1); + setCurrentMonth(currentMonth + 1); + } else { + setCurrentDate(1); + setCurrentMonth(1); + setCurrentYear(currentYear + 1); + } + } + }; + + /** + * Moves the calendar view to today's date. + */ + const handleTodayButton = (): void => { + /*istanbul ignore next*/ + setCurrentYear(today.getFullYear()); + setCurrentMonth(today.getMonth()); + setCurrentDate(today.getDate()); + }; + + const timezoneString = `UTC${ + new Date().getTimezoneOffset() > 0 ? '-' : '+' + }${String(Math.floor(Math.abs(new Date().getTimezoneOffset()) / 60)).padStart( + 2, + '0', + )}:${String(Math.abs(new Date().getTimezoneOffset()) % 60).padStart(2, '0')}`; + + /*istanbul ignore next*/ + const renderHours = (): JSX.Element => { + const toggleExpand = (index: number): void => { + if (expanded === index) { + setExpanded(-1); + } else { + setExpanded(index); + } + }; + + /*istanbul ignore next*/ + const allDayEventsList: JSX.Element[] = + events + ?.filter((datas) => { + /*istanbul ignore next*/ + const currDate = new Date(currentYear, currentMonth, currentDate); + if ( + datas.startTime == undefined && + datas.startDate == dayjs(currDate).format('YYYY-MM-DD') + ) { + return datas; + } + }) + .map((datas: InterfaceEventListCardProps) => { + const attendees: { _id: string }[] = []; + datas.attendees?.forEach((attendee: { _id: string }) => { + const r = { + _id: attendee._id, + }; + + attendees.push(r); + }); + + return ( + + ); + }) || []; + + return ( + <> +
    +
    +

    {timezoneString}

    +
    +
    +
    0 + ? styles.event_list_parent_current + : styles.event_list_parent + } + > +
    +
    + {expanded === -100 + ? allDayEventsList + : allDayEventsList?.slice(0, 1)} +
    + {(allDayEventsList?.length > 2 || + (windowWidth <= 700 && allDayEventsList?.length > 0)) && ( + + )} +
    +
    +
    + {hours.map((hour, index) => { + const timeEventsList: JSX.Element[] = + events + ?.filter((datas) => { + const currDate = new Date( + currentYear, + currentMonth, + currentDate, + ); + + if ( + parseInt(datas.startTime?.slice(0, 2) as string).toString() == + (index % 24).toString() && + datas.startDate == dayjs(currDate).format('YYYY-MM-DD') + ) { + return datas; + } + }) + .map((datas: InterfaceEventListCardProps) => { + const attendees: { _id: string }[] = []; + datas.attendees?.forEach((attendee: { _id: string }) => { + const r = { + _id: attendee._id, + }; + + attendees.push(r); + }); + + return ( + + ); + }) || []; + /*istanbul ignore next*/ + return ( +
    +
    +

    {`${hour}`}

    +
    +
    +
    0 + ? styles.event_list_parent_current + : styles.event_list_parent + } + > + {index % 24 == new Date().getHours() && + new Date().getDate() == currentDate && ( + + )} +
    +
    + {/*istanbul ignore next*/} + {expanded === index + ? timeEventsList + : timeEventsList?.slice(0, 1)} +
    + {(timeEventsList?.length > 1 || + (windowWidth <= 700 && timeEventsList?.length > 0)) && ( + + )} +
    +
    +
    + ); + })} + + ); + }; + + const renderDays = (): JSX.Element[] => { + const monthStart = new Date(currentYear, currentMonth, 1); + const monthEnd = new Date(currentYear, currentMonth + 1, 0); + const startDate = new Date( + monthStart.getFullYear(), + monthStart.getMonth(), + monthStart.getDate() - monthStart.getDay(), + ); + const endDate = new Date( + monthEnd.getFullYear(), + monthEnd.getMonth(), + monthEnd.getDate() + (6 - monthEnd.getDay()), + ); + const days = []; + let currentDate = startDate; + while (currentDate <= endDate) { + days.push(currentDate); + currentDate = new Date( + currentDate.getFullYear(), + currentDate.getMonth(), + currentDate.getDate() + 1, + ); + } + + return days.map((date, index) => { + const className = [ + date.getDay() === 0 || date.getDay() === 6 ? styles.day_weekends : '', + date.toLocaleDateString() === today.toLocaleDateString() //Styling for today day cell + ? styles.day__today + : '', + date.getMonth() !== currentMonth ? styles.day__outside : '', //Styling for days outside the current month + selectedDate?.getTime() === date.getTime() ? styles.day__selected : '', + styles.day, + ].join(' '); + const toggleExpand = (index: number): void => { + /*istanbul ignore next*/ + if (expanded === index) { + setExpanded(-1); + } else { + setExpanded(index); + } + }; + /*istanbul ignore next*/ + const allEventsList: JSX.Element[] = + events + ?.filter((datas) => { + if (datas.startDate == dayjs(date).format('YYYY-MM-DD')) + return datas; + }) + .map((datas: InterfaceEventListCardProps) => { + const attendees: { _id: string }[] = []; + datas.attendees?.forEach((attendee: { _id: string }) => { + const r = { + _id: attendee._id, + }; + + attendees.push(r); + }); + + return ( + + ); + }) || []; + + const holidayList: JSX.Element[] = holidays + .filter((holiday) => { + if (holiday.date == dayjs(date).format('MM-DD')) return holiday; + }) + .map((holiday) => { + return ; + }); + return ( +
    0 && styles.day__events) + } + data-testid="day" + > + {date.getDate()} + {date.getMonth() !== currentMonth ? null : ( +
    +
    +
    {holidayList}
    + { + /*istanbul ignore next*/ + expanded === index + ? allEventsList + : holidayList?.length > 0 + ? /*istanbul ignore next*/ + allEventsList?.slice(0, 1) + : allEventsList?.slice(0, 2) + } +
    + {(allEventsList?.length > 2 || + (windowWidth <= 700 && allEventsList?.length > 0)) && ( + /*istanbul ignore next*/ + + )} +
    + )} +
    + ); + }); + }; + + return ( +
    + {viewType != ViewType.YEAR && ( +
    + + +
    + {viewType == ViewType.DAY ? `${currentDate}` : ``} {currentYear}{' '} +
    {months[currentMonth]}
    +
    + +
    + +
    +
    + )} +
    + {viewType == ViewType.MONTH ? ( +
    +
    + {weekdays.map((weekday, index) => ( +
    + {weekday} +
    + ))} +
    +
    {renderDays()}
    +
    + ) : ( + // +
    + {viewType == ViewType.YEAR ? ( + + ) : ( +
    {renderHours()}
    + )} +
    + )} +
    +
    + {viewType == ViewType.YEAR ? ( + + ) : ( +
    {renderHours()}
    + )} +
    +
    + ); +}; + +export default Calendar; diff --git a/src/components/EventCalendar/EventHeader.test.tsx b/src/components/EventCalendar/EventHeader.test.tsx new file mode 100644 index 0000000000..e18d066306 --- /dev/null +++ b/src/components/EventCalendar/EventHeader.test.tsx @@ -0,0 +1,99 @@ +import React, { act } from 'react'; // Import act for async testing +import { render, fireEvent } from '@testing-library/react'; +import EventHeader from './EventHeader'; +import { ViewType } from '../../screens/OrganizationEvents/OrganizationEvents'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; + +describe('EventHeader Component', () => { + const viewType = ViewType.MONTH; + const handleChangeView = jest.fn(); + const showInviteModal = jest.fn(); + + it('renders correctly', () => { + const { getByTestId } = render( + + + , + ); + + expect(getByTestId('searchEvent')).toBeInTheDocument(); + expect(getByTestId('createEventModalBtn')).toBeInTheDocument(); + }); + + it('calls handleChangeView with selected view type', async () => { + // Add async keyword + const { getByTestId } = render( + + + , + ); + + fireEvent.click(getByTestId('selectViewType')); + + await act(async () => { + fireEvent.click(getByTestId('selectDay')); + }); + + // Expect handleChangeView to be called with the new view type + expect(handleChangeView).toHaveBeenCalledTimes(1); + }); + it('calls handleChangeView with selected event type', async () => { + const { getByTestId } = render( + + + , + ); + + fireEvent.click(getByTestId('eventType')); + + await act(async () => { + fireEvent.click(getByTestId('events')); + }); + + expect(handleChangeView).toHaveBeenCalledTimes(1); + }); + + it('calls showInviteModal when create event button is clicked', () => { + const { getByTestId } = render( + + + , + ); + + fireEvent.click(getByTestId('createEventModalBtn')); + expect(showInviteModal).toHaveBeenCalled(); + }); + it('updates the input value when changed', () => { + const { getByTestId } = render( + + + , + ); + + const input = getByTestId('searchEvent') as HTMLInputElement; + fireEvent.change(input, { target: { value: 'test event' } }); + + expect(input.value).toBe('test event'); + }); +}); diff --git a/src/components/EventCalendar/EventHeader.tsx b/src/components/EventCalendar/EventHeader.tsx new file mode 100644 index 0000000000..1e4653ebf4 --- /dev/null +++ b/src/components/EventCalendar/EventHeader.tsx @@ -0,0 +1,127 @@ +import React, { useState } from 'react'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Search } from '@mui/icons-material'; +import styles from './EventCalendar.module.css'; +import { ViewType } from '../../screens/OrganizationEvents/OrganizationEvents'; +import { useTranslation } from 'react-i18next'; + +/** + * Props for the EventHeader component. + */ +interface InterfaceEventHeaderProps { + viewType: ViewType; + handleChangeView: (item: string | null) => void; + showInviteModal: () => void; +} + +/** + * EventHeader component displays the header for the event calendar. + * It includes a search field, view type dropdown, event type dropdown, and a button to create an event. + * + * @param viewType - The current view type of the calendar. + * @param handleChangeView - Function to handle changing the view type. + * @param showInviteModal - Function to show the invite modal for creating an event. + * @returns JSX.Element - The rendered EventHeader component. + */ +function eventHeader({ + viewType, + handleChangeView, + showInviteModal, +}: InterfaceEventHeaderProps): JSX.Element { + const [eventName, setEventName] = useState(''); + const { t } = useTranslation('translation', { + keyPrefix: 'organizationEvents', + }); + + return ( +
    +
    +
    + setEventName(e.target.value)} + /> + +
    +
    +
    +
    + + + {viewType} + + + + {ViewType.MONTH} + + + {ViewType.DAY} + + + {ViewType.YEAR} + + + +
    +
    + + + {t('eventType')} + + + + Events + + + Workshops + + + +
    + +
    +
    +
    + ); +} + +export default eventHeader; diff --git a/src/components/EventCalendar/YearlyEventCalender.module.css b/src/components/EventCalendar/YearlyEventCalender.module.css new file mode 100644 index 0000000000..723cb20e22 --- /dev/null +++ b/src/components/EventCalendar/YearlyEventCalender.module.css @@ -0,0 +1,354 @@ +.calendar { + font-family: sans-serif; + font-size: 1.2rem; + margin-bottom: 20px; +} +.calendar__header { + display: flex; + margin-bottom: 2rem; + align-items: center; + margin: 0px 10px 0px 10px; +} +.input { + flex: 1; + position: relative; +} +.calendar__header_month { + margin: 0.5rem; + color: #707070; + font-weight: bold; +} +.space { + flex: 1; + display: flex; + align-items: center; + justify-content: space-around; +} +.button { + color: #707070; + background-color: rgba(0, 0, 0, 0); + font-weight: bold; + border: 0px; +} +.calendar__weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + background-color: #707070; + height: 60px; +} +.calendar__scroll { + height: 80vh; + padding: 10px; +} +.weekday { + display: flex; + justify-content: center; + align-items: center; + color: #fff; + background-color: #31bb6b; + font-weight: 600; +} +.calendar__days { + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + grid-template-rows: repeat(6, 1fr); +} +.calendar_hour_text_container { + display: flex; + flex-direction: row; + align-items: flex-end; + border-right: 1px solid #8d8d8d55; + width: 40px; +} +.calendar_hour_text { + top: -10px; + left: -5px; + position: relative; + color: #707070; + font-size: 12px; +} +.calendar_timezone_text { + top: -10px; + left: -11px; + position: relative; + color: #707070; + font-size: 9px; +} +.calendar_hour_block { + display: flex; + flex-direction: row; + border-bottom: 1px solid #8d8d8d55; + position: relative; + height: 50px; + border-bottom-right-radius: 5px; +} +.event_list_parent { + position: relative; + width: 100%; +} +.event_list_parent_current { + background-color: #def6e1; + position: relative; + width: 100%; +} +.dummyWidth { + width: 1px; +} +.day { + background-color: #ffffff; + padding-left: 0.3rem; + padding-right: 0.3rem; + background-color: white; + border: 1px solid #8d8d8d55; + color: black; + font-weight: 600; + height: 8rem; + position: relative; +} +.day__outside { + /* background-color: #ededee !important; */ + color: #89898996 !important; +} +.day__selected { + background-color: #007bff; + color: #707070; +} +.day__today { + background-color: #def6e1; + font-weight: 700; + text-decoration: underline; + color: #31bb6b; +} +.day__events { + background-color: #def6e1; +} +/* yearly calender styling */ +.yearlyCalender { + background-color: #ffffff; + box-sizing: border-box; +} + +.circularButton { + width: 25px; + height: 25px; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0); + border: none; + cursor: pointer; + margin-left: 0.75rem; +} +.circularButton:hover { + background-color: rgba(82, 172, 255, 0.5); +} +.closebtn { + padding: 10px; +} + +.yearlyCalendarHeader { + display: flex; + flex-direction: row; +} + +.yearlyCalendarHeader > div { + font-weight: 600; + font-size: 2rem; + padding: 0 10px; + color: #4b4b4b; +} +.noEventAvailable { + background-color: rgba(255, 255, 255, 0); +} + +.card { + /* box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2); */ + padding: 16px; + text-align: center; + /* background-color: #f1f1f1; */ + height: 21rem; +} +.cardHeader { + text-align: left; +} + +.row { + margin: 1px -5px; +} + +/* Clear floats after the columns */ +.row:after { + content: ''; + display: table; + clear: both; +} + +.weekday__yearly { + display: flex; + justify-content: center; + align-items: center; + /* color: #fff; */ + background-color: #ffffff; + font-weight: 600; +} +.day__yearly { + background-color: #ffffff; + padding-left: 0.3rem; + padding-right: 0.3rem; + background-color: white; + /* border: 1px solid #8d8d8d55; */ + color: #4b4b4b; + font-weight: 600; + height: 2rem; + position: relative; +} +* { + box-sizing: border-box; +} + +/* Float four columns side by side */ +.column { + float: left; + width: 25%; + padding: 10px; +} + +/* Remove extra left and right margins, due to padding */ +.row { + margin: 0 -5px; +} + +/* Clear floats after the columns */ +.row:after { + content: ''; + display: table; + clear: both; +} +/* yearly calender styling ends */ +.btn__today { + transition: ease-in all 200ms; + font-family: Arial; + color: #ffffff; + font-size: 18px; + padding: 10px 20px 10px 20px; + text-decoration: none; + margin-left: 20px; + border: none; +} +.btnsContainer { + display: flex; + margin: 2.5rem 0 2.5rem 0; +} + +.btnsContainer .btnsBlock { + display: flex; +} + +.btnsContainer .btnsBlock button { + margin-left: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.dropdown { + border-color: #31bb6b; + background-color: white; + color: #31bb6b; + box-shadow: 0px 2px 1px rgba(49, 187, 107, 0.5); /* Added blur effect */ +} + +.btnsContainer .input { + flex: 1; + position: relative; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); +} + +.btnsContainer .input button { + width: 52px; +} +.searchBtn { + margin-bottom: 10px; +} + +.inputField { + margin-top: 10px; + margin-bottom: 10px; + background-color: white; + box-shadow: 0 1px 1px #31bb6b; +} +.inputField > button { + padding-top: 10px; + padding-bottom: 10px; +} +.btn__more { + border: 0px; + font-size: 14px; + background-color: initial; + font-weight: 600; + transition: all 200ms; + position: relative; + display: block; + margin: -9px; + margin-top: -28px; +} +.btn__more:hover { + color: #3ce080; +} + +.expand_event_list { + display: block; +} +.list_container { + padding: 5px; + width: fit-content; + display: flex; + flex-direction: row; +} +.event_list_hour { + display: flex; + flex-direction: row; +} +.expand_list_container { + width: 200px; + max-height: 250px; + z-index: 10; + position: absolute; + left: auto; + right: auto; + overflow: auto; + padding: 10px 4px 0px 4px; + background-color: rgb(241, 241, 241); + border: 1px solid rgb(201, 201, 201); + border-radius: 5px; + margin: 5px; +} +.flex_grow { + flex-grow: 1; +} + +@media only screen and (max-width: 700px) { + .event_list { + display: none; + } + .expand_list_container { + width: 150px; + padding: 4px 4px 0px 4px; + } + .day { + height: 5rem; + } +} + +@media only screen and (max-width: 500px) { + .btn__more { + font-size: 12px; + } + + .column { + float: left; + width: 100%; + padding: 10px; + } +} diff --git a/src/components/EventCalendar/YearlyEventCalender.tsx b/src/components/EventCalendar/YearlyEventCalender.tsx new file mode 100644 index 0000000000..63870ded3c --- /dev/null +++ b/src/components/EventCalendar/YearlyEventCalender.tsx @@ -0,0 +1,412 @@ +import EventListCard from 'components/EventListCard/EventListCard'; +import dayjs from 'dayjs'; +import Button from 'react-bootstrap/Button'; +import React, { useState, useEffect } from 'react'; +import styles from './YearlyEventCalender.module.css'; +import type { ViewType } from 'screens/OrganizationEvents/OrganizationEvents'; +import { ChevronLeft, ChevronRight } from '@mui/icons-material'; +import type { InterfaceRecurrenceRule } from 'utils/recurrenceUtils'; + +/** + * Interface for event data used in the calendar. + */ +interface InterfaceEventListCardProps { + userRole?: string; + key?: string; + _id: string; + location: string; + title: string; + description: string; + startDate: string; + endDate: string; + startTime: string | null; + endTime: string | null; + allDay: boolean; + recurring: boolean; + recurrenceRule: InterfaceRecurrenceRule | null; + isRecurringEventException: boolean; + isPublic: boolean; + isRegisterable: boolean; + attendees?: { + _id: string; + }[]; + creator?: { + firstName: string; + lastName: string; + _id: string; + }; +} + +interface InterfaceCalendarProps { + eventData: InterfaceEventListCardProps[]; + refetchEvents?: () => void; + orgData?: InterfaceIOrgList; + userRole?: string; + userId?: string; + viewType?: ViewType; +} + +enum Status { + ACTIVE = 'ACTIVE', + BLOCKED = 'BLOCKED', + DELETED = 'DELETED', +} + +/** + * Enum for different user roles. + */ +enum Role { + USER = 'USER', + SUPERADMIN = 'SUPERADMIN', + ADMIN = 'ADMIN', +} + +/** + * Interface for event attendees. + */ +interface InterfaceIEventAttendees { + userId: string; + user?: string; + status?: Status; + createdAt?: Date; +} + +/** + * Interface for organization list. + */ +interface InterfaceIOrgList { + admins: { _id: string }[]; +} + +/** + * Calendar component to display events for a selected year. + * + * This component renders a yearly calendar with navigation to view previous and next years. + * It displays events for each day, with functionality to expand and view details of events. + * + * @param eventData - Array of event data to display on the calendar. + * @param refetchEvents - Function to refresh the event data. + * @param orgData - Organization data to filter events. + * @param userRole - Role of the user for access control. + * @param userId - ID of the user for filtering events they are attending. + * @param viewType - Type of view for the calendar. + * @returns JSX.Element - The rendered calendar component. + */ +const Calendar: React.FC = ({ + eventData, + refetchEvents, + orgData, + userRole, + userId, +}) => { + const [selectedDate] = useState(null); + const weekdaysShorthand = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; + const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + const today = new Date(); + const [currentYear, setCurrentYear] = useState(today.getFullYear()); + const [events, setEvents] = useState( + null, + ); + const [expandedY, setExpandedY] = useState(null); + + /** + * Filters events based on user role, organization data, and user ID. + * + * @param eventData - Array of event data to filter. + * @param orgData - Organization data for filtering events. + * @param userRole - Role of the user for access control. + * @param userId - ID of the user for filtering events they are attending. + * @returns Filtered array of event data. + */ + const filterData = ( + eventData: InterfaceEventListCardProps[], + orgData?: InterfaceIOrgList, + userRole?: string, + userId?: string, + ): InterfaceEventListCardProps[] => { + const data: InterfaceEventListCardProps[] = []; + if (userRole === Role.SUPERADMIN) return eventData; + // Hard to test all the cases + /* istanbul ignore next */ + if (userRole === Role.ADMIN) { + eventData?.forEach((event) => { + if (event.isPublic) data.push(event); + if (!event.isPublic) { + const filteredOrg: boolean | undefined = orgData?.admins?.some( + (data) => data._id === userId, + ); + + if (filteredOrg) { + data.push(event); + } + } + }); + } else { + eventData?.forEach((event) => { + if (event.isPublic) data.push(event); + const userAttending = event.attendees?.some( + (data) => data._id === userId, + ); + if (userAttending) { + data.push(event); + } + }); + } + return data; + }; + + useEffect(() => { + const data = filterData(eventData, orgData, userRole, userId); + setEvents(data); + }, [eventData, orgData, userRole, userId]); + + /** + * Navigates to the previous year. + */ + const handlePrevYear = (): void => { + /*istanbul ignore next*/ + setCurrentYear(currentYear - 1); + }; + + /** + * Navigates to the next year. + */ + const handleNextYear = (): void => { + /*istanbul ignore next*/ + setCurrentYear(currentYear + 1); + }; + + /** + * Renders the days of the month for the calendar. + * + * @returns Array of JSX elements representing the days of each month. + */ + const renderMonthDays = (): JSX.Element[] => { + const renderedMonths: JSX.Element[] = []; + + for (let monthInx = 0; monthInx < 12; monthInx++) { + const monthStart = new Date(currentYear, monthInx, 1); + const monthEnd = new Date(currentYear, monthInx + 1, 0); + + const startDate = new Date(monthStart); + const dayOfWeek = startDate.getDay(); + const diff = startDate.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); + startDate.setDate(diff); + + const endDate = new Date(monthEnd); + const endDayOfWeek = endDate.getDay(); + const diffEnd = + endDate.getDate() + (7 - endDayOfWeek) - (endDayOfWeek === 0 ? 7 : 0); + endDate.setDate(diffEnd); + + const days = []; + let currentDate = startDate; + while (currentDate <= endDate) { + days.push(currentDate); + currentDate = new Date( + currentDate.getFullYear(), + currentDate.getMonth(), + currentDate.getDate() + 1, + ); + } + + const renderedDays = days.map((date, dayIndex) => { + const className = [ + date.toLocaleDateString() === today.toLocaleDateString() + ? styles.day__today + : '', + date.getMonth() !== monthInx ? styles.day__outside : '', + selectedDate?.getTime() === date.getTime() + ? styles.day__selected + : '', + styles.day__yearly, + ].join(' '); + + const eventsForCurrentDate = events?.filter((event) => { + return dayjs(event.startDate).isSame(date, 'day'); + }); + + /*istanbul ignore next*/ + const renderedEvents = + eventsForCurrentDate?.map((datas: InterfaceEventListCardProps) => { + const attendees: { _id: string }[] = []; + datas.attendees?.forEach((attendee: { _id: string }) => { + const r = { + _id: attendee._id, + }; + + attendees.push(r); + }); + + return ( + + ); + }) || []; + + /*istanbul ignore next*/ + const toggleExpand = (index: string): void => { + if (expandedY === index) { + setExpandedY(null); + } else { + setExpandedY(index); + } + }; + + /*istanbul ignore next*/ + return ( +
    + {date.getDate()} +
    +
    + {expandedY === `${monthInx}-${dayIndex}` && renderedEvents} +
    + {renderedEvents && renderedEvents?.length > 0 && ( + + )} + {renderedEvents && renderedEvents?.length == 0 && ( + + )} +
    +
    + ); + }); + + renderedMonths.push( +
    +
    +
    {months[monthInx]}
    +
    + {weekdaysShorthand.map((weekday, index) => ( +
    + {weekday} +
    + ))} +
    +
    {renderedDays}
    +
    +
    , + ); + } + + return renderedMonths; + }; + + /** + * Renders the yearly calendar with navigation buttons. + * + * @returns JSX.Element - The rendered yearly calendar component. + */ + const renderYearlyCalendar = (): JSX.Element => { + return ( +
    +
    + +
    {currentYear}
    + +
    + +
    +
    {renderMonthDays()}
    +
    +
    + ); + }; + + return ( +
    +
    +
    {renderYearlyCalendar()}
    +
    +
    + ); +}; + +export default Calendar; diff --git a/src/components/EventCalendar/constants.js b/src/components/EventCalendar/constants.js new file mode 100644 index 0000000000..f2b770426c --- /dev/null +++ b/src/components/EventCalendar/constants.js @@ -0,0 +1,56 @@ +export const holidays = [ + { name: 'May Day / Labour Day', date: '05-01', month: 'May' }, // May 1st + { name: "Mother's Day", date: '05-08', month: 'May' }, // Second Sunday in May + { name: "Father's Day", date: '06-19', month: 'June' }, // Third Sunday in June + { name: 'Independence Day (US)', date: '07-04', month: 'July' }, // July 4th + { name: 'Oktoberfest', date: '09-21', month: 'September' }, // September 21st (starts in September, ends in October) + { name: 'Halloween', date: '10-31', month: 'October' }, // October 31st + { name: 'Diwali', date: '11-04', month: 'November' }, + { + name: 'Remembrance Day / Veterans Day', + date: '11-11', + month: 'November', + }, + { name: 'Christmas Day', date: '12-25', month: 'December' }, // December 25th +]; +export const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +export const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; +export const hours = [ + '12 AM', + '01 AM', + '02 AM', + '03 AM', + '04 AM', + '05 AM', + '06 AM', + '07 AM', + '08 AM', + '09 AM', + '10 AM', + '11 AM', + '12 PM', + '01 PM', + '02 PM', + '03 PM', + '04 PM', + '05 PM', + '06 PM', + '07 PM', + '08 PM', + '09 PM', + '10 PM', + '11 PM', +]; diff --git a/src/components/EventDashboardScreen/EventDashboardScreen.module.css b/src/components/EventDashboardScreen/EventDashboardScreen.module.css new file mode 100644 index 0000000000..33c642dd95 --- /dev/null +++ b/src/components/EventDashboardScreen/EventDashboardScreen.module.css @@ -0,0 +1,198 @@ +.gap { + gap: 20px; +} + +.mainContainer { + width: 50%; + flex-grow: 3; + padding: 20px; + max-height: 100%; + overflow: auto; +} + +.containerHeight { + height: calc(100vh - 66px); +} + +.colorLight { + background-color: #f1f3f6; +} + +.pageContainer { + display: flex; + flex-direction: column; + min-height: 100vh; + padding: 1rem 1.5rem 0 calc(300px + 2rem + 1.5rem); +} + +.expand { + padding-left: 4rem; + animation: moveLeft 0.5s ease-in-out; +} +.avatarStyle { + border-radius: 100%; +} +.profileContainer { + border: none; + padding: 2.1rem 0.5rem; + height: 52px; + border-radius: 8px 0px 0px 8px; + display: flex; + align-items: center; + background-color: white !important; + box-shadow: + 0 4px 4px 0 rgba(177, 177, 177, 0.2), + 0 6px 20px 0 rgba(151, 151, 151, 0.19); +} +.profileContainer:focus { + outline: none; + background-color: var(--bs-gray-100); +} +.imageContainer { + width: 56px; +} +.profileContainer .profileText { + flex: 1; + text-align: start; + overflow: hidden; + margin-right: 4px; +} +.angleDown { + margin-left: 4px; +} +.profileContainer .profileText .primaryText { + font-size: 1rem; + font-weight: 600; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; /* number of lines to show */ + -webkit-box-orient: vertical; + word-wrap: break-word; + white-space: normal; +} +.profileContainer .profileText .secondaryText { + font-size: 0.8rem; + font-weight: 400; + color: var(--bs-secondary); + display: block; + text-transform: capitalize; +} + +.contract { + padding-left: calc(300px + 2rem + 1.5rem); + animation: moveRight 0.5s ease-in-out; +} + +.collapseSidebarButton { + position: fixed; + height: 40px; + bottom: 0; + z-index: 9999; + width: calc(300px + 2rem); + background-color: rgba(245, 245, 245, 0.7); + color: black; + border: none; + border-radius: 0px; +} + +.collapseSidebarButton:hover, +.opendrawer:hover { + opacity: 1; + color: black !important; +} +.opendrawer { + position: fixed; + display: flex; + align-items: center; + justify-content: center; + top: 0; + left: 0; + width: 40px; + height: 100vh; + z-index: 9999; + background-color: rgba(245, 245, 245); + border: none; + border-radius: 0px; + margin-right: 20px; + color: black; +} +.profileDropdown { + background-color: transparent !important; +} +.profileDropdown .dropdown-toggle .btn .btn-normal { + display: none !important; + background-color: transparent !important; +} +.dropdownToggle { + background-image: url(/public/images/svg/angleDown.svg); + background-repeat: no-repeat; + background-position: center; + background-color: azure; +} + +.dropdownToggle::after { + border-top: none !important; + border-bottom: none !important; +} + +.opendrawer:hover { + transition: background-color 0.5s ease; + background-color: var(--bs-primary); +} +.collapseSidebarButton:hover { + transition: background-color 0.5s ease; + background-color: var(--bs-primary); +} + +@media (max-width: 1120px) { + .contract { + padding-left: calc(250px + 2rem + 1.5rem); + } + .collapseSidebarButton { + width: calc(250px + 2rem); + } +} + +@media (max-height: 900px) { + .pageContainer { + padding: 1rem 1.5rem 0 calc(300px + 2rem); + } + .collapseSidebarButton { + height: 30px; + width: calc(300px + 1rem); + } +} +@media (max-height: 650px) { + .pageContainer { + padding: 1rem 1.5rem 0 calc(270px); + } + .collapseSidebarButton { + width: 250px; + height: 20px; + } + .opendrawer { + width: 30px; + } +} + +/* For tablets */ +@media (max-width: 820px) { + .pageContainer { + padding-left: 2.5rem; + } + + .opendrawer { + width: 25px; + } + + .contract, + .expand { + animation: none; + } + + .collapseSidebarButton { + width: 100%; + left: 0; + right: 0; + } +} diff --git a/src/components/EventDashboardScreen/EventDashboardScreen.test.tsx b/src/components/EventDashboardScreen/EventDashboardScreen.test.tsx new file mode 100644 index 0000000000..6b48b034a9 --- /dev/null +++ b/src/components/EventDashboardScreen/EventDashboardScreen.test.tsx @@ -0,0 +1,166 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import 'jest-location-mock'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import EventDashboardScreen from './EventDashboardScreen'; +import { ORGANIZATIONS_LIST } from 'GraphQl/Queries/Queries'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import useLocalStorage from 'utils/useLocalstorage'; +const { setItem } = useLocalStorage(); + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +let mockID: string | undefined = '123'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: mockID }), +})); + +const MOCKS = [ + { + request: { + query: ORGANIZATIONS_LIST, + variables: { id: '123' }, + }, + result: { + data: { + organizations: [ + { + _id: '123', + image: null, + creator: { + firstName: 'John', + lastName: 'Doe', + email: 'JohnDoe@example.com', + }, + name: 'Test Organization', + description: 'Testing this organization', + address: { + city: 'Mountain View', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 Main Street', + line2: 'Apt 456', + postalCode: '94040', + sortingCode: 'XYZ-789', + state: 'CA', + }, + userRegistrationRequired: true, + visibleInSearch: true, + members: [], + admins: [], + membershipRequests: [], + blockedUsers: [], + }, + ], + }, + }, + }, +]; +const link = new StaticMockLink(MOCKS, true); + +const resizeWindow = (width: number): void => { + window.innerWidth = width; + fireEvent(window, new Event('resize')); +}; + +const clickToggleMenuBtn = (toggleButton: HTMLElement): void => { + fireEvent.click(toggleButton); +}; + +describe('Testing LeftDrawer in OrganizationScreen', () => { + test('should be redirected to / if IsLoggedIn is false', async () => { + setItem('IsLoggedIn', false); + render( + + + + + + + + + , + ); + expect(window.location.pathname).toEqual('/'); + }); + test('should be redirected to / if ss is false', async () => { + setItem('IsLoggedIn', true); + render( + + + + + + + + + , + ); + }); + test('Testing LeftDrawer in page functionality', async () => { + setItem('IsLoggedIn', true); + setItem('AdminFor', [ + { _id: '6637904485008f171cf29924', __typename: 'Organization' }, + ]); + render( + + + + + + + + + , + ); + const toggleButton = screen.getByTestId('toggleMenuBtn') as HTMLElement; + const icon = toggleButton.querySelector('i'); + + // Resize window to a smaller width + resizeWindow(800); + clickToggleMenuBtn(toggleButton); + expect(icon).toHaveClass('fa fa-angle-double-right'); + // Resize window back to a larger width + + resizeWindow(1000); + clickToggleMenuBtn(toggleButton); + expect(icon).toHaveClass('fa fa-angle-double-left'); + + clickToggleMenuBtn(toggleButton); + expect(icon).toHaveClass('fa fa-angle-double-right'); + }); + + test('should be redirected to / if orgId is undefined', async () => { + mockID = undefined; + render( + + + + + + + + + , + ); + expect(window.location.pathname).toEqual('/'); + }); +}); diff --git a/src/components/EventDashboardScreen/EventDashboardScreen.tsx b/src/components/EventDashboardScreen/EventDashboardScreen.tsx new file mode 100644 index 0000000000..11b9ec936f --- /dev/null +++ b/src/components/EventDashboardScreen/EventDashboardScreen.tsx @@ -0,0 +1,159 @@ +import LeftDrawerOrg from 'components/LeftDrawerOrg/LeftDrawerOrg'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import { Navigate, Outlet, useLocation, useParams } from 'react-router-dom'; +import { updateTargets } from 'state/action-creators'; +import { useAppDispatch } from 'state/hooks'; +import type { RootState } from 'state/reducers'; +import type { TargetsType } from 'state/reducers/routesReducer'; +import styles from './EventDashboardScreen.module.css'; +import ProfileDropdown from 'components/ProfileDropdown/ProfileDropdown'; +import useLocalStorage from 'utils/useLocalstorage'; +import type { InterfaceMapType } from 'utils/interfaces'; + +/** + * The EventDashboardScreen component is the main dashboard view for event management. + * It includes navigation, a sidebar, and a profile dropdown. + * + * @returns JSX.Element - The rendered EventDashboardScreen component. + */ +const EventDashboardScreen = (): JSX.Element => { + const { getItem } = useLocalStorage(); + const isLoggedIn = getItem('IsLoggedIn'); + const adminFor = getItem('AdminFor'); + const location = useLocation(); + const titleKey: string | undefined = map[location.pathname.split('/')[2]]; + const { t } = useTranslation('translation', { keyPrefix: titleKey }); + const [hideDrawer, setHideDrawer] = useState(null); + const { orgId } = useParams(); + + // Redirect to home if orgId is not present or if user is not logged in + if (!orgId) { + return ; + } + + if (isLoggedIn === false) return ; + if (adminFor === null) { + return ( + <> +
    +
    +
    +
    +

    {t('title')}

    +
    + +
    +
    +
    + + ); + } + + // Access targets from Redux store + const appRoutes: { + targets: TargetsType[]; + } = useSelector((state: RootState) => state.appRoutes); + const { targets } = appRoutes; + + const dispatch = useAppDispatch(); + + // Update targets when orgId changes + useEffect(() => { + dispatch(updateTargets(orgId)); + }, [orgId]); + + /** + * Handles window resize events to toggle the visibility of the sidebar drawer. + */ + const handleResize = (): void => { + if (window.innerWidth <= 820 && !hideDrawer) { + setHideDrawer(true); + } + }; + + /** + * Toggles the visibility of the sidebar drawer. + */ + const toggleDrawer = (): void => { + setHideDrawer(!hideDrawer); + }; + + useEffect(() => { + handleResize(); + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, [hideDrawer]); + + return ( + <> + +
    + +
    +
    +
    +
    +

    {t('title')}

    +
    + +
    + +
    + + ); +}; + +export default EventDashboardScreen; + +const map: InterfaceMapType = { + orgdash: 'dashboard', + orgpeople: 'organizationPeople', + requests: 'requests', + orgads: 'advertisement', + member: 'memberDetail', + orgevents: 'organizationEvents', + orgactionitems: 'organizationActionItems', + orgcontribution: 'orgContribution', + orgpost: 'orgPost', + orgfunds: 'funds', + orgfundcampaign: 'fundCampaign', + fundCampaignPledge: 'pledges', + orgsetting: 'orgSettings', + orgstore: 'addOnStore', + blockuser: 'blockUnblockUser', + orgvenues: 'organizationVenues', + event: 'eventManagement', +}; diff --git a/src/components/EventListCard/EventListCard.module.css b/src/components/EventListCard/EventListCard.module.css new file mode 100644 index 0000000000..1e47972a42 --- /dev/null +++ b/src/components/EventListCard/EventListCard.module.css @@ -0,0 +1,223 @@ +.cards h2 { + font-size: 15px; + color: #4e4c4c; + font-weight: 500; +} +.cards > h3 { + font-size: 17px; +} +.cards > p { + font-size: 14px; + margin-top: 0px; + margin-bottom: 7px; +} +.cards a { + color: #fff; + font-weight: 600; +} +.cards a:hover { + color: black; +} +.cards { + position: relative; + overflow: hidden; + transition: all 0.3s; + margin-bottom: 5px; +} +.dispflex { + display: flex; + cursor: pointer; + justify-content: space-between; + margin: 10px 5px 5px 0px; +} +.eventtitle { + margin-bottom: 0px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} +.iconContainer { + display: flex; + justify-content: flex-end; +} +.icon { + margin: 2px; +} + +.cards { + width: 100%; + background: #5cacf7 !important; + padding: 2px 3px; + border-radius: 5px; + border: 1px solid #e8e8e8; + box-shadow: 0 3px 2px #e8e8e8; + color: #737373; + box-sizing: border-box; +} +.cards:last-child:nth-last-child(odd) { + grid-column: auto / span 2; +} + +.cards:first-child:nth-last-child(even), +.cards:first-child:nth-last-child(even) ~ .box { + grid-column: auto / span 1; +} + +.sidebarsticky > input { + text-decoration: none; + margin-bottom: 50px; + border-color: #e8e5e5; + width: 80%; + border-radius: 7px; + padding-top: 5px; + padding-bottom: 5px; + padding-right: 10px; + padding-left: 10px; + box-shadow: none; +} +.datediv { + display: flex; + flex-direction: row; + margin-top: 5px; + margin-bottom: 5px; +} +.datebox { + width: 90%; + border-radius: 7px; + border-color: #e8e5e5; + outline: none; + box-shadow: none; + padding-top: 2px; + padding-bottom: 2px; + padding-right: 5px; + padding-left: 5px; +} +.datediv > div > p { + margin-bottom: 0.5rem; +} + +.startDate { + margin-right: 0.25rem; +} +.endDate { + margin-left: 1.5rem; +} + +.checkboxdiv > div label { + margin-right: 50px; +} +.checkboxdiv > label > input { + margin-left: 10px; +} + +.dispflex > input { + width: 20%; + border: none; + box-shadow: none; + margin-top: 5px; +} + +.checkboxContainer { + display: flex; + justify-content: space-between; +} + +.checkboxdiv { + display: flex; + flex-direction: column; +} + +.preview { + display: flex; + flex-direction: row; + font-weight: 700; + font-size: 16px; + color: #000000; + margin: 0; +} +.view { + margin-left: 2%; + font-weight: 600; + font-size: 16px; + color: #707070; +} +.flexdir { + display: flex; + flex-direction: row; + justify-content: space-between; + border: none; +} + +.form_wrapper { + margin-top: 27px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + position: absolute; + display: flex; + flex-direction: column; + padding: 20px 30px; + background: #ffffff; + border-color: #e8e5e5; + border-width: 5px; + border-radius: 10px; + width: 40%; +} + +.form_wrapper form { + display: flex; + align-items: left; + justify-content: left; + flex-direction: column; +} +.titlemodal { + color: #000000; + font-weight: 600; + font-size: 24px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 4px solid #31bb6b; + width: 50%; +} +.cancel > i { + margin-top: 5px; + transform: scale(1.2); + cursor: pointer; + color: #707070; +} +.modalbody { + width: 50px; +} +.list_box { + height: 70vh; + overflow-y: auto; + width: auto; +} +@media only screen and (max-width: 600px) { + .form_wrapper { + width: 90%; + top: 45%; + } + .checkboxContainer { + flex-direction: column; + } + + .datediv { + flex-direction: column; + } + + .datediv > div { + width: 100%; + margin-left: 0; + margin-bottom: 10px; + } + + .datediv > div p { + margin-bottom: 5px; + } +} + +.customButton { + width: 90%; + margin: 0 auto; +} diff --git a/src/components/EventListCard/EventListCard.test.tsx b/src/components/EventListCard/EventListCard.test.tsx new file mode 100644 index 0000000000..afe81f436e --- /dev/null +++ b/src/components/EventListCard/EventListCard.test.tsx @@ -0,0 +1,945 @@ +import React, { act } from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import type { InterfaceEventListCardProps } from './EventListCard'; +import EventListCard from './EventListCard'; +import i18n from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { BrowserRouter, MemoryRouter, Route, Routes } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import { toast } from 'react-toastify'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import useLocalStorage from 'utils/useLocalstorage'; +import { props } from './EventListCardProps'; +import { ERROR_MOCKS, MOCKS } from './EventListCardMocks'; + +const { setItem } = useLocalStorage(); + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(ERROR_MOCKS, true); + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const translations = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventListCard ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const renderEventListCard = ( + props: InterfaceEventListCardProps, +): RenderResult => { + const { key, ...restProps } = props; // Destructure the key and separate other props + + return render( + + + + + + + } + /> + } + /> + Event Dashboard (Admin)
    } + /> + Event Dashboard (User)
    } + /> + + + + + + , + ); +}; + +describe('Testing Event List Card', () => { + const updateData = { + title: 'Updated title', + description: 'This is a new update', + isPublic: true, + recurring: false, + isRegisterable: true, + allDay: false, + location: 'New Delhi', + startDate: '03/18/2022', + endDate: '03/20/2022', + startTime: '09:00 AM', + endTime: '05:00 PM', + }; + + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + afterAll(() => { + localStorage.clear(); + jest.clearAllMocks(); + }); + + test('Testing for event modal', async () => { + renderEventListCard(props[1]); + + userEvent.click(screen.getByTestId('card')); + + await waitFor(() => { + expect(screen.getByTestId('eventModalCloseBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('eventModalCloseBtn')); + + await waitFor(() => { + expect( + screen.queryByTestId('eventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('Should navigate to "/" if orgId is not defined', async () => { + render( + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(window.location.pathname).toEqual('/'); + }); + }); + + test('Should render default text if event details are null', async () => { + renderEventListCard(props[0]); + + await waitFor(() => { + expect(screen.getByText('Dogs Care')).toBeInTheDocument(); + }); + }); + + test('should render props and text elements test for the screen', async () => { + renderEventListCard(props[1]); + + expect(screen.getByText(props[1].eventName)).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('card')); + + await waitFor(() => { + expect(screen.getByTestId('updateDescription')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('updateDescription')).toHaveValue( + props[1].eventDescription, + ); + expect(screen.getByTestId('updateLocation')).toHaveValue( + props[1].eventLocation, + ); + + userEvent.click(screen.getByTestId('eventModalCloseBtn')); + + await waitFor(() => { + expect( + screen.queryByTestId('eventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('Should render truncated event name when length is more than 100', async () => { + const longEventName = 'a'.repeat(101); + renderEventListCard({ ...props[1], eventName: longEventName }); + + userEvent.click(screen.getByTestId('card')); + + await waitFor(() => { + expect(screen.getByTestId('updateTitle')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('updateTitle')).toHaveValue( + `${longEventName.substring(0, 100)}...`, + ); + + userEvent.click(screen.getByTestId('eventModalCloseBtn')); + + await waitFor(() => { + expect( + screen.queryByTestId('eventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('Should render full event name when length is less than or equal to 100', async () => { + const shortEventName = 'a'.repeat(100); + renderEventListCard({ ...props[1], eventName: shortEventName }); + + userEvent.click(screen.getByTestId('card')); + + await waitFor(() => { + expect(screen.getByTestId('updateTitle')).toBeInTheDocument(); + }); + + expect(screen.getByTestId('updateTitle')).toHaveValue(shortEventName); + + userEvent.click(screen.getByTestId('eventModalCloseBtn')); + + await waitFor(() => { + expect( + screen.queryByTestId('eventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('Should render truncated event description when length is more than 256', async () => { + const longEventDescription = 'a'.repeat(257); + + renderEventListCard({ + ...props[1], + eventDescription: longEventDescription, + }); + + userEvent.click(screen.getByTestId('card')); + + await waitFor(() => { + expect(screen.getByTestId('updateDescription')).toBeInTheDocument(); + }); + expect(screen.getByTestId('updateDescription')).toHaveValue( + `${longEventDescription.substring(0, 256)}...`, + ); + + userEvent.click(screen.getByTestId('eventModalCloseBtn')); + + await waitFor(() => { + expect( + screen.queryByTestId('eventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('Should render full event description when length is less than or equal to 256', async () => { + const shortEventDescription = 'a'.repeat(256); + + renderEventListCard({ + ...props[1], + eventDescription: shortEventDescription, + }); + + userEvent.click(screen.getByTestId('card')); + + await waitFor(() => { + expect(screen.getByTestId('updateDescription')).toBeInTheDocument(); + }); + expect(screen.getByTestId('updateDescription')).toHaveValue( + shortEventDescription, + ); + + userEvent.click(screen.getByTestId('eventModalCloseBtn')); + + await waitFor(() => { + expect( + screen.queryByTestId('eventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('Should navigate to event dashboard when clicked (For Admin)', async () => { + renderEventListCard(props[1]); + + userEvent.click(screen.getByTestId('card')); + + await waitFor(() => { + expect(screen.getByTestId('showEventDashboardBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('showEventDashboardBtn')); + + await waitFor(() => { + expect(screen.queryByTestId('card')).not.toBeInTheDocument(); + expect(screen.queryByText('Event Dashboard (Admin)')).toBeInTheDocument(); + }); + }); + + test('Should navigate to event dashboard when clicked (For User)', async () => { + setItem('userId', '123'); + renderEventListCard(props[2]); + + userEvent.click(screen.getByTestId('card')); + + await waitFor(() => { + expect(screen.getByTestId('showEventDashboardBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('showEventDashboardBtn')); + + await waitFor(() => { + expect(screen.queryByTestId('card')).not.toBeInTheDocument(); + expect(screen.queryByText('Event Dashboard (User)')).toBeInTheDocument(); + }); + }); + + test('Should update a non-recurring event', async () => { + renderEventListCard(props[1]); + + userEvent.click(screen.getByTestId('card')); + + const eventTitle = screen.getByTestId('updateTitle'); + fireEvent.change(eventTitle, { target: { value: '' } }); + userEvent.type(eventTitle, updateData.title); + + const eventDescription = screen.getByTestId('updateDescription'); + fireEvent.change(eventDescription, { target: { value: '' } }); + userEvent.type(eventDescription, updateData.description); + + const eventLocation = screen.getByTestId('updateLocation'); + fireEvent.change(eventLocation, { target: { value: '' } }); + userEvent.type(eventLocation, updateData.location); + + const startDatePicker = screen.getByLabelText(translations.startDate); + fireEvent.change(startDatePicker, { + target: { value: updateData.startDate }, + }); + + const endDatePicker = screen.getByLabelText(translations.endDate); + fireEvent.change(endDatePicker, { + target: { value: updateData.endDate }, + }); + + userEvent.click(screen.getByTestId('updateAllDay')); + userEvent.click(screen.getByTestId('updateIsPublic')); + userEvent.click(screen.getByTestId('updateRegistrable')); + userEvent.click(screen.getByTestId('updateEventBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(translations.eventUpdated); + }); + + await waitFor(() => { + expect( + screen.queryByTestId('eventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('Should update a non all day non-recurring event', async () => { + renderEventListCard(props[1]); + + userEvent.click(screen.getByTestId('card')); + + const eventTitle = screen.getByTestId('updateTitle'); + fireEvent.change(eventTitle, { target: { value: '' } }); + userEvent.type(eventTitle, updateData.title); + + const eventDescription = screen.getByTestId('updateDescription'); + fireEvent.change(eventDescription, { target: { value: '' } }); + userEvent.type(eventDescription, updateData.description); + + const eventLocation = screen.getByTestId('updateLocation'); + fireEvent.change(eventLocation, { target: { value: '' } }); + userEvent.type(eventLocation, updateData.location); + + const startDatePicker = screen.getByLabelText(translations.startDate); + fireEvent.change(startDatePicker, { + target: { value: updateData.startDate }, + }); + + const endDatePicker = screen.getByLabelText(translations.endDate); + fireEvent.change(endDatePicker, { + target: { value: updateData.endDate }, + }); + + const startTimePicker = screen.getByLabelText(translations.startTime); + fireEvent.change(startTimePicker, { + target: { value: updateData.startTime }, + }); + + const endTimePicker = screen.getByLabelText(translations.endTime); + fireEvent.change(endTimePicker, { + target: { value: updateData.endTime }, + }); + + userEvent.click(screen.getByTestId('updateIsPublic')); + userEvent.click(screen.getByTestId('updateRegistrable')); + + userEvent.click(screen.getByTestId('updateEventBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(translations.eventUpdated); + }); + + await waitFor(() => { + expect( + screen.queryByTestId('eventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('should update a single event to be recurring', async () => { + renderEventListCard(props[1]); + + userEvent.click(screen.getByTestId('card')); + + const eventTitle = screen.getByTestId('updateTitle'); + fireEvent.change(eventTitle, { target: { value: '' } }); + userEvent.type(eventTitle, updateData.title); + + const eventDescription = screen.getByTestId('updateDescription'); + fireEvent.change(eventDescription, { target: { value: '' } }); + userEvent.type(eventDescription, updateData.description); + + const eventLocation = screen.getByTestId('updateLocation'); + fireEvent.change(eventLocation, { target: { value: '' } }); + userEvent.type(eventLocation, updateData.location); + + const startDatePicker = screen.getByLabelText(translations.startDate); + fireEvent.change(startDatePicker, { + target: { value: updateData.startDate }, + }); + + const endDatePicker = screen.getByLabelText(translations.endDate); + fireEvent.change(endDatePicker, { + target: { value: updateData.endDate }, + }); + + userEvent.click(screen.getByTestId('updateAllDay')); + userEvent.click(screen.getByTestId('updateRecurring')); + userEvent.click(screen.getByTestId('updateIsPublic')); + userEvent.click(screen.getByTestId('updateRegistrable')); + userEvent.click(screen.getByTestId('updateEventBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(translations.eventUpdated); + }); + + await waitFor(() => { + expect( + screen.queryByTestId('eventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('should show different update options for a recurring event based on different conditions', async () => { + renderEventListCard(props[5]); + + userEvent.click(screen.getByTestId('card')); + + await waitFor(() => { + expect(screen.queryByTestId('updateEventBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('updateEventBtn')); + + // shows options to update thisInstance and thisAndFollowingInstances, and allInstances + await waitFor(() => { + expect(screen.getByTestId('update-thisInstance')).toBeInTheDocument(); + expect( + screen.getByTestId('update-thisAndFollowingInstances'), + ).toBeInTheDocument(); + expect(screen.getByTestId('update-allInstances')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('eventUpdateOptionsModalCloseBtn')); + + await waitFor(() => { + expect(screen.getByLabelText(translations.startDate)).toBeInTheDocument(); + }); + + // change the event dates + let startDatePicker = screen.getByLabelText(translations.startDate); + fireEvent.change(startDatePicker, { + target: { value: updateData.startDate }, + }); + + let endDatePicker = screen.getByLabelText(translations.endDate); + fireEvent.change(endDatePicker, { + target: { value: updateData.endDate }, + }); + + userEvent.click(screen.getByTestId('updateEventBtn')); + + // shows options to update thisInstance and thisAndFollowingInstances only + await waitFor(() => { + expect(screen.getByTestId('update-thisInstance')).toBeInTheDocument(); + expect( + screen.getByTestId('update-thisAndFollowingInstances'), + ).toBeInTheDocument(); + expect( + screen.queryByTestId('update-allInstances'), + ).not.toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('eventUpdateOptionsModalCloseBtn')); + + await waitFor(() => { + expect(screen.getByLabelText(translations.startDate)).toBeInTheDocument(); + }); + + // reset the event dates to their original values + startDatePicker = screen.getByLabelText(translations.startDate); + fireEvent.change(startDatePicker, { + target: { value: '03/17/2022' }, + }); + + endDatePicker = screen.getByLabelText(translations.endDate); + fireEvent.change(endDatePicker, { + target: { value: '03/17/2022' }, + }); + + // now change the recurrence rule of the event + await waitFor(() => { + expect(screen.getByTestId('recurrenceOptions')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect(screen.getByTestId('customRecurrence')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('customRecurrence')); + + await waitFor(() => { + expect( + screen.getByTestId('customRecurrenceFrequencyDropdown'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('customRecurrenceFrequencyDropdown')); + + await waitFor(() => { + expect(screen.getByTestId('customDailyRecurrence')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('customDailyRecurrence')); + + await waitFor(() => { + expect( + screen.getByTestId('customRecurrenceSubmitBtn'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('customRecurrenceSubmitBtn')); + + await waitFor(() => { + expect(screen.getByTestId('updateEventBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('updateEventBtn')); + + // shows options to update thisAndFollowingInstances and allInstances only + await waitFor(() => { + expect( + screen.queryByTestId('update-thisInstance'), + ).not.toBeInTheDocument(); + expect( + screen.getByTestId('update-thisAndFollowingInstances'), + ).toBeInTheDocument(); + expect(screen.queryByTestId('update-allInstances')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('eventUpdateOptionsModalCloseBtn')); + + await waitFor(() => { + expect(screen.getByTestId('eventModalCloseBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('eventModalCloseBtn')); + + await waitFor(() => { + expect( + screen.queryByTestId('eventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('should show recurrenceRule as changed if the recurrence weekdays have changed', async () => { + renderEventListCard(props[4]); + + userEvent.click(screen.getByTestId('card')); + + await waitFor(() => { + expect(screen.getByTestId('recurrenceOptions')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect(screen.getByTestId('customRecurrence')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('customRecurrence')); + + // since the current recurrence weekDay for the current recurring event is "SATURDAY", + // let's first deselect it, and then we'll select a different day + // the recurrence rule should be marked as changed and we should see the option to update + // thisAndFollowingInstances and allInstances only + await waitFor(() => { + expect(screen.getAllByTestId('recurrenceWeekDay')[6]).toBeInTheDocument(); + }); + + // deselect saturday, which is the 7th day in recurrenceWeekDay options + userEvent.click(screen.getAllByTestId('recurrenceWeekDay')[6]); + + // select a different day, say wednesday, the 4th day in recurrenceWeekDay options + userEvent.click(screen.getAllByTestId('recurrenceWeekDay')[3]); + + userEvent.click(screen.getByTestId('customRecurrenceSubmitBtn')); + + await waitFor(() => { + expect(screen.getByTestId('updateEventBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('updateEventBtn')); + + // shows options to update thisInstance and thisAndFollowingInstances, and allInstances + await waitFor(() => { + expect( + screen.queryByTestId('update-thisInstance'), + ).not.toBeInTheDocument(); + expect( + screen.getByTestId('update-thisAndFollowingInstances'), + ).toBeInTheDocument(); + expect(screen.getByTestId('update-allInstances')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('eventUpdateOptionsModalCloseBtn')); + + await waitFor(() => { + expect(screen.getByTestId('eventModalCloseBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('eventModalCloseBtn')); + + await waitFor(() => { + expect( + screen.queryByTestId('eventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('should update all instances of a recurring event', async () => { + renderEventListCard(props[6]); + + userEvent.click(screen.getByTestId('card')); + + await waitFor(() => { + expect(screen.getByTestId('updateTitle')).toBeInTheDocument(); + }); + + const eventTitle = screen.getByTestId('updateTitle'); + fireEvent.change(eventTitle, { target: { value: '' } }); + userEvent.type(eventTitle, updateData.title); + + const eventDescription = screen.getByTestId('updateDescription'); + fireEvent.change(eventDescription, { target: { value: '' } }); + userEvent.type(eventDescription, updateData.description); + + const eventLocation = screen.getByTestId('updateLocation'); + fireEvent.change(eventLocation, { target: { value: '' } }); + userEvent.type(eventLocation, updateData.location); + + userEvent.click(screen.getByTestId('updateEventBtn')); + + // shows options to update thisInstance and thisAndFollowingInstances, and allInstances + await waitFor(() => { + expect(screen.getByTestId('update-thisInstance')).toBeInTheDocument(); + expect( + screen.getByTestId('update-thisAndFollowingInstances'), + ).toBeInTheDocument(); + expect(screen.getByTestId('update-allInstances')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('update-allInstances')); + userEvent.click(screen.getByTestId('recurringEventUpdateOptionSubmitBtn')); + + await waitFor(() => { + expect(screen.getByTestId('updateEventBtn')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(translations.eventUpdated); + }); + + await waitFor(() => { + expect( + screen.queryByTestId('eventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('should update thisAndFollowingInstances of a recurring event', async () => { + renderEventListCard(props[5]); + + userEvent.click(screen.getByTestId('card')); + + await waitFor(() => { + expect(screen.getByLabelText(translations.startDate)).toBeInTheDocument(); + }); + + // change the event dates + const startDatePicker = screen.getByLabelText(translations.startDate); + fireEvent.change(startDatePicker, { + target: { value: updateData.startDate }, + }); + + const endDatePicker = screen.getByLabelText(translations.endDate); + fireEvent.change(endDatePicker, { + target: { value: updateData.endDate }, + }); + + // now change the recurrence rule of the event + await waitFor(() => { + expect(screen.getByTestId('recurrenceOptions')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect(screen.getByTestId('customRecurrence')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('customRecurrence')); + + await waitFor(() => { + expect( + screen.getByTestId('customRecurrenceFrequencyDropdown'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('customRecurrenceFrequencyDropdown')); + + await waitFor(() => { + expect(screen.getByTestId('customDailyRecurrence')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('customDailyRecurrence')); + + await waitFor(() => { + expect( + screen.getByTestId('customRecurrenceSubmitBtn'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('customRecurrenceSubmitBtn')); + + await waitFor(() => { + expect(screen.getByTestId('updateEventBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('updateEventBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(translations.eventUpdated); + }); + + await waitFor(() => { + expect( + screen.queryByTestId('eventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('should render the delete modal', async () => { + renderEventListCard(props[1]); + + userEvent.click(screen.getByTestId('card')); + + await waitFor(() => { + expect(screen.getByTestId('deleteEventModalBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('deleteEventModalBtn')); + + await waitFor(() => { + expect( + screen.getByTestId('eventDeleteModalCloseBtn'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('eventDeleteModalCloseBtn')); + + await waitFor(() => { + expect( + screen.queryByTestId('eventDeleteModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByTestId('eventModalCloseBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('eventModalCloseBtn')); + + await waitFor(() => { + expect( + screen.queryByTestId('eventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('should call the delete event mutation when the "Yes" button is clicked', async () => { + renderEventListCard(props[1]); + + userEvent.click(screen.getByTestId('card')); + + await waitFor(() => { + expect(screen.getByTestId('deleteEventModalBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('deleteEventModalBtn')); + + await waitFor(() => { + expect(screen.getByTestId('deleteEventBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('deleteEventBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(translations.eventDeleted); + }); + + await waitFor(() => { + expect( + screen.queryByTestId('eventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('select different delete options on recurring events & then delete the recurring event', async () => { + renderEventListCard(props[4]); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('card')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('card')); + + await waitFor(() => { + expect(screen.getByTestId('deleteEventModalBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('deleteEventModalBtn')); + + await waitFor(() => { + expect( + screen.getByTestId('delete-thisAndFollowingInstances'), + ).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('delete-thisAndFollowingInstances')); + + userEvent.click(screen.getByTestId('delete-allInstances')); + userEvent.click(screen.getByTestId('delete-thisInstance')); + + userEvent.click(screen.getByTestId('deleteEventBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(translations.eventDeleted); + }); + + await waitFor(() => { + expect( + screen.queryByTestId('eventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('should show an error toast when the delete event mutation fails', async () => { + // Destructure key from props[1] and pass it separately to avoid spreading it + const { key, ...otherProps } = props[1]; + render( + + + + + + + } + /> + } + /> + + + + + + , + ); + + userEvent.click(screen.getByTestId('card')); + userEvent.click(screen.getByTestId('deleteEventModalBtn')); + userEvent.click(screen.getByTestId('deleteEventBtn')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + test('handle register should work properly', async () => { + setItem('userId', '456'); + + renderEventListCard(props[2]); + + userEvent.click(screen.getByTestId('card')); + + await waitFor(() => { + expect(screen.getByTestId('registerEventBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('registerEventBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + `Successfully registered for ${props[2].eventName}`, + ); + }); + + await waitFor(() => { + expect( + screen.queryByTestId('eventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('should show already registered text when the user is registered for an event', async () => { + renderEventListCard(props[3]); + + userEvent.click(screen.getByTestId('card')); + + expect( + screen.getByText(translations.alreadyRegistered), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/EventListCard/EventListCard.tsx b/src/components/EventListCard/EventListCard.tsx new file mode 100644 index 0000000000..ffa508ff7c --- /dev/null +++ b/src/components/EventListCard/EventListCard.tsx @@ -0,0 +1,102 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './EventListCard.module.css'; +import { Navigate, useParams } from 'react-router-dom'; +import EventListCardModals from './EventListCardModals'; +import type { InterfaceRecurrenceRule } from 'utils/recurrenceUtils'; + +/** + * Props for the EventListCard component. + */ +export interface InterfaceEventListCardProps { + refetchEvents?: () => void; + userRole?: string; + key: string; + id: string; + eventLocation: string; + eventName: string; + eventDescription: string; + startDate: string; + endDate: string; + startTime: string | null; + endTime: string | null; + allDay: boolean; + recurring: boolean; + recurrenceRule: InterfaceRecurrenceRule | null; + isRecurringEventException: boolean; + isPublic: boolean; + isRegisterable: boolean; + registrants?: { + _id: string; + }[]; + creator?: { + firstName: string; + lastName: string; + _id: string; + }; +} + +/** + * Component that displays an event card with a modal for event details. + * + * @param props - The props for the EventListCard component. + * @returns The rendered EventListCard component. + */ +function eventListCard(props: InterfaceEventListCardProps): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'eventListCard', + }); + const { t: tCommon } = useTranslation('common'); + + const [eventModalIsOpen, setEventModalIsOpen] = useState(false); + + const { orgId } = useParams(); + + // Redirect to home if orgId is not present + if (!orgId) { + return ; + } + + /** + * Opens the event modal. + */ + const showViewModal = (): void => { + setEventModalIsOpen(true); + }; + + /** + * Closes the event modal. + */ + const hideViewModal = (): void => { + setEventModalIsOpen(false); + }; + + return ( + <> +
    +
    +

    + {props.eventName ? <>{props.eventName} : <>Dogs Care} +

    +
    +
    + + + + ); +} +export {}; +export default eventListCard; diff --git a/src/components/EventListCard/EventListCardMocks.ts b/src/components/EventListCard/EventListCardMocks.ts new file mode 100644 index 0000000000..6312b5af51 --- /dev/null +++ b/src/components/EventListCard/EventListCardMocks.ts @@ -0,0 +1,201 @@ +import { + DELETE_EVENT_MUTATION, + REGISTER_EVENT, + UPDATE_EVENT_MUTATION, +} from 'GraphQl/Mutations/mutations'; + +export const MOCKS = [ + { + request: { + query: DELETE_EVENT_MUTATION, + variables: { id: '1' }, + }, + result: { + data: { + removeEvent: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: DELETE_EVENT_MUTATION, + variables: { id: '1', recurringEventDeleteType: 'thisInstance' }, + }, + result: { + data: { + removeEvent: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: UPDATE_EVENT_MUTATION, + variables: { + id: '1', + title: 'Updated title', + description: 'This is a new update', + isPublic: false, + recurring: false, + isRegisterable: true, + allDay: true, + startDate: '2022-03-18', + endDate: '2022-03-20', + location: 'New Delhi', + }, + }, + result: { + data: { + updateEvent: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: UPDATE_EVENT_MUTATION, + variables: { + id: '1', + title: 'Updated title', + description: 'This is a new update', + isPublic: false, + recurring: false, + isRegisterable: true, + allDay: false, + startDate: '2022-03-18', + endDate: '2022-03-20', + location: 'New Delhi', + startTime: '09:00:00', + endTime: '17:00:00', + }, + }, + result: { + data: { + updateEvent: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: UPDATE_EVENT_MUTATION, + variables: { + id: '1', + title: 'Updated title', + description: 'This is a new update', + isPublic: false, + recurring: true, + recurringEventUpdateType: 'thisInstance', + isRegisterable: true, + allDay: true, + startDate: '2022-03-18', + endDate: '2022-03-20', + location: 'New Delhi', + recurrenceStartDate: '2022-03-18', + recurrenceEndDate: null, + frequency: 'WEEKLY', + weekDays: ['FRIDAY'], + interval: 1, + }, + }, + result: { + data: { + updateEvent: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: UPDATE_EVENT_MUTATION, + variables: { + id: '1', + title: 'Updated title', + description: 'This is a new update', + isPublic: true, + recurring: true, + recurringEventUpdateType: 'allInstances', + isRegisterable: false, + allDay: true, + startDate: '2022-03-17', + endDate: '2022-03-17', + location: 'New Delhi', + recurrenceStartDate: '2022-03-17', + recurrenceEndDate: '2023-03-17', + frequency: 'MONTHLY', + weekDays: ['THURSDAY'], + interval: 1, + weekDayOccurenceInMonth: 3, + }, + }, + result: { + data: { + updateEvent: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: UPDATE_EVENT_MUTATION, + variables: { + id: '1', + title: 'Shelter for Cats', + description: 'This is shelter for cat event', + isPublic: true, + recurring: true, + recurringEventUpdateType: 'thisAndFollowingInstances', + isRegisterable: false, + allDay: true, + startDate: '2022-03-18', + endDate: '2022-03-20', + location: 'India', + recurrenceStartDate: '2022-03-18', + recurrenceEndDate: null, + frequency: 'DAILY', + interval: 1, + }, + }, + result: { + data: { + updateEvent: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: REGISTER_EVENT, + variables: { eventId: '1' }, + }, + result: { + data: { + registerForEvent: [ + { + _id: '123', + }, + ], + }, + }, + }, +]; + +export const ERROR_MOCKS = [ + { + request: { + query: DELETE_EVENT_MUTATION, + variables: { + id: '1', + }, + }, + error: new Error('Something went wrong'), + }, +]; diff --git a/src/components/EventListCard/EventListCardModals.tsx b/src/components/EventListCard/EventListCardModals.tsx new file mode 100644 index 0000000000..193890941c --- /dev/null +++ b/src/components/EventListCard/EventListCardModals.tsx @@ -0,0 +1,834 @@ +import React, { useEffect, useState } from 'react'; +import { Button, Form, Modal, Popover } from 'react-bootstrap'; +import styles from './EventListCard.module.css'; +import { DatePicker, TimePicker } from '@mui/x-date-pickers'; +import dayjs from 'dayjs'; +import type { Dayjs } from 'dayjs'; +import type { InterfaceEventListCardProps } from './EventListCard'; +import { + type InterfaceRecurrenceRuleState, + type RecurringEventMutationType, + Days, + Frequency, + allInstances, + getRecurrenceRuleText, + getWeekDayOccurenceInMonth, + recurringEventMutationOptions, + thisAndFollowingInstances, + thisInstance, + haveInstanceDatesChanged, + hasRecurrenceRuleChanged, +} from 'utils/recurrenceUtils'; +import useLocalStorage from 'utils/useLocalstorage'; +import RecurrenceOptions from 'components/RecurrenceOptions/RecurrenceOptions'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + DELETE_EVENT_MUTATION, + REGISTER_EVENT, + UPDATE_EVENT_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import { useMutation } from '@apollo/client'; +import { toast } from 'react-toastify'; +import { errorHandler } from 'utils/errorHandler'; + +enum Role { + USER = 'USER', + SUPERADMIN = 'SUPERADMIN', + ADMIN = 'ADMIN', +} + +/** + * Converts a time string to a Dayjs object representing the current date with the specified time. + * @param time - A string representing the time in 'HH:mm:ss' format. + * @returns A Dayjs object with the current date and specified time. + */ +const timeToDayJs = (time: string): Dayjs => { + const dateTimeString = dayjs().format('YYYY-MM-DD') + ' ' + time; + return dayjs(dateTimeString, { format: 'YYYY-MM-DD HH:mm:ss' }); +}; + +/** + * Properties for the `EventListCardModals` component. + * eventListCardProps - The properties of the event list card. + * eventModalIsOpen - Boolean indicating if the event modal is open. + * hideViewModal - Function to hide the event modal. + * t - Function for translation of text. + * tCommon - Function for translation of common text. + */ +interface InterfaceEventListCardModalProps { + eventListCardProps: InterfaceEventListCardProps; + eventModalIsOpen: boolean; + hideViewModal: () => void; + t: (key: string) => string; + tCommon: (key: string) => string; +} + +/** + * The `EventListCardModals` component displays the modals related to events, such as viewing, + * updating, and deleting events. + * @param props - The properties for the component. + * @returns A JSX element containing the event modals. + */ +function EventListCardModals({ + eventListCardProps, + eventModalIsOpen, + hideViewModal, + t, + tCommon, +}: InterfaceEventListCardModalProps): JSX.Element { + const { refetchEvents } = eventListCardProps; + + const { getItem } = useLocalStorage(); + const userId = getItem('userId'); + + const { orgId } = useParams(); + const navigate = useNavigate(); + + const [alldaychecked, setAllDayChecked] = useState(eventListCardProps.allDay); + const [recurringchecked, setRecurringChecked] = useState( + eventListCardProps.recurring, + ); + const [publicchecked, setPublicChecked] = useState( + eventListCardProps.isPublic, + ); + const [registrablechecked, setRegistrableChecked] = useState( + eventListCardProps.isRegisterable, + ); + const [eventDeleteModalIsOpen, setEventDeleteModalIsOpen] = useState(false); + const [recurringEventUpdateModalIsOpen, setRecurringEventUpdateModalIsOpen] = + useState(false); + const [eventStartDate, setEventStartDate] = useState( + new Date(eventListCardProps.startDate), + ); + const [eventEndDate, setEventEndDate] = useState( + new Date(eventListCardProps.endDate), + ); + + const [recurrenceRuleState, setRecurrenceRuleState] = + useState({ + recurrenceStartDate: eventStartDate, + recurrenceEndDate: null, + frequency: Frequency.WEEKLY, + weekDays: [Days[eventStartDate.getDay()]], + interval: 1, + count: undefined, + weekDayOccurenceInMonth: undefined, + }); + + const { + recurrenceStartDate, + recurrenceEndDate, + frequency, + weekDays, + interval, + count, + weekDayOccurenceInMonth, + } = recurrenceRuleState; + + const recurrenceRuleText = getRecurrenceRuleText(recurrenceRuleState); + + const [formState, setFormState] = useState({ + title: eventListCardProps.eventName, + eventdescrip: eventListCardProps.eventDescription, + location: eventListCardProps.eventLocation, + startTime: eventListCardProps.startTime?.split('.')[0] || '08:00:00', + endTime: eventListCardProps.endTime?.split('.')[0] || '08:00:00', + }); + + const [recurringEventDeleteType, setRecurringEventDeleteType] = + useState(thisInstance); + + const [recurringEventUpdateType, setRecurringEventUpdateType] = + useState(thisInstance); + + const [recurringEventUpdateOptions, setRecurringEventUpdateOptions] = + useState([ + thisInstance, + thisAndFollowingInstances, + allInstances, + ]); + + const [ + shouldShowRecurringEventUpdateOptions, + setShouldShowRecurringEventUpdateOptions, + ] = useState(true); + + useEffect(() => { + if (eventModalIsOpen) { + if (eventListCardProps.recurrenceRule) { + // get the recurrence rule + const { recurrenceRule } = eventListCardProps; + + // set the recurrence rule state + setRecurrenceRuleState({ + recurrenceStartDate: new Date(recurrenceRule.recurrenceStartDate), + recurrenceEndDate: recurrenceRule.recurrenceEndDate + ? new Date(recurrenceRule.recurrenceEndDate) + : null, + frequency: recurrenceRule.frequency, + weekDays: recurrenceRule.weekDays, + interval: recurrenceRule.interval, + count: recurrenceRule.count ?? undefined, + weekDayOccurenceInMonth: + recurrenceRule.weekDayOccurenceInMonth ?? undefined, + }); + } + } + }, [eventModalIsOpen]); + + // a state to specify whether the recurrence rule has changed + const [recurrenceRuleChanged, setRecurrenceRuleChanged] = useState(false); + + // a state to specify whether the instance's startDate or endDate has changed + const [instanceDatesChanged, setInstanceDatesChanged] = useState(false); + + // the `recurrenceRuleChanged` & `instanceDatesChanged` are required, + // because we will provide recurring event update options based on them, i.e.: + // - if the `instanceDatesChanged` is true, we'll not provide the option to update "allInstances" + // - if the `recurrenceRuleChanged` is true, we'll not provide the option to update "thisInstance" + // - if both are true, we'll only provide the option to update "thisAndFollowingInstances" + // updating recurring events is not very straightforward, + // find more info on the approach in this doc https://docs.talawa.io/docs/functionalities/recurring-events + + useEffect(() => { + setInstanceDatesChanged( + haveInstanceDatesChanged( + eventListCardProps.startDate, + eventListCardProps.endDate, + dayjs(eventStartDate).format('YYYY-MM-DD'), // convert to date string + dayjs(eventEndDate).format('YYYY-MM-DD'), // convert to date string + ), + ); + setRecurrenceRuleChanged( + hasRecurrenceRuleChanged( + eventListCardProps.recurrenceRule, + recurrenceRuleState, + ), + ); + }, [eventStartDate, eventEndDate, recurrenceRuleState]); + + useEffect(() => { + if (instanceDatesChanged) { + setRecurringEventUpdateType(thisInstance); + setRecurringEventUpdateOptions([thisInstance, thisAndFollowingInstances]); + setShouldShowRecurringEventUpdateOptions(true); + } + + if (recurrenceRuleChanged) { + setRecurringEventUpdateType(thisAndFollowingInstances); + setRecurringEventUpdateOptions([thisAndFollowingInstances, allInstances]); + setShouldShowRecurringEventUpdateOptions(true); + } + + if (recurrenceRuleChanged && instanceDatesChanged) { + setRecurringEventUpdateType(thisAndFollowingInstances); + setShouldShowRecurringEventUpdateOptions(false); + } + + if (!recurrenceRuleChanged && !instanceDatesChanged) { + setRecurringEventUpdateType(thisInstance); + setRecurringEventUpdateOptions([ + thisInstance, + thisAndFollowingInstances, + allInstances, + ]); + setShouldShowRecurringEventUpdateOptions(true); + } + }, [recurrenceRuleChanged, instanceDatesChanged]); + + const [updateEvent] = useMutation(UPDATE_EVENT_MUTATION); + + const updateEventHandler = async (): Promise => { + try { + const { data } = await updateEvent({ + variables: { + id: eventListCardProps.id, + title: formState.title, + description: formState.eventdescrip, + isPublic: publicchecked, + recurring: recurringchecked, + recurringEventUpdateType: recurringchecked + ? recurringEventUpdateType + : undefined, + isRegisterable: registrablechecked, + allDay: alldaychecked, + startDate: dayjs(eventStartDate).format('YYYY-MM-DD'), + endDate: dayjs(eventEndDate).format('YYYY-MM-DD'), + location: formState.location, + startTime: !alldaychecked ? formState.startTime : undefined, + endTime: !alldaychecked ? formState.endTime : undefined, + recurrenceStartDate: recurringchecked + ? recurringEventUpdateType === thisAndFollowingInstances && + (instanceDatesChanged || recurrenceRuleChanged) + ? dayjs(eventStartDate).format('YYYY-MM-DD') + : dayjs(recurrenceStartDate).format('YYYY-MM-DD') + : undefined, + recurrenceEndDate: recurringchecked + ? recurrenceEndDate + ? dayjs(recurrenceEndDate).format('YYYY-MM-DD') + : null + : undefined, + frequency: recurringchecked ? frequency : undefined, + weekDays: + recurringchecked && + (frequency === Frequency.WEEKLY || + (frequency === Frequency.MONTHLY && weekDayOccurenceInMonth)) + ? weekDays + : undefined, + interval: recurringchecked ? interval : undefined, + count: recurringchecked ? count : undefined, + weekDayOccurenceInMonth: recurringchecked + ? weekDayOccurenceInMonth + : undefined, + }, + }); + + if (data) { + toast.success(t('eventUpdated') as string); + setRecurringEventUpdateModalIsOpen(false); + hideViewModal(); + if (refetchEvents) { + refetchEvents(); + } + } + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }; + + const handleEventUpdate = async (): Promise => { + if (!eventListCardProps.recurring) { + await updateEventHandler(); + } else { + if (shouldShowRecurringEventUpdateOptions) { + setRecurringEventUpdateModalIsOpen(true); + } else { + await updateEventHandler(); + } + } + }; + + const [deleteEvent] = useMutation(DELETE_EVENT_MUTATION); + + const deleteEventHandler = async (): Promise => { + try { + const { data } = await deleteEvent({ + variables: { + id: eventListCardProps.id, + recurringEventDeleteType: eventListCardProps.recurring + ? recurringEventDeleteType + : undefined, + }, + }); + + if (data) { + toast.success(t('eventDeleted') as string); + setEventDeleteModalIsOpen(false); + hideViewModal(); + if (refetchEvents) { + refetchEvents(); + } + } + } catch (error: unknown) { + errorHandler(t, error); + } + }; + + const toggleDeleteModal = (): void => { + setEventDeleteModalIsOpen(!eventDeleteModalIsOpen); + }; + + const isInitiallyRegistered = eventListCardProps?.registrants?.some( + (registrant) => registrant._id === userId, + ); + const [registerEventMutation] = useMutation(REGISTER_EVENT); + const [isRegistered, setIsRegistered] = useState(isInitiallyRegistered); + + const registerEventHandler = async (): Promise => { + if (!isRegistered) { + try { + const { data } = await registerEventMutation({ + variables: { + eventId: eventListCardProps.id, + }, + }); + + if (data) { + toast.success( + `Successfully registered for ${eventListCardProps.eventName}`, + ); + setIsRegistered(true); + hideViewModal(); + } + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error); + } + } + }; + + const toggleRecurringEventUpdateModal = (): void => { + setRecurringEventUpdateModalIsOpen(!recurringEventUpdateModalIsOpen); + }; + + const openEventDashboard = (): void => { + const userPath = eventListCardProps.userRole === Role.USER ? 'user/' : ''; + console.log(`/${userPath}event/${orgId}/${eventListCardProps.id}`); + navigate(`/${userPath}event/${orgId}/${eventListCardProps.id}`); + }; + + const popover = ( + + {recurrenceRuleText} + + ); + + return ( + <> + {/* preview modal */} + + +

    {t('eventDetails')}

    + +
    + +
    +

    {t('eventTitle')}

    + 100 + ? formState.title.substring(0, 100) + '...' + : formState.title + } + onChange={(e): void => { + setFormState({ + ...formState, + title: e.target.value, + }); + }} + disabled={ + !(eventListCardProps.creator?._id === userId) && + eventListCardProps.userRole === Role.USER + } + /> +

    {tCommon('description')}

    + 256 + ? formState.eventdescrip.substring(0, 256) + '...' + : formState.eventdescrip + } + onChange={(e): void => { + setFormState({ + ...formState, + eventdescrip: e.target.value, + }); + }} + disabled={ + !(eventListCardProps.creator?._id === userId) && + eventListCardProps.userRole === Role.USER + } + /> +

    {tCommon('location')}

    + { + setFormState({ + ...formState, + location: e.target.value, + }); + }} + disabled={ + !(eventListCardProps.creator?._id === userId) && + eventListCardProps.userRole === Role.USER + } + /> +
    +
    + { + if (date) { + setEventStartDate(date?.toDate()); + setEventEndDate( + eventEndDate < date?.toDate() + ? date?.toDate() + : eventEndDate, + ); + if (!eventListCardProps.recurring) { + setRecurrenceRuleState({ + ...recurrenceRuleState, + recurrenceStartDate: date?.toDate(), + weekDays: [Days[date?.toDate().getDay()]], + weekDayOccurenceInMonth: weekDayOccurenceInMonth + ? /* istanbul ignore next */ getWeekDayOccurenceInMonth( + date?.toDate(), + ) + : undefined, + }); + } + } + }} + /> +
    +
    + { + if (date) { + setEventEndDate(date?.toDate()); + } + }} + minDate={dayjs(eventStartDate)} + /> +
    +
    + {!alldaychecked && ( +
    +
    + { + if (time) { + setFormState({ + ...formState, + startTime: time?.format('HH:mm:ss'), + endTime: + timeToDayJs(formState.endTime) < time + ? time?.format('HH:mm:ss') + : /* istanbul ignore next */ + formState.endTime, + }); + } + }} + disabled={alldaychecked} + /> +
    +
    + { + if (time) { + setFormState({ + ...formState, + endTime: time?.format('HH:mm:ss'), + }); + } + }} + minTime={timeToDayJs(formState.startTime)} + disabled={alldaychecked} + /> +
    +
    + )} +
    +
    +
    + + { + setAllDayChecked(!alldaychecked); + }} + disabled={ + !(eventListCardProps.creator?._id === userId) && + eventListCardProps.userRole === Role.USER + } + /> +
    +
    + + { + setRecurringChecked(!recurringchecked); + }} + disabled={ + !(eventListCardProps.creator?._id === userId) && + eventListCardProps.userRole === Role.USER + } + /> +
    +
    +
    +
    + + { + setPublicChecked(!publicchecked); + }} + disabled={ + !(eventListCardProps.creator?._id === userId) && + eventListCardProps.userRole === Role.USER + } + /> +
    +
    + + { + setRegistrableChecked(!registrablechecked); + }} + disabled={ + !(eventListCardProps.creator?._id === userId) && + eventListCardProps.userRole === Role.USER + } + /> +
    +
    +
    + + {/* Recurrence Options */} + {recurringchecked && ( + + )} + +
    + + {(eventListCardProps.userRole !== Role.USER || + eventListCardProps.creator?._id === userId) && ( + + )} + {(eventListCardProps.userRole !== Role.USER || + eventListCardProps.creator?._id === userId) && ( + + )} + {(eventListCardProps.userRole !== Role.USER || + eventListCardProps.creator?._id === userId) && ( + + )} + {eventListCardProps.userRole === Role.USER && + !(eventListCardProps.creator?._id === userId) && + (isRegistered ? ( + + ) : ( + + ))} + +
    + + {/* recurring event update options modal */} + + + + {t('editEvent')} + + + +
    + {recurringEventUpdateOptions.map((option, index) => ( +
    + + setRecurringEventUpdateType( + e.target.value as RecurringEventMutationType, + ) + } + defaultChecked={option === recurringEventUpdateType} + data-testid={`update-${option}`} + /> +
    + ))} +
    +
    + + + + +
    + + {/* delete modal */} + + + + {t('deleteEvent')} + + + + {!eventListCardProps.recurring && t('deleteEventMsg')} + {eventListCardProps.recurring && ( + <> +
    + {recurringEventMutationOptions.map((option, index) => ( +
    + + setRecurringEventDeleteType( + e.target.value as RecurringEventMutationType, + ) + } + defaultChecked={option === recurringEventDeleteType} + data-testid={`delete-${option}`} + /> +
    + ))} +
    + + )} +
    + + + + +
    + + ); +} + +export default EventListCardModals; diff --git a/src/components/EventListCard/EventListCardProps.ts b/src/components/EventListCard/EventListCardProps.ts new file mode 100644 index 0000000000..9aef474254 --- /dev/null +++ b/src/components/EventListCard/EventListCardProps.ts @@ -0,0 +1,194 @@ +import { Frequency, WeekDays } from 'utils/recurrenceUtils'; +import type { InterfaceEventListCardProps } from './EventListCard'; + +export const props: InterfaceEventListCardProps[] = [ + { + key: '', + id: '', + eventLocation: '', + eventName: '', + eventDescription: '', + startDate: '', + endDate: '', + startTime: '', + endTime: '', + allDay: false, + recurring: false, + recurrenceRule: null, + isRecurringEventException: false, + isPublic: false, + isRegisterable: false, + refetchEvents: (): void => { + /* refetch function */ + }, + }, + { + key: '123', + id: '1', + eventLocation: 'India', + eventName: 'Shelter for Dogs', + eventDescription: 'This is shelter for dogs event', + startDate: '2022-03-19', + endDate: '2022-03-26', + startTime: '02:00', + endTime: '06:00', + allDay: false, + recurring: false, + recurrenceRule: null, + isRecurringEventException: false, + isPublic: true, + isRegisterable: false, + refetchEvents: (): void => { + /* refetch function */ + }, + }, + { + userRole: 'USER', + key: '123', + id: '1', + eventLocation: 'India', + eventName: 'Shelter for Dogs', + eventDescription: 'This is shelter for dogs event', + startDate: '2022-03-19', + endDate: '2022-03-26', + startTime: '02:00', + endTime: '06:00', + allDay: true, + recurring: false, + recurrenceRule: null, + isRecurringEventException: false, + isPublic: true, + isRegisterable: false, + creator: { + firstName: 'Joe', + lastName: 'David', + _id: '123', + }, + registrants: [ + { + _id: '234', + }, + ], + refetchEvents: (): void => { + /* refetch function */ + }, + }, + { + userRole: 'USER', + key: '123', + id: '1', + eventLocation: 'India', + eventName: 'Shelter for Dogs', + eventDescription: 'This is shelter for dogs event', + startDate: '2022-03-19', + endDate: '2022-03-26', + startTime: '02:00', + endTime: '06:00', + allDay: true, + recurring: false, + recurrenceRule: null, + isRecurringEventException: false, + isPublic: true, + isRegisterable: false, + creator: { + firstName: 'Joe', + lastName: 'David', + _id: '123', + }, + registrants: [ + { + _id: '456', + }, + ], + refetchEvents: (): void => { + /* refetch function */ + }, + }, + { + userRole: 'ADMIN', + key: '123', + id: '1', + eventLocation: 'India', + eventName: 'Shelter for Cats', + eventDescription: 'This is shelter for cat event', + startDate: '2022-03-19', + endDate: '2022-03-19', + startTime: '2:00', + endTime: '6:00', + allDay: false, + recurring: true, + recurrenceRule: { + recurrenceStartDate: '2022-03-19', + recurrenceEndDate: '2022-03-26', + frequency: Frequency.WEEKLY, + weekDays: [WeekDays.SATURDAY], + interval: 1, + count: null, + weekDayOccurenceInMonth: null, + }, + isRecurringEventException: false, + isPublic: true, + isRegisterable: false, + refetchEvents: (): void => { + /* refetch function */ + }, + }, + { + userRole: 'ADMIN', + key: '123', + id: '1', + eventLocation: 'India', + eventName: 'Shelter for Cats', + eventDescription: 'This is shelter for cat event', + startDate: '2022-03-17', + endDate: '2022-03-17', + startTime: null, + endTime: null, + allDay: true, + recurring: true, + recurrenceRule: { + recurrenceStartDate: '2022-03-17', + recurrenceEndDate: null, + frequency: Frequency.MONTHLY, + weekDays: [WeekDays.THURSDAY], + interval: 1, + count: null, + weekDayOccurenceInMonth: 3, + }, + isRecurringEventException: false, + isPublic: true, + isRegisterable: false, + refetchEvents: (): void => { + /* refetch function */ + }, + }, + { + userRole: 'ADMIN', + key: '123', + id: '1', + eventLocation: 'India', + eventName: 'Shelter for Cats', + eventDescription: 'This is shelter for cat event', + startDate: '2022-03-17', + endDate: '2022-03-17', + startTime: null, + endTime: null, + allDay: true, + recurring: true, + recurrenceRule: { + recurrenceStartDate: '2022-03-17', + recurrenceEndDate: '2023-03-17', + frequency: Frequency.MONTHLY, + weekDays: [WeekDays.THURSDAY], + interval: 1, + count: null, + weekDayOccurenceInMonth: 3, + }, + isRecurringEventException: false, + isPublic: true, + isRegisterable: false, + refetchEvents: (): void => { + /* refetch function */ + }, + }, +]; diff --git a/src/components/EventManagement/Dashboard/EventDashboard.mocks.ts b/src/components/EventManagement/Dashboard/EventDashboard.mocks.ts new file mode 100644 index 0000000000..f4f1a3025b --- /dev/null +++ b/src/components/EventManagement/Dashboard/EventDashboard.mocks.ts @@ -0,0 +1,63 @@ +import { EVENT_DETAILS } from 'GraphQl/Queries/Queries'; + +export const MOCKS_WITH_TIME = [ + { + request: { + query: EVENT_DETAILS, + variables: { id: 'event123' }, + }, + result: { + data: { + event: { + _id: 'event123', + title: 'Test Event', + description: 'Test Description', + startDate: '2024-01-01', + endDate: '2024-01-02', + startTime: '09:00:00', + endTime: '17:00:00', + allDay: false, + location: 'India', + recurring: false, + attendees: [{ _id: 'user1' }, { _id: 'user2' }], + creator: { + _id: 'creator1', + firstName: 'John', + lastName: 'Doe', + }, + }, + }, + }, + }, +]; + +export const MOCKS_WITHOUT_TIME = [ + { + request: { + query: EVENT_DETAILS, + variables: { id: 'event123' }, + }, + result: { + data: { + event: { + _id: 'event123', + title: 'Test Event', + description: 'Test Description', + startDate: '2024-01-01', + endDate: '2024-01-02', + startTime: null, + endTime: null, + allDay: true, + location: 'India', + recurring: false, + attendees: [{ _id: 'user1' }, { _id: 'user2' }], + creator: { + _id: 'creator1', + firstName: 'John', + lastName: 'Doe', + }, + }, + }, + }, + }, +]; diff --git a/src/components/EventManagement/Dashboard/EventDashboard.module.css b/src/components/EventManagement/Dashboard/EventDashboard.module.css new file mode 100644 index 0000000000..37336002bb --- /dev/null +++ b/src/components/EventManagement/Dashboard/EventDashboard.module.css @@ -0,0 +1,101 @@ +.eventContainer { + display: flex; + align-items: start; +} + +.eventDetailsBox { + position: relative; + box-sizing: border-box; + background: #ffffff; + width: 66%; + padding: 0.3rem; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1); + border-radius: 20px; + margin-bottom: 0; + margin-top: 20px; +} +.ctacards { + padding: 20px; + width: 100%; + display: flex; + background-color: #ffffff; + margin: 0 4px; + justify-content: space-between; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1); + align-items: center; + border-radius: 20px; +} +.ctacards span { + color: rgb(181, 181, 181); + font-size: small; +} +/* .eventDetailsBox::before { + content: ''; + position: absolute; + top: 0; + height: 100%; + width: 6px; + background-color: #31bb6b; + border-radius: 20px; +} */ + +.time { + display: flex; + justify-content: space-between; + padding: 15px; + padding-bottom: 0px; + width: 33%; + + box-sizing: border-box; + background: #ffffff; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1); + border-radius: 20px; + margin-bottom: 0; + margin-top: 20px; + margin-left: 10px; +} + +.startTime, +.endTime { + display: flex; + font-size: 20px; +} + +.to { + padding-right: 10px; +} + +.startDate, +.endDate { + color: #808080; + font-size: 14px; +} + +.titlename { + font-weight: 600; + font-size: 25px; + padding: 15px; + padding-bottom: 0px; + width: 50%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.description { + color: #737373; + font-weight: 300; + font-size: 14px; + word-wrap: break-word; + padding: 15px; + padding-bottom: 0px; +} + +.toporgloc { + font-size: 16px; + padding: 0.5rem; +} + +.toporgloc span { + color: #737373; +} diff --git a/src/components/EventManagement/Dashboard/EventDashboard.test.tsx b/src/components/EventManagement/Dashboard/EventDashboard.test.tsx new file mode 100644 index 0000000000..dc605a1604 --- /dev/null +++ b/src/components/EventManagement/Dashboard/EventDashboard.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { render, act, fireEvent } from '@testing-library/react'; +import EventDashboard from './EventDashboard'; +import { BrowserRouter } from 'react-router-dom'; +import { ToastContainer } from 'react-toastify'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { ApolloLink, DefaultOptions } from '@apollo/client'; + +import { MOCKS_WITHOUT_TIME, MOCKS_WITH_TIME } from './EventDashboard.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +const mockWithTime = new StaticMockLink(MOCKS_WITH_TIME, true); +const mockWithoutTime = new StaticMockLink(MOCKS_WITHOUT_TIME, true); + +// We want to disable all forms of caching so that we do not need to define a custom merge function in testing for the network requests +const defaultOptions: DefaultOptions = { + watchQuery: { + fetchPolicy: 'no-cache', + errorPolicy: 'ignore', + }, + query: { + fetchPolicy: 'no-cache', + errorPolicy: 'all', + }, +}; + +async function wait(ms = 500): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const mockID = 'event123'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ eventId: mockID }), +})); + +const renderEventDashboard = (mockLink: ApolloLink): RenderResult => { + return render( + + + + + + + + + + , + ); +}; + +describe('Testing Event Dashboard Screen', () => { + test('The page should display event details correctly and also show the time if provided', async () => { + const { getByTestId } = renderEventDashboard(mockWithTime); + + await wait(); + + expect(getByTestId('event-title')).toBeInTheDocument(); + expect(getByTestId('event-description')).toBeInTheDocument(); + expect(getByTestId('event-location')).toHaveTextContent('India'); + + expect(getByTestId('registrations-card')).toBeInTheDocument(); + expect(getByTestId('attendees-card')).toBeInTheDocument(); + expect(getByTestId('feedback-card')).toBeInTheDocument(); + expect(getByTestId('feedback-rating')).toHaveTextContent('4/5'); + + const editButton = getByTestId('edit-event-button'); + fireEvent.click(editButton); + expect(getByTestId('event-title')).toBeInTheDocument(); + const closeButton = getByTestId('eventModalCloseBtn'); + fireEvent.click(closeButton); + }); + + test('The page should display event details correctly and should not show the time if it is null', async () => { + const { getByTestId } = renderEventDashboard(mockWithoutTime); + await wait(); + + expect(getByTestId('event-title')).toBeInTheDocument(); + expect(getByTestId('event-time')).toBeInTheDocument(); + }); + + test('Should show loader while data is being fetched', async () => { + const { getByTestId, queryByTestId } = renderEventDashboard(mockWithTime); + expect(getByTestId('spinner')).toBeInTheDocument(); + // Wait for loading to complete + await wait(); + + // Verify spinner is gone + expect(queryByTestId('spinner')).not.toBeInTheDocument(); + + // Verify content is visible + expect(getByTestId('event-title')).toBeInTheDocument(); + }); +}); diff --git a/src/components/EventManagement/Dashboard/EventDashboard.tsx b/src/components/EventManagement/Dashboard/EventDashboard.tsx new file mode 100644 index 0000000000..d3552702c6 --- /dev/null +++ b/src/components/EventManagement/Dashboard/EventDashboard.tsx @@ -0,0 +1,188 @@ +import React, { useState } from 'react'; +import { Col, Row } from 'react-bootstrap'; +import styles from './EventDashboard.module.css'; +import { useTranslation } from 'react-i18next'; +import { EVENT_DETAILS } from 'GraphQl/Queries/Queries'; +import { useQuery } from '@apollo/client'; +import Loader from 'components/Loader/Loader'; +import { Edit } from '@mui/icons-material'; +import EventListCardModals from 'components/EventListCard/EventListCardModals'; +import type { InterfaceEventListCardProps } from 'components/EventListCard/EventListCard'; +import { formatDate } from 'utils/dateFormatter'; + +/** + * Component that displays event details. + * + * @param props - The props for the EventDashboard component. + * @param eventId - The ID of the event to fetch and display. + * @returns The rendered EventDashboard component. + */ +const EventDashboard = (props: { eventId: string }): JSX.Element => { + const { eventId } = props; + const { t } = useTranslation(['translation', 'common']); + // const tEventManagement = (key: string): string => t(`eventManagement.${key}`); + const tEventList = (key: string): string => t(`eventListCard.${key}`); + const [eventModalIsOpen, setEventModalIsOpen] = useState(false); + + const { data: eventData, loading: eventInfoLoading } = useQuery( + EVENT_DETAILS, + { + variables: { id: eventId }, + }, + ); + + /** + * Formats a time string (HH:MM) to a more readable format. + * + * @param timeString - The time string to format. + * @returns - The formatted time string. + */ + function formatTime(timeString: string): string { + const [hours, minutes] = timeString.split(':').slice(0, 2); + return `${hours}:${minutes}`; + } + + const showViewModal = (): void => { + setEventModalIsOpen(true); + }; + + const hideViewModal = (): void => { + setEventModalIsOpen(false); + }; + + if (eventInfoLoading) { + return ; + } + + const eventListCardProps: InterfaceEventListCardProps = { + userRole: '', + key: eventData.event._id, + id: eventData.event._id, + eventLocation: eventData.event.location, + eventName: eventData.event.title, + eventDescription: eventData.event.description, + startDate: eventData.event.startDate, + endDate: eventData.event.endDate, + startTime: eventData.event.startTime, + endTime: eventData.event.endTime, + allDay: eventData.event.allDay, + recurring: eventData.event.recurring, + recurrenceRule: eventData.event.recurrenceRule, + isRecurringEventException: eventData.event.isRecurringEventException, + isPublic: eventData.event.isPublic, + isRegisterable: eventData.event.isRegisterable, + registrants: eventData.event.attendees, + creator: eventData.event.creator, + }; + + // Render event details + return ( +
    + + +
    +
    + userImage +
    +

    + + {eventData.event.attendees.length} + +

    + No of Registrations +
    +
    +
    + userImage +
    +

    + + {eventData.event.attendees.length} + +

    + No of Attendees +
    +
    +
    + userImage +
    +

    + 4/5 +

    + Average Feedback +
    +
    +
    + +
    +
    + +

    + {eventData.event.title} +

    +

    + {eventData.event.description} +

    +

    + Location: {eventData.event.location} +

    +

    + Registrants:{' '} + {eventData?.event?.attendees?.length} +

    +
    + Recurring Event:{' '} + + {eventData.event.recurring ? 'Active' : 'Inactive'} + +
    +
    +
    +

    + + {eventData.event.startTime !== null + ? `${formatTime(eventData.event.startTime)}` + : ``} + {' '} + + {formatDate(eventData.event.startDate)}{' '} + +

    +

    {t('to')}

    +

    + + {eventData.event.endTime !== null + ? `${formatTime(eventData.event.endTime)}` + : ``} + {' '} + + {formatDate(eventData.event.endDate)}{' '} + +

    +
    +
    + +
    +
    + ); +}; + +export default EventDashboard; diff --git a/src/components/EventManagement/EventAgendaItems/EventAgendaItems.module.css b/src/components/EventManagement/EventAgendaItems/EventAgendaItems.module.css new file mode 100644 index 0000000000..9d1c32b766 --- /dev/null +++ b/src/components/EventManagement/EventAgendaItems/EventAgendaItems.module.css @@ -0,0 +1,22 @@ +.eventAgendaItemContainer h2 { + margin: 0.6rem 0; +} + +.btnsContainer { + display: flex; + gap: 10px; +} + +@media (max-width: 768px) { + .btnsContainer { + margin-bottom: 0; + display: flex; + flex-direction: column; + } + + .createAgendaItemButton { + position: absolute; + top: 1rem; + right: 2rem; + } +} diff --git a/src/components/EventManagement/EventAgendaItems/EventAgendaItems.test.tsx b/src/components/EventManagement/EventAgendaItems/EventAgendaItems.test.tsx new file mode 100644 index 0000000000..3bce7ad11e --- /dev/null +++ b/src/components/EventManagement/EventAgendaItems/EventAgendaItems.test.tsx @@ -0,0 +1,214 @@ +import React from 'react'; +import { + render, + screen, + waitFor, + act, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import 'jest-localstorage-mock'; +import { MockedProvider } from '@apollo/client/testing'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import i18n from 'utils/i18nForTest'; +// import { toast } from 'react-toastify'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +import EventAgendaItems from './EventAgendaItems'; + +import { + MOCKS, + MOCKS_ERROR_QUERY, + // MOCKS_ERROR_MUTATION, +} from './EventAgendaItemsMocks'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ eventId: '123' }), +})); + +//temporarily fixes react-beautiful-dnd droppable method's depreciation error +//needs to be fixed in React 19 +jest.spyOn(console, 'error').mockImplementation((message) => { + if (message.includes('Support for defaultProps will be removed')) { + return; + } + console.error(message); +}); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(MOCKS_ERROR_QUERY, true); +// const link3 = new StaticMockLink(MOCKS_ERROR_MUTATION, true); + +const translations = JSON.parse( + JSON.stringify(i18n.getDataByLanguage('en')?.translation.agendaItems), +); + +describe('Testing Agenda Items Components', () => { + const formData = { + title: 'AgendaItem 1', + description: 'AgendaItem 1 Description', + duration: '30', + relatedEventId: '123', + organizationId: '111', + sequence: 1, + categories: ['Category 1'], + attachments: [], + urls: [], + }; + test('Component loads correctly', async () => { + window.location.assign('/event/111/123'); + const { getByText } = render( + + + + + {} + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.createAgendaItem)).toBeInTheDocument(); + }); + }); + + test('render error component on unsuccessful agenda item query', async () => { + window.location.assign('/event/111/123'); + const { queryByText } = render( + + + + + {} + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + queryByText(translations.createAgendaItem), + ).not.toBeInTheDocument(); + }); + }); + + test('opens and closes the create agenda item modal', async () => { + window.location.assign('/event/111/123'); + render( + + + + + + {} + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createAgendaItemBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('createAgendaItemBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('createAgendaItemModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('createAgendaItemModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('createAgendaItemModalCloseBtn'), + ); + }); + test('creates new agenda item', async () => { + window.location.assign('/event/111/123'); + render( + + + + + + {} + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createAgendaItemBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('createAgendaItemBtn')); + + await waitFor(() => { + expect( + screen.getByTestId('createAgendaItemModalCloseBtn'), + ).toBeInTheDocument(); + }); + + userEvent.type( + screen.getByPlaceholderText(translations.enterTitle), + formData.title, + ); + + userEvent.type( + screen.getByPlaceholderText(translations.enterDescription), + formData.description, + ); + userEvent.type( + screen.getByPlaceholderText(translations.enterDuration), + formData.duration, + ); + const categorySelect = screen.getByTestId('categorySelect'); + userEvent.click(categorySelect); + await waitFor(() => { + const categoryOption = screen.getByText('Category 1'); + userEvent.click(categoryOption); + }); + + userEvent.click(screen.getByTestId('createAgendaItemFormBtn')); + + await waitFor(() => { + // expect(toast.success).toBeCalledWith(translations.agendaItemCreated); + }); + }); +}); diff --git a/src/components/EventManagement/EventAgendaItems/EventAgendaItems.tsx b/src/components/EventManagement/EventAgendaItems/EventAgendaItems.tsx new file mode 100644 index 0000000000..b49ade4626 --- /dev/null +++ b/src/components/EventManagement/EventAgendaItems/EventAgendaItems.tsx @@ -0,0 +1,230 @@ +import React, { useState } from 'react'; +import type { ChangeEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from 'react-bootstrap'; + +import { WarningAmberRounded } from '@mui/icons-material'; +import { toast } from 'react-toastify'; + +import { useMutation, useQuery } from '@apollo/client'; +import { + AGENDA_ITEM_CATEGORY_LIST, + AgendaItemByEvent, +} from 'GraphQl/Queries/Queries'; +import { CREATE_AGENDA_ITEM_MUTATION } from 'GraphQl/Mutations/mutations'; + +import type { + InterfaceAgendaItemCategoryList, + InterfaceAgendaItemList, +} from 'utils/interfaces'; +import AgendaItemsContainer from 'components/AgendaItems/AgendaItemsContainer'; +import AgendaItemsCreateModal from 'components/AgendaItems/AgendaItemsCreateModal'; + +import styles from './EventAgendaItems.module.css'; +import Loader from 'components/Loader/Loader'; + +/** + * Component to manage and display agenda items for a specific event. + * + * @param props - The component props. + * @param eventId - The ID of the event to manage agenda items for. + * @returns The rendered component. + */ +function EventAgendaItems(props: { eventId: string }): JSX.Element { + const { eventId } = props; + + const { t } = useTranslation('translation', { + keyPrefix: 'agendaItems', + }); + + // Extract organization ID from URL + const url: string = window.location.href; + const startIdx: number = url.indexOf('/event/') + '/event/'.length; + const orgId: string = url.slice(startIdx, url.indexOf('/', startIdx)); + + // State to manage the create agenda item modal visibility + const [agendaItemCreateModalIsOpen, setAgendaItemCreateModalIsOpen] = + useState(false); + + // State to manage form values + const [formState, setFormState] = useState({ + agendaItemCategoryIds: [''], + title: '', + description: '', + duration: '', + attachments: [''], + urls: [''], + }); + + // Query for agenda item categories + const { + data: agendaCategoryData, + loading: agendaCategoryLoading, + error: agendaCategoryError, + }: { + data: InterfaceAgendaItemCategoryList | undefined; + loading: boolean; + error?: Error | undefined; + } = useQuery(AGENDA_ITEM_CATEGORY_LIST, { + variables: { organizationId: orgId }, + notifyOnNetworkStatusChange: true, + }); + + // Query for agenda items by event + const { + data: agendaItemData, + loading: agendaItemLoading, + error: agendaItemError, + refetch: refetchAgendaItem, + }: { + data: InterfaceAgendaItemList | undefined; + loading: boolean; + error?: unknown | undefined; + refetch: () => void; + } = useQuery(AgendaItemByEvent, { + variables: { relatedEventId: eventId }, //eventId + notifyOnNetworkStatusChange: true, + }); + + // Mutation for creating an agenda item + const [createAgendaItem] = useMutation(CREATE_AGENDA_ITEM_MUTATION); + + /** + * Handler for creating a new agenda item. + * + * @param e - The form submit event. + */ + const createAgendaItemHandler = async ( + e: ChangeEvent, + ): Promise => { + e.preventDefault(); + try { + await createAgendaItem({ + variables: { + input: { + title: formState.title, + description: formState.description, + relatedEventId: eventId, + organizationId: orgId, + sequence: (agendaItemData?.agendaItemByEvent.length || 0) + 1 || 1, // Assign sequence based on current length + duration: formState.duration, + categories: formState.agendaItemCategoryIds, + attachments: formState.attachments, + urls: formState.urls, + }, + }, + }); + + // Reset form state and hide modal + setFormState({ + title: '', + description: '', + duration: '', + agendaItemCategoryIds: [''], + attachments: [''], + urls: [''], + }); + hideCreateModal(); + refetchAgendaItem(); + toast.success(t('agendaItemCreated') as string); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + /** + * Toggles the visibility of the create agenda item modal. + */ + const showCreateModal = (): void => { + setAgendaItemCreateModalIsOpen(!agendaItemCreateModalIsOpen); + }; + + /** + * Hides the create agenda item modal. + */ + const hideCreateModal = (): void => { + setAgendaItemCreateModalIsOpen(!agendaItemCreateModalIsOpen); + }; + + // Show loader while data is loading + if (agendaItemLoading || agendaCategoryLoading) return ; + + // Show error message if there is an error loading data + if (agendaItemError || agendaCategoryError) { + const errorMessage = + agendaCategoryError?.message || + (agendaItemError as Error)?.message || + 'Unknown error'; + + return ( +
    +
    + +
    + Error occurred while loading{' '} + {agendaCategoryError ? 'Agenda Categories' : 'Agenda Items'} Data +
    + {errorMessage} +
    +
    +
    + ); + } + + return ( +
    +
    +
    +
    +
    + {/* setSearchValue(e.target.value)} + value={searchValue} + data-testid="search" + /> */} +
    + + +
    +
    + +
    + + +
    + + +
    + ); +} + +export default EventAgendaItems; diff --git a/src/components/EventManagement/EventAgendaItems/EventAgendaItemsMocks.ts b/src/components/EventManagement/EventAgendaItems/EventAgendaItemsMocks.ts new file mode 100644 index 0000000000..619a1e70f8 --- /dev/null +++ b/src/components/EventManagement/EventAgendaItems/EventAgendaItemsMocks.ts @@ -0,0 +1,133 @@ +import { CREATE_AGENDA_ITEM_MUTATION } from 'GraphQl/Mutations/AgendaItemMutations'; + +import { AgendaItemByEvent } from 'GraphQl/Queries/AgendaItemQueries'; +import { AGENDA_ITEM_CATEGORY_LIST } from 'GraphQl/Queries/AgendaCategoryQueries'; + +export const MOCKS = [ + { + request: { + query: AGENDA_ITEM_CATEGORY_LIST, + variables: { organizationId: '111' }, + }, + result: { + data: { + agendaItemCategoriesByOrganization: [ + { + _id: 'agendaItemCategory1', + name: 'Category 1', + description: 'Test Description', + createdBy: { + _id: 'user0', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + ], + }, + }, + }, + { + request: { + query: AgendaItemByEvent, + variables: { relatedEventId: '123' }, + }, + result: { + data: { + agendaItemByEvent: [ + { + _id: 'agendaItem1', + title: 'AgendaItem 1', + description: 'AgendaItem 1 Description', + duration: '30', + attachments: [], + createdBy: { + _id: 'user0', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + urls: [], + users: [], + sequence: 1, + categories: [ + { + _id: 'agendaItemCategory1', + name: 'Category 1', + }, + ], + organization: { + _id: '111', + name: 'Unity Foundation', + }, + relatedEvent: { + _id: '123', + title: 'Aerobics for Everyone', + }, + }, + ], + }, + }, + }, + { + request: { + query: CREATE_AGENDA_ITEM_MUTATION, + variables: { + input: { + title: 'AgendaItem 1', + description: 'AgendaItem 1 Description', + duration: '30', + relatedEventId: '123', + organizationId: '111', + sequence: 1, + categories: ['Category 1'], + attachments: [], + urls: [], + }, + }, + }, + result: { + data: { + createAgendaItem: { + _id: 'agendaItem1', + }, + }, + }, + }, +]; + +export const MOCKS_ERROR_MUTATION = [ + { + request: { + query: CREATE_AGENDA_ITEM_MUTATION, + variables: { + input: { + title: 'AgendaItem 1', + description: 'AgendaItem 1 Description', + duration: '30', + relatedEventId: '123', + organizationId: '111', + sequence: 1, + categories: ['agendaItemCategory1'], + attachments: [], + urls: [], + }, + }, + }, + error: new Error('Mock Graphql Error'), + }, + { + request: { + query: AgendaItemByEvent, + variables: { relatedEventId: '123' }, + }, + error: new Error('Mock Graphql Error'), + }, + { + request: { + query: AGENDA_ITEM_CATEGORY_LIST, + variables: { organizationId: '111' }, + }, + error: new Error('Mock Graphql Error'), + }, +]; + +export const MOCKS_ERROR_QUERY = []; diff --git a/src/components/EventManagement/EventAttendance/Attendance.mocks.ts b/src/components/EventManagement/EventAttendance/Attendance.mocks.ts new file mode 100644 index 0000000000..2dc8c89571 --- /dev/null +++ b/src/components/EventManagement/EventAttendance/Attendance.mocks.ts @@ -0,0 +1,62 @@ +import { EVENT_ATTENDEES } from 'GraphQl/Queries/Queries'; + +export const MOCKS = [ + { + request: { + query: EVENT_ATTENDEES, + variables: {}, // Removed id since it's not required based on error + }, + result: { + data: { + event: { + attendees: [ + { + _id: '6589386a2caa9d8d69087484', + firstName: 'Bruce', + lastName: 'Garza', + gender: null, + birthDate: null, + createdAt: '2023-04-13T10:23:17.742', + eventsAttended: [ + { + __typename: 'Event', + _id: '660fdf7d2c1ef6c7db1649ad', + }, + { + __typename: 'Event', + _id: '660fdd562c1ef6c7db1644f7', + }, + ], + __typename: 'User', + }, + { + _id: '6589386a2caa9d8d69087485', + firstName: 'Jane', + lastName: 'Smith', + gender: null, + birthDate: null, + createdAt: '2023-04-13T10:23:17.742', + eventsAttended: [ + { + __typename: 'Event', + _id: '660fdf7d2c1ef6c7db1649ad', + }, + ], + __typename: 'User', + }, + ], + }, + }, + }, + }, +]; + +export const MOCKS_ERROR = [ + { + request: { + query: EVENT_ATTENDEES, + variables: {}, + }, + error: new Error('An error occurred'), + }, +]; diff --git a/src/components/EventManagement/EventAttendance/AttendedEventList.test.tsx b/src/components/EventManagement/EventAttendance/AttendedEventList.test.tsx new file mode 100644 index 0000000000..2d60081acf --- /dev/null +++ b/src/components/EventManagement/EventAttendance/AttendedEventList.test.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import AttendedEventList from './AttendedEventList'; +import { EVENT_DETAILS } from 'GraphQl/Queries/Queries'; +import { BrowserRouter } from 'react-router-dom'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; +import { formatDate } from 'utils/dateFormatter'; + +const mockEvent = { + _id: 'event123', + title: 'Test Event', + description: 'This is a test event description', + startDate: '2023-05-01', + endDate: '2023-05-02', + startTime: '09:00:00', + endTime: '17:00:00', + allDay: false, + location: 'Test Location', + recurring: true, + baseRecurringEvent: { + _id: 'recurringEvent123', + }, + organization: { + _id: 'org456', + members: [ + { _id: 'member1', firstName: 'John', lastName: 'Doe' }, + { _id: 'member2', firstName: 'Jane', lastName: 'Smith' }, + ], + }, + attendees: [{ _id: 'user1' }, { _id: 'user2' }], +}; + +const mocks = [ + { + request: { + query: EVENT_DETAILS, + variables: { id: 'event123' }, + }, + result: { + data: { + event: mockEvent, + }, + }, + }, +]; + +describe('Testing AttendedEventList', () => { + const props = { + eventId: 'event123', + }; + + test('Component renders and displays event details correctly', async () => { + const { queryByText, queryByTitle } = render( + + + + + + + , + ); + + expect(queryByText('Loading...')).toBeInTheDocument(); + + await waitFor(() => { + expect(queryByText('Test Event')).toBeInTheDocument(); + expect(queryByText(formatDate(mockEvent.startDate))).toBeInTheDocument(); + expect(queryByTitle('Event Date')).toBeInTheDocument(); + }); + }); + + test('Component handles error state gracefully', async () => { + const errorMock = [ + { + request: { + query: EVENT_DETAILS, + variables: { id: 'event123' }, + }, + error: new Error('An error occurred'), + }, + ]; + + const { queryByText } = render( + + + + + + + , + ); + + await waitFor(() => { + expect(queryByText('Loading...')).not.toBeInTheDocument(); + // The component doesn't explicitly render an error message, so we just check that the event details are not rendered + expect(queryByText('Test Event')).not.toBeInTheDocument(); + }); + }); + + test('Component renders link with correct URL', async () => { + const { container } = render( + + + + + + + , + ); + + await waitFor(() => { + const link = container.querySelector('a'); + expect(link).not.toBeNull(); + expect(link).toHaveAttribute('href', expect.stringContaining('/event/')); + }); + }); +}); diff --git a/src/components/EventManagement/EventAttendance/AttendedEventList.tsx b/src/components/EventManagement/EventAttendance/AttendedEventList.tsx new file mode 100644 index 0000000000..2d0286feb0 --- /dev/null +++ b/src/components/EventManagement/EventAttendance/AttendedEventList.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { TableBody, TableCell, TableRow, Table } from '@mui/material'; +import { EVENT_DETAILS } from 'GraphQl/Queries/Queries'; +import { useQuery } from '@apollo/client'; +import { Link, useParams } from 'react-router-dom'; +import { formatDate } from 'utils/dateFormatter'; +import DateIcon from 'assets/svgs/cardItemDate.svg?react'; +interface InterfaceEventsAttended { + eventId: string; +} +/** + * Component to display a list of events attended by a member + * @param eventId - The ID of the event to display details for + * @returns A table row containing event details with a link to the event + */ +const AttendedEventList: React.FC = ({ eventId }) => { + const { orgId: currentOrg } = useParams(); + const { data, loading, error } = useQuery(EVENT_DETAILS, { + variables: { id: eventId }, + fetchPolicy: 'cache-first', + errorPolicy: 'all', + }); + + if (error || data?.error) { + return

    Error loading event details. Please try again later.

    ; + } + + const event = data?.event ?? null; + + if (loading) return

    Loading...

    ; + return ( + + + + {event && ( + + + + +
    +
    {event.title}
    +
    {formatDate(event.startDate)}
    +
    + +
    +
    + )} +
    +
    +
    + ); +}; +export default AttendedEventList; diff --git a/src/components/EventManagement/EventAttendance/EventAttendance.test.tsx b/src/components/EventManagement/EventAttendance/EventAttendance.test.tsx new file mode 100644 index 0000000000..db44357d07 --- /dev/null +++ b/src/components/EventManagement/EventAttendance/EventAttendance.test.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import type { RenderResult } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + cleanup, + waitFor, +} from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { I18nextProvider } from 'react-i18next'; +import EventAttendance from './EventAttendance'; +import { store } from 'state/store'; +import userEvent from '@testing-library/user-event'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import { MOCKS } from './Attendance.mocks'; + +const link = new StaticMockLink(MOCKS, true); + +async function wait(): Promise { + await waitFor(() => { + return Promise.resolve(); + }); +} +jest.mock('react-chartjs-2', () => ({ + Line: () => null, + Bar: () => null, + Pie: () => null, +})); + +const renderEventAttendance = (): RenderResult => { + return render( + + + + + + + + + , + ); +}; + +describe('Event Attendance Component', () => { + beforeEach(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ eventId: 'event123', orgId: 'org123' }), + })); + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + test('Component loads correctly with table headers', async () => { + renderEventAttendance(); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('table-header-row')).toBeInTheDocument(); + expect(screen.getByTestId('header-member-name')).toBeInTheDocument(); + expect(screen.getByTestId('header-status')).toBeInTheDocument(); + }); + }); + + test('Renders attendee data correctly', async () => { + renderEventAttendance(); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('attendee-name-0')).toBeInTheDocument(); + expect(screen.getByTestId('attendee-name-1')).toHaveTextContent( + 'Jane Smith', + ); + }); + }); + + test('Search filters attendees by name correctly', async () => { + renderEventAttendance(); + + await wait(); + + const searchInput = screen.getByTestId('searchByName'); + fireEvent.change(searchInput, { target: { value: 'Bruce' } }); + + await waitFor(() => { + const filteredAttendee = screen.getByTestId('attendee-name-0'); + expect(filteredAttendee).toHaveTextContent('Bruce Garza'); + }); + }); + + test('Sort functionality changes attendee order', async () => { + renderEventAttendance(); + + await wait(); + + const sortDropdown = screen.getByTestId('sort-dropdown'); + userEvent.click(sortDropdown); + userEvent.click(screen.getByText('Sort')); + + await waitFor(() => { + const attendees = screen.getAllByTestId('attendee-name-0'); + expect(attendees[0]).toHaveTextContent('Bruce Garza'); + }); + }); + + test('Date filter shows correct number of attendees', async () => { + renderEventAttendance(); + + await wait(); + + userEvent.click(screen.getByText('Filter: All')); + userEvent.click(screen.getByText('This Month')); + + await waitFor(() => { + expect(screen.getByText('Attendees not Found')).toBeInTheDocument(); + }); + }); + test('Statistics modal opens and closes correctly', async () => { + renderEventAttendance(); + await wait(); + + expect(screen.queryByTestId('attendance-modal')).not.toBeInTheDocument(); + + const statsButton = screen.getByTestId('stats-modal'); + userEvent.click(statsButton); + + await waitFor(() => { + expect(screen.getByTestId('attendance-modal')).toBeInTheDocument(); + }); + + const closeButton = screen.getByTestId('close-button'); + userEvent.click(closeButton); + + await waitFor(() => { + expect(screen.queryByTestId('attendance-modal')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/EventManagement/EventAttendance/EventAttendance.tsx b/src/components/EventManagement/EventAttendance/EventAttendance.tsx new file mode 100644 index 0000000000..17f063f6b5 --- /dev/null +++ b/src/components/EventManagement/EventAttendance/EventAttendance.tsx @@ -0,0 +1,376 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { BiSearch as Search } from 'react-icons/bi'; +import { + Paper, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, +} from '@mui/material'; +import { + Button, + Dropdown, + DropdownButton, + Table, + FormControl, +} from 'react-bootstrap'; +import styles from './EventsAttendance.module.css'; +import { useLazyQuery } from '@apollo/client'; +import { EVENT_ATTENDEES } from 'GraphQl/Queries/Queries'; +import { useParams, Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { AttendanceStatisticsModal } from './EventStatistics'; +import AttendedEventList from './AttendedEventList'; +import type { InterfaceMember } from './InterfaceEvents'; +enum FilterPeriod { + ThisMonth = 'This Month', + ThisYear = 'This Year', + All = 'All', +} +/** + * Component to manage and display event attendance information + * Includes filtering and sorting functionality for attendees + * @returns JSX element containing the event attendance interface + */ +function EventAttendance(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'eventAttendance', + }); + const { eventId } = useParams<{ eventId: string }>(); + const { orgId: currentUrl } = useParams(); + const [filteredAttendees, setFilteredAttendees] = useState( + [], + ); + const [sortOrder, setSortOrder] = useState<'ascending' | 'descending'>( + 'ascending', + ); + const [filteringBy, setFilteringBy] = useState( + FilterPeriod.All, + ); + const [show, setShow] = useState(false); + + const sortAttendees = (attendees: InterfaceMember[]): InterfaceMember[] => { + return [...attendees].sort((a, b) => { + const nameA = `${a.firstName} ${a.lastName}`.toLowerCase(); + const nameB = `${b.firstName} ${b.lastName}`.toLowerCase(); + return sortOrder === 'ascending' + ? nameA.localeCompare(nameB) + : /*istanbul ignore next*/ + nameB.localeCompare(nameA); + }); + }; + + const filterAttendees = (attendees: InterfaceMember[]): InterfaceMember[] => { + const now = new Date(); + return filteringBy === 'All' + ? attendees + : attendees.filter((attendee) => { + const attendeeDate = new Date(attendee.createdAt); + const isSameYear = attendeeDate.getFullYear() === now.getFullYear(); + return filteringBy === 'This Month' + ? isSameYear && attendeeDate.getMonth() === now.getMonth() + : /*istanbul ignore next*/ + isSameYear; + }); + }; + + const filterAndSortAttendees = ( + attendees: InterfaceMember[], + ): InterfaceMember[] => { + return sortAttendees(filterAttendees(attendees)); + }; + const searchEventAttendees = (value: string): void => { + const searchValueLower = value.toLowerCase().trim(); + + const filtered = (memberData?.event?.attendees ?? []).filter( + (attendee: InterfaceMember) => { + const fullName = + `${attendee.firstName} ${attendee.lastName}`.toLowerCase(); + return ( + attendee.firstName?.toLowerCase().includes(searchValueLower) || + attendee.lastName?.toLowerCase().includes(searchValueLower) || + attendee.email?.toLowerCase().includes(searchValueLower) || + fullName.includes(searchValueLower) + ); + }, + ); + + const finalFiltered = filterAndSortAttendees(filtered); + setFilteredAttendees(finalFiltered); + }; + const showModal = (): void => setShow(true); + const handleClose = (): void => setShow(false); + + const statistics = useMemo(() => { + const totalMembers = filteredAttendees.length; + const membersAttended = filteredAttendees.filter( + (member) => member?.eventsAttended && member.eventsAttended.length > 0, + ).length; + const attendanceRate = + totalMembers > 0 + ? Number(((membersAttended / totalMembers) * 100).toFixed(2)) + : 0; + + return { totalMembers, membersAttended, attendanceRate }; + }, [filteredAttendees]); + + const [getEventAttendees, { data: memberData, loading, error }] = + useLazyQuery(EVENT_ATTENDEES, { + variables: { + id: eventId, + }, + fetchPolicy: 'cache-and-network', + nextFetchPolicy: 'cache-first', + errorPolicy: 'all', + notifyOnNetworkStatusChange: true, + }); + + useEffect(() => { + if (memberData?.event?.attendees) { + const updatedAttendees = filterAndSortAttendees( + memberData.event.attendees, + ); + setFilteredAttendees(updatedAttendees); + } + }, [sortOrder, filteringBy, memberData]); + + useEffect(() => { + getEventAttendees(); + }, [eventId, getEventAttendees]); + + if (loading) return

    {t('loading')}

    ; + /*istanbul ignore next*/ + if (error) return

    {error.message}

    ; + + return ( +
    + +
    +
    + +
    +
    +
    + searchEventAttendees(e.target.value)} + /> + +
    + + + Sort + Filter: {filteringBy} + + } + onSelect={(eventKey) => setFilteringBy(eventKey as FilterPeriod)} + > + This Month + This Year + All + + + Sort + Sort + + } + onSelect={ + /*istanbul ignore next*/ + (eventKey) => setSortOrder(eventKey as 'ascending' | 'descending') + } + > + Ascending + Descending + +
    +
    + {/*

    {totalMembers}

    */} + + + + + + # + + + {t('Member Name')} + + + {t('Status')} + + + {t('Events Attended')} + + + {t('Task Assigned')} + + + + + {filteredAttendees.length === 0 ? ( + + + {t('noAttendees')} + + + ) : ( + filteredAttendees.map( + (member: InterfaceMember, index: number) => ( + + + {index + 1} + + + + {member.firstName} {member.lastName} + + + + {member.__typename === 'User' ? t('Member') : t('Admin')} + + ( + + ), + )} + > + + + {member.eventsAttended + ? member.eventsAttended.length + : /*istanbul ignore next*/ + '0'} + + + + + {member.tagsAssignedWith ? ( + /*istanbul ignore next*/ + member.tagsAssignedWith.edges.map( + /*istanbul ignore next*/ + ( + edge: { node: { name: string } }, + tagIndex: number, + ) =>
    {edge.node.name}
    , + ) + ) : ( +
    None
    + )} +
    +
    + ), + ) + )} +
    +
    +
    +
    + ); +} + +export default EventAttendance; diff --git a/src/components/EventManagement/EventAttendance/EventStatistics.test.tsx b/src/components/EventManagement/EventAttendance/EventStatistics.test.tsx new file mode 100644 index 0000000000..03f4671a5e --- /dev/null +++ b/src/components/EventManagement/EventAttendance/EventStatistics.test.tsx @@ -0,0 +1,358 @@ +import React from 'react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { AttendanceStatisticsModal } from './EventStatistics'; +import { MockedProvider } from '@apollo/client/testing'; +import { EVENT_DETAILS, RECURRING_EVENTS } from 'GraphQl/Queries/Queries'; +import userEvent from '@testing-library/user-event'; +import { exportToCSV } from 'utils/chartToPdf'; + +// Mock chart.js to avoid canvas errors +jest.mock('react-chartjs-2', () => ({ + Line: () => null, + Bar: () => null, +})); +// Mock react-router-dom +jest.mock('react-router-dom', () => ({ + useParams: () => ({ + orgId: 'org123', + eventId: 'event123', + }), +})); +jest.mock('utils/chartToPdf', () => ({ + exportToCSV: jest.fn(), +})); +const mocks = [ + { + request: { + query: EVENT_DETAILS, + variables: { id: 'event123' }, + }, + result: { + data: { + event: { + _id: 'event123', + title: 'Test Event', + description: 'Test Description', + startDate: '2023-01-01', + endDate: '2023-01-02', + startTime: '09:00', + endTime: '17:00', + allDay: false, + location: 'Test Location', + recurring: true, + baseRecurringEvent: { _id: 'base123' }, + organization: { + _id: 'org123', + members: [ + { _id: 'user1', firstName: 'John', lastName: 'Doe' }, + { _id: 'user2', firstName: 'Jane', lastName: 'Smith' }, + ], + }, + attendees: [ + { + _id: 'user1', + gender: 'MALE', + }, + { + _id: 'user2', + gender: 'FEMALE', + }, + ], + }, + }, + }, + }, + { + request: { + query: RECURRING_EVENTS, + variables: { baseRecurringEventId: 'base123' }, + }, + result: { + data: { + getRecurringEvents: [ + { + _id: 'event123', + startDate: '2023-01-01', + title: 'Test Event 1', + attendees: [ + { _id: 'user1', gender: 'MALE' }, + { _id: 'user2', gender: 'FEMALE' }, + ], + }, + { + _id: 'event456', + startDate: '2023-01-08', + title: 'Test Event 2', + attendees: [ + { _id: 'user1', gender: 'MALE' }, + { _id: 'user3', gender: 'OTHER' }, + ], + }, + { + _id: 'event789', + startDate: '2023-01-15', + title: 'Test Event 3', + attendees: [ + { _id: 'user2', gender: 'FEMALE' }, + { _id: 'user3', gender: 'OTHER' }, + ], + }, + ], + }, + }, + }, +]; + +const mockMemberData = [ + { + _id: 'user1', + firstName: 'John', + lastName: 'Doe', + gender: 'MALE', + birthDate: new Date('1990-01-01'), + email: 'john@example.com' as `${string}@${string}.${string}`, + createdAt: '2023-01-01', + __typename: 'User', + tagsAssignedWith: { + edges: [], + }, + }, + { + _id: 'user2', + firstName: 'Jane', + lastName: 'Smith', + gender: 'FEMALE', + birthDate: new Date('1985-05-05'), + email: 'jane@example.com' as `${string}@${string}.${string}`, + createdAt: '2023-01-01', + __typename: 'User', + tagsAssignedWith: { + edges: [], + }, + }, +]; + +const mockStatistics = { + totalMembers: 2, + membersAttended: 1, + attendanceRate: 50, +}; + +describe('AttendanceStatisticsModal', () => { + test('renders modal with correct initial state', async () => { + render( + + {}} + statistics={mockStatistics} + memberData={mockMemberData} + t={(key) => key} + /> + , + ); + + await waitFor(() => { + expect(screen.getByTestId('attendance-modal')).toBeInTheDocument(); + expect(screen.getByTestId('gender-button')).toBeInTheDocument(); + expect(screen.getByTestId('age-button')).toBeInTheDocument(); + }); + }); + + test('switches between gender and age demographics', async () => { + render( + + {}} + statistics={mockStatistics} + memberData={mockMemberData} + t={(key) => key} + /> + , + ); + + await waitFor(() => { + const genderButton = screen.getByTestId('gender-button'); + const ageButton = screen.getByTestId('age-button'); + + userEvent.click(ageButton); + expect(ageButton).toHaveClass('btn-success'); + expect(genderButton).toHaveClass('btn-light'); + + userEvent.click(genderButton); + expect(genderButton).toHaveClass('btn-success'); + expect(ageButton).toHaveClass('btn-light'); + }); + }); + + test('handles data demographics export functionality', async () => { + const mockExportToCSV = jest.fn(); + (exportToCSV as jest.Mock).mockImplementation(mockExportToCSV); + + render( + + {}} + statistics={mockStatistics} + memberData={mockMemberData} + t={(key) => key} + /> + , + ); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: 'Export Data' }), + ).toBeInTheDocument(); + }); + + await act(async () => { + const exportButton = screen.getByRole('button', { name: 'Export Data' }); + await userEvent.click(exportButton); + }); + + await act(async () => { + const demographicsExport = screen.getByTestId('demographics-export'); + await userEvent.click(demographicsExport); + }); + + expect(mockExportToCSV).toHaveBeenCalled(); + }); + test('handles data trends export functionality', async () => { + const mockExportToCSV = jest.fn(); + (exportToCSV as jest.Mock).mockImplementation(mockExportToCSV); + + render( + + {}} + statistics={mockStatistics} + memberData={mockMemberData} + t={(key) => key} + /> + , + ); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: 'Export Data' }), + ).toBeInTheDocument(); + }); + + await act(async () => { + const exportButton = screen.getByRole('button', { name: 'Export Data' }); + await userEvent.click(exportButton); + }); + + await act(async () => { + const demographicsExport = screen.getByTestId('trends-export'); + await userEvent.click(demographicsExport); + }); + + expect(mockExportToCSV).toHaveBeenCalled(); + }); + + test('displays recurring event data correctly', async () => { + render( + + {}} + statistics={mockStatistics} + memberData={mockMemberData} + t={(key) => key} + /> + , + ); + + await waitFor(() => { + expect(screen.getByTestId('today-button')).toBeInTheDocument(); + }); + }); + test('handles pagination and today button correctly', async () => { + render( + + {}} + statistics={mockStatistics} + memberData={mockMemberData} + t={(key) => key} + /> + , + ); + + // Wait for initial render + await waitFor(() => { + expect(screen.getByTestId('today-button')).toBeInTheDocument(); + }); + + // Test pagination + await act(async () => { + const nextButton = screen.getByAltText('right-arrow'); + await userEvent.click(nextButton); + }); + + await act(async () => { + const prevButton = screen.getByAltText('left-arrow'); + await userEvent.click(prevButton); + }); + + // Test today button + await act(async () => { + const todayButton = screen.getByTestId('today-button'); + await userEvent.click(todayButton); + }); + + // Verify buttons are present and interactive + expect(screen.getByAltText('right-arrow')).toBeInTheDocument(); + expect(screen.getByAltText('left-arrow')).toBeInTheDocument(); + expect(screen.getByTestId('today-button')).toBeInTheDocument(); + }); + + test('handles pagination in recurring events view', async () => { + render( + + {}} + statistics={mockStatistics} + memberData={mockMemberData} + t={(key) => key} + /> + , + ); + + await waitFor(() => { + const nextButton = screen.getByAltText('right-arrow'); + const prevButton = screen.getByAltText('left-arrow'); + + userEvent.click(nextButton); + userEvent.click(prevButton); + }); + }); + + test('closes modal correctly', async () => { + const handleClose = jest.fn(); + render( + + key} + /> + , + ); + + await waitFor(() => { + const closeButton = screen.getByTestId('close-button'); + userEvent.click(closeButton); + expect(handleClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/EventManagement/EventAttendance/EventStatistics.tsx b/src/components/EventManagement/EventAttendance/EventStatistics.tsx new file mode 100644 index 0000000000..5dda9e88a8 --- /dev/null +++ b/src/components/EventManagement/EventAttendance/EventStatistics.tsx @@ -0,0 +1,583 @@ +import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import { + Modal, + Button, + ButtonGroup, + Tooltip, + OverlayTrigger, + Dropdown, +} from 'react-bootstrap'; +import 'react-datepicker/dist/react-datepicker.css'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + Title, + Tooltip as ChartToolTip, + Legend, +} from 'chart.js'; +import { Bar, Line } from 'react-chartjs-2'; +import { useParams } from 'react-router-dom'; +import { EVENT_DETAILS, RECURRING_EVENTS } from 'GraphQl/Queries/Queries'; +import { useLazyQuery } from '@apollo/client'; +import { exportToCSV } from 'utils/chartToPdf'; +import type { ChartOptions, TooltipItem } from 'chart.js'; +import type { + InterfaceAttendanceStatisticsModalProps, + InterfaceEvent, + InterfaceRecurringEvent, +} from './InterfaceEvents'; +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + Title, + ChartToolTip, + Legend, +); +/** + * Component to display statistical information about event attendance + * Shows metrics like total attendees, filtering options, and attendance trends + * @returns JSX element with event statistics dashboard + */ + +export const AttendanceStatisticsModal: React.FC< + InterfaceAttendanceStatisticsModalProps +> = ({ show, handleClose, statistics, memberData, t }): JSX.Element => { + const [selectedCategory, setSelectedCategory] = useState('Gender'); + const { orgId, eventId } = useParams(); + const [currentPage, setCurrentPage] = useState(0); + const eventsPerPage = 10; + const [loadEventDetails, { data: eventData }] = useLazyQuery(EVENT_DETAILS); + const [loadRecurringEvents, { data: recurringData }] = + useLazyQuery(RECURRING_EVENTS); + const isEventRecurring = eventData?.event?.recurring; + const currentEventIndex = useMemo(() => { + if (!recurringData?.getRecurringEvents || !eventId) return -1; + return recurringData.getRecurringEvents.findIndex( + (event: InterfaceEvent) => event._id === eventId, + ); + }, [recurringData, eventId]); + useEffect(() => { + if (currentEventIndex >= 0) { + const newPage = Math.floor(currentEventIndex / eventsPerPage); + setCurrentPage(newPage); + } + }, [currentEventIndex, eventsPerPage]); + const filteredRecurringEvents = useMemo( + () => recurringData?.getRecurringEvents || [], + [recurringData], + ); + const totalEvents = filteredRecurringEvents.length; + const totalPages = Math.ceil(totalEvents / eventsPerPage); + + const paginatedRecurringEvents = useMemo(() => { + const startIndex = currentPage * eventsPerPage; + const endIndex = Math.min(startIndex + eventsPerPage, totalEvents); + return filteredRecurringEvents.slice(startIndex, endIndex); + }, [filteredRecurringEvents, currentPage, eventsPerPage, totalEvents]); + + const attendeeCounts = useMemo( + () => + paginatedRecurringEvents.map( + (event: InterfaceEvent) => event.attendees.length, + ), + [paginatedRecurringEvents], + ); + const chartOptions: ChartOptions<'line'> = { + responsive: true, + maintainAspectRatio: false, + animation: false, + scales: { y: { beginAtZero: true } }, + plugins: { + tooltip: { + callbacks: { + label: + /*istanbul ignore next*/ + (context: TooltipItem<'line'>) => { + const label = context.dataset.label || ''; + const value = context.parsed.y; + const isCurrentEvent = + paginatedRecurringEvents[context.dataIndex]._id === eventId; + return isCurrentEvent + ? `${label}: ${value} (Current Event)` + : `${label}: ${value}`; + }, + }, + }, + }, + }; + const eventLabels = useMemo( + () => + paginatedRecurringEvents.map((event: InterfaceEvent) => { + const date = (() => { + try { + const eventDate = new Date(event.startDate); + if (Number.isNaN(eventDate.getTime())) { + /*istanbul ignore next*/ + console.error(`Invalid date for event: ${event._id}`); + /*istanbul ignore next*/ + return 'Invalid date'; + } + return eventDate.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + } catch (error) { + /*istanbul ignore next*/ + console.error( + `Error formatting date for event: ${event._id}`, + error, + ); + /*istanbul ignore next*/ + return 'Invalid date'; + } + })(); + // Highlight the current event in the label + return event._id === eventId ? `→ ${date}` : date; + }), + [paginatedRecurringEvents, eventId], + ); + + const maleCounts = useMemo( + () => + paginatedRecurringEvents.map( + (event: InterfaceEvent) => + event.attendees.filter((attendee) => attendee.gender === 'MALE') + .length, + ), + [paginatedRecurringEvents], + ); + + const femaleCounts = useMemo( + () => + paginatedRecurringEvents.map( + (event: InterfaceEvent) => + event.attendees.filter((attendee) => attendee.gender === 'FEMALE') + .length, + ), + [paginatedRecurringEvents], + ); + + const otherCounts = useMemo( + () => + paginatedRecurringEvents.map( + (event: InterfaceEvent) => + event.attendees.filter( + (attendee) => + attendee.gender === 'OTHER' || attendee.gender === null, + ).length, + ), + [paginatedRecurringEvents], + ); + + const chartData = useMemo( + () => ({ + labels: eventLabels, + datasets: [ + { + label: 'Attendee Count', + data: attendeeCounts, + fill: true, + borderColor: '#008000', + pointRadius: paginatedRecurringEvents.map( + (event: InterfaceRecurringEvent) => (event._id === eventId ? 8 : 3), + ), + pointBackgroundColor: paginatedRecurringEvents.map( + (event: InterfaceRecurringEvent) => + event._id === eventId ? '#008000' : 'transparent', + ), + }, + { + label: 'Male Attendees', + data: maleCounts, + fill: false, + borderColor: '#0000FF', + }, + { + label: 'Female Attendees', + data: femaleCounts, + fill: false, + borderColor: '#FF1493', + }, + { + label: 'Other Attendees', + data: otherCounts, + fill: false, + borderColor: '#FFD700', + }, + ], + }), + [eventLabels, attendeeCounts, maleCounts, femaleCounts, otherCounts], + ); + + const handlePreviousPage = useCallback( + /*istanbul ignore next*/ + () => { + setCurrentPage((prevPage) => Math.max(prevPage - 1, 0)); + }, + [], + ); + + const handleNextPage = useCallback( + /*istanbul ignore next*/ + () => { + if (currentPage < totalPages - 1) { + setCurrentPage((prevPage) => prevPage + 1); + } + }, + [currentPage, totalPages], + ); + + const handleDateChange = useCallback((date: Date | null) => { + if (date) { + setCurrentPage(0); + } + }, []); + const categoryLabels = useMemo( + () => + selectedCategory === 'Gender' + ? ['Male', 'Female', 'Other'] + : ['Under 18', '18-40', 'Over 40'], + [selectedCategory], + ); + + const categoryData = useMemo( + () => + selectedCategory === 'Gender' + ? [ + memberData.filter((member) => member.gender === 'MALE').length, + memberData.filter((member) => member.gender === 'FEMALE').length, + memberData.filter( + (member) => + member.gender === 'OTHER' || + member.gender === null || + member.gender === '', + ).length, + ] + : [ + memberData.filter((member) => { + const today = new Date(); + const birthDate = new Date(member.birthDate); + let age = today.getFullYear() - birthDate.getFullYear(); + const monthDiff = today.getMonth() - birthDate.getMonth(); + if ( + monthDiff < 0 || + (monthDiff === 0 && today.getDate() < birthDate.getDate()) + ) { + /*istanbul ignore next*/ + age--; + } + return age < 18; + }).length, + memberData.filter((member) => { + const age = + new Date().getFullYear() - + new Date(member.birthDate).getFullYear(); + return age >= 18 && age <= 40; + }).length, + memberData.filter( + (member) => + new Date().getFullYear() - + new Date(member.birthDate).getFullYear() > + 40, + ).length, + ], + [selectedCategory, memberData], + ); + + const handleCategoryChange = useCallback((category: string): void => { + setSelectedCategory(category); + }, []); + + const exportTrendsToCSV = useCallback(() => { + const headers = [ + 'Date', + 'Attendee Count', + 'Male Attendees', + 'Female Attendees', + 'Other Attendees', + ]; + const data = [ + headers, + ...eventLabels.map((label: string, index: number) => [ + label, + attendeeCounts[index], + maleCounts[index], + femaleCounts[index], + otherCounts[index], + ]), + ]; + exportToCSV(data, 'attendance_trends.csv'); + }, [eventLabels, attendeeCounts, maleCounts, femaleCounts, otherCounts]); + + const exportDemographicsToCSV = useCallback(() => { + const headers = [selectedCategory, 'Count']; + const data = [ + headers, + ...categoryLabels.map((label, index) => [label, categoryData[index]]), + ]; + exportToCSV(data, `${selectedCategory.toLowerCase()}_demographics.csv`); + }, [selectedCategory, categoryLabels, categoryData]); + + /*istanbul ignore next*/ + const handleExport = (eventKey: string | null): void => { + switch (eventKey) { + case 'trends': + try { + exportTrendsToCSV(); + } catch (error) { + console.error('Failed to export trends:', error); + } + break; + case 'demographics': + try { + exportDemographicsToCSV(); + } catch (error) { + console.error('Failed to export demographics:', error); + } + break; + default: + return; + } + }; + useEffect(() => { + if (eventId) { + loadEventDetails({ variables: { id: eventId } }); + } + }, [eventId, loadEventDetails]); + useEffect(() => { + if (eventId && orgId && eventData?.event?.baseRecurringEvent?._id) { + loadRecurringEvents({ + variables: { + baseRecurringEventId: eventData?.event?.baseRecurringEvent?._id, + }, + }); + } + }, [eventId, orgId, eventData, loadRecurringEvents]); + return ( + + + + {t('historical_statistics')} + + + +
    +
    + {isEventRecurring ? ( +
    + +
    +

    Trends

    +
    +
    + Previous Page} + > + + + + Next Page} + > + + +
    +
    + ) : ( +
    +

    + {statistics.totalMembers} +

    +
    +

    Attendance Count

    +
    +
    + )} +
    + + + + + +
    +

    Demography

    +
    +
    +
    +
    + + + + Export Data + + + {isEventRecurring && ( + + Trends + + )} + + Demographics + + + + + +
    + ); +}; diff --git a/src/components/EventManagement/EventAttendance/EventsAttendance.module.css b/src/components/EventManagement/EventAttendance/EventsAttendance.module.css new file mode 100644 index 0000000000..2ee236a4da --- /dev/null +++ b/src/components/EventManagement/EventAttendance/EventsAttendance.module.css @@ -0,0 +1,35 @@ +.input { + display: flex; + width: 100%; + position: relative; +} +.customcell { + background-color: #31bb6b !important; + color: white !important; + font-size: medium !important; + font-weight: 500 !important; + padding-top: 10px !important; + padding-bottom: 10px !important; +} + +.eventsAttended, +.membername { + color: blue; +} +.actionBtn { + /* color:#39a440 !important; */ + background-color: #ffffff !important; +} +.actionBtn:hover, +.actionBtn:focus, +.actionBtn:active { + color: #39a440 !important; +} + +.table-body > .table-row { + background-color: #fff !important; +} + +.table-body > .table-row:nth-child(2n) { + background: #afffe8 !important; +} diff --git a/src/components/EventManagement/EventAttendance/InterfaceEvents.ts b/src/components/EventManagement/EventAttendance/InterfaceEvents.ts new file mode 100644 index 0000000000..7fc75ae4af --- /dev/null +++ b/src/components/EventManagement/EventAttendance/InterfaceEvents.ts @@ -0,0 +1,82 @@ +export interface InterfaceAttendanceStatisticsModalProps { + show: boolean; + handleClose: () => void; + statistics: { + totalMembers: number; + membersAttended: number; + attendanceRate: number; + }; + memberData: InterfaceMember[]; + t: (key: string) => string; +} + +export interface InterfaceMember { + createdAt: string; + firstName: string; + lastName: string; + email: `${string}@${string}.${string}`; + gender: string; + eventsAttended?: { + _id: string; + }[]; + birthDate: Date; + __typename: string; + _id: string; + tagsAssignedWith: { + edges: { + cursor: string; + node: { + name: string; + }; + }[]; + }; +} + +export interface InterfaceEvent { + _id: string; + title: string; + description: string; + startDate: string; + endDate: string; + location: string; + startTime: string; + endTime: string; + allDay: boolean; + recurring: boolean; + recurrenceRule: { + recurrenceStartDate: string; + recurrenceEndDate?: string | null; + frequency: string; + weekDays: string[]; + interval: number; + count?: number; + weekDayOccurenceInMonth?: number; + }; + isRecurringEventException: boolean; + isPublic: boolean; + isRegisterable: boolean; + attendees: { + _id: string; + firstName: string; + lastName: string; + email: string; + gender: string; + birthDate: string; + }[]; + __typename: string; +} + +export interface InterfaceRecurringEvent { + _id: string; + title: string; + startDate: string; + endDate: string; + frequency: InterfaceEvent['recurrenceRule']['frequency']; + interval: InterfaceEvent['recurrenceRule']['interval']; + attendees: { + _id: string; + gender: 'MALE' | 'FEMALE' | 'OTHER' | 'PREFER_NOT_TO_SAY'; + }[]; + isPublic: boolean; + isRegisterable: boolean; +} diff --git a/src/components/EventRegistrantsModal/AddOnSpotAttendee.test.tsx b/src/components/EventRegistrantsModal/AddOnSpotAttendee.test.tsx new file mode 100644 index 0000000000..c0dc20d200 --- /dev/null +++ b/src/components/EventRegistrantsModal/AddOnSpotAttendee.test.tsx @@ -0,0 +1,177 @@ +import React from 'react'; +import { render, fireEvent, screen, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { BrowserRouter } from 'react-router-dom'; +import { SIGNUP_MUTATION } from 'GraphQl/Mutations/mutations'; +import AddOnSpotAttendee from './AddOnSpotAttendee'; +import userEvent from '@testing-library/user-event'; +import type { RenderResult } from '@testing-library/react'; +import { toast } from 'react-toastify'; +import { Provider } from 'react-redux'; +import { I18nextProvider } from 'react-i18next'; +import { store } from 'state/store'; +import i18nForTest from '../../utils/i18nForTest'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const mockProps = { + show: true, + handleClose: jest.fn(), + reloadMembers: jest.fn(), +}; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ eventId: '123', orgId: '123' }), +})); + +const MOCKS = [ + { + request: { + query: SIGNUP_MUTATION, + variables: { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + phoneNo: '1234567890', + gender: 'Male', + password: '123456', + orgId: '123', + }, + }, + result: { + data: { + signUp: { + user: { + _id: '1', + }, + accessToken: 'mock-access-token', + refreshToken: 'mock-refresh-token', + }, + }, + }, + }, +]; + +const ERROR_MOCKS = [ + { + ...MOCKS[0], + error: new Error('Failed to add attendee'), + }, +]; + +const renderAddOnSpotAttendee = (): RenderResult => { + return render( + + + + + + + + + , + ); +}; + +describe('AddOnSpotAttendee Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the component with all form fields', async () => { + renderAddOnSpotAttendee(); + + expect(screen.getByText('On-spot Attendee')).toBeInTheDocument(); + expect(screen.getByLabelText('First Name')).toBeInTheDocument(); + expect(screen.getByLabelText('Last Name')).toBeInTheDocument(); + expect(screen.getByLabelText('Email')).toBeInTheDocument(); + expect(screen.getByLabelText('Phone No.')).toBeInTheDocument(); + expect(screen.getByLabelText('Gender')).toBeInTheDocument(); + }); + + it('handles form input changes correctly', async () => { + renderAddOnSpotAttendee(); + + const firstNameInput = screen.getByLabelText('First Name'); + const lastNameInput = screen.getByLabelText('Last Name'); + const emailInput = screen.getByLabelText('Email'); + + userEvent.type(firstNameInput, 'John'); + userEvent.type(lastNameInput, 'Doe'); + userEvent.type(emailInput, 'john@example.com'); + + expect(firstNameInput).toHaveValue('John'); + expect(lastNameInput).toHaveValue('Doe'); + expect(emailInput).toHaveValue('john@example.com'); + }); + + it('submits form successfully and calls necessary callbacks', async () => { + renderAddOnSpotAttendee(); + + userEvent.type(screen.getByLabelText('First Name'), 'John'); + userEvent.type(screen.getByLabelText('Last Name'), 'Doe'); + userEvent.type(screen.getByLabelText('Email'), 'john@example.com'); + userEvent.type(screen.getByLabelText('Phone No.'), '1234567890'); + const genderSelect = screen.getByLabelText('Gender'); + fireEvent.change(genderSelect, { target: { value: 'Male' } }); + + fireEvent.submit(screen.getByTestId('onspot-attendee-form')); + await waitFor(() => { + expect(toast.success).toHaveBeenCalled(); + expect(mockProps.reloadMembers).toHaveBeenCalled(); + expect(mockProps.handleClose).toHaveBeenCalled(); + }); + }); + + it('displays error when organization ID is missing', async () => { + render( + + + + + , + ); + + fireEvent.submit(screen.getByTestId('onspot-attendee-form')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + it('displays error when required fields are missing', async () => { + renderAddOnSpotAttendee(); + + fireEvent.submit(screen.getByTestId('onspot-attendee-form')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + it('handles mutation error appropriately', async () => { + render( + + + + + + + + + , + ); + + userEvent.type(screen.getByLabelText('First Name'), 'John'); + userEvent.type(screen.getByLabelText('Last Name'), 'Doe'); + fireEvent.submit(screen.getByTestId('onspot-attendee-form')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/EventRegistrantsModal/AddOnSpotAttendee.tsx b/src/components/EventRegistrantsModal/AddOnSpotAttendee.tsx new file mode 100644 index 0000000000..6de839ce04 --- /dev/null +++ b/src/components/EventRegistrantsModal/AddOnSpotAttendee.tsx @@ -0,0 +1,209 @@ +import { SIGNUP_MUTATION } from 'GraphQl/Mutations/mutations'; +import React, { useState } from 'react'; +import { Modal, Form, Button, Spinner } from 'react-bootstrap'; +import { useParams } from 'react-router-dom'; +import { useMutation } from '@apollo/client'; +import { toast } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import type { + InterfaceAddOnSpotAttendeeProps, + InterfaceFormData, +} from 'utils/interfaces'; +import { useTranslation } from 'react-i18next'; +import { errorHandler } from 'utils/errorHandler'; +/** + * Modal component for adding on-spot attendees to an event + * @param show - Boolean to control modal visibility + * @param handleClose - Function to handle modal close + * @param reloadMembers - Function to refresh member list after adding attendee + * @returns Modal component with form for adding new attendee + */ +const AddOnSpotAttendee: React.FC = ({ + show, + handleClose, + reloadMembers, +}) => { + const [formData, setFormData] = useState({ + firstName: '', + lastName: '', + email: '', + phoneNo: '', + gender: '', + }); + const { t } = useTranslation('translation', { keyPrefix: 'onSpotAttendee' }); + const { t: tCommon } = useTranslation('common'); + const { orgId } = useParams<{ orgId: string }>(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [addSignUp] = useMutation(SIGNUP_MUTATION); + const validateForm = (): boolean => { + if (!formData.firstName || !formData.lastName || !formData.email) { + toast.error(t('invalidDetailsMessage')); + return false; + } + return true; + }; + + const resetForm = (): void => { + setFormData({ + firstName: '', + lastName: '', + email: '', + phoneNo: '', + gender: '', + }); + }; + + const handleChange = ( + e: React.ChangeEvent< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + >, + ): void => { + const target = e.target as + | HTMLInputElement + | HTMLSelectElement + | HTMLTextAreaElement; + setFormData((prev) => ({ + ...prev, + [target.name]: target.value, + })); + }; + + const handleSubmit = async ( + e: React.FormEvent, + ): Promise => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setIsSubmitting(true); + + try { + const response = await addSignUp({ + variables: { + ...formData, + password: '123456', + orgId, + }, + }); + + if (response.data?.signUp) { + toast.success(t('attendeeAddedSuccess')); + resetForm(); + reloadMembers(); + handleClose(); + } + } catch (error) { + /* istanbul ignore next */ + errorHandler(t, error as Error); + } finally { + setIsSubmitting(false); + } + }; + return ( + <> + + + {t('title')} + + +
    +
    + + + {tCommon('firstName')} + + + + + + {tCommon('lastName')} + + + +
    + + {t('phoneNumber')} + + + + + {tCommon('email')} + + + + + {tCommon('gender')} + + + + + + + +
    + +
    +
    +
    + + ); +}; + +export default AddOnSpotAttendee; diff --git a/src/components/EventRegistrantsModal/EventRegistrantsModal.module.css b/src/components/EventRegistrantsModal/EventRegistrantsModal.module.css new file mode 100644 index 0000000000..0f78d81c01 --- /dev/null +++ b/src/components/EventRegistrantsModal/EventRegistrantsModal.module.css @@ -0,0 +1,43 @@ +.loader, +.loader:after { + border-radius: 50%; + width: 10em; + height: 10em; +} +.loader { + margin: 60px auto; + margin-top: 35vh !important; + font-size: 10px; + position: relative; + text-indent: -9999em; + border-top: 1.1em solid rgba(255, 255, 255, 0.2); + border-right: 1.1em solid rgba(255, 255, 255, 0.2); + border-bottom: 1.1em solid rgba(255, 255, 255, 0.2); + border-left: 1.1em solid #febc59; + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); + -webkit-animation: load8 1.1s infinite linear; + animation: load8 1.1s infinite linear; +} + +@-webkit-keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} diff --git a/src/components/EventRegistrantsModal/EventRegistrantsModal.test.tsx b/src/components/EventRegistrantsModal/EventRegistrantsModal.test.tsx new file mode 100644 index 0000000000..8ca76393cd --- /dev/null +++ b/src/components/EventRegistrantsModal/EventRegistrantsModal.test.tsx @@ -0,0 +1,403 @@ +import React from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { EventRegistrantsModal } from './EventRegistrantsModal'; +import { EVENT_ATTENDEES, MEMBERS_LIST } from 'GraphQl/Queries/Queries'; +import { + ADD_EVENT_ATTENDEE, + REMOVE_EVENT_ATTENDEE, +} from 'GraphQl/Mutations/mutations'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; +import { ToastContainer } from 'react-toastify'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +const queryMockWithoutRegistrant = [ + { + request: { + query: EVENT_ATTENDEES, + variables: { id: 'event123' }, + }, + result: { + data: { + event: { + attendees: [], + }, + }, + }, + }, +]; + +const queryMockWithRegistrant = [ + { + request: { + query: EVENT_ATTENDEES, + variables: { id: 'event123' }, + }, + result: { + data: { + event: { + attendees: [ + { + _id: 'user1', + firstName: 'John', + lastName: 'Doe', + createdAt: '2023-01-01', + gender: 'Male', + birthDate: '1990-01-01', + eventsAttended: { + _id: 'event123', + }, + }, + ], + }, + }, + }, + }, +]; + +const queryMockOrgMembers = [ + { + request: { + query: MEMBERS_LIST, + variables: { id: 'org123' }, + }, + result: { + data: { + organizations: [ + { + _id: 'org123', + members: [ + { + _id: 'user1', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'johndoe@example.com', + createdAt: '2023-01-01', + organizationsBlockedBy: [], + }, + ], + }, + ], + }, + }, + }, +]; +const queryMockWithoutOrgMembers = [ + { + request: { + query: MEMBERS_LIST, + variables: { id: 'org123' }, + }, + result: { + data: { + organizations: [ + { + _id: 'org123', + members: [], + }, + ], + }, + }, + }, +]; + +const successfulAddRegistrantMock = [ + { + request: { + query: ADD_EVENT_ATTENDEE, + variables: { userId: 'user1', eventId: 'event123' }, + }, + result: { + data: { + addEventAttendee: { _id: 'user1' }, + }, + }, + }, +]; + +const unsuccessfulAddRegistrantMock = [ + { + request: { + query: ADD_EVENT_ATTENDEE, + variables: { userId: 'user1', eventId: 'event123' }, + }, + error: new Error('Oops'), + }, +]; + +const successfulRemoveRegistrantMock = [ + { + request: { + query: REMOVE_EVENT_ATTENDEE, + variables: { userId: 'user1', eventId: 'event123' }, + }, + result: { + data: { + removeEventAttendee: { _id: 'user1' }, + }, + }, + }, +]; + +const unsuccessfulRemoveRegistrantMock = [ + { + request: { + query: REMOVE_EVENT_ATTENDEE, + variables: { userId: 'user1', eventId: 'event123' }, + }, + error: new Error('Oops'), + }, +]; + +describe('Testing Event Registrants Modal', () => { + const props = { + show: true, + eventId: 'event123', + orgId: 'org123', + handleClose: jest.fn(), + }; + + test('The modal should be rendered, correct text must be displayed when there are no attendees and add attendee mutation must function properly', async () => { + const { queryByText, queryByLabelText } = render( + + + + + + + + + + + + , + ); + + await waitFor(() => + expect(queryByText('Event Registrants')).toBeInTheDocument(), + ); + + await waitFor(() => + expect(queryByText('Registered Registrants')).toBeInTheDocument(), + ); + + await waitFor(() => + expect( + queryByText('There are no registered attendees for this event.'), + ).toBeInTheDocument(), + ); + + // Get warning modal on blank button click + fireEvent.click(queryByText('Add Registrant') as Element); + + await waitFor(() => + expect( + queryByText('Please choose an user to add first!'), + ).toBeInTheDocument(), + ); + + // Choose a user to add as an attendee + const attendeeInput = queryByLabelText('Add an Registrant'); + fireEvent.change(attendeeInput as Element, { + target: { value: 'John Doe' }, + }); + fireEvent.keyDown(attendeeInput as HTMLElement, { key: 'ArrowDown' }); + fireEvent.keyDown(attendeeInput as HTMLElement, { key: 'Enter' }); + + fireEvent.click(queryByText('Add Registrant') as Element); + + await waitFor(() => + expect(queryByText('Adding the attendee...')).toBeInTheDocument(), + ); + + await waitFor(() => + expect(queryByText('Attendee added Successfully')).toBeInTheDocument(), + ); + }); + + test('Add attendee mutation must fail properly', async () => { + const { queryByText, queryByLabelText } = render( + + + + + + + + + + + + , + ); + + await waitFor(() => + expect( + queryByText('There are no registered attendees for this event.'), + ).toBeInTheDocument(), + ); + + // Choose a user to add as an attendee + const attendeeInput = queryByLabelText('Add an Registrant'); + fireEvent.change(attendeeInput as Element, { + target: { value: 'John Doe' }, + }); + fireEvent.keyDown(attendeeInput as HTMLElement, { key: 'ArrowDown' }); + fireEvent.keyDown(attendeeInput as HTMLElement, { key: 'Enter' }); + + fireEvent.click(queryByText('Add Registrant') as Element); + + await waitFor(() => + expect(queryByText('Adding the attendee...')).toBeInTheDocument(), + ); + + await waitFor(() => + expect(queryByText('Error adding attendee')).toBeInTheDocument(), + ); + }); + + test('Assigned attendees must be shown with badges and delete attendee mutation must function properly', async () => { + const { queryByText, queryByTestId } = render( + + + + + + + + + + + + , + ); + + await waitFor(() => + expect(queryByText('Registered Registrants')).toBeInTheDocument(), + ); + + await waitFor(() => expect(queryByText('John Doe')).toBeInTheDocument()); + + fireEvent.click(queryByTestId('CancelIcon') as Element); + + await waitFor(() => + expect(queryByText('Removing the attendee...')).toBeInTheDocument(), + ); + + await waitFor(() => + expect(queryByText('Attendee removed Successfully')).toBeInTheDocument(), + ); + }); + + test('Delete attendee mutation must fail properly', async () => { + const { queryByText, getByTestId } = render( + + + + + + + + + + + + , + ); + + await waitFor(() => + expect(queryByText('Registered Registrants')).toBeInTheDocument(), + ); + + await waitFor(() => expect(queryByText('John Doe')).toBeInTheDocument()); + + const deleteButton = getByTestId('CancelIcon'); + fireEvent.click(deleteButton); + + await waitFor(() => + expect(queryByText('Removing the attendee...')).toBeInTheDocument(), + ); + + await waitFor(() => + expect(queryByText('Error removing attendee')).toBeInTheDocument(), + ); + }); + test('Autocomplete functionality works correctly', async () => { + const { getByTitle, getByText, getByPlaceholderText } = render( + + + + + + + + + + + + , + ); + + // Wait for loading state to finish + await waitFor(() => { + const autocomplete = getByPlaceholderText( + 'Choose the user that you want to add', + ); + expect(autocomplete).toBeInTheDocument(); + }); + + // Test empty state with no options + const autocomplete = getByPlaceholderText( + 'Choose the user that you want to add', + ); + fireEvent.change(autocomplete, { target: { value: 'NonexistentUser' } }); + + await waitFor(() => { + expect(getByText('No Registrations found')).toBeInTheDocument(); + expect(getByText('Add Onspot Registration')).toBeInTheDocument(); + }); + + // Test clicking "Add Onspot Registration" + fireEvent.click(getByText('Add Onspot Registration')); + expect(getByText('Add Onspot Registration')).toBeInTheDocument(); + const closeButton = getByTitle('Close'); + fireEvent.click(closeButton); + }); +}); diff --git a/src/components/EventRegistrantsModal/EventRegistrantsModal.tsx b/src/components/EventRegistrantsModal/EventRegistrantsModal.tsx new file mode 100644 index 0000000000..d48d3b7439 --- /dev/null +++ b/src/components/EventRegistrantsModal/EventRegistrantsModal.tsx @@ -0,0 +1,207 @@ +import React, { useState } from 'react'; +import { Modal, Button } from 'react-bootstrap'; +import { toast } from 'react-toastify'; +import { useMutation, useQuery } from '@apollo/client'; +import { EVENT_ATTENDEES, MEMBERS_LIST } from 'GraphQl/Queries/Queries'; +import { + ADD_EVENT_ATTENDEE, + REMOVE_EVENT_ATTENDEE, +} from 'GraphQl/Mutations/mutations'; +import styles from 'components/EventRegistrantsModal/EventRegistrantsModal.module.css'; +import Avatar from '@mui/material/Avatar'; +import Chip from '@mui/material/Chip'; +import Stack from '@mui/material/Stack'; +import TextField from '@mui/material/TextField'; +import Autocomplete from '@mui/material/Autocomplete'; +import { useTranslation } from 'react-i18next'; +import AddOnSpotAttendee from './AddOnSpotAttendee'; + +// Props for the EventRegistrantsModal component +type ModalPropType = { + show: boolean; + eventId: string; + orgId: string; + handleClose: () => void; +}; + +// User information interface +interface InterfaceUser { + _id: string; + firstName: string; + lastName: string; +} + +/** + * Modal component for managing event registrants. + * Allows adding and removing attendees from an event. + * + * @param show - Whether the modal is visible or not. + * @param eventId - The ID of the event. + * @param orgId - The ID of the organization. + * @param handleClose - Function to close the modal. + * @returns JSX element representing the modal. + */ +export const EventRegistrantsModal = (props: ModalPropType): JSX.Element => { + const { eventId, orgId, handleClose, show } = props; + const [member, setMember] = useState(null); + const [open, setOpen] = useState(false); + + // Hooks for mutation operations + const [addRegistrantMutation] = useMutation(ADD_EVENT_ATTENDEE); + const [removeRegistrantMutation] = useMutation(REMOVE_EVENT_ATTENDEE); + + // Translation hooks + const { t } = useTranslation('translation', { + keyPrefix: 'eventRegistrantsModal', + }); + const { t: tCommon } = useTranslation('common'); + + // Query hooks to fetch event attendees and organization members + const { + data: attendeesData, + loading: attendeesLoading, + refetch: attendeesRefetch, + } = useQuery(EVENT_ATTENDEES, { + variables: { id: eventId }, + }); + + const { data: memberData, loading: memberLoading } = useQuery(MEMBERS_LIST, { + variables: { id: orgId }, + }); + + // Function to add a new registrant to the event + const addRegistrant = (): void => { + if (member == null) { + toast.warning('Please choose an user to add first!'); + return; + } + toast.warn('Adding the attendee...'); + addRegistrantMutation({ + variables: { + userId: member._id, + eventId: eventId, + }, + }) + .then(() => { + toast.success( + tCommon('addedSuccessfully', { item: 'Attendee' }) as string, + ); + attendeesRefetch(); // Refresh the list of attendees + }) + .catch((err) => { + toast.error(t('errorAddingAttendee') as string); + toast.error(err.message); + }); + }; + + // Function to remove a registrant from the event + const deleteRegistrant = (userId: string): void => { + toast.warn('Removing the attendee...'); + removeRegistrantMutation({ + variables: { + userId, + eventId: eventId, + }, + }) + .then(() => { + toast.success( + tCommon('removedSuccessfully', { item: 'Attendee' }) as string, + ); + attendeesRefetch(); // Refresh the list of attendees + }) + .catch((err) => { + toast.error(t('errorRemovingAttendee') as string); + toast.error(err.message); + }); + }; + + // Show a loading screen if data is still being fetched + if (attendeesLoading || memberLoading) { + return ( + <> +
    + + ); + } + + return ( + <> + + setOpen(false) + } + reloadMembers={ + /*istanbul ignore next */ + () => { + attendeesRefetch(); + } + } + /> + + Event Registrants + + +
    Registered Registrants
    + {attendeesData.event.attendees.length == 0 + ? `There are no registered attendees for this event.` + : null} + + {attendeesData.event.attendees.map((attendee: InterfaceUser) => ( + {`${attendee.firstName[0]}${attendee.lastName[0]}`} + } + label={`${attendee.firstName} ${attendee.lastName}`} + variant="outlined" + key={attendee._id} + onDelete={(): void => deleteRegistrant(attendee._id)} + /> + ))} + +
    + + { + setMember(newMember); + }} + noOptionsText={ +
    +

    No Registrations found

    + { + setOpen(true); + }} + > + Add Onspot Registration + +
    + } + options={memberData.organizations[0].members} + getOptionLabel={(member: InterfaceUser): string => + `${member.firstName} ${member.lastName}` + } + renderInput={(params): React.ReactNode => ( + + )} + /> +
    +
    + + + +
    + + ); +}; diff --git a/src/components/EventRegistrantsModal/EventRegistrantsWrapper.module.css b/src/components/EventRegistrantsModal/EventRegistrantsWrapper.module.css new file mode 100644 index 0000000000..59b31333af --- /dev/null +++ b/src/components/EventRegistrantsModal/EventRegistrantsWrapper.module.css @@ -0,0 +1,13 @@ +button .iconWrapper { + width: 36px; + padding-right: 4px; + margin-right: 4px; + transform: translateY(4px); +} + +button .iconWrapperSm { + width: 36px; + display: flex; + justify-content: center; + align-items: center; +} diff --git a/src/components/EventRegistrantsModal/EventRegistrantsWrapper.test.tsx b/src/components/EventRegistrantsModal/EventRegistrantsWrapper.test.tsx new file mode 100644 index 0000000000..d1707e8520 --- /dev/null +++ b/src/components/EventRegistrantsModal/EventRegistrantsWrapper.test.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { EventRegistrantsWrapper } from './EventRegistrantsWrapper'; +import { EVENT_ATTENDEES, MEMBERS_LIST } from 'GraphQl/Queries/Queries'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; +import { ToastContainer } from 'react-toastify'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +const queryMock = [ + { + request: { + query: EVENT_ATTENDEES, + variables: { id: 'event123' }, + }, + result: { + data: { + event: { + attendees: [], + }, + }, + }, + }, + { + request: { + query: MEMBERS_LIST, + variables: { id: 'org123' }, + }, + result: { + data: { + organizations: [ + { + _id: 'org123', + members: [ + { + _id: 'user1', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@palisadoes.com', + image: '', + createdAt: '12/12/22', + organizationsBlockedBy: [], + }, + ], + }, + ], + }, + }, + }, +]; + +describe('Testing Event Registrants Wrapper', () => { + const props = { + eventId: 'event123', + orgId: 'org123', + }; + + test('The button should work properly', async () => { + const { queryByText, queryByRole } = render( + + + + + + + + + + + + , + ); + + // Open the modal + fireEvent.click(queryByText('Show Registrants') as Element); + + await waitFor(() => + expect(queryByText('Event Registrants')).toBeInTheDocument(), + ); + + // Close the modal + fireEvent.click(queryByRole('button', { name: /close/i }) as HTMLElement); + await waitFor(() => + expect(queryByText('Event Registrants')).not.toBeInTheDocument(), + ); + }); +}); diff --git a/src/components/EventRegistrantsModal/EventRegistrantsWrapper.tsx b/src/components/EventRegistrantsModal/EventRegistrantsWrapper.tsx new file mode 100644 index 0000000000..b198fcdd6d --- /dev/null +++ b/src/components/EventRegistrantsModal/EventRegistrantsWrapper.tsx @@ -0,0 +1,60 @@ +import React, { useState } from 'react'; +import { EventRegistrantsModal } from './EventRegistrantsModal'; +import { Button } from 'react-bootstrap'; +import IconComponent from 'components/IconComponent/IconComponent'; +import styles from './EventRegistrantsWrapper.module.css'; + +// Props for the EventRegistrantsWrapper component +type PropType = { + eventId: string; + orgId: string; +}; + +/** + * Wrapper component that displays a button to show the event registrants modal. + * + * @param eventId - The ID of the event. + * @param orgId - The ID of the organization. + * @returns JSX element representing the wrapper with a button to show the modal. + */ +export const EventRegistrantsWrapper = ({ + eventId, + orgId, +}: PropType): JSX.Element => { + // State to control the visibility of the modal + const [showModal, setShowModal] = useState(false); + + return ( + <> + {/* Button to open the event registrants modal */} + + + {/* Render the EventRegistrantsModal if showModal is true */} + {showModal && ( + { + setShowModal(false); // Hide the modal when closed + }} + eventId={eventId} + orgId={orgId} + /> + )} + + ); +}; diff --git a/src/components/EventStats/EventStats.module.css b/src/components/EventStats/EventStats.module.css new file mode 100644 index 0000000000..44ba75a0a8 --- /dev/null +++ b/src/components/EventStats/EventStats.module.css @@ -0,0 +1,35 @@ +.stackEvents { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + margin: 10px 20px; + padding: 10px 20px; + overflow: hidden; + gap: 2px; + column-gap: 2px; +} + +@media screen and (min-width: 801px) { + .stackEvents { + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + padding: 0 2rem; + margin: 0 40px; + gap: 5px; + column-gap: 4px; + } +} + +@media screen and (min-width: 768px) and (max-width: 800px) { + .stackEvents { + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + padding: 0 2rem; + margin: 0 20px; + gap: 5px; + column-gap: 4px; + } +} diff --git a/src/components/EventStats/EventStats.test.tsx b/src/components/EventStats/EventStats.test.tsx new file mode 100644 index 0000000000..e2496fa5af --- /dev/null +++ b/src/components/EventStats/EventStats.test.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { EventStats } from './EventStats'; +import { BrowserRouter } from 'react-router-dom'; +import { EVENT_FEEDBACKS } from 'GraphQl/Queries/Queries'; + +// Mock the modules for PieChart rendering as they require a trasformer being used (which is not done by Jest) +// These modules are used by the Feedback component +jest.mock('@mui/x-charts/PieChart', () => ({ + pieArcLabelClasses: jest.fn(), + PieChart: jest.fn().mockImplementation(() => <>Test), + pieArcClasses: jest.fn(), +})); + +const mockData = [ + { + request: { + query: EVENT_FEEDBACKS, + variables: { + id: 'eventStats123', + }, + }, + result: { + data: { + event: { + _id: 'eventStats123', + feedback: [ + { + _id: 'feedback1', + review: 'review1', + rating: 5, + }, + ], + averageFeedbackScore: 5, + }, + }, + }, + }, +]; + +describe('Testing Event Stats', () => { + const props = { + eventId: 'eventStats123', + show: true, + handleClose: jest.fn(), + }; + + test('The stats should be rendered properly', async () => { + const { queryByText } = render( + + + + + , + ); + + await waitFor(() => + expect(queryByText('Event Statistics')).toBeInTheDocument(), + ); + }); +}); diff --git a/src/components/EventStats/EventStats.tsx b/src/components/EventStats/EventStats.tsx new file mode 100644 index 0000000000..ce1e55e4bc --- /dev/null +++ b/src/components/EventStats/EventStats.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { Modal } from 'react-bootstrap'; +import { FeedbackStats } from './Statistics/Feedback'; +import { ReviewStats } from './Statistics/Review'; +import { AverageRating } from './Statistics/AverageRating'; +import styles from './Loader.module.css'; +import eventStatsStyles from './EventStats.module.css'; +import { useQuery } from '@apollo/client'; +import { EVENT_FEEDBACKS } from 'GraphQl/Queries/Queries'; + +// Props for the EventStats component +type ModalPropType = { + show: boolean; + eventId: string; + handleClose: () => void; +}; + +/** + * Component that displays event statistics in a modal. + * Shows feedback, reviews, and average rating for the event. + * + * @param show - Whether the modal is visible or not. + * @param handleClose - Function to close the modal. + * @param eventId - The ID of the event. + * @returns JSX element representing the event statistics modal. + */ +export const EventStats = ({ + show, + handleClose, + eventId, +}: ModalPropType): JSX.Element => { + // Query to fetch event feedback data + const { data, loading } = useQuery(EVENT_FEEDBACKS, { + variables: { id: eventId }, + }); + + // Show a loading screen while data is being fetched + if (loading) { + return ( + <> +
    + + ); + } + + return ( + <> + + + Event Statistics + + + {/* Render feedback statistics */} + +
    + {/* Render review statistics and average rating */} + + +
    +
    +
    + + ); +}; diff --git a/src/components/EventStats/EventStatsWrapper.module.css b/src/components/EventStats/EventStatsWrapper.module.css new file mode 100644 index 0000000000..f5f42546c3 --- /dev/null +++ b/src/components/EventStats/EventStatsWrapper.module.css @@ -0,0 +1,13 @@ +button .iconWrapper { + width: 32px; + padding-right: 4px; + margin-right: 4px; + transform: translateY(4px); +} + +button .iconWrapperSm { + width: 32px; + display: flex; + justify-content: center; + align-items: center; +} diff --git a/src/components/EventStats/EventStatsWrapper.test.tsx b/src/components/EventStats/EventStatsWrapper.test.tsx new file mode 100644 index 0000000000..0e64ac13cc --- /dev/null +++ b/src/components/EventStats/EventStatsWrapper.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { EventStatsWrapper } from './EventStatsWrapper'; +import { BrowserRouter } from 'react-router-dom'; +import { EVENT_FEEDBACKS } from 'GraphQl/Queries/Queries'; + +// Mock the modules for PieChart rendering as they require a trasformer being used (which is not done by Jest) +jest.mock('@mui/x-charts/PieChart', () => ({ + pieArcLabelClasses: jest.fn(), + PieChart: jest.fn().mockImplementation(() => <>Test), + pieArcClasses: jest.fn(), +})); + +const mockData = [ + { + request: { + query: EVENT_FEEDBACKS, + variables: { + id: 'eventStats123', + }, + }, + result: { + data: { + event: { + _id: 'eventStats123', + feedback: [ + { + _id: 'feedback1', + review: 'review1', + rating: 5, + }, + ], + averageFeedbackScore: 5, + }, + }, + }, + }, +]; + +// Mock the modules for PieChart rendering as they require a trasformer being used (which is not done by Jest) +// These modules are used by the Feedback component +jest.mock('@mui/x-charts/PieChart', () => ({ + pieArcLabelClasses: jest.fn(), + PieChart: jest.fn().mockImplementation(() => <>Test), + pieArcClasses: jest.fn(), +})); + +describe('Testing Event Stats Wrapper', () => { + const props = { + eventId: 'eventStats123', + }; + + test('The button to open and close the modal should work properly', async () => { + const { queryByText, queryByRole } = render( + + + + + , + ); + + // Open the modal + fireEvent.click(queryByText('View Event Statistics') as Element); + + await waitFor(() => + expect(queryByText('Event Statistics')).toBeInTheDocument(), + ); + + // Close the modal + fireEvent.click(queryByRole('button', { name: /close/i }) as HTMLElement); + await waitFor(() => + expect(queryByText('Event Statistics')).not.toBeInTheDocument(), + ); + }); +}); diff --git a/src/components/EventStats/EventStatsWrapper.tsx b/src/components/EventStats/EventStatsWrapper.tsx new file mode 100644 index 0000000000..fb701a00da --- /dev/null +++ b/src/components/EventStats/EventStatsWrapper.tsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react'; +import { EventStats } from './EventStats'; +import { Button } from 'react-bootstrap'; +import IconComponent from 'components/IconComponent/IconComponent'; +import styles from './EventStatsWrapper.module.css'; + +// Props for the EventStatsWrapper component +type PropType = { + eventId: string; +}; + +/** + * Wrapper component that displays a button to show event statistics. + * + * @param eventId - The ID of the event. + * @returns JSX element representing the wrapper with a button to view event statistics. + */ +export const EventStatsWrapper = ({ eventId }: PropType): JSX.Element => { + // State to control the visibility of the EventStats component + const [showModal, setShowModal] = useState(false); + + return ( + <> + {/* Button to open the event statistics view */} + + + {/* Render the EventStats component if showModal is true */} + setShowModal(false)} // Hide the EventStats component when closed + key={eventId || 'eventStatsDetails'} // Use eventId as key for the component + eventId={eventId} + /> + + ); +}; diff --git a/src/components/EventStats/Loader.module.css b/src/components/EventStats/Loader.module.css new file mode 100644 index 0000000000..0f78d81c01 --- /dev/null +++ b/src/components/EventStats/Loader.module.css @@ -0,0 +1,43 @@ +.loader, +.loader:after { + border-radius: 50%; + width: 10em; + height: 10em; +} +.loader { + margin: 60px auto; + margin-top: 35vh !important; + font-size: 10px; + position: relative; + text-indent: -9999em; + border-top: 1.1em solid rgba(255, 255, 255, 0.2); + border-right: 1.1em solid rgba(255, 255, 255, 0.2); + border-bottom: 1.1em solid rgba(255, 255, 255, 0.2); + border-left: 1.1em solid #febc59; + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); + -webkit-animation: load8 1.1s infinite linear; + animation: load8 1.1s infinite linear; +} + +@-webkit-keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} diff --git a/src/components/EventStats/Statistics/AverageRating.test.tsx b/src/components/EventStats/Statistics/AverageRating.test.tsx new file mode 100644 index 0000000000..01cf6461e3 --- /dev/null +++ b/src/components/EventStats/Statistics/AverageRating.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { AverageRating } from './AverageRating'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; +import { ToastContainer } from 'react-toastify'; + +const props = { + data: { + event: { + _id: '123', + feedback: [ + { + _id: 'feedback1', + review: 'review1', + rating: 5, + }, + { + _id: 'feedback2', + review: 'review2', + rating: 5, + }, + { + _id: 'feedback3', + review: null, + rating: 5, + }, + ], + averageFeedbackScore: 5, + }, + }, +}; + +describe('Testing Average Rating Card', () => { + test('The component should be rendered and the Score should be shown', async () => { + const { queryByText } = render( + + + + + + + + , + ); + + await waitFor(() => + expect(queryByText('Average Review Score')).toBeInTheDocument(), + ); + + await waitFor(() => + expect(queryByText('Rated 5.00 / 5')).toBeInTheDocument(), + ); + }); +}); diff --git a/src/components/EventStats/Statistics/AverageRating.tsx b/src/components/EventStats/Statistics/AverageRating.tsx new file mode 100644 index 0000000000..9f1a157e01 --- /dev/null +++ b/src/components/EventStats/Statistics/AverageRating.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import Card from 'react-bootstrap/Card'; +import Rating from '@mui/material/Rating'; +import FavoriteIcon from '@mui/icons-material/Favorite'; +import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder'; +import Typography from '@mui/material/Typography'; + +// Props for the AverageRating component +type ModalPropType = { + data: { + event: { + _id: string; + averageFeedbackScore: number; + feedback: FeedbackType[]; + }; + }; +}; + +// Type representing individual feedback +type FeedbackType = { + _id: string; + rating: number; + review: string | null; +}; + +/** + * Component that displays the average rating for an event. + * Shows a rating value and a star rating icon. + * + * @param data - Data containing the average feedback score to be displayed. + * @returns JSX element representing the average rating card with a star rating. + */ +export const AverageRating = ({ data }: ModalPropType): JSX.Element => { + return ( + <> + + + +

    Average Review Score

    +
    + + Rated {data.event.averageFeedbackScore.toFixed(2)} / 5 + + } + size="medium" + emptyIcon={} + sx={{ + '& .MuiRating-iconFilled': { + color: '#ff6d75', // Color for filled stars + }, + '& .MuiRating-iconHover': { + color: '#ff3d47', // Color for star on hover + }, + }} + /> +
    +
    + + ); +}; diff --git a/src/components/EventStats/Statistics/Feedback.test.tsx b/src/components/EventStats/Statistics/Feedback.test.tsx new file mode 100644 index 0000000000..9abdee4c57 --- /dev/null +++ b/src/components/EventStats/Statistics/Feedback.test.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { FeedbackStats } from './Feedback'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; +import { ToastContainer } from 'react-toastify'; + +// Mock the modules for PieChart rendering as they require a trasformer being used (which is not done by Jest) +jest.mock('@mui/x-charts/PieChart', () => ({ + pieArcLabelClasses: jest.fn(), + PieChart: jest.fn().mockImplementation(() => <>Test), + pieArcClasses: jest.fn(), +})); + +const nonEmptyProps = { + data: { + event: { + _id: '123', + feedback: [ + { + _id: 'feedback1', + review: 'review1', + rating: 5, + }, + { + _id: 'feedback2', + review: 'review2', + rating: 5, + }, + { + _id: 'feedback3', + review: null, + rating: 5, + }, + ], + averageFeedbackScore: 5, + }, + }, +}; + +const emptyProps = { + data: { + event: { + _id: '123', + feedback: [], + averageFeedbackScore: 5, + }, + }, +}; + +describe('Testing Feedback Statistics Card', () => { + test('The component should be rendered and the feedback should be shown if present', async () => { + const { queryByText } = render( + + + + + + + + , + ); + + await waitFor(() => + expect(queryByText('Feedback Analysis')).toBeInTheDocument(), + ); + + await waitFor(() => + expect( + queryByText('3 people have filled feedback for this event.'), + ).toBeInTheDocument(), + ); + + await waitFor(() => { + expect(queryByText('Test')).toBeInTheDocument(); + }); + }); + + test('The component should be rendered and message should be shown if no feedback is present', async () => { + const { queryByText } = render( + + + + + + + + , + ); + + await waitFor(() => + expect(queryByText('Feedback Analysis')).toBeInTheDocument(), + ); + + await waitFor(() => + expect( + queryByText('Please ask attendees to submit feedback for insights!'), + ).toBeInTheDocument(), + ); + }); +}); diff --git a/src/components/EventStats/Statistics/Feedback.tsx b/src/components/EventStats/Statistics/Feedback.tsx new file mode 100644 index 0000000000..a6a255828f --- /dev/null +++ b/src/components/EventStats/Statistics/Feedback.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { + PieChart, + pieArcClasses, + pieArcLabelClasses, +} from '@mui/x-charts/PieChart'; +import Card from 'react-bootstrap/Card'; + +// Props for the FeedbackStats component +type ModalPropType = { + data: { + event: { + _id: string; + averageFeedbackScore: number | null; + feedback: FeedbackType[]; + }; + }; +}; + +// Type representing individual feedback +type FeedbackType = { + _id: string; + rating: number; + review: string | null; +}; + +/** + * Component that displays a pie chart of feedback ratings for an event. + * Shows how many people gave each rating. + * + * @param data - Data containing event feedback to be displayed in the chart. + * @returns JSX element representing the feedback analysis card with a pie chart. + */ +export const FeedbackStats = ({ data }: ModalPropType): JSX.Element => { + // Colors for the pie chart slices, from green (high ratings) to red (low ratings) + const ratingColors = [ + '#57bb8a', // Green + '#94bd77', + '#d4c86a', + '#e9b861', + '#e79a69', + '#dd776e', // Red + ]; + + // Count the number of feedbacks for each rating + const count: Record = {}; + + data.event.feedback.forEach((feedback: FeedbackType) => { + if (feedback.rating in count) count[feedback.rating]++; + else count[feedback.rating] = 1; + }); + + // Prepare data for the pie chart + const chartData = []; + for (let rating = 0; rating <= 5; rating++) { + if (rating in count) + chartData.push({ + id: rating, + value: count[rating], + label: `${rating} (${count[rating]})`, + color: ratingColors[5 - rating], + }); + } + + return ( + <> + + + +

    Feedback Analysis

    +
    +
    + {data.event.feedback.length} people have filled feedback for this + event. +
    + {data.event.feedback.length ? ( + + `${item.id} (${item.value})`, + innerRadius: 30, + outerRadius: 120, + paddingAngle: 2, + cornerRadius: 5, + startAngle: 0, + highlightScope: { faded: 'global', highlighted: 'item' }, + faded: { innerRadius: 30, additionalRadius: -30 }, + }, + ]} + sx={{ + [`& .${pieArcClasses.faded}`]: { + fill: 'gray', + }, + [`& .${pieArcLabelClasses.root}`]: { + fill: 'black', + fontSize: '15px', + }, + [`& .${pieArcLabelClasses.faded}`]: { + display: 'none', + }, + }} + width={380} + height={380} + /> + ) : ( + <>Please ask attendees to submit feedback for insights! + )} +
    +
    + + ); +}; diff --git a/src/components/EventStats/Statistics/Review.test.tsx b/src/components/EventStats/Statistics/Review.test.tsx new file mode 100644 index 0000000000..9093444ab2 --- /dev/null +++ b/src/components/EventStats/Statistics/Review.test.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { ReviewStats } from './Review'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; +import { ToastContainer } from 'react-toastify'; + +const nonEmptyReviewProps = { + data: { + event: { + _id: '123', + feedback: [ + { + _id: 'feedback1', + review: 'review1', + rating: 5, + }, + { + _id: 'feedback2', + review: 'review2', + rating: 5, + }, + { + _id: 'feedback3', + review: null, + rating: 5, + }, + ], + averageFeedbackScore: 5, + }, + }, +}; + +const emptyReviewProps = { + data: { + event: { + _id: '123', + feedback: [ + { + _id: 'feedback3', + review: null, + rating: 5, + }, + ], + averageFeedbackScore: 5, + }, + }, +}; + +describe('Testing Review Statistics Card', () => { + test('The component should be rendered and the reviews should be shown if present', async () => { + const { queryByText } = render( + + + + + + + + , + ); + + await waitFor(() => expect(queryByText('Reviews')).toBeInTheDocument()); + + await waitFor(() => + expect(queryByText('Filled by 2 people.')).toBeInTheDocument(), + ); + + await waitFor(() => expect(queryByText('review2')).toBeInTheDocument()); + }); + + test('The component should be rendered and message should be shown if no review is present', async () => { + const { queryByText } = render( + + + + + + + + , + ); + + await waitFor(() => expect(queryByText('Reviews')).toBeInTheDocument()); + + await waitFor(() => + expect( + queryByText('Waiting for people to talk about the event...'), + ).toBeInTheDocument(), + ); + }); +}); diff --git a/src/components/EventStats/Statistics/Review.tsx b/src/components/EventStats/Statistics/Review.tsx new file mode 100644 index 0000000000..343a8107c3 --- /dev/null +++ b/src/components/EventStats/Statistics/Review.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import Card from 'react-bootstrap/Card'; +import Rating from '@mui/material/Rating'; + +// Props for the ReviewStats component +type ModalPropType = { + data: { + event: { + _id: string; + averageFeedbackScore: number | null; + feedback: FeedbackType[]; + }; + }; +}; + +// Type representing individual feedback +type FeedbackType = { + _id: string; + rating: number; + review: string | null; +}; + +/** + * Component that displays reviews for an event. + * Shows a list of reviews with ratings and text. + * + * @param data - Data containing event feedback to be displayed. + * @returns JSX element representing the reviews card. + */ +export const ReviewStats = ({ data }: ModalPropType): JSX.Element => { + // Filter out feedback that has a review + const reviews = data.event.feedback.filter( + (feedback: FeedbackType) => feedback.review != null, + ); + + return ( + <> + + + +

    Reviews

    +
    +
    Filled by {reviews.length} people.
    + {reviews.length ? ( + reviews.map((review) => ( +
    +
    + +

    {review.review}

    +
    +
    + )) + ) : ( + <>Waiting for people to talk about the event... + )} +
    +
    + + ); +}; diff --git a/src/components/HolidayCards/HolidayCard.module.css b/src/components/HolidayCards/HolidayCard.module.css new file mode 100644 index 0000000000..c7686ab870 --- /dev/null +++ b/src/components/HolidayCards/HolidayCard.module.css @@ -0,0 +1,12 @@ +.card { + background-color: #b4dcb7; + font-size: 14px; + font-weight: 400; + display: flex; + padding: 8px 4px; + border-radius: 5px; + margin-bottom: 4px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} diff --git a/src/components/HolidayCards/HolidayCard.tsx b/src/components/HolidayCards/HolidayCard.tsx new file mode 100644 index 0000000000..56ac86405d --- /dev/null +++ b/src/components/HolidayCards/HolidayCard.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import styles from './HolidayCard.module.css'; + +// Props for the HolidayCard component +interface InterfaceHolidayList { + holidayName: string; +} + +/** + * Component that displays a card with the name of a holiday. + * + * @param props - Contains the holidayName to be displayed on the card. + * @returns JSX element representing a card with the holiday name. + */ +const HolidayCard = (props: InterfaceHolidayList): JSX.Element => { + /*istanbul ignore next*/ + return
    {props?.holidayName}
    ; +}; + +export default HolidayCard; diff --git a/src/components/IconComponent/IconComponent.test.tsx b/src/components/IconComponent/IconComponent.test.tsx new file mode 100644 index 0000000000..3ba6ccd84d --- /dev/null +++ b/src/components/IconComponent/IconComponent.test.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import IconComponent from './IconComponent'; + +const screenTestIdMap: Record> = { + MyOrganizations: { + name: 'My Organizations', + testId: 'Icon-Component-MyOrganizationsIcon', + }, + Dashboard: { + name: 'Dashboard', + testId: 'Icon-Component-DashboardIcon', + }, + People: { + name: 'People', + testId: 'Icon-Component-PeopleIcon', + }, + Tags: { + name: 'Tags', + testId: 'Icon-Component-TagsIcon', + }, + Tag: { + name: 'Tag', + testId: 'Icon-Component-TagIcon', + }, + Requests: { + name: 'Requests', + testId: 'Icon-Component-RequestsIcon', + }, + Events: { + name: 'Events', + testId: 'Icon-Component-EventsIcon', + }, + ActionItems: { + name: 'Action Items', + testId: 'Icon-Component-ActionItemIcon', + }, + Posts: { + name: 'Posts', + testId: 'Icon-Component-PostsIcon', + }, + BlockUnblock: { + name: 'Block/Unblock', + testId: 'Block/Icon-Component-UnblockIcon', + }, + Plugins: { + name: 'Plugins', + testId: 'Icon-Component-PluginsIcon', + }, + Settings: { + name: 'Settings', + testId: 'Icon-Component-SettingsIcon', + }, + ListEventRegistrants: { + name: 'List Event Registrants', + testId: 'Icon-Component-List-Event-Registrants', + }, + CheckInRegistrants: { + name: 'Check In Registrants', + testId: 'Icon-Component-Check-In-Registrants', + }, + Advertisement: { + name: 'Advertisement', + testId: 'Icon-Component-Advertisement', + }, + Funds: { + name: 'Funds', + testId: 'Icon-Component-Funds', + }, + Venues: { + name: 'Venues', + testId: 'Icon-Component-Venues', + }, + Donate: { + name: 'Donate', + testId: 'Icon-Component-Donate', + }, + Campaigns: { + name: 'Campaigns', + testId: 'Icon-Component-Campaigns', + }, + MyPledges: { + name: 'My Pledges', + testId: 'Icon-Component-My-Pledges', + }, + Volunteer: { + name: 'Volunteer', + testId: 'Icon-Component-Volunteer', + }, + default: { + name: 'default', + testId: 'Icon-Component-DefaultIcon', + }, +}; + +describe('Testing Collapsible Dropdown component', () => { + it('Renders the correct icon according to the component', () => { + for (const component in screenTestIdMap) { + render(); + expect( + screen.getByTestId(screenTestIdMap[component].testId), + ).toBeInTheDocument(); + } + }); +}); diff --git a/src/components/IconComponent/IconComponent.tsx b/src/components/IconComponent/IconComponent.tsx new file mode 100644 index 0000000000..8430aca131 --- /dev/null +++ b/src/components/IconComponent/IconComponent.tsx @@ -0,0 +1,157 @@ +import { + QuestionMarkOutlined, + ContactPageOutlined, + NewspaperOutlined, +} from '@mui/icons-material'; +import ActionItemIcon from 'assets/svgs/actionItem.svg?react'; +import BlockUserIcon from 'assets/svgs/blockUser.svg?react'; +import CheckInRegistrantsIcon from 'assets/svgs/checkInRegistrants.svg?react'; +import DashboardIcon from 'assets/svgs/dashboard.svg?react'; +import EventsIcon from 'assets/svgs/events.svg?react'; +import FundsIcon from 'assets/svgs/funds.svg?react'; +import ListEventRegistrantsIcon from 'assets/svgs/listEventRegistrants.svg?react'; +import OrganizationsIcon from 'assets/svgs/organizations.svg?react'; +import PeopleIcon from 'assets/svgs/people.svg?react'; +import TagsIcon from 'assets/svgs/tags.svg?react'; +import TagIcon from 'assets/svgs/tag.svg?react'; +import PluginsIcon from 'assets/svgs/plugins.svg?react'; +import PostsIcon from 'assets/svgs/posts.svg?react'; +import SettingsIcon from 'assets/svgs/settings.svg?react'; +import VenueIcon from 'assets/svgs/venues.svg?react'; +import RequestsIcon from 'assets/svgs/requests.svg?react'; +import { MdOutlineVolunteerActivism } from 'react-icons/md'; + +import React from 'react'; + +export interface InterfaceIconComponent { + name: string; + fill?: string; + height?: string; + width?: string; +} +/** + * Renders an icon based on the provided name. + * + * @param props - Contains the name of the icon and optional styles (fill, height, width). + * @returns JSX element representing the icon. + */ +const iconComponent = (props: InterfaceIconComponent): JSX.Element => { + switch (props.name) { + case 'My Organizations': + return ( + + ); + case 'Dashboard': + return ( + + ); + case 'People': + return ; + case 'Tags': + return ; + case 'Tag': + return ; + case 'Requests': + return ( + + ); + case 'Events': + return ; + case 'Action Items': + return ( + + ); + case 'Posts': + return ; + case 'Block/Unblock': + return ( + + ); + case 'Plugins': + return ( + + ); + case 'Settings': + return ( + + ); + case 'List Event Registrants': + return ( + + ); + case 'Check In Registrants': + return ( + + ); + case 'Advertisement': + return ( + + ); + case 'Funds': + return ( + + ); + case 'Venues': + return ( + + ); + case 'Donate': + return ( + + ); + case 'Campaigns': + return ( + + ); + case 'My Pledges': + return ( + + ); + case 'Volunteer': + return ( + + ); + default: + return ( + + ); + } +}; + +export default iconComponent; diff --git a/src/components/InfiniteScrollLoader/InfiniteScrollLoader.module.css b/src/components/InfiniteScrollLoader/InfiniteScrollLoader.module.css new file mode 100644 index 0000000000..a5b609ae75 --- /dev/null +++ b/src/components/InfiniteScrollLoader/InfiniteScrollLoader.module.css @@ -0,0 +1,23 @@ +.simpleLoader { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; +} + +.spinner { + width: 2rem; + height: 2rem; + margin: 1rem 0; + border: 4px solid transparent; + border-top-color: var(--bs-gray-400); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/components/InfiniteScrollLoader/InfiniteScrollLoader.test.tsx b/src/components/InfiniteScrollLoader/InfiniteScrollLoader.test.tsx new file mode 100644 index 0000000000..1e179e0de0 --- /dev/null +++ b/src/components/InfiniteScrollLoader/InfiniteScrollLoader.test.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import InfiniteScrollLoader from './InfiniteScrollLoader'; + +describe('Testing InfiniteScrollLoader component', () => { + test('Component should be rendered properly', () => { + render(); + + expect(screen.getByTestId('infiniteScrollLoader')).toBeInTheDocument(); + expect( + screen.getByTestId('infiniteScrollLoaderSpinner'), + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/InfiniteScrollLoader/InfiniteScrollLoader.tsx b/src/components/InfiniteScrollLoader/InfiniteScrollLoader.tsx new file mode 100644 index 0000000000..7846889cdb --- /dev/null +++ b/src/components/InfiniteScrollLoader/InfiniteScrollLoader.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import styles from './InfiniteScrollLoader.module.css'; + +/** + * A Loader for infinite scroll. + */ + +const InfiniteScrollLoader = (): JSX.Element => { + return ( +
    +
    +
    + ); +}; + +export default InfiniteScrollLoader; diff --git a/src/components/LeftDrawer/LeftDrawer.module.css b/src/components/LeftDrawer/LeftDrawer.module.css new file mode 100644 index 0000000000..86948b9930 --- /dev/null +++ b/src/components/LeftDrawer/LeftDrawer.module.css @@ -0,0 +1,239 @@ +.leftDrawer { + width: calc(300px + 2rem); + position: fixed; + top: 0; + bottom: 0; + z-index: 100; + display: flex; + flex-direction: column; + padding: 1rem 1rem 0 1rem; + background-color: #f6f8fc; + transition: 0.5s; + font-family: var(--bs-leftDrawer-font-family); +} + +.activeDrawer { + width: calc(300px + 2rem); + position: fixed; + top: 0; + left: 0; + bottom: 0; + animation: comeToRightBigScreen 0.5s ease-in-out; +} + +.inactiveDrawer { + position: fixed; + top: 0; + left: calc(-300px - 2rem); + bottom: 0; + animation: goToLeftBigScreen 0.5s ease-in-out; +} + +.leftDrawer .talawaLogo { + width: 100%; + height: 65px; +} + +.leftDrawer .talawaText { + font-size: 20px; + text-align: center; + font-weight: 500; +} + +.leftDrawer .titleHeader { + margin: 2rem 0 1rem 0; + font-weight: 600; +} + +.leftDrawer .optionList button { + display: flex; + align-items: center; + width: 100%; + text-align: start; + margin-bottom: 0.8rem; + border-radius: 16px; + outline: none; + border: none; +} + +.leftDrawer .optionList button .iconWrapper { + width: 36px; +} + +.leftDrawer .profileContainer { + border: none; + width: 100%; + padding: 2.1rem 0.5rem; + height: 52px; + display: flex; + align-items: center; + background-color: var(--bs-white); +} + +.leftDrawer .profileContainer:focus { + outline: none; + background-color: var(--bs-gray-100); +} + +.leftDrawer .imageContainer { + width: 68px; +} + +.leftDrawer .profileContainer img { + height: 52px; + width: 52px; + border-radius: 50%; +} + +.leftDrawer .profileContainer .profileText { + flex: 1; + text-align: start; +} + +.leftDrawer .profileContainer .profileText .primaryText { + font-size: 1.1rem; + font-weight: 600; +} + +.leftDrawer .profileContainer .profileText .secondaryText { + font-size: 0.8rem; + font-weight: 400; + color: var(--bs-secondary); + display: block; + text-transform: capitalize; +} + +@media (max-width: 1120px) { + .leftDrawer { + width: calc(250px + 2rem); + padding: 1rem 1rem 0 1rem; + } +} + +/* For tablets */ +@media (max-width: 820px) { + .hideElemByDefault { + display: none; + } + + .leftDrawer { + width: 100%; + left: 0; + right: 0; + } + + .inactiveDrawer { + opacity: 0; + left: 0; + z-index: -1; + animation: closeDrawer 0.4s ease-in-out; + } + + .activeDrawer { + display: flex; + z-index: 100; + animation: openDrawer 0.6s ease-in-out; + } + + .logout { + margin-bottom: 2.5rem !important; + } +} + +@keyframes goToLeftBigScreen { + from { + left: 0; + } + + to { + opacity: 0.1; + left: calc(-300px - 2rem); + } +} + +/* Webkit prefix for older browser compatibility */ +@-webkit-keyframes goToLeftBigScreen { + from { + left: 0; + } + + to { + opacity: 0.1; + left: calc(-300px - 2rem); + } +} + +@keyframes comeToRightBigScreen { + from { + opacity: 0.4; + left: calc(-300px - 2rem); + } + + to { + opacity: 1; + left: 0; + } +} + +/* Webkit prefix for older browser compatibility */ +@-webkit-keyframes comeToRightBigScreen { + from { + opacity: 0.4; + left: calc(-300px - 2rem); + } + + to { + opacity: 1; + left: 0; + } +} + +@keyframes closeDrawer { + from { + left: 0; + opacity: 1; + } + + to { + left: -1000px; + opacity: 0; + } +} + +/* Webkit prefix for older browser compatibility */ +@-webkit-keyframes closeDrawer { + from { + left: 0; + opacity: 1; + } + + to { + left: -1000px; + opacity: 0; + } +} + +@keyframes openDrawer { + from { + opacity: 0; + left: -1000px; + } + + to { + left: 0; + opacity: 1; + } +} + +/* Webkit prefix for older browser compatibility */ +@-webkit-keyframes openDrawer { + from { + opacity: 0; + left: -1000px; + } + + to { + left: 0; + opacity: 1; + } +} diff --git a/src/components/LeftDrawer/LeftDrawer.test.tsx b/src/components/LeftDrawer/LeftDrawer.test.tsx new file mode 100644 index 0000000000..6701d2d4bb --- /dev/null +++ b/src/components/LeftDrawer/LeftDrawer.test.tsx @@ -0,0 +1,226 @@ +import React, { act } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import 'jest-localstorage-mock'; +import { I18nextProvider } from 'react-i18next'; +import { BrowserRouter } from 'react-router-dom'; + +import i18nForTest from 'utils/i18nForTest'; +import type { InterfaceLeftDrawerProps } from './LeftDrawer'; +import LeftDrawer from './LeftDrawer'; +import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { MockedProvider } from '@apollo/react-testing'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem } = useLocalStorage(); + +const props = { + hideDrawer: true, + setHideDrawer: jest.fn(), +}; + +const resizeWindow = (width: number): void => { + window.innerWidth = width; + fireEvent(window, new Event('resize')); +}; + +const propsOrg: InterfaceLeftDrawerProps = { + ...props, +}; + +const MOCKS = [ + { + request: { + query: REVOKE_REFRESH_TOKEN, + }, + result: {}, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +beforeEach(() => { + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + setItem( + 'UserImage', + 'https://api.dicebear.com/5.x/initials/svg?seed=John%20Doe', + ); +}); + +afterEach(() => { + jest.clearAllMocks(); + localStorage.clear(); +}); + +describe('Testing Left Drawer component for SUPERADMIN', () => { + test('Component should be rendered properly', async () => { + setItem('UserImage', ''); + setItem('SuperAdmin', true); + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + + await act(async () => { + render( + + + + + + + , + ); + }); + + expect(screen.getByText('My Organizations')).toBeInTheDocument(); + expect(screen.getByText('Users')).toBeInTheDocument(); + expect(screen.getByText('Community Profile')).toBeInTheDocument(); + expect(screen.getByText('Talawa Admin Portal')).toBeInTheDocument(); + + const orgsBtn = screen.getByTestId(/orgsBtn/i); + const rolesBtn = screen.getByTestId(/rolesBtn/i); + const communityProfileBtn = screen.getByTestId(/communityProfileBtn/i); + + await act(async () => { + orgsBtn.click(); + }); + + expect( + orgsBtn.className.includes('text-white btn btn-success'), + ).toBeTruthy(); + expect(rolesBtn.className.includes('text-secondary btn')).toBeTruthy(); + expect( + communityProfileBtn.className.includes('text-secondary btn'), + ).toBeTruthy(); + + await act(async () => { + userEvent.click(rolesBtn); + }); + + expect(global.window.location.pathname).toContain('/users'); + + await act(async () => { + userEvent.click(communityProfileBtn); + }); + }); + + test('Testing Drawer when hideDrawer is null', async () => { + const tempProps: InterfaceLeftDrawerProps = { + ...props, + hideDrawer: false, + }; + + await act(async () => { + render( + + + + + + + , + ); + }); + }); + + test('Testing Drawer when hideDrawer is false', async () => { + const tempProps: InterfaceLeftDrawerProps = { + ...props, + hideDrawer: false, + }; + + await act(async () => { + render( + + + + + + + , + ); + }); + }); + + test('Testing Drawer when the screen size is less than or equal to 820px', async () => { + const tempProps: InterfaceLeftDrawerProps = { + ...props, + hideDrawer: false, + }; + resizeWindow(800); + + await act(async () => { + render( + + + + + + + , + ); + }); + + expect(screen.getByText('My Organizations')).toBeInTheDocument(); + expect(screen.getByText('Talawa Admin Portal')).toBeInTheDocument(); + + const orgsBtn = screen.getByTestId(/orgsBtn/i); + + await act(async () => { + orgsBtn.click(); + }); + + expect( + orgsBtn.className.includes('text-white btn btn-success'), + ).toBeTruthy(); + }); +}); + +describe('Testing Left Drawer component for ADMIN', () => { + test('Components should be rendered properly', async () => { + await act(async () => { + render( + + + + + + + , + ); + }); + + expect(screen.getByText('My Organizations')).toBeInTheDocument(); + expect(screen.getByText('Talawa Admin Portal')).toBeInTheDocument(); + + expect(screen.getAllByText(/admin/i)).toHaveLength(1); + + const orgsBtn = screen.getByTestId(/orgsBtn/i); + + await act(async () => { + orgsBtn.click(); + }); + + expect( + orgsBtn.className.includes('text-white btn btn-success'), + ).toBeTruthy(); + + // These screens aren't meant for admins, so they should not be present + expect(screen.queryByTestId(/rolesBtn/i)).toBeNull(); + + await act(async () => { + userEvent.click(orgsBtn); + }); + + expect(global.window.location.pathname).toContain('/orglist'); + }); +}); diff --git a/src/components/LeftDrawer/LeftDrawer.tsx b/src/components/LeftDrawer/LeftDrawer.tsx new file mode 100644 index 0000000000..4628603ba1 --- /dev/null +++ b/src/components/LeftDrawer/LeftDrawer.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import Button from 'react-bootstrap/Button'; +import { useTranslation } from 'react-i18next'; +import { NavLink } from 'react-router-dom'; +import OrganizationsIcon from 'assets/svgs/organizations.svg?react'; +import RolesIcon from 'assets/svgs/roles.svg?react'; +import SettingsIcon from 'assets/svgs/settings.svg?react'; +import TalawaLogo from 'assets/svgs/talawa.svg?react'; +import styles from './LeftDrawer.module.css'; +import useLocalStorage from 'utils/useLocalstorage'; + +export interface InterfaceLeftDrawerProps { + hideDrawer: boolean | null; // Controls the visibility of the drawer + setHideDrawer: React.Dispatch>; // Function to set the visibility state +} + +/** + * LeftDrawer component for displaying navigation options. + * + * @param hideDrawer - Determines if the drawer should be hidden or shown. + * @param setHideDrawer - Function to update the visibility state of the drawer. + * @returns JSX element for the left navigation drawer. + */ +const leftDrawer = ({ + hideDrawer, + setHideDrawer, +}: InterfaceLeftDrawerProps): JSX.Element => { + const { t } = useTranslation('translation', { keyPrefix: 'leftDrawer' }); + const { t: tCommon } = useTranslation('common'); + + const { getItem } = useLocalStorage(); + const superAdmin = getItem('SuperAdmin'); + + /** + * Handles link click to hide the drawer on smaller screens. + */ + const handleLinkClick = (): void => { + if (window.innerWidth <= 820) { + setHideDrawer(true); + } + }; + + return ( + <> +
    + +

    {tCommon('talawaAdminPortal')}

    +
    + {tCommon('menu')} +
    +
    + + {({ isActive }) => ( + + )} + + {superAdmin && ( + <> + + {({ isActive }) => ( + + )} + + + {({ isActive }) => ( + + )} + + + )} +
    +
    + + ); +}; + +export default leftDrawer; diff --git a/src/components/LeftDrawerOrg/LeftDrawerOrg.module.css b/src/components/LeftDrawerOrg/LeftDrawerOrg.module.css new file mode 100644 index 0000000000..6296b1aa73 --- /dev/null +++ b/src/components/LeftDrawerOrg/LeftDrawerOrg.module.css @@ -0,0 +1,362 @@ +.leftDrawer { + width: calc(300px + 2rem); + min-height: 100%; + position: fixed; + top: 0; + bottom: 0; + z-index: 100; + display: flex; + flex-direction: column; + padding: 0.8rem 0rem 0 1rem; + background-color: #f6f8fc; + transition: 0.5s; + font-family: var(--bs-leftDrawer-font-family); +} + +.avatarContainer { + display: flex; + justify-content: center; + align-items: center; + margin: 1rem 0; + width: 75%; + height: 75%; +} + +.activeDrawer { + width: calc(300px + 2rem); + position: fixed; + top: 0; + left: 0; + bottom: 0; + animation: comeToRightBigScreen 0.5s ease-in-out; +} + +.inactiveDrawer { + position: fixed; + top: 0; + left: calc(-300px - 2rem); + bottom: 0; + animation: goToLeftBigScreen 0.5s ease-in-out; +} + +.leftDrawer .brandingContainer { + display: flex; + justify-content: flex-start; + align-items: center; +} + +.leftDrawer .organizationContainer button { + position: relative; + margin: 0.7rem 0; + padding: 2.5rem 0.1rem; + border-radius: 16px; +} + +.leftDrawer .talawaLogo { + width: 50px; + height: 50px; + margin-right: 0.3rem; +} + +.leftDrawer .talawaText { + font-size: 18px; + font-weight: 500; +} + +.leftDrawer .titleHeader { + font-weight: 600; + margin: 0.6rem 0rem; +} + +.leftDrawer .optionList { + height: 100%; + overflow-y: scroll; + padding-bottom: 1.5rem; +} + +.leftDrawer .optionList button { + display: flex; + align-items: center; + width: 100%; + text-align: start; + margin-bottom: 0.8rem; + border-radius: 16px; + font-size: 16px; + padding: 0.6rem; + padding-left: 0.8rem; + outline: none; + border: none; +} + +.leftDrawer button .iconWrapper { + width: 36px; +} + +.leftDrawer .optionList .collapseBtn { + height: 48px; + border: none; +} + +.leftDrawer button .iconWrapperSm { + width: 36px; + display: flex; + justify-content: center; + align-items: center; +} + +.leftDrawer .organizationContainer .profileContainer { + background-color: #e0e9ff; + padding-right: 10px; +} + +.leftDrawer .profileContainer { + border: none; + width: 100%; + height: 52px; + border-radius: 16px; + display: flex; + align-items: center; + background-color: var(--bs-white); +} + +.leftDrawer .profileContainer:focus { + outline: none; +} + +.leftDrawer .imageContainer { + width: 68px; + margin-left: 0.75rem; + margin-right: 8px; +} + +.leftDrawer .profileContainer img { + height: 52px; + width: 52px; + border-radius: 50%; +} + +.leftDrawer .profileContainer .profileText { + flex: 1; + text-align: start; + overflow: hidden; +} + +.leftDrawer .profileContainer .profileText .primaryText { + font-size: 1.1rem; + font-weight: 600; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; /* number of lines to show */ + -webkit-box-orient: vertical; + word-wrap: break-word; + white-space: normal; +} + +.leftDrawer .profileContainer .profileText .secondaryText { + font-size: 0.8rem; + font-weight: 400; + /* color: var(--bs-secondary); */ + display: block; + text-transform: capitalize; +} + +@media (max-width: 1120px) { + .leftDrawer { + width: calc(250px + 2rem); + padding: 1rem 0rem 0 1rem; + } +} + +/* For tablets */ +@media (max-height: 900px) { + .leftDrawer { + width: calc(300px + 1rem); + } + .leftDrawer .talawaLogo { + width: 38px; + height: 38px; + margin-right: 0.4rem; + } + .leftDrawer .talawaText { + font-size: 1rem; + } + .leftDrawer .organizationContainer button { + margin: 0.6rem 0; + padding: 2.2rem 0.1rem; + } + .leftDrawer .optionList button { + margin-bottom: 0.05rem; + font-size: 16px; + padding-left: 0.8rem; + } + .leftDrawer .profileContainer .profileText .primaryText { + font-size: 1rem; + } + .leftDrawer .profileContainer .profileText .secondaryText { + font-size: 0.8rem; + } +} +@media (max-height: 650px) { + .leftDrawer { + padding: 0.5rem 0rem 0 0.8rem; + width: calc(250px); + } + .leftDrawer .talawaText { + font-size: 0.8rem; + } + .leftDrawer .organizationContainer button { + margin: 0.2rem 0; + padding: 1.6rem 0rem; + } + .leftDrawer .titleHeader { + font-size: 16px; + } + .leftDrawer .optionList button { + margin-bottom: 0.05rem; + font-size: 14px; + padding: 0.4rem; + padding-left: 0.8rem; + } + .leftDrawer .profileContainer .profileText .primaryText { + font-size: 0.8rem; + } + .leftDrawer .profileContainer .profileText .secondaryText { + font-size: 0.6rem; + } + .leftDrawer .imageContainer { + width: 40px; + margin-left: 0.75rem; + margin-right: 12px; + } + .leftDrawer .imageContainer img { + width: 40px; + height: 100%; + } +} + +@media (max-width: 820px) { + .hideElemByDefault { + display: none; + } + + .leftDrawer { + width: 100%; + left: 0; + right: 0; + } + + .inactiveDrawer { + opacity: 0; + left: 0; + z-index: -1; + animation: closeDrawer 0.2s ease-in-out; + } + + .activeDrawer { + display: flex; + z-index: 100; + animation: openDrawer 0.4s ease-in-out; + } + + .logout { + margin-bottom: 2.5rem; + } +} + +@keyframes goToLeftBigScreen { + from { + left: 0; + } + + to { + opacity: 0.1; + left: calc(-300px - 2rem); + } +} + +/* Webkit prefix for older browser compatibility */ +@-webkit-keyframes goToLeftBigScreen { + from { + left: 0; + } + + to { + opacity: 0.1; + left: calc(-300px - 2rem); + } +} + +@keyframes comeToRightBigScreen { + from { + opacity: 0.4; + left: calc(-300px - 2rem); + } + + to { + opacity: 1; + left: 0; + } +} + +/* Webkit prefix for older browser compatibility */ +@-webkit-keyframes comeToRightBigScreen { + from { + opacity: 0.4; + left: calc(-300px - 2rem); + } + + to { + opacity: 1; + left: 0; + } +} + +@keyframes closeDrawer { + from { + left: 0; + opacity: 1; + } + + to { + left: -1000px; + opacity: 0; + } +} + +/* Webkit prefix for older browser compatibility */ +@-webkit-keyframes closeDrawer { + from { + left: 0; + opacity: 1; + } + + to { + left: -1000px; + opacity: 0; + } +} + +@keyframes openDrawer { + from { + opacity: 0; + left: -1000px; + } + + to { + left: 0; + opacity: 1; + } +} + +/* Webkit prefix for older browser compatibility */ +@-webkit-keyframes openDrawer { + from { + opacity: 0; + left: -1000px; + } + + to { + left: 0; + opacity: 1; + } +} diff --git a/src/components/LeftDrawerOrg/LeftDrawerOrg.test.tsx b/src/components/LeftDrawerOrg/LeftDrawerOrg.test.tsx new file mode 100644 index 0000000000..71f3593499 --- /dev/null +++ b/src/components/LeftDrawerOrg/LeftDrawerOrg.test.tsx @@ -0,0 +1,474 @@ +import React, { act } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import 'jest-localstorage-mock'; +import { I18nextProvider } from 'react-i18next'; +import { BrowserRouter, MemoryRouter } from 'react-router-dom'; + +import i18nForTest from 'utils/i18nForTest'; +import type { InterfaceLeftDrawerProps } from './LeftDrawerOrg'; +import LeftDrawerOrg from './LeftDrawerOrg'; +import { Provider } from 'react-redux'; +import { MockedProvider } from '@apollo/react-testing'; +import { store } from 'state/store'; +import { ORGANIZATIONS_LIST } from 'GraphQl/Queries/Queries'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem } = useLocalStorage(); + +const props: InterfaceLeftDrawerProps = { + orgId: '123', + targets: [ + { + name: 'Admin Profile', + url: '/member/123', + }, + { + name: 'Dashboard', + url: '/orgdash/123', + }, + { + name: 'People', + url: '/orgpeople/123', + }, + { + name: 'Events', + url: '/orgevents/123', + }, + { + name: 'Posts', + url: '/orgpost/123', + }, + { + name: 'Block/Unblock', + url: '/blockuser/123', + }, + { + name: 'Plugins', + subTargets: [ + { + name: 'Plugin Store', + url: '/orgstore/123', + icon: 'fa-store', + }, + ], + }, + { + name: 'Settings', + url: '/orgsetting/123', + }, + { + name: 'All Organizations', + url: '/orglist/123', + }, + ], + hideDrawer: false, + setHideDrawer: jest.fn(), +}; + +const MOCKS = [ + { + request: { + query: REVOKE_REFRESH_TOKEN, + }, + result: { + data: { + revokeRefreshTokenForUser: true, + }, + }, + }, + { + request: { + query: ORGANIZATIONS_LIST, + variables: { id: '123' }, + }, + result: { + data: { + organizations: [ + { + _id: '123', + image: null, + creator: { + firstName: 'John', + lastName: 'Doe', + email: 'JohnDoe@example.com', + }, + name: 'Test Organization', + description: 'Testing this organization', + address: { + city: 'Delhi', + countryCode: 'IN', + dependentLocality: 'Some Dependent Locality', + line1: '123 Random Street', + line2: 'Apartment 456', + postalCode: '110001', + sortingCode: 'ABC-123', + state: 'Delhi', + }, + userRegistrationRequired: true, + visibleInSearch: true, + members: [ + { + _id: 'john123', + firstName: 'John', + lastName: 'Doe', + email: 'JohnDoe@example.com', + }, + { + _id: 'jane123', + firstName: 'Jane', + lastName: 'Doe', + email: 'JaneDoe@example.com', + }, + ], + admins: [ + { + _id: 'john123', + firstName: 'John', + lastName: 'Doe', + email: 'JohnDoe@example.com', + createdAt: '12-03-2024', + }, + ], + membershipRequests: [], + blockedUsers: [], + }, + ], + }, + }, + }, +]; + +const MOCKS_WITH_IMAGE = [ + { + request: { + query: ORGANIZATIONS_LIST, + variables: { id: '123' }, + }, + result: { + data: { + organizations: [ + { + _id: '123', + image: + 'https://api.dicebear.com/5.x/initials/svg?seed=Test%20Organization', + creator: { + firstName: 'John', + lastName: 'Doe', + email: 'JohnDoe@example.com', + }, + name: 'Test Organization', + description: 'Testing this organization', + address: { + city: 'Delhi', + countryCode: 'IN', + dependentLocality: 'Some Dependent Locality', + line1: '123 Random Street', + line2: 'Apartment 456', + postalCode: '110001', + sortingCode: 'ABC-123', + state: 'Delhi', + }, + userRegistrationRequired: true, + visibleInSearch: true, + members: [ + { + _id: 'john123', + firstName: 'John', + lastName: 'Doe', + email: 'JohnDoe@example.com', + }, + { + _id: 'jane123', + firstName: 'Jane', + lastName: 'Doe', + email: 'JaneDoe@example.com', + }, + ], + admins: [ + { + _id: 'john123', + firstName: 'John', + lastName: 'Doe', + email: 'JohnDoe@example.com', + createdAt: '12-03-2024', + }, + ], + membershipRequests: [], + blockedUsers: [], + }, + ], + }, + }, + }, +]; + +const MOCKS_EMPTY = [ + { + request: { + query: ORGANIZATIONS_LIST, + variables: { id: '123' }, + }, + result: { + data: { + organizations: [], + }, + }, + }, +]; + +const MOCKS_EMPTY_ORGID = [ + { + request: { + query: ORGANIZATIONS_LIST, + variables: { id: '' }, + }, + result: { + data: { + organizations: [], + }, + }, + }, +]; + +const defaultScreens = [ + 'Dashboard', + 'People', + 'Events', + 'Posts', + 'Block/Unblock', + 'Plugins', + 'Settings', + 'All Organizations', +]; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const resizeWindow = (width: number): void => { + window.innerWidth = width; + fireEvent(window, new Event('resize')); +}; + +beforeEach(() => { + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + setItem( + 'UserImage', + 'https://api.dicebear.com/5.x/initials/svg?seed=John%20Doe', + ); +}); + +afterEach(() => { + jest.clearAllMocks(); + localStorage.clear(); +}); + +const link = new StaticMockLink(MOCKS, true); +const linkImage = new StaticMockLink(MOCKS_WITH_IMAGE, true); +const linkEmpty = new StaticMockLink(MOCKS_EMPTY, true); +const linkEmptyOrgId = new StaticMockLink(MOCKS_EMPTY_ORGID, true); + +describe('Testing LeftDrawerOrg component for SUPERADMIN', () => { + test('Component should be rendered properly', async () => { + setItem('UserImage', ''); + setItem('SuperAdmin', true); + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + render( + + + + + + + + + , + ); + await wait(); + defaultScreens.map((screenName) => { + expect(screen.getByText(screenName)).toBeInTheDocument(); + }); + }); + + test('Testing Profile Page & Organization Detail Modal', async () => { + setItem('UserImage', ''); + setItem('SuperAdmin', true); + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + render( + + + + + + + + + , + ); + await wait(); + expect(screen.getByTestId(/orgBtn/i)).toBeInTheDocument(); + }); + + test('Should not show org not found error when viewing admin profile', async () => { + setItem('UserImage', ''); + setItem('SuperAdmin', true); + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + setItem('id', '123'); + render( + + + + + + + + + , + ); + await wait(); + expect( + screen.queryByText(/Error occured while loading Organization data/i), + ).not.toBeInTheDocument(); + }); + + test('Testing Menu Buttons', async () => { + setItem('UserImage', ''); + setItem('SuperAdmin', true); + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + render( + + + + + + + + + , + ); + await wait(); + userEvent.click(screen.getByText('Dashboard')); + expect(global.window.location.pathname).toContain('/orgdash/123'); + }); + + test('Testing when screen size is less than 820px', async () => { + setItem('SuperAdmin', true); + render( + + + + + + + + + , + ); + await wait(); + resizeWindow(800); + expect(screen.getAllByText(/Dashboard/i)[0]).toBeInTheDocument(); + expect(screen.getAllByText(/People/i)[0]).toBeInTheDocument(); + + const peopelBtn = screen.getByTestId(/People/i); + userEvent.click(peopelBtn); + await wait(); + expect(window.location.pathname).toContain('/orgpeople/123'); + }); + + test('Testing when image is present for Organization', async () => { + setItem('UserImage', ''); + setItem('SuperAdmin', true); + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + render( + + + + + + + + + , + ); + await wait(); + }); + + test('Testing when Organization does not exists', async () => { + setItem('UserImage', ''); + setItem('SuperAdmin', true); + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + render( + + + + + + + + + , + ); + await wait(); + expect( + screen.getByText(/Error occured while loading Organization data/i), + ).toBeInTheDocument(); + }); + + test('Testing Drawer when hideDrawer is null', () => { + setItem('UserImage', ''); + setItem('SuperAdmin', true); + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + render( + + + + + + + + + , + ); + }); + + test('Testing Drawer when hideDrawer is true', () => { + setItem('UserImage', ''); + setItem('SuperAdmin', true); + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + render( + + + + + + + + + , + ); + }); +}); diff --git a/src/components/LeftDrawerOrg/LeftDrawerOrg.tsx b/src/components/LeftDrawerOrg/LeftDrawerOrg.tsx new file mode 100644 index 0000000000..a687351c98 --- /dev/null +++ b/src/components/LeftDrawerOrg/LeftDrawerOrg.tsx @@ -0,0 +1,214 @@ +import { useQuery } from '@apollo/client'; +import { WarningAmberOutlined } from '@mui/icons-material'; +import { ORGANIZATIONS_LIST } from 'GraphQl/Queries/Queries'; +import CollapsibleDropdown from 'components/CollapsibleDropdown/CollapsibleDropdown'; +import IconComponent from 'components/IconComponent/IconComponent'; +import React, { useEffect, useMemo, useState } from 'react'; +import Button from 'react-bootstrap/Button'; +import { useTranslation } from 'react-i18next'; +import { NavLink, useLocation } from 'react-router-dom'; +import type { TargetsType } from 'state/reducers/routesReducer'; +import type { InterfaceQueryOrganizationsListObject } from 'utils/interfaces'; +import AngleRightIcon from 'assets/svgs/angleRight.svg?react'; +import TalawaLogo from 'assets/svgs/talawa.svg?react'; +import styles from './LeftDrawerOrg.module.css'; +import Avatar from 'components/Avatar/Avatar'; +import useLocalStorage from 'utils/useLocalstorage'; + +export interface InterfaceLeftDrawerProps { + orgId: string; + targets: TargetsType[]; + hideDrawer: boolean | null; + setHideDrawer: React.Dispatch>; +} + +/** + * LeftDrawerOrg component for displaying organization details and navigation options. + * + * @param orgId - ID of the current organization. + * @param targets - List of navigation targets. + * @param hideDrawer - Determines if the drawer should be hidden or shown. + * @param setHideDrawer - Function to update the visibility state of the drawer. + * @returns JSX element for the left navigation drawer with organization details. + */ +const leftDrawerOrg = ({ + targets, + orgId, + hideDrawer, + setHideDrawer, +}: InterfaceLeftDrawerProps): JSX.Element => { + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + const location = useLocation(); + const { getItem } = useLocalStorage(); + const userId = getItem('id'); + const getIdFromPath = (pathname: string): string => { + if (!pathname) return ''; + const segments = pathname.split('/'); + // Index 2 (third segment) represents the ID in paths like /member/{userId} + return segments.length > 2 ? segments[2] : ''; + }; + const [isProfilePage, setIsProfilePage] = useState(false); + const [showDropdown, setShowDropdown] = useState(false); + const [organization, setOrganization] = useState< + InterfaceQueryOrganizationsListObject | undefined + >(undefined); + const { + data, + loading, + }: { + data: + | { organizations: InterfaceQueryOrganizationsListObject[] } + | undefined; + loading: boolean; + } = useQuery(ORGANIZATIONS_LIST, { + variables: { id: orgId }, + }); + + // Get the ID from the current path + const pathId = useMemo( + () => getIdFromPath(location.pathname), + [location.pathname], + ); + // Check if the current page is admin profile page + useEffect(() => { + // if param id is equal to userId, then it is a profile page + setIsProfilePage(pathId === userId); + }, [location, userId]); + + // Set organization data when query data is available + useEffect(() => { + let isMounted = true; + if (data && isMounted) { + setOrganization(data?.organizations[0]); + } else { + setOrganization(undefined); + } + return () => { + isMounted = false; + }; + }, [data]); + + /** + * Handles link click to hide the drawer on smaller screens. + */ + const handleLinkClick = (): void => { + if (window.innerWidth <= 820) { + setHideDrawer(true); + } + }; + + return ( + <> +
    + {/* Branding Section */} +
    + + + {tCommon('talawaAdminPortal')} + +
    + + {/* Organization Section */} +
    + {loading ? ( + <> + + ) + ) : ( + + )} +
    + + {/* Options List */} +
    + {tCommon('menu')} +
    +
    + {targets.map(({ name, url }, index) => { + return url ? ( + + {({ isActive }) => ( + + )} + + ) : ( + + ); + })} +
    +
    + + ); +}; + +export default leftDrawerOrg; diff --git a/src/components/Loader/Loader.module.css b/src/components/Loader/Loader.module.css new file mode 100644 index 0000000000..aad512e826 --- /dev/null +++ b/src/components/Loader/Loader.module.css @@ -0,0 +1,25 @@ +.spinner_wrapper { + height: 100vh; + width: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +.spinnerXl { + width: 6rem; + height: 6rem; + border-width: 0.5rem; +} + +.spinnerLg { + height: 4rem; + width: 4rem; + border-width: 0.3rem; +} + +.spinnerSm { + height: 2rem; + width: 2rem; + border-width: 0.2rem; +} diff --git a/src/components/Loader/Loader.test.tsx b/src/components/Loader/Loader.test.tsx new file mode 100644 index 0000000000..c512b480e3 --- /dev/null +++ b/src/components/Loader/Loader.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import Loader from './Loader'; + +describe('Testing Loader component', () => { + test('Component should be rendered properly', () => { + render(); + + expect(screen.getByTestId('spinner-wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('spinner')).toBeInTheDocument(); + }); + + test('Component should render on custom sizes', () => { + render(); + + expect(screen.getByTestId('spinner-wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('spinner')).toBeInTheDocument(); + }); + + test('Component should render with large size', () => { + render(); + + expect(screen.getByTestId('spinner-wrapper')).toBeInTheDocument(); + expect(screen.getByTestId('spinner')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Loader/Loader.tsx b/src/components/Loader/Loader.tsx new file mode 100644 index 0000000000..b59c735175 --- /dev/null +++ b/src/components/Loader/Loader.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import styles from './Loader.module.css'; +import { Spinner } from 'react-bootstrap'; + +interface InterfaceLoaderProps { + styles?: StyleSheet | string; // Custom styles for the spinner wrapper + size?: 'sm' | 'lg' | 'xl'; // Size of the spinner +} + +/** + * Loader component for displaying a loading spinner. + * + * @param styles - Optional custom styles for the spinner wrapper. + * @param size - Size of the spinner. Can be 'sm', 'lg', or 'xl'. + * @returns JSX element for a loading spinner. + */ +const Loader = (props: InterfaceLoaderProps): JSX.Element => { + return ( + <> +
    + +
    + + ); +}; + +export default Loader; diff --git a/src/components/LoginPortalToggle/LoginPortalToggle.module.css b/src/components/LoginPortalToggle/LoginPortalToggle.module.css new file mode 100644 index 0000000000..5983e20905 --- /dev/null +++ b/src/components/LoginPortalToggle/LoginPortalToggle.module.css @@ -0,0 +1,34 @@ +.navLinkClass { + display: inline-block; + padding: 0.375rem 0.75rem; + font-size: 1rem; + line-height: 1.4; + text-align: center; + vertical-align: middle; + cursor: pointer; + color: var(--bs-gray-900); + border-radius: 0.3rem; + width: 100%; + box-sizing: border-box; + border: 1px solid var(--bs-gray-700); + font-weight: 500; + transition: all 0.25s ease; +} + +.navLinkClass:hover { + color: var(--bs-white); + background-color: var(--bs-gray); + border-color: var(--bs-gray); +} + +.activeLink { + color: var(--bs-white); + border: 1px solid var(--bs-primary); + background-color: var(--bs-primary); +} + +.activeLink:hover { + color: var(--bs-white); + background-color: var(--bs-primary); + border: 1px solid var(--bs-primary); +} diff --git a/src/components/LoginPortalToggle/LoginPortalToggle.test.tsx b/src/components/LoginPortalToggle/LoginPortalToggle.test.tsx new file mode 100644 index 0000000000..327e6cccea --- /dev/null +++ b/src/components/LoginPortalToggle/LoginPortalToggle.test.tsx @@ -0,0 +1,33 @@ +import React, { act } from 'react'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { I18nextProvider } from 'react-i18next'; +import LoginPortalToggle from './LoginPortalToggle'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Testing LoginPortalToggle component', () => { + test('Component Should be rendered properly', async () => { + const mockOnToggle = jest.fn(); + render( + + + + + + + , + ); + + await wait(); + }); +}); diff --git a/src/components/LoginPortalToggle/LoginPortalToggle.tsx b/src/components/LoginPortalToggle/LoginPortalToggle.tsx new file mode 100644 index 0000000000..76305b02d3 --- /dev/null +++ b/src/components/LoginPortalToggle/LoginPortalToggle.tsx @@ -0,0 +1,58 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './LoginPortalToggle.module.css'; +import Col from 'react-bootstrap/Col'; +import Row from 'react-bootstrap/Row'; +import { NavLink } from 'react-router-dom'; + +interface InterfaceLoginPortalToggleProps { + onToggle: (role: 'admin' | 'user') => void; // Callback function for role change +} + +/** + * Component for toggling between admin and user login portals. + * + * @param onToggle - Callback function to handle role changes ('admin' or 'user'). + * @returns JSX element for login portal toggle. + */ +function loginPortalToggle({ + onToggle, +}: InterfaceLoginPortalToggleProps): JSX.Element { + const { t: tCommon } = useTranslation('common'); + const [activeRole, setActiveRole] = useState<'admin' | 'user'>('admin'); // Default role is 'admin' + + /** + * Handles navigation link click and updates the active role. + * + * @param role - The role to be activated ('admin' or 'user'). + */ + const handleNavLinkClick = (role: 'admin' | 'user'): void => { + onToggle(role); // Invoke the callback with the new role + setActiveRole(role); // Update the active role + }; + + return ( + + + handleNavLinkClick('admin')} + > + {tCommon('admin')} + + + + handleNavLinkClick('user')} + > + {tCommon('user')} + + + + ); +} + +export default loginPortalToggle; diff --git a/src/components/MemberDetail/EventsAttendedByMember.test.tsx b/src/components/MemberDetail/EventsAttendedByMember.test.tsx new file mode 100644 index 0000000000..23ae0efa3b --- /dev/null +++ b/src/components/MemberDetail/EventsAttendedByMember.test.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/client/testing'; +import { EVENT_DETAILS } from 'GraphQl/Queries/Queries'; +import EventsAttendedByMember from './EventsAttendedByMember'; +import { BrowserRouter } from 'react-router-dom'; + +const mockEventData = { + event: { + _id: 'event123', + title: 'Test Event', + description: 'Test Description', + startDate: '2023-01-01', + endDate: '2023-01-02', + startTime: '09:00', + endTime: '17:00', + allDay: false, + location: 'Test Location', + recurring: true, + baseRecurringEvent: { _id: 'base123' }, + organization: { + _id: 'org123', + members: [ + { _id: 'user1', firstName: 'John', lastName: 'Doe' }, + { _id: 'user2', firstName: 'Jane', lastName: 'Smith' }, + ], + }, + attendees: [ + { _id: 'user1', gender: 'MALE' }, + { _id: 'user2', gender: 'FEMALE' }, + ], + }, +}; + +const mocks = [ + { + request: { + query: EVENT_DETAILS, + variables: { id: 'event123' }, + }, + result: { + data: mockEventData, + }, + }, +]; + +const errorMocks = [ + { + request: { + query: EVENT_DETAILS, + variables: { id: 'event123' }, + }, + error: new Error('An error occurred'), + }, +]; + +describe('EventsAttendedByMember', () => { + test('renders loading state initially', () => { + render( + + + + + , + ); + + expect(screen.getByTestId('loading')).toBeInTheDocument(); + expect(screen.getByText('Loading event details...')).toBeInTheDocument(); + }); + + test('renders error state when query fails', async () => { + render( + + + + + , + ); + + const errorMessage = await screen.findByTestId('error'); + expect(errorMessage).toBeInTheDocument(); + expect( + screen.getByText('Unable to load event details. Please try again later.'), + ).toBeInTheDocument(); + }); + + test('renders event card with correct data when query succeeds', async () => { + render( + + + + + , + ); + + await screen.findByTestId('EventsAttendedCard'); + + expect(screen.getByText('Test Event')).toBeInTheDocument(); + expect(screen.getByText('Test Location')).toBeInTheDocument(); + }); +}); diff --git a/src/components/MemberDetail/EventsAttendedByMember.tsx b/src/components/MemberDetail/EventsAttendedByMember.tsx new file mode 100644 index 0000000000..ce926d84eb --- /dev/null +++ b/src/components/MemberDetail/EventsAttendedByMember.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { useQuery } from '@apollo/client'; +import { EVENT_DETAILS } from 'GraphQl/Queries/Queries'; +import EventAttendedCard from './EventsAttendedCardItem'; +import { Spinner } from 'react-bootstrap'; +/** + * Component to display events attended by a specific member + * @param eventsId - ID of the event to fetch and display details for + * @returns Event card component with event details + */ +interface InterfaceEventsAttendedByMember { + eventsId: string; +} + +function EventsAttendedByMember({ + eventsId, +}: InterfaceEventsAttendedByMember): JSX.Element { + const { + data: events, + loading, + error, + } = useQuery(EVENT_DETAILS, { + variables: { id: eventsId }, + }); + + if (loading) + return ( +
    + +

    Loading event details...

    +
    + ); + if (error) + return ( +
    +

    Unable to load event details. Please try again later.

    +
    + ); + + const { organization, _id, startDate, title, location } = events.event; + + return ( + + ); +} + +export default EventsAttendedByMember; diff --git a/src/components/MemberDetail/EventsAttendedCardItem.test.tsx b/src/components/MemberDetail/EventsAttendedCardItem.test.tsx new file mode 100644 index 0000000000..afbb19eeea --- /dev/null +++ b/src/components/MemberDetail/EventsAttendedCardItem.test.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; +import EventAttendedCard from './EventsAttendedCardItem'; + +interface InterfaceEventAttendedCardProps { + type: 'Event'; + title: string; + startdate: string; + time: string; + location: string; + orgId: string; + eventId: string; +} + +describe('EventAttendedCard', () => { + const mockProps: InterfaceEventAttendedCardProps = { + type: 'Event' as const, + title: 'Test Event', + startdate: '2023-05-15', + time: '14:00', + location: 'Test Location', + orgId: 'org123', + eventId: 'event456', + }; + + const renderComponent = (props = mockProps): void => { + render( + + + , + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders event details correctly', () => { + renderComponent(); + + expect(screen.getByText('Test Event')).toBeInTheDocument(); + expect(screen.getByText('MAY')).toBeInTheDocument(); + expect(screen.getByText('15')).toBeInTheDocument(); + expect(screen.getByText('Test Location')).toBeInTheDocument(); + }); + + it('renders link with correct path', () => { + renderComponent(); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/event/org123/event456'); + }); + + it('renders location icon', () => { + renderComponent(); + expect(screen.getByTestId('LocationOnIcon')).toBeInTheDocument(); + }); + + it('renders chevron right icon', () => { + renderComponent(); + expect(screen.getByTestId('ChevronRightIcon')).toBeInTheDocument(); + }); +}); diff --git a/src/components/MemberDetail/EventsAttendedCardItem.tsx b/src/components/MemberDetail/EventsAttendedCardItem.tsx new file mode 100644 index 0000000000..cfed19e4dd --- /dev/null +++ b/src/components/MemberDetail/EventsAttendedCardItem.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import dayjs from 'dayjs'; +import { Card, Row, Col } from 'react-bootstrap'; +import { MdChevronRight, MdLocationOn } from 'react-icons/md'; +import { Link } from 'react-router-dom'; +/** + * Card component to display individual event attendance information + * Shows event details including title, date, location and organization + * @param orgId - Organization ID + * @param eventId - Event ID + * @param startdate - Event start date + * @param title - Event title + * @param location - Event location + * @returns Card component with formatted event information + */ +export interface InterfaceCardItem { + title: string; + time?: string; + startdate?: string; + creator?: string; + location?: string; + eventId?: string; + orgId?: string; +} + +const EventAttendedCard = (props: InterfaceCardItem): JSX.Element => { + const { title, startdate, location, orgId, eventId } = props; + + return ( + + + + +
    + {startdate && dayjs(startdate).isValid() ? ( + <> +
    + {dayjs(startdate).format('MMM').toUpperCase()} +
    +
    + {dayjs(startdate).format('D')} +
    + + ) : ( + /*istanbul ignore next*/ +
    Date N/A
    + )} +
    + + +
    {title}
    +

    + + {location} +

    + + + + + + +
    +
    +
    +
    + ); +}; + +export default EventAttendedCard; diff --git a/src/components/MemberDetail/EventsAttendedMemberModal.test.tsx b/src/components/MemberDetail/EventsAttendedMemberModal.test.tsx new file mode 100644 index 0000000000..ebdc3fff4c --- /dev/null +++ b/src/components/MemberDetail/EventsAttendedMemberModal.test.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MockedProvider } from '@apollo/client/testing'; +import { BrowserRouter } from 'react-router-dom'; +import EventsAttendedMemberModal from './EventsAttendedMemberModal'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { changeLanguage: () => Promise.resolve() }, + }), +})); + +jest.mock('./customTableCell', () => ({ + CustomTableCell: ({ eventId }: { eventId: string }) => ( + + {`Event ${eventId}`} + 2024-03-14 + Yes + 5 + + ), +})); + +const mockEvents = Array.from({ length: 6 }, (_, index) => ({ + _id: `${index + 1}`, + name: `Event ${index + 1}`, + date: '2024-03-14', + isRecurring: true, + attendees: 5, +})); + +describe('EventsAttendedMemberModal', () => { + const defaultProps = { + eventsAttended: mockEvents, + setShow: jest.fn(), + show: true, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('renders modal with correct title when show is true', () => { + render( + + + + + , + ); + + expect(screen.getByText('Events Attended List')).toBeInTheDocument(); + expect(screen.getByText('Showing 1 - 5 of 6 Events')).toBeInTheDocument(); + }); + + test('displays empty state message when no events', () => { + render( + + + + + , + ); + + expect(screen.getByText('noeventsAttended')).toBeInTheDocument(); + }); + + test('renders correct number of events per page', () => { + render( + + + + + , + ); + + const eventRows = screen.getAllByTestId('event-row'); + expect(eventRows).toHaveLength(5); + expect(screen.getByText('Event 1')).toBeInTheDocument(); + expect(screen.getByText('Event 5')).toBeInTheDocument(); + }); + + test('handles pagination correctly', () => { + render( + + + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Go to next page' })); + expect(screen.getByText('Event 6')).toBeInTheDocument(); + }); + + test('closes modal when close button is clicked', () => { + const mockSetShow = jest.fn(); + render( + + + + + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(mockSetShow).toHaveBeenCalledWith(false); + expect(mockSetShow).toHaveBeenCalledTimes(1); + }); + + test('displays correct pagination info', () => { + render( + + + + + , + ); + + expect(screen.getByText('Showing 1 - 5 of 6 Events')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Go to next page' })); + expect(screen.getByText('Showing 6 - 6 of 6 Events')).toBeInTheDocument(); + }); +}); diff --git a/src/components/MemberDetail/EventsAttendedMemberModal.tsx b/src/components/MemberDetail/EventsAttendedMemberModal.tsx new file mode 100644 index 0000000000..69913998b1 --- /dev/null +++ b/src/components/MemberDetail/EventsAttendedMemberModal.tsx @@ -0,0 +1,131 @@ +import React, { useState, useMemo } from 'react'; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + Pagination, +} from '@mui/material'; +import { Modal } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import styles from '../../screens/MemberDetail/MemberDetail.module.css'; +import { CustomTableCell } from './customTableCell'; +/** + * Modal component to display paginated list of events attended by a member + * @param eventsAttended - Array of events attended by the member + * @param setShow - Function to control modal visibility + * @param show - Boolean to control modal visibility + * @param eventsPerPage - Number of events to display per page + * @returns Modal component with paginated events list + */ +interface InterfaceEvent { + _id: string; + name: string; + date: string; + isRecurring: boolean; + attendees: number; +} + +interface InterfaceEventsAttendedMemberModalProps { + eventsAttended: InterfaceEvent[]; + setShow: (show: boolean) => void; + show: boolean; + eventsPerPage?: number; +} + +const EventsAttendedMemberModal: React.FC< + InterfaceEventsAttendedMemberModalProps +> = ({ eventsAttended, setShow, show, eventsPerPage = 5 }) => { + const { t } = useTranslation('translation', { + keyPrefix: 'memberDetail', + }); + const [page, setPage] = useState(1); + + const handleClose = (): void => { + setShow(false); + }; + + const handleChangePage = ( + event: React.ChangeEvent, + newPage: number, + ): void => { + setPage(newPage); + }; + + const totalPages = useMemo( + () => Math.ceil(eventsAttended.length / eventsPerPage), + [eventsAttended.length, eventsPerPage], + ); + + const paginatedEvents = useMemo( + () => + eventsAttended.slice((page - 1) * eventsPerPage, page * eventsPerPage), + [eventsAttended, page, eventsPerPage], + ); + + return ( + + + Events Attended List + + + {eventsAttended.length === 0 ? ( +

    {t('noeventsAttended')}

    + ) : ( + <> +
    + Showing {(page - 1) * eventsPerPage + 1} -{' '} + {Math.min(page * eventsPerPage, eventsAttended.length)} of{' '} + {eventsAttended.length} Events +
    + + + + + + Event Name + + + Date of Event + + + Recurring Event + + + Attendees + + + + + {paginatedEvents.map((event) => ( + + ))} + +
    +
    +
    +
    + { + if (type === 'page') return `Go to page ${page}`; + return `Go to ${type} page`; + }} + /> +
    +
    + + )} +
    +
    + ); +}; + +export default EventsAttendedMemberModal; diff --git a/src/components/MemberDetail/customTableCell.test.tsx b/src/components/MemberDetail/customTableCell.test.tsx new file mode 100644 index 0000000000..bc296a74f3 --- /dev/null +++ b/src/components/MemberDetail/customTableCell.test.tsx @@ -0,0 +1,171 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/client/testing'; +import { BrowserRouter } from 'react-router-dom'; +import { CustomTableCell } from './customTableCell'; +import { EVENT_DETAILS } from 'GraphQl/Queries/Queries'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const mocks = [ + { + request: { + query: EVENT_DETAILS, + variables: { id: 'event123' }, + }, + result: { + data: { + event: { + _id: 'event123', + title: 'Test Event', + description: 'This is a test event description', + startDate: '2023-05-01', + endDate: '2023-05-02', + startTime: '09:00:00', + endTime: '17:00:00', + allDay: false, + location: 'Test Location', + recurring: true, + baseRecurringEvent: { + _id: 'recurringEvent123', + }, + organization: { + _id: 'org456', + members: [ + { _id: 'member1', firstName: 'John', lastName: 'Doe' }, + { _id: 'member2', firstName: 'Jane', lastName: 'Smith' }, + ], + }, + attendees: [{ _id: 'user1' }, { _id: 'user2' }], + }, + }, + }, + }, +]; + +describe('CustomTableCell', () => { + it('renders event details correctly', async () => { + render( + + + + + + +
    +
    +
    , + ); + + await waitFor(() => screen.getByTestId('custom-row')); + + expect(screen.getByText('Test Event')).toBeInTheDocument(); + expect(screen.getByText('May 1, 2023')).toBeInTheDocument(); + expect(screen.getByText('Yes')).toBeInTheDocument(); + expect(screen.getByText('2')).toBeInTheDocument(); + + const link = screen.getByRole('link', { name: 'Test Event' }); + expect(link).toHaveAttribute('href', '/event/org456/event123'); + }); + + it('displays loading state', () => { + render( + + + + + +
    +
    , + ); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + // it('displays error state', async () => { + // const errorMock = [ + // { + // request: { + // query: EVENT_DETAILS, + // variables: { id: 'event123' }, + // }, + // error: new Error('An error occurred'), + // }, + // ]; + + // render( + // + // + // + // + // + //
    + //
    , + // ); + + // await waitFor( + // () => { + // expect( + // screen.getByText('Error loading event details'), + // ).toBeInTheDocument(); + // }, + // { timeout: 2000 }, + // ); + + // // Check if the error message from toast has been called + // expect(toast.error).toHaveBeenCalledWith('An error occurred'); + // }); + + // it('displays no event found message', async () => { + // const noEventMock = [ + // { + // request: { + // query: EVENT_DETAILS, + // variables: { id: 'event123' }, + // }, + // result: { + // data: { + // event: { + // _id: null, + // title: null, + // startDate: null, + // description: null, + // endDate: null, + // startTime: null, + // endTime: null, + // allDay: false, + // location: null, + // recurring: null, + // organization: { + // _id: null, + // members: [], + // }, + // baseRecurringEvent: { + // _id: 'recurringEvent123', + // }, + // attendees: [], + // }, + // }, + // }, + // }, + // ]; + + // render( + // + // + // + // + // + //
    + //
    , + // ); + + // await waitFor(() => screen.getByText('No event found')); + // expect(screen.getByText('No event found')).toBeInTheDocument(); + // }); +}); diff --git a/src/components/MemberDetail/customTableCell.tsx b/src/components/MemberDetail/customTableCell.tsx new file mode 100644 index 0000000000..b8cc2bdd98 --- /dev/null +++ b/src/components/MemberDetail/customTableCell.tsx @@ -0,0 +1,78 @@ +import { useQuery } from '@apollo/client'; +import { CircularProgress, TableCell, TableRow } from '@mui/material'; +import { EVENT_DETAILS } from 'GraphQl/Queries/Queries'; +import React from 'react'; +import styles from '../../screens/MemberDetail/MemberDetail.module.css'; +import { Link } from 'react-router-dom'; +/** + * Custom table cell component to display event details + * @param eventId - ID of the event to fetch and display + * @returns TableRow component with event information + */ + +export const CustomTableCell: React.FC<{ eventId: string }> = ({ eventId }) => { + const { data, loading, error } = useQuery(EVENT_DETAILS, { + variables: { + id: eventId, + }, + errorPolicy: 'all', + fetchPolicy: 'cache-first', + nextFetchPolicy: 'cache-and-network', + pollInterval: 30000, + }); + + if (loading) + return ( + + + + + + ); + /*istanbul ignore next*/ + if (error) { + return ( + + + {`Unable to load event details. Please try again later.`} + + + ); + } + const event = data?.event; + /*istanbul ignore next*/ + if (!event) { + return ( + + + Event not found or has been deleted + + + ); + } + + return ( + + + + {event.title} + + + + {new Date(event.startDate).toLocaleDateString(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + timeZone: 'UTC', + })} + + {event.recurring ? 'Yes' : 'No'} + + {event.attendees?.length ?? 0} + + + ); +}; diff --git a/src/components/MemberRequestCard/MemberRequestCard.module.css b/src/components/MemberRequestCard/MemberRequestCard.module.css new file mode 100644 index 0000000000..fdc99eba83 --- /dev/null +++ b/src/components/MemberRequestCard/MemberRequestCard.module.css @@ -0,0 +1,57 @@ +.memberlist { + margin-top: -1px; +} +.memberimg { + border-radius: 10px; + margin-left: 10px; +} +.singledetails { + display: flex; + flex-direction: row; + justify-content: space-between; +} +.singledetails p { + margin-bottom: -5px; +} +.singledetails_data_left { + margin-top: 10px; + margin-left: 10px; + color: #707070; +} +.singledetails_data_right { + justify-content: right; + margin-top: 10px; + text-align: right; + color: #707070; +} +.membername { + font-size: 16px; + font-weight: bold; +} +.memberfont { + margin-top: 3px; +} +.memberfont > span { + width: 80%; +} +.memberfontcreated { + margin-top: 18px; +} +.memberfontcreatedbtn { + margin-top: 33px; + border-radius: 7px; + border-color: #e8e5e5; + background-color: #f7f7f7; + padding-right: 10px; + padding-left: 10px; + justify-content: flex-end; + float: right; + text-align: right; + box-shadow: none; +} +#grid_wrapper { + align-items: left; +} +.peoplelistdiv { + margin-right: 50px; +} diff --git a/src/components/MemberRequestCard/MemberRequestCard.test.tsx b/src/components/MemberRequestCard/MemberRequestCard.test.tsx new file mode 100644 index 0000000000..a38a046ea2 --- /dev/null +++ b/src/components/MemberRequestCard/MemberRequestCard.test.tsx @@ -0,0 +1,122 @@ +import React, { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; + +import { + ACCEPT_ORGANIZATION_REQUEST_MUTATION, + REJECT_ORGANIZATION_REQUEST_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import MemberRequestCard from './MemberRequestCard'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +const MOCKS = [ + { + request: { + query: ACCEPT_ORGANIZATION_REQUEST_MUTATION, + variables: { id: '123' }, + }, + result: { + data: { + organizations: [ + { + _id: '1', + }, + ], + }, + }, + }, + { + request: { + query: REJECT_ORGANIZATION_REQUEST_MUTATION, + variables: { userid: '234' }, + }, + result: { + data: { + organizations: [ + { + _id: '2', + }, + ], + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Testing Member Request Card', () => { + const props = { + id: '1', + memberName: 'John Doe', + memberLocation: 'India', + joinDate: '18/03/2022', + memberImage: 'image', + email: 'johndoe@gmail.com', + }; + + global.alert = jest.fn(); + + it('should render props and text elements test for the page component', async () => { + global.confirm = (): boolean => true; + + render( + + + + + , + ); + + await wait(); + userEvent.click(screen.getByText(/Accept/i)); + userEvent.click(screen.getByText(/Reject/i)); + + expect(screen.getByAltText(/userImage/i)).toBeInTheDocument(); + expect(screen.getByText(/Joined:/i)).toBeInTheDocument(); + expect(screen.getByText(props.memberName)).toBeInTheDocument(); + expect(screen.getByText(props.memberLocation)).toBeInTheDocument(); + expect(screen.getByText(props.joinDate)).toBeInTheDocument(); + expect(screen.getByText(props.email)).toBeInTheDocument(); + }); + + it('should render text elements when props value is not passed', async () => { + global.confirm = (): boolean => false; + + render( + + + + + , + ); + + await wait(); + userEvent.click(screen.getByText(/Accept/i)); + userEvent.click(screen.getByText(/Reject/i)); + + expect(screen.getByAltText(/userImage/i)).toBeInTheDocument(); + expect(screen.getByText(/Joined:/i)).toBeInTheDocument(); + expect(screen.queryByText(props.memberName)).not.toBeInTheDocument(); + expect(screen.getByText(props.memberLocation)).toBeInTheDocument(); + expect(screen.getByText(props.joinDate)).toBeInTheDocument(); + expect(screen.getByText(props.email)).toBeInTheDocument(); + }); +}); diff --git a/src/components/MemberRequestCard/MemberRequestCard.tsx b/src/components/MemberRequestCard/MemberRequestCard.tsx new file mode 100644 index 0000000000..c45b698506 --- /dev/null +++ b/src/components/MemberRequestCard/MemberRequestCard.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import styles from './MemberRequestCard.module.css'; +import Row from 'react-bootstrap/Row'; +import Col from 'react-bootstrap/Col'; +import Button from 'react-bootstrap/Button'; +import { useMutation } from '@apollo/client'; +import { + ACCEPT_ORGANIZATION_REQUEST_MUTATION, + REJECT_ORGANIZATION_REQUEST_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import defaultImg from 'assets/images/blank.png'; +import { errorHandler } from 'utils/errorHandler'; + +interface InterfaceMemberRequestCardProps { + id: string; // Unique identifier for the member + memberName: string; // Name of the member + memberLocation: string; // Location of the member + joinDate: string; // Date when the member joined + memberImage: string; // URL for the member's image + email: string; // Email of the member +} + +/** + * Component for displaying and managing member requests. + * + * @param props - Properties for the member request card. + * @returns JSX element for member request card. + */ +function MemberRequestCard( + props: InterfaceMemberRequestCardProps, +): JSX.Element { + const [acceptMutation] = useMutation(ACCEPT_ORGANIZATION_REQUEST_MUTATION); + const [rejectMutation] = useMutation(REJECT_ORGANIZATION_REQUEST_MUTATION); + + const { t } = useTranslation('translation', { + keyPrefix: 'membershipRequest', + }); + const { t: tCommon } = useTranslation('common'); + + /** + * Handles accepting a member request. + * Displays a success message and reloads the page. + */ + const addMember = async (): Promise => { + try { + await acceptMutation({ + variables: { + id: props.id, + }, + }); + + /* istanbul ignore next */ + toast.success(t('memberAdded') as string); + /* istanbul ignore next */ + setTimeout(() => { + window.location.reload(); + }, 2000); + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }; + + /** + * Handles rejecting a member request. + * Confirms rejection and reloads the page if confirmed. + */ + const rejectMember = async (): Promise => { + const sure = window.confirm('Are you sure you want to Reject Request ?'); + if (sure) { + try { + await rejectMutation({ + variables: { + userid: props.id, + }, + }); + + /* istanbul ignore next */ + window.location.reload(); + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error); + } + } + }; + + return ( + <> +
    + + {props.memberImage ? ( + userImage + ) : ( + userImage + )} + +
    +

    + {props.memberName ? <>{props.memberName} : <>Dogs Care} +

    +

    {props.memberLocation}

    +

    {props.email}

    +
    +
    +

    + {tCommon('joined')}: {props.joinDate} +

    + + +
    + +
    +
    +
    + + ); +} +export default MemberRequestCard; diff --git a/src/components/NotFound/NotFound.module.css b/src/components/NotFound/NotFound.module.css new file mode 100644 index 0000000000..a209bc9f48 --- /dev/null +++ b/src/components/NotFound/NotFound.module.css @@ -0,0 +1,22 @@ +.section { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.error { + font-size: 1.2rem; + font-weight: 500; +} + +@media (min-width: 440px) and (max-width: 570px) { + .section { + margin-left: 50px; + } + .error { + font-size: 1.1rem; + font-style: oblique; + } +} diff --git a/src/components/NotFound/NotFound.test.tsx b/src/components/NotFound/NotFound.test.tsx new file mode 100644 index 0000000000..54c0bcfe4a --- /dev/null +++ b/src/components/NotFound/NotFound.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; + +import { render, screen } from '@testing-library/react'; +import NotFound from './NotFound'; + +describe('Tesing the NotFound Component', () => { + it('renders the component with the correct title for posts', () => { + render( + + + , + ); + expect(screen.getByText(/Not Found!/i)).toBeInTheDocument(); + }); + + it('renders the component with the correct title for users', () => { + render( + + + , + ); + expect(screen.getByText(/Not Found!/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/NotFound/NotFound.tsx b/src/components/NotFound/NotFound.tsx new file mode 100644 index 0000000000..6b5332bbcf --- /dev/null +++ b/src/components/NotFound/NotFound.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +import styles from './NotFound.module.css'; + +interface InterfaceNotFoundProps { + title: string; // Title of the page or resource not found + keyPrefix: string; // Translation key prefix +} + +/** + * Component to display a "Not Found" message. + * + * @param title - Title of the page or resource that was not found. + * @param keyPrefix - Prefix for translation keys. + * @returns JSX element for the "Not Found" page. + */ +function notFound(props: InterfaceNotFoundProps): JSX.Element { + const key = props.keyPrefix.toString(); + const { t } = useTranslation('translation', { + keyPrefix: key, + }); + return ( + <> +
    +

    {t(`${props.title} not found!`)}

    +
    + + ); +} + +export default notFound; diff --git a/src/components/OrgAdminListCard/OrgAdminListCard.test.tsx b/src/components/OrgAdminListCard/OrgAdminListCard.test.tsx new file mode 100644 index 0000000000..7baea946d2 --- /dev/null +++ b/src/components/OrgAdminListCard/OrgAdminListCard.test.tsx @@ -0,0 +1,108 @@ +import React, { act } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import type { RenderResult } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { REMOVE_ADMIN_MUTATION } from 'GraphQl/Mutations/mutations'; +import OrgAdminListCard from './OrgAdminListCard'; +import i18nForTest from 'utils/i18nForTest'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +const MOCKS = [ + { + request: { + query: REMOVE_ADMIN_MUTATION, + variables: { userid: '456', orgid: '987' }, + }, + result: { + data: { + removeAdmin: { + _id: '456', + }, + }, + }, + }, +]; +const link = new StaticMockLink(MOCKS, true); +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const renderOrgAdminListCard = (props: { + toggleRemoveModal: () => boolean; + id: string | undefined; +}): RenderResult => { + return render( + + + + + } + /> + orgListScreen
    } + /> + + + + , + ); +}; +jest.mock('i18next-browser-languagedetector', () => ({ + init: jest.fn(), + type: 'languageDetector', + detect: jest.fn(() => 'en'), + cacheUserLanguage: jest.fn(), +})); +describe('Testing Organization Admin List Card', () => { + global.alert = jest.fn(); + + beforeEach(() => { + Object.defineProperty(window, 'location', { + writable: true, + value: { reload: jest.fn() }, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should render props and text elements test for the page component', async () => { + const props = { + toggleRemoveModal: () => true, + id: '456', + }; + + renderOrgAdminListCard(props); + + await wait(); + + userEvent.click(screen.getByTestId(/removeAdminBtn/i)); + + await wait(2000); + }); + + test('Should not render text elements when props value is not passed', async () => { + const props = { + toggleRemoveModal: () => true, + id: undefined, + }; + + renderOrgAdminListCard(props); + + await waitFor(() => { + const orgListScreen = screen.getByTestId('orgListScreen'); + expect(orgListScreen).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/OrgAdminListCard/OrgAdminListCard.tsx b/src/components/OrgAdminListCard/OrgAdminListCard.tsx new file mode 100644 index 0000000000..f8fc454823 --- /dev/null +++ b/src/components/OrgAdminListCard/OrgAdminListCard.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import Button from 'react-bootstrap/Button'; +import Modal from 'react-bootstrap/Modal'; +import { useMutation } from '@apollo/client'; +import { toast } from 'react-toastify'; +import { REMOVE_ADMIN_MUTATION } from 'GraphQl/Mutations/mutations'; +import { useTranslation } from 'react-i18next'; +import { Navigate, useParams } from 'react-router-dom'; +import { errorHandler } from 'utils/errorHandler'; + +interface InterfaceOrgPeopleListCardProps { + id: string | undefined; + toggleRemoveModal: () => void; +} +/** + * Component to confirm and handle the removal of an admin. + * + * @param id - ID of the admin to be removed. + * @param toggleRemoveModal - Function to toggle the visibility of the modal. + * @returns JSX element for the removal confirmation modal. + */ +function orgAdminListCard(props: InterfaceOrgPeopleListCardProps): JSX.Element { + if (!props.id) { + return ; + } + const { orgId: currentUrl } = useParams(); + const [remove] = useMutation(REMOVE_ADMIN_MUTATION); + + const { t } = useTranslation('translation', { + keyPrefix: 'orgAdminListCard', + }); + const { t: tCommon } = useTranslation('common'); + + /** + * Function to remove the admin from the organization + * and display a success message. + */ + const removeAdmin = async (): Promise => { + try { + const { data } = await remove({ + variables: { + userid: props.id, + orgid: currentUrl, + }, + }); + if (data) { + toast.success(t('adminRemoved') as string); + setTimeout(() => { + window.location.reload(); + }, 2000); + } + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }; + return ( + <> + + +
    {t('removeAdmin')}
    + +
    + {t('removeAdminMsg')} + + + + +
    + + ); +} +export default orgAdminListCard; diff --git a/src/components/OrgContriCards/OrgContriCards.module.css b/src/components/OrgContriCards/OrgContriCards.module.css new file mode 100644 index 0000000000..d20b696621 --- /dev/null +++ b/src/components/OrgContriCards/OrgContriCards.module.css @@ -0,0 +1,22 @@ +.cards { + width: 45%; + background: #fcfcfc; + margin: 10px 20px; + padding: 20px 30px; + border-radius: 5px; + border: 1px solid #e8e8e8; + box-shadow: 0 3px 5px #c9c9c9; + margin-right: 40px; + color: #737373; +} +.cards > h2 { + font-size: 19px; +} +.cards > h3 { + font-size: 17px; +} +.cards > p { + font-size: 14px; + margin-top: -5px; + margin-bottom: 7px; +} diff --git a/src/components/OrgContriCards/OrgContriCards.test.tsx b/src/components/OrgContriCards/OrgContriCards.test.tsx new file mode 100644 index 0000000000..4f202cd355 --- /dev/null +++ b/src/components/OrgContriCards/OrgContriCards.test.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import type { NormalizedCacheObject } from '@apollo/client'; +import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client'; +import { I18nextProvider } from 'react-i18next'; + +import OrgContriCards from './OrgContriCards'; +import i18nForTest from 'utils/i18nForTest'; +import { BACKEND_URL } from 'Constant/constant'; + +const client: ApolloClient = new ApolloClient({ + cache: new InMemoryCache(), + uri: BACKEND_URL, +}); + +describe('Testing the Organization Contributions Cards', () => { + const props = { + key: '123', + id: '123', + userName: 'John Doe', + contriDate: '06/03/2022', + contriAmount: '500', + contriTransactionId: 'QW56DA88', + userEmail: 'johndoe@gmail.com', + }; + + it('should render props and text elements test for the page component', () => { + render( + + + + + , + ); + expect(screen.getByText('Date:')).toBeInTheDocument(); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('06/03/2022')).toBeInTheDocument(); + expect(screen.getByText('500')).toBeInTheDocument(); + expect(screen.getByText('QW56DA88')).toBeInTheDocument(); + expect(screen.getByText('johndoe@gmail.com')).toBeInTheDocument(); + }); +}); diff --git a/src/components/OrgContriCards/OrgContriCards.tsx b/src/components/OrgContriCards/OrgContriCards.tsx new file mode 100644 index 0000000000..6635be09b8 --- /dev/null +++ b/src/components/OrgContriCards/OrgContriCards.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import Row from 'react-bootstrap/Row'; +import Col from 'react-bootstrap/Col'; +import { useTranslation } from 'react-i18next'; + +import styles from './OrgContriCards.module.css'; + +/** + * Props for the OrgContriCards component + */ +interface InterfaceOrgContriCardsProps { + key: string; + id: string; + userName: string; + contriDate: string; + contriAmount: string; + contriTransactionId: string; + userEmail: string; +} + +/** + * Component to display organization contribution cards + * + * This component shows the contribution details of a user in a card format. It includes + * the user's name, email, contribution date, transaction ID, and the contribution amount. + * + * @param props - The properties passed to the component + * @returns JSX.Element representing a contribution card + */ +function orgContriCards(props: InterfaceOrgContriCardsProps): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'orgContriCards', + }); + + return ( + <> + + +

    {props.userName}

    +

    {props.userEmail}

    +

    + {t('date')}:{props.contriDate} +

    +

    + {t('transactionId')}: {props.contriTransactionId} +

    +

    + {t('amount')}: $ {props.contriAmount} +

    + +
    + + ); +} +export default orgContriCards; diff --git a/src/components/OrgDelete/OrgDelete.test.tsx b/src/components/OrgDelete/OrgDelete.test.tsx new file mode 100644 index 0000000000..b9b9ca2572 --- /dev/null +++ b/src/components/OrgDelete/OrgDelete.test.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import type { NormalizedCacheObject } from '@apollo/client'; +import { ApolloClient, ApolloProvider, InMemoryCache } from '@apollo/client'; +import { I18nextProvider } from 'react-i18next'; + +import OrgDelete from './OrgDelete'; +import i18nForTest from 'utils/i18nForTest'; +import { BACKEND_URL } from 'Constant/constant'; + +const client: ApolloClient = new ApolloClient({ + cache: new InMemoryCache(), + uri: BACKEND_URL, +}); + +describe('Testing Organization People List Card', () => { + test('should render props and text elements test for the page component', () => { + render( + + + + + , + ); + expect(screen.getByText('Delete Org')).toBeInTheDocument(); + }); +}); diff --git a/src/components/OrgDelete/OrgDelete.tsx b/src/components/OrgDelete/OrgDelete.tsx new file mode 100644 index 0000000000..e596445e5c --- /dev/null +++ b/src/components/OrgDelete/OrgDelete.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +/** + * Component for displaying organization deletion message + * + * This component renders a message related to deleting an organization. + * + * @returns JSX.Element representing the organization deletion message + */ +function orgDelete(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'orgDelete', + }); + + return ( + <> + {/* Container for the organization deletion message */} +
    + {t('deleteOrg')} +
    + + ); +} +export default orgDelete; diff --git a/src/components/OrgListCard/OrgListCard.module.css b/src/components/OrgListCard/OrgListCard.module.css new file mode 100644 index 0000000000..430ea318d6 --- /dev/null +++ b/src/components/OrgListCard/OrgListCard.module.css @@ -0,0 +1,141 @@ +.orgCard { + background-color: var(--bs-white); + margin: 0.5rem; + height: calc(120px + 2rem); + padding: 1rem; + border-radius: 8px; + outline: 1px solid var(--bs-gray-200); + position: relative; +} + +.orgCard .innerContainer { + display: flex; +} + +.orgCard .innerContainer .orgImgContainer { + display: flex; + justify-content: center; + align-items: center; + position: relative; + overflow: hidden; + border-radius: 4px; +} + +.orgCard .innerContainer .orgImgContainer { + width: 125px; + height: 120px; + object-fit: contain; +} + +.orgCard .innerContainer .orgImgContainer { + width: 125px; + height: 120px; + background-color: var(--bs-gray-200); +} + +.orgCard .innerContainer .content { + flex: 1; + margin-left: 1rem; + width: 70%; + margin-top: 0.7rem; +} + +.orgCard button { + position: absolute; + bottom: 1rem; + right: 1rem; + z-index: 1; +} + +.flaskIcon { + margin-top: 4px; +} + +.manageBtn { + display: flex; + justify-content: space-around; + width: 8rem; +} + +.orgName { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + font-size: 1rem; +} + +.orgdesc { + font-size: 0.9rem; + color: var(--bs-gray-600); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + line-clamp: 1; + -webkit-box-orient: vertical; + max-width: 20rem; +} + +.orgadmin { + font-size: 0.9rem; +} + +.orgmember { + font-size: 0.9rem; +} + +.address { + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + line-clamp: 1; + -webkit-box-orient: vertical; + align-items: center; +} + +.address h6 { + font-size: 0.9rem; + color: var(--bs-gray-600); +} + +@media (max-width: 580px) { + .orgCard { + height: unset; + margin: 0.5rem 0; + padding: 1.25rem 1.5rem; + } + + .orgCard .innerContainer { + flex-direction: column; + } + + .orgCard .innerContainer .orgImgContainer { + margin-bottom: 0.8rem; + } + + .orgCard .innerContainer .orgImgContainer img { + height: auto; + width: 100%; + } + + .orgCard .innerContainer .content { + margin-left: 0; + } + + .orgCard button { + bottom: 0; + right: 0; + position: relative; + margin-left: auto; + display: block; + } + + .flaskIcon { + margin-bottom: 6px; + } + + .manageBtn { + display: flex; + justify-content: space-around; + width: 100%; + } +} diff --git a/src/components/OrgListCard/OrgListCard.test.tsx b/src/components/OrgListCard/OrgListCard.test.tsx new file mode 100644 index 0000000000..4072265ea4 --- /dev/null +++ b/src/components/OrgListCard/OrgListCard.test.tsx @@ -0,0 +1,149 @@ +import React, { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; + +import i18nForTest from 'utils/i18nForTest'; +import type { InterfaceOrgListCardProps } from './OrgListCard'; +import OrgListCard from './OrgListCard'; +import userEvent from '@testing-library/user-event'; +import { BrowserRouter } from 'react-router-dom'; +import { IS_SAMPLE_ORGANIZATION_QUERY } from 'GraphQl/Queries/Queries'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { MockedProvider } from '@apollo/react-testing'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem, removeItem } = useLocalStorage(); + +const MOCKS = [ + { + request: { + query: IS_SAMPLE_ORGANIZATION_QUERY, + variables: { + isSampleOrganizationId: 'xyz', + }, + }, + result: { + data: { + isSampleOrganization: true, + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const props: InterfaceOrgListCardProps = { + data: { + _id: 'xyz', + name: 'Dogs Care', + image: 'https://api.dicebear.com/5.x/initials/svg?seed=John%20Doe', + address: { + city: 'Sample City', + countryCode: 'US', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Sample Street', + line2: 'Apartment 456', + postalCode: '12345', + sortingCode: 'ABC-123', + state: 'Sample State', + }, + admins: [ + { + _id: '123', + }, + { + _id: '456', + }, + ], + members: [], + createdAt: '04/07/2019', + creator: { + _id: 'abc', + firstName: 'John', + lastName: 'Doe', + }, + }, +}; + +describe('Testing the Super Dash List', () => { + test('should render props and text elements test for the page component', async () => { + removeItem('id'); + setItem('id', '123'); // Means the user is an admin + + render( + + + + + + + , + ); + await wait(); + expect(screen.getByAltText(/Dogs Care image/i)).toBeInTheDocument(); + expect(screen.getByText(/Admins:/i)).toBeInTheDocument(); + expect(screen.getByText(/Members:/i)).toBeInTheDocument(); + expect(screen.getByText('Dogs Care')).toBeInTheDocument(); + expect(screen.getByText(/Sample City/i)).toBeInTheDocument(); + expect(screen.getByText(/123 Sample Street/i)).toBeInTheDocument(); + expect(screen.getByTestId(/manageBtn/i)).toBeInTheDocument(); + expect(screen.getByTestId(/flaskIcon/i)).toBeInTheDocument(); + userEvent.click(screen.getByTestId(/manageBtn/i)); + removeItem('id'); + }); + + test('Testing if the props data is not provided', () => { + window.location.assign('/orgdash'); + + render( + + + + + + + , + ); + + expect(window.location).toBeAt('/orgdash'); + }); + + test('Testing if component is rendered properly when image is null', () => { + const imageNullProps = { + ...props, + ...{ data: { ...props.data, ...{ image: null } } }, + }; + render( + + + + + + + , + ); + expect(screen.getByTestId(/emptyContainerForImage/i)).toBeInTheDocument(); + }); + + test('Testing if user is redirected to orgDash screen', () => { + render( + + + + + + + , + ); + userEvent.click(screen.getByTestId('manageBtn')); + }); +}); diff --git a/src/components/OrgListCard/OrgListCard.tsx b/src/components/OrgListCard/OrgListCard.tsx new file mode 100644 index 0000000000..10365d2364 --- /dev/null +++ b/src/components/OrgListCard/OrgListCard.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import FlaskIcon from 'assets/svgs/flask.svg?react'; +import Button from 'react-bootstrap/Button'; +import { useTranslation } from 'react-i18next'; +import styles from './OrgListCard.module.css'; +import { useNavigate } from 'react-router-dom'; +import type { + InterfaceOrgConnectionInfoType, + InterfaceQueryOrganizationsListObject, +} from 'utils/interfaces'; +import { + IS_SAMPLE_ORGANIZATION_QUERY, + ORGANIZATIONS_LIST, +} from 'GraphQl/Queries/Queries'; +import { useQuery } from '@apollo/client'; +import { Tooltip } from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; + +/** + * Props for the OrgListCard component + */ +export interface InterfaceOrgListCardProps { + data: InterfaceOrgConnectionInfoType; +} + +/** + * Component for displaying a list card for an organization + * + * This component renders a card that displays information about an organization, + * including its name, address, members, and admins. It also provides a button + * to manage the organization, navigating to the organization's dashboard. + * + * @param props - The properties passed to the component + * @returns JSX.Element representing an organization list card + */ +function orgListCard(props: InterfaceOrgListCardProps): JSX.Element { + // Destructure data from props + const { _id, admins, image, address, members, name } = props.data; + + // Query to check if the organization is a sample organization + const { data } = useQuery(IS_SAMPLE_ORGANIZATION_QUERY, { + variables: { + isSampleOrganizationId: _id, + }, + }); + + // Use navigate hook from react-router-dom to navigate to the organization dashboard + const navigate = useNavigate(); + + // Query to get the organization list + const { + data: userData, + }: { + data?: { + organizations: InterfaceQueryOrganizationsListObject[]; + }; + } = useQuery(ORGANIZATIONS_LIST, { + variables: { id: _id }, + }); + + // Handle click event to navigate to the organization dashboard + function handleClick(): void { + const url = '/orgdash/' + _id; + + // Dont change the below two lines + navigate(url); + } + + const { t } = useTranslation('translation', { + keyPrefix: 'orgListCard', + }); + const { t: tCommon } = useTranslation('common'); + + return ( + <> + {/* Container for the organization card */} +
    +
    + {/* Container for the organization image */} +
    + {image ? ( + {`${name} + ) : ( + + )} +
    +
    + {/* Tooltip for the organization name */} + +

    {name}

    +
    + {/* Description of the organization */} +
    + {userData?.organizations[0].description} +
    + {/* Display the organization address if available */} + {address && address.city && ( +
    +
    + {address.line1}, + {address.city}, + {address.countryCode} +
    +
    + )} + {/* Display the number of admins and members */} +
    + {tCommon('admins')}: {admins.length}     +   {tCommon('members')}: {members.length} +
    +
    +
    + {/* Button to manage the organization */} + +
    + + ); +} +export default orgListCard; diff --git a/src/components/OrgPeopleListCard/OrgPeopleListCard.test.tsx b/src/components/OrgPeopleListCard/OrgPeopleListCard.test.tsx new file mode 100644 index 0000000000..7cee31107f --- /dev/null +++ b/src/components/OrgPeopleListCard/OrgPeopleListCard.test.tsx @@ -0,0 +1,81 @@ +import React, { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; + +import OrgPeopleListCard from './OrgPeopleListCard'; +import { REMOVE_MEMBER_MUTATION } from 'GraphQl/Mutations/mutations'; +import i18nForTest from 'utils/i18nForTest'; +import { BrowserRouter } from 'react-router-dom'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +const MOCKS = [ + { + request: { + query: REMOVE_MEMBER_MUTATION, + variable: { userid: '123', orgid: '456' }, + }, + result: { + data: { + organizations: [ + { + _id: '1', + }, + ], + }, + }, + }, +]; +const link = new StaticMockLink(MOCKS, true); +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Testing Organization People List Card', () => { + const props = { + toggleRemoveModal: () => true, + id: '1', + }; + global.alert = jest.fn(); + + test('should render props and text elements test for the page component', async () => { + global.confirm = (): boolean => true; + + render( + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId(/removeMemberBtn/i)); + }); + + test('Should not render modal when id is undefined', async () => { + global.confirm = (): boolean => false; + + render( + + + + true} /> + + + , + ); + + await wait(); + + expect(window.location.pathname).toEqual('/orglist'); + }); +}); diff --git a/src/components/OrgPeopleListCard/OrgPeopleListCard.tsx b/src/components/OrgPeopleListCard/OrgPeopleListCard.tsx new file mode 100644 index 0000000000..dca72b84e7 --- /dev/null +++ b/src/components/OrgPeopleListCard/OrgPeopleListCard.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import Button from 'react-bootstrap/Button'; +import Modal from 'react-bootstrap/Modal'; +import { useMutation } from '@apollo/client'; +import { toast } from 'react-toastify'; +import { useTranslation } from 'react-i18next'; +import { REMOVE_MEMBER_MUTATION } from 'GraphQl/Mutations/mutations'; +import { useParams, Navigate } from 'react-router-dom'; +import { errorHandler } from 'utils/errorHandler'; +import styles from '../../style/app.module.css'; +import { Close } from '@mui/icons-material'; + +/** + * Props for the OrgPeopleListCard component + */ +interface InterfaceOrgPeopleListCardProps { + id: string | undefined; + toggleRemoveModal: () => void; +} + +/** + * Component for displaying a modal to remove a member from an organization + * + * This component shows a modal that confirms the removal of a member from the organization. + * It performs the removal action and displays success or error messages. + * + * @param props - The properties passed to the component + * @returns JSX.Element representing the organization people list card modal + */ +function orgPeopleListCard( + props: InterfaceOrgPeopleListCardProps, +): JSX.Element { + // Get the current organization ID from the URL parameters + const { orgId: currentUrl } = useParams(); + + // If the member ID is not provided, navigate to the organization list + if (!props.id) { + return ; + } + + // Mutation to remove a member from the organization + const [remove] = useMutation(REMOVE_MEMBER_MUTATION); + + const { t } = useTranslation('translation', { + keyPrefix: 'orgPeopleListCard', + }); + const { t: tCommon } = useTranslation('common'); + + // Function to remove a member and handle success or error + const removeMember = async (): Promise => { + try { + const { data } = await remove({ + variables: { + userid: props.id, + orgid: currentUrl, + }, + }); + // If the mutation is successful, show a success message and reload the page + /* istanbul ignore next */ + if (data) { + toast.success(t('memberRemoved') as string); + setTimeout(() => { + window.location.reload(); + }, 2000); + } + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }; + + return ( +
    + {/* Modal to confirm member removal */} + + +
    {t('removeMember')}
    + {/* Button to close the modal */} + +
    + {t('removeMemberMsg')} + + {/* Button to cancel the removal action */} + + {/* Button to confirm the removal action */} + + +
    +
    + ); +} +export {}; +export default orgPeopleListCard; diff --git a/src/components/OrgPostCard/OrgPostCard.module.css b/src/components/OrgPostCard/OrgPostCard.module.css new file mode 100644 index 0000000000..c7ff8073d2 --- /dev/null +++ b/src/components/OrgPostCard/OrgPostCard.module.css @@ -0,0 +1,278 @@ +.cards h2 { + font-size: 20px; +} +.cards > h3 { + font-size: 17px; +} +.card { + width: 100%; + height: 20rem; + margin-bottom: 2rem; +} +.postimage { + border-radius: 0px; + width: 100%; + height: 12rem; + max-width: 100%; + max-height: 12rem; + object-fit: cover; + position: relative; + color: black; +} +.preview { + display: flex; + position: relative; + width: 100%; + margin-top: 10px; + justify-content: center; +} +.preview img { + width: 400px; + height: auto; +} +.preview video { + width: 400px; + height: auto; +} +.nopostimage { + border-radius: 0px; + width: 100%; + height: 12rem; + max-height: 12rem; + object-fit: cover; + position: relative; +} +.cards:hover { + filter: brightness(0.8); +} +.cards:hover::before { + opacity: 0.5; +} +.knowMoreText { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + opacity: 0; + color: white; + padding: 10px; + font-weight: bold; + font-size: 1.5rem; + transition: opacity 0.3s ease-in-out; +} + +.cards:hover .knowMoreText { + opacity: 1; +} +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba( + 0, + 0, + 0, + 0.9 + ); /* Dark grey modal background with transparency */ + z-index: 9999; +} + +.modalContent { + display: flex; + align-items: center; + justify-content: center; + background-color: #fff; + padding: 20px; + max-width: 800px; + max-height: 600px; + overflow: auto; +} + +.modalImage { + flex: 1; + margin-right: 20px; + width: 25rem; + height: 15rem; +} +.nomodalImage { + flex: 1; + margin-right: 20px; + width: 100%; + height: 15rem; +} + +.modalImage img, +.modalImage video { + border-radius: 0px; + width: 100%; + height: 25rem; + max-width: 25rem; + max-height: 15rem; + object-fit: cover; + position: relative; +} +.modalInfo { + flex: 1; +} +.title { + font-size: 16px; + color: #000; + font-weight: 600; +} +.text { + font-size: 13px; + color: #000; + font-weight: 300; +} +.author { + color: #737373; + font-weight: 100; + font-size: 13px; +} +.closeButton { + position: relative; + bottom: 5rem; + right: 10px; + padding: 4px; + background-color: red; /* Red close button color */ + color: #fff; + border: none; + cursor: pointer; +} +.closeButtonP { + position: absolute; + top: 0px; + right: 0px; + background: transparent; + transform: scale(1.2); + cursor: pointer; + border: none; + color: #707070; + font-weight: 600; + font-size: 16px; + cursor: pointer; +} +.cards:hover::after { + opacity: 1; + mix-blend-mode: normal; +} +.cards > p { + font-size: 14px; + margin-top: 0px; + margin-bottom: 7px; +} +.cards a { + color: #737373; + font-weight: 600; +} +.cards a:hover { + color: black; +} +.infodiv { + margin-bottom: 7px; + width: 15rem; + text-align: justify; + word-wrap: break-word; +} +.infodiv > p { + margin: 0; +} +.dispflex { + display: flex; + justify-content: space-between; +} +.iconContainer { + display: flex; +} +.icon { + transform: scale(0.75); + cursor: pointer; +} +/* .cards { + width: 75%; + background: #fcfcfc; + margin: 10px 40px; + padding: 20px 30px; + border-radius: 5px; + border: 1px solid #e8e8e8; + box-shadow: 0 3px 5px #c9c9c9; + margin-right: 30px; + color: #737373; + box-sizing: border-box; +} */ +.cards:last-child:nth-last-child(odd) { + grid-column: auto / span 2; +} +.cards:first-child:nth-last-child(even), +.cards:first-child:nth-last-child(even) ~ .box { + grid-column: auto / span 1; +} +.toggleClickBtn { + color: #31bb6b; + cursor: pointer; + border: none; + font-size: 12px; + background-color: white; +} +.toggleClickBtnNone { + display: none; +} +/* Menu Modal Styles */ +.menuModal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.7); /* Dark grey modal background */ + z-index: 9999; +} + +.menuContent { + display: flex; + align-items: center; + justify-content: center; + background-color: #fff; + padding-top: 20px; + max-width: 700px; + max-height: 500px; + overflow: hidden; + position: relative; +} + +.menuOptions { + list-style-type: none; + padding: 0; + margin: 0; +} + +.menuOptions li { + padding: 10px; + border-bottom: 1px solid #ccc; + padding-left: 100px; + padding-right: 100px; + cursor: pointer; +} + +.moreOptionsButton { + position: relative; + bottom: 5rem; + right: 10px; + padding: 2px; + background-color: transparent; + color: #000; + border: none; + cursor: pointer; +} +.list { + color: red; + cursor: pointer; +} diff --git a/src/components/OrgPostCard/OrgPostCard.test.tsx b/src/components/OrgPostCard/OrgPostCard.test.tsx new file mode 100644 index 0000000000..7105e5e8f2 --- /dev/null +++ b/src/components/OrgPostCard/OrgPostCard.test.tsx @@ -0,0 +1,788 @@ +import React from 'react'; +import { + act, + render, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import OrgPostCard from './OrgPostCard'; +import { I18nextProvider } from 'react-i18next'; +import userEvent from '@testing-library/user-event'; +import 'jest-localstorage-mock'; +import { + DELETE_POST_MUTATION, + UPDATE_POST_MUTATION, + TOGGLE_PINNED_POST, +} from 'GraphQl/Mutations/mutations'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import convertToBase64 from 'utils/convertToBase64'; +import { BrowserRouter } from 'react-router-dom'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem } = useLocalStorage(); + +const MOCKS = [ + { + request: { + query: DELETE_POST_MUTATION, + variables: { id: '12' }, + }, + result: { + data: { + removePost: { + _id: '12', + }, + }, + }, + }, + { + request: { + query: UPDATE_POST_MUTATION, + variables: { + id: '12', + title: 'updated title', + text: 'This is a updated text', + }, + }, + result: { + data: { + updatePost: { + _id: '12', + }, + }, + }, + }, + { + request: { + query: TOGGLE_PINNED_POST, + variables: { + id: '12', + }, + }, + result: { + data: { + togglePostPin: { + _id: '12', + }, + }, + }, + }, +]; +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); +jest.mock('i18next-browser-languagedetector', () => ({ + init: jest.fn(), + type: 'languageDetector', + detect: jest.fn(() => 'en'), + cacheUserLanguage: jest.fn(), +})); +const link = new StaticMockLink(MOCKS, true); +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} +describe('Testing Organization Post Card', () => { + const originalLocation = window.location; + + beforeAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: { + reload: jest.fn(), + }, + }); + }); + + afterAll(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + }); + + const props = { + id: '12', + postID: '123', + postTitle: 'Event Info', + postInfo: 'Time change', + postAuthor: 'John Doe', + postPhoto: 'test.png', + postVideo: 'test.mp4', + pinned: false, + }; + + jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + })); + jest.mock('react', () => ({ + ...jest.requireActual('react'), + useRef: jest.fn(), + })); + global.alert = jest.fn(); + + test('Opens post on image click', () => { + const { getByTestId, getByAltText } = render( + + + + + , + ); + userEvent.click(screen.getByAltText('image')); + + expect(getByTestId('card-text')).toBeInTheDocument(); + expect(getByTestId('card-title')).toBeInTheDocument(); + expect(getByAltText('image')).toBeInTheDocument(); + }); + test('renders with default props', () => { + const { getByAltText, getByTestId } = render( + + + + + , + ); + expect(getByTestId('card-text')).toBeInTheDocument(); + expect(getByTestId('card-title')).toBeInTheDocument(); + expect(getByAltText('image')).toBeInTheDocument(); + }); + test('toggles "Read more" button', () => { + const { getByTestId } = render( + + + + + , + ); + userEvent.click(screen.getByAltText('image')); + const toggleButton = getByTestId('toggleBtn'); + fireEvent.click(toggleButton); + expect(toggleButton).toHaveTextContent('hide'); + fireEvent.click(toggleButton); + expect(toggleButton).toHaveTextContent('Read more'); + }); + test('opens and closes edit modal', async () => { + setItem('id', '123'); + + render( + + + + + , + ); + await wait(); + userEvent.click(screen.getByAltText('image')); + userEvent.click(screen.getByTestId('moreiconbtn')); + userEvent.click(screen.getByTestId('editPostModalBtn')); + + const createOrgBtn = screen.getByTestId('modalOrganizationHeader'); + expect(createOrgBtn).toBeInTheDocument(); + userEvent.click(createOrgBtn); + userEvent.click(screen.getByTestId('closeOrganizationModal')); + }); + test('Should render text elements when props value is not passed', async () => { + global.confirm = (): boolean => false; + render( + + + + + , + ); + await wait(); + userEvent.click(screen.getByAltText('image')); + expect(screen.getByAltText('Post Image')).toBeInTheDocument(); + }); + test('Testing post updating after post is updated', async () => { + const { getByTestId } = render( + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByAltText('image')); + userEvent.click(screen.getByTestId('moreiconbtn')); + + userEvent.click(screen.getByTestId('editPostModalBtn')); + fireEvent.change(getByTestId('updateTitle'), { + target: { value: 'updated title' }, + }); + fireEvent.change(getByTestId('updateText'), { + target: { value: 'This is a updated text' }, + }); + const postVideoUrlInput = screen.queryByTestId('postVideoUrl'); + if (postVideoUrlInput) { + fireEvent.change(getByTestId('postVideoUrl'), { + target: { value: 'This is a updated video' }, + }); + userEvent.click(screen.getByPlaceholderText(/video/i)); + const input = getByTestId('postVideoUrl'); + const file = new File(['test-video'], 'test.mp4', { type: 'video/mp4' }); + Object.defineProperty(input, 'files', { + value: [file], + }); + fireEvent.change(input); + await waitFor(() => { + convertToBase64(file); + }); + + userEvent.click(screen.getByTestId('closePreview')); + } + const imageUrlInput = screen.queryByTestId('postImageUrl'); + if (imageUrlInput) { + fireEvent.change(getByTestId('postImageUrl'), { + target: { value: 'This is a updated image' }, + }); + userEvent.click(screen.getByPlaceholderText(/image/i)); + const input = getByTestId('postImageUrl'); + const file = new File(['test-image'], 'test.jpg', { type: 'image/jpeg' }); + Object.defineProperty(input, 'files', { + value: [file], + }); + fireEvent.change(input); + + // Simulate the asynchronous base64 conversion function + await waitFor(() => { + convertToBase64(file); // Replace with the expected base64-encoded image + }); + document.getElementById = jest.fn(() => input); + const clearImageButton = getByTestId('closeimage'); + fireEvent.click(clearImageButton); + } + userEvent.click(screen.getByTestId('updatePostBtn')); + + await waitFor( + () => { + expect(window.location.reload).toHaveBeenCalled(); + }, + { timeout: 2500 }, + ); + }); + test('Testing post updating functionality fail case', async () => { + const props2 = { + id: '', + postID: '123', + postTitle: 'Event Info', + postInfo: 'Time change', + postAuthor: 'John Doe', + postPhoto: 'test.png', + postVideo: 'test.mp4', + pinned: true, + }; + const { getByTestId } = render( + + + + + , + ); + + await wait(); + userEvent.click(screen.getByAltText('image')); + userEvent.click(screen.getByTestId('moreiconbtn')); + + userEvent.click(screen.getByTestId('editPostModalBtn')); + fireEvent.change(getByTestId('updateTitle'), { + target: { value: 'updated title' }, + }); + fireEvent.change(getByTestId('updateText'), { + target: { value: 'This is a updated text' }, + }); + const postVideoUrlInput = screen.queryByTestId('postVideoUrl'); + if (postVideoUrlInput) { + fireEvent.change(getByTestId('postVideoUrl'), { + target: { value: 'This is a updated video' }, + }); + userEvent.click(screen.getByPlaceholderText(/video/i)); + const input = getByTestId('postVideoUrl'); + const file = new File(['test-video'], 'test.mp4', { type: 'video/mp4' }); + Object.defineProperty(input, 'files', { + value: [file], + }); + fireEvent.change(input); + await waitFor(() => { + convertToBase64(file); + }); + + userEvent.click(screen.getByTestId('closePreview')); + } + const imageUrlInput = screen.queryByTestId('postImageUrl'); + if (imageUrlInput) { + fireEvent.change(getByTestId('postImageUrl'), { + target: { value: 'This is a updated image' }, + }); + userEvent.click(screen.getByPlaceholderText(/image/i)); + const input = getByTestId('postImageUrl'); + const file = new File(['test-image'], 'test.jpg', { type: 'image/jpeg' }); + Object.defineProperty(input, 'files', { + value: [file], + }); + fireEvent.change(input); + + // Simulate the asynchronous base64 conversion function + await waitFor(() => { + convertToBase64(file); // Replace with the expected base64-encoded image + }); + document.getElementById = jest.fn(() => input); + const clearImageButton = getByTestId('closeimage'); + fireEvent.click(clearImageButton); + } + userEvent.click(screen.getByTestId('updatePostBtn')); + + await waitFor( + () => { + expect(window.location.reload).toHaveBeenCalled(); + }, + { timeout: 2500 }, + ); + }); + test('Testing pin post functionality', async () => { + render( + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByAltText('image')); + userEvent.click(screen.getByTestId('moreiconbtn')); + userEvent.click(screen.getByTestId('pinpostBtn')); + + await waitFor( + () => { + expect(window.location.reload).toHaveBeenCalled(); + }, + { timeout: 3000 }, + ); + }); + test('Testing pin post functionality fail case', async () => { + const props2 = { + id: '', + postID: '123', + postTitle: 'Event Info', + postInfo: 'Time change', + postAuthor: 'John Doe', + postPhoto: 'test.png', + postVideo: 'test.mp4', + pinned: true, + }; + render( + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByAltText('image')); + userEvent.click(screen.getByTestId('moreiconbtn')); + userEvent.click(screen.getByTestId('pinpostBtn')); + }); + test('Testing post delete functionality', async () => { + render( + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByAltText('image')); + userEvent.click(screen.getByTestId('moreiconbtn')); + + userEvent.click(screen.getByTestId('deletePostModalBtn')); + fireEvent.click(screen.getByTestId('deletePostBtn')); + + await waitFor( + () => { + expect(window.location.reload).toHaveBeenCalled(); + }, + { timeout: 3000 }, + ); + }); + test('Testing post delete functionality fail case', async () => { + const props2 = { + id: '', + postID: '123', + postTitle: 'Event Info', + postInfo: 'Time change', + postAuthor: 'John Doe', + postPhoto: 'test.png', + postVideo: 'test.mp4', + pinned: true, + }; + render( + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByAltText('image')); + userEvent.click(screen.getByTestId('moreiconbtn')); + + userEvent.click(screen.getByTestId('deletePostModalBtn')); + fireEvent.click(screen.getByTestId('deletePostBtn')); + }); + test('Testing close functionality of primary modal', async () => { + render( + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByAltText('image')); + userEvent.click(screen.getByTestId('closeiconbtn')); + + //Primary Modal is closed + expect(screen.queryByTestId('moreiconbtn')).not.toBeInTheDocument(); + }); + test('Testing close functionality of secondary modal', async () => { + render( + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByAltText('image')); + userEvent.click(screen.getByTestId('moreiconbtn')); + userEvent.click(screen.getByTestId('closebtn')); + + //Secondary Modal is closed + expect(screen.queryByTestId('deletePostModalBtn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('editPostModalBtn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('pinpostBtn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('closebtn')).not.toBeInTheDocument(); + }); + test('renders without "Read more" button when postInfo length is less than or equal to 43', () => { + render( + + + + + , + ); + expect(screen.queryByTestId('toggleBtn')).not.toBeInTheDocument(); + }); + test('renders with "Read more" button when postInfo length is more than 43', () => { + const props2 = { + id: '12', + postID: '123', + postTitle: 'Event Info', + postInfo: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus lacinia odio vitae vestibulum.', // Length is greater than 43 + postAuthor: 'John Doe', + postPhoto: 'photoLink', + postVideo: 'videoLink', + pinned: false, + }; + render( + + + + + , + ); + userEvent.click(screen.getByAltText('image')); + + expect(screen.getByTestId('toggleBtn')).toBeInTheDocument(); + }); + test('updates state variables correctly when handleEditModal is called', () => { + const link2 = new StaticMockLink(MOCKS, true); + render( + + + , + ); + userEvent.click(screen.getByAltText('image')); + + userEvent.click(screen.getByTestId('moreiconbtn')); + + expect(screen.queryByTestId('editPostModalBtn')).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('editPostModalBtn')); + + //Primary Modal is closed + expect(screen.queryByTestId('closeiconbtn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('moreiconbtn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('toggleBtn')).not.toBeInTheDocument(); + + //Secondary Modal is closed + expect(screen.queryByTestId('deletePostModalBtn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('editPostModalBtn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('pinpostBtn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('closebtn')).not.toBeInTheDocument(); + }); + test('updates state variables correctly when handleDeleteModal is called', () => { + const link2 = new StaticMockLink(MOCKS, true); + render( + + + , + ); + userEvent.click(screen.getByAltText('image')); + + userEvent.click(screen.getByTestId('moreiconbtn')); + + expect(screen.queryByTestId('deletePostModalBtn')).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('deletePostModalBtn')); + + //Primary Modal is closed + expect(screen.queryByTestId('closeiconbtn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('moreiconbtn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('toggleBtn')).not.toBeInTheDocument(); + + //Secondary Modal is closed + expect(screen.queryByTestId('deletePostModalBtn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('editPostModalBtn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('pinpostBtn')).not.toBeInTheDocument(); + expect(screen.queryByTestId('closebtn')).not.toBeInTheDocument(); + }); + test('clears postvideo state and resets file input value', async () => { + const { getByTestId } = render( + + + + + , + ); + + userEvent.click(screen.getByAltText('image')); + userEvent.click(screen.getByTestId('moreiconbtn')); + + userEvent.click(screen.getByTestId('editPostModalBtn')); + + const postVideoUrlInput = screen.queryByTestId('postVideoUrl'); + + if (postVideoUrlInput) { + fireEvent.change(getByTestId('postVideoUrl'), { + target: { value: '' }, + }); + userEvent.click(screen.getByPlaceholderText(/video/i)); + const input = getByTestId('postVideoUrl'); + const file = new File(['test-video'], 'test.mp4', { type: 'video/mp4' }); + Object.defineProperty(input, 'files', { + value: [file], + }); + fireEvent.change(input); + await waitFor(() => { + convertToBase64(file); + }); + + userEvent.click(screen.getByTestId('closePreview')); + } + }); + test('clears postimage state and resets file input value', async () => { + const { getByTestId } = render( + + + + + , + ); + + userEvent.click(screen.getByAltText('image')); + userEvent.click(screen.getByTestId('moreiconbtn')); + + userEvent.click(screen.getByTestId('editPostModalBtn')); + + const imageUrlInput = screen.queryByTestId('postImageUrl'); + + if (imageUrlInput) { + fireEvent.change(getByTestId('postImageUrl'), { + target: { value: '' }, + }); + userEvent.click(screen.getByPlaceholderText(/image/i)); + const input = getByTestId('postImageUrl'); + const file = new File(['test-image'], 'test.jpg', { type: 'image/jpeg' }); + Object.defineProperty(input, 'files', { + value: [file], + }); + fireEvent.change(input); + + // Simulate the asynchronous base64 conversion function + await waitFor(() => { + convertToBase64(file); // Replace with the expected base64-encoded image + }); + document.getElementById = jest.fn(() => input); + const clearImageButton = getByTestId('closeimage'); + fireEvent.click(clearImageButton); + } + }); + test('clears postitle state and resets file input value', async () => { + const { getByTestId } = render( + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByAltText('image')); + userEvent.click(screen.getByTestId('moreiconbtn')); + userEvent.click(screen.getByTestId('editPostModalBtn')); + + fireEvent.change(getByTestId('updateTitle'), { + target: { value: '' }, + }); + + userEvent.click(screen.getByTestId('updatePostBtn')); // Should not update post + + expect(screen.getByTestId('updateTitle')).toHaveValue(''); + expect(screen.getByTestId('closeOrganizationModal')).toBeInTheDocument(); + expect(screen.getByTestId('updatePostBtn')).toBeInTheDocument(); + }); + test('clears postinfo state and resets file input value', async () => { + const { getByTestId } = render( + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByAltText('image')); + userEvent.click(screen.getByTestId('moreiconbtn')); + userEvent.click(screen.getByTestId('editPostModalBtn')); + + fireEvent.change(getByTestId('updateText'), { + target: { value: '' }, + }); + + userEvent.click(screen.getByTestId('updatePostBtn')); // Should not update post + + expect(screen.getByTestId('updateText')).toHaveValue(''); + expect(screen.getByTestId('closeOrganizationModal')).toBeInTheDocument(); + expect(screen.getByTestId('updatePostBtn')).toBeInTheDocument(); + }); + test('Testing create organization modal', async () => { + setItem('id', '123'); + + render( + + + + + , + ); + + await wait(); + userEvent.click(screen.getByAltText('image')); + + userEvent.click(screen.getByTestId('moreiconbtn')); + + userEvent.click(screen.getByTestId('editPostModalBtn')); + const createOrgBtn = screen.getByTestId('modalOrganizationHeader'); + expect(createOrgBtn).toBeInTheDocument(); + userEvent.click(createOrgBtn); + userEvent.click(screen.getByTestId('closeOrganizationModal')); + }); + test('should toggle post pin when pin button is clicked', async () => { + const { getByTestId } = render( + + + + + , + ); + userEvent.click(screen.getByAltText('image')); + + userEvent.click(screen.getByTestId('moreiconbtn')); + const pinButton = getByTestId('pinpostBtn'); + fireEvent.click(pinButton); + await waitFor(() => { + expect(MOCKS[2].request.variables).toEqual({ + id: '12', + }); + }); + }); + test('testing video play and pause on mouse enter and leave events', async () => { + const { getByTestId } = render( + + + + + , + ); + + const card = getByTestId('cardVid'); + + HTMLVideoElement.prototype.play = jest.fn(); + HTMLVideoElement.prototype.pause = jest.fn(); + + fireEvent.mouseEnter(card); + expect(HTMLVideoElement.prototype.play).toHaveBeenCalled(); + + fireEvent.mouseLeave(card); + expect(HTMLVideoElement.prototype.pause).toHaveBeenCalled(); + }); + test('for rendering when no image and no video is available', async () => { + const props2 = { + id: '', + postID: '123', + postTitle: 'Event Info', + postInfo: 'Time change', + postAuthor: 'John Doe', + postPhoto: '', + postVideo: '', + pinned: true, + }; + + const { getByAltText } = render( + + + + + , + ); + + expect(getByAltText('image not found')).toBeInTheDocument(); + }); +}); diff --git a/src/components/OrgPostCard/OrgPostCard.tsx b/src/components/OrgPostCard/OrgPostCard.tsx new file mode 100644 index 0000000000..b7cc419d12 --- /dev/null +++ b/src/components/OrgPostCard/OrgPostCard.tsx @@ -0,0 +1,611 @@ +import { useMutation } from '@apollo/client'; +import { Close, MoreVert, PushPin } from '@mui/icons-material'; +import { + DELETE_POST_MUTATION, + TOGGLE_PINNED_POST, + UPDATE_POST_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import AboutImg from 'assets/images/defaultImg.png'; +import type { ChangeEvent } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { Form, Button, Card, Modal } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import convertToBase64 from 'utils/convertToBase64'; +import { errorHandler } from 'utils/errorHandler'; +import type { InterfacePostForm } from 'utils/interfaces'; +import styles from './OrgPostCard.module.css'; +interface InterfaceOrgPostCardProps { + postID: string; + id: string; + postTitle: string; + postInfo: string; + postAuthor: string; + postPhoto: string | null; + postVideo: string | null; + pinned: boolean; +} +export default function OrgPostCard( + props: InterfaceOrgPostCardProps, +): JSX.Element { + const { + postID, // Destructure the key prop from props + // ...rest // Spread the rest of the props + } = props; + const [postformState, setPostFormState] = useState({ + posttitle: '', + postinfo: '', + postphoto: '', + postvideo: '', + pinned: false, + }); + const [postPhotoUpdated, setPostPhotoUpdated] = useState(false); + const [postVideoUpdated, setPostVideoUpdated] = useState(false); + const [togglePost, setPostToggle] = useState('Read more'); + const [showEditModal, setShowEditModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [modalVisible, setModalVisible] = useState(false); + const [menuVisible, setMenuVisible] = useState(false); + const [playing, setPlaying] = useState(false); + const videoRef = useRef(null); + const [toggle] = useMutation(TOGGLE_PINNED_POST); + const togglePostPin = async (id: string, pinned: boolean): Promise => { + try { + const { data } = await toggle({ + variables: { + id, + }, + }); + if (data) { + setModalVisible(false); + setMenuVisible(false); + toast.success(`${pinned ? 'Post unpinned' : 'Post pinned'}`); + setTimeout(() => { + window.location.reload(); + }, 2000); + } + } catch (error: unknown) { + if (error instanceof Error) { + /* istanbul ignore next */ + errorHandler(t, error); + } + } + }; + const toggleShowEditModal = (): void => { + const { postTitle, postInfo, postPhoto, postVideo, pinned } = props; + setPostFormState({ + posttitle: postTitle, + postinfo: postInfo, + postphoto: postPhoto, + postvideo: postVideo, + pinned: pinned, + }); + setPostPhotoUpdated(false); + setPostVideoUpdated(false); + setShowEditModal((prev) => !prev); + }; + const toggleShowDeleteModal = (): void => setShowDeleteModal((prev) => !prev); + const handleVideoPlay = (): void => { + setPlaying(true); + videoRef.current?.play(); + }; + const handleVideoPause = (): void => { + setPlaying(false); + videoRef.current?.pause(); + }; + const handleCardClick = (): void => { + setModalVisible(true); + }; + const handleMoreOptionsClick = (): void => { + setMenuVisible(true); + }; + const clearImageInput = (): void => { + setPostFormState({ + ...postformState, + postphoto: '', + }); + setPostPhotoUpdated(true); + const fileInput = document.getElementById( + 'postImageUrl', + ) as HTMLInputElement; + if (fileInput) { + fileInput.value = ''; + } + }; + const clearVideoInput = (): void => { + setPostFormState({ + ...postformState, + postvideo: '', + }); + setPostVideoUpdated(true); + const fileInput = document.getElementById( + 'postVideoUrl', + ) as HTMLInputElement; + if (fileInput) { + fileInput.value = ''; + } + }; + function handletoggleClick(): void { + if (togglePost === 'Read more') { + setPostToggle('hide'); + } else { + setPostToggle('Read more'); + } + } + function handleEditModal(): void { + const { postPhoto, postVideo } = props; + setModalVisible(false); + setMenuVisible(false); + setShowEditModal(true); + setPostFormState({ + ...postformState, + postphoto: postPhoto, + postvideo: postVideo, + }); + } + function handleDeleteModal(): void { + setModalVisible(false); + setMenuVisible(false); + setShowDeleteModal(true); + } + useEffect(() => { + setPostFormState({ + posttitle: props.postTitle, + postinfo: props.postInfo, + postphoto: props.postPhoto, + postvideo: props.postVideo, + pinned: props.pinned, + }); + }, []); + const { t } = useTranslation('translation', { + keyPrefix: 'orgPostCard', + }); + const { t: tCommon } = useTranslation('common'); + const [deletePostMutation] = useMutation(DELETE_POST_MUTATION); + const [updatePostMutation] = useMutation(UPDATE_POST_MUTATION); + const deletePost = async (): Promise => { + try { + const { data } = await deletePostMutation({ + variables: { + id: props.id, + }, + }); + if (data) { + toast.success(t('postDeleted') as string); + toggleShowDeleteModal(); + setTimeout(() => { + window.location.reload(); + }); + } + } catch (error: unknown) { + errorHandler(t, error); + } + }; + const handleInputEvent = ( + e: ChangeEvent, + ): void => { + const { name, value } = e.target; + setPostFormState((prevPostFormState) => ({ + ...prevPostFormState, + [name]: value, + })); + }; + const updatePostHandler = async ( + e: ChangeEvent, + ): Promise => { + e.preventDefault(); + try { + const { data } = await updatePostMutation({ + variables: { + id: props.id, + title: postformState.posttitle, + text: postformState.postinfo, + ...(postPhotoUpdated && { + imageUrl: postformState.postphoto, + }), + ...(postVideoUpdated && { + videoUrl: postformState.postvideo, + }), + }, + }); + if (data) { + toast.success(t('postUpdated') as string); + setTimeout(() => { + window.location.reload(); + }, 2000); + } + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + return ( + <> +
    +
    + {props.postVideo && ( + + + + {props.pinned && ( + + )} + + {props.postTitle} + + + {props.postInfo} + + + {props.postAuthor} + + + + )} + {props.postPhoto ? ( + + + + {props.pinned && ( + + )} + + {props.postTitle} + + {props.postInfo} + {props.postAuthor} + + + ) : !props.postVideo ? ( + + + + + {props.pinned && ( + + )} + + {props.postTitle} + + + {props.postInfo && props.postInfo.length > 20 + ? props.postInfo.substring(0, 20) + '...' + : props.postInfo} + {' '} + + {props.postAuthor} + + + + + ) : ( + '' + )} +
    + {modalVisible && ( +
    +
    + {props.postPhoto && ( +
    + Post Image +
    + )} + {props.postVideo && ( +
    + +
    + )} + {!props.postPhoto && !props.postVideo && ( +
    + {' '} + Post Image +
    + )} +
    +

    + {t('author')}: {props.postAuthor} +

    +
    + {togglePost === 'Read more' ? ( +

    + {props.postInfo.length > 43 + ? props.postInfo.substring(0, 40) + '...' + : props.postInfo} +

    + ) : ( +

    {props.postInfo}

    + )} + +
    +
    + + +
    +
    + )} + {menuVisible && ( +
    +
    +
      +
    • + {tCommon('edit')} +
    • +
    • + {t('deletePost')} +
    • +
    • => + togglePostPin(props.id, props.pinned) + } + > + {!props.pinned ? 'Pin post' : 'Unpin post'} +
    • +
    • setMenuVisible(false)} + data-testid="closebtn" + > + {tCommon('close')} +
    • +
    +
    +
    + )} +
    + + +
    {t('deletePost')}
    + +
    + {t('deletePostMsg')} + + + + +
    + + + {t('editPost')} + +
    + + {t('postTitle')} + + {t('information')} + + {!props.postPhoto && ( + <> + {t('image')} + , + ): Promise => { + setPostFormState((prevPostFormState) => ({ + ...prevPostFormState, + postphoto: '', + })); + setPostPhotoUpdated(true); + const file = e.target.files?.[0]; + if (file) { + setPostFormState({ + ...postformState, + postphoto: await convertToBase64(file), + }); + } + }} + /> + {props.postPhoto && ( + <> + {postformState.postphoto && ( +
    + Post Image Preview + +
    + )} + + )} + + )} + {!props.postVideo && ( + <> + {t('video')} + , + ): Promise => { + setPostFormState((prevPostFormState) => ({ + ...prevPostFormState, + postvideo: '', + })); + setPostVideoUpdated(true); + const target = e.target as HTMLInputElement; + const file = target.files && target.files[0]; + if (file) { + const videoBase64 = await convertToBase64(file); + setPostFormState({ + ...postformState, + postvideo: videoBase64, + }); + } + }} + /> + {postformState.postvideo && ( +
    + + +
    + )} + + )} +
    + + + + +
    +
    + + ); +} diff --git a/src/components/OrgSettings/ActionItemCategories/CategoryModal.test.tsx b/src/components/OrgSettings/ActionItemCategories/CategoryModal.test.tsx new file mode 100644 index 0000000000..39d4884e8b --- /dev/null +++ b/src/components/OrgSettings/ActionItemCategories/CategoryModal.test.tsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import type { ApolloLink } from '@apollo/client'; +import { MOCKS, MOCKS_ERROR } from './OrgActionItemCategoryMocks'; +import type { InterfaceActionItemCategoryModal } from './CategoryModal'; +import CategoryModal from './CategoryModal'; +import { toast } from 'react-toastify'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link3 = new StaticMockLink(MOCKS_ERROR); +const translations = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.orgActionItemCategories ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const categoryProps: InterfaceActionItemCategoryModal[] = [ + { + isOpen: true, + hide: jest.fn(), + refetchCategories: jest.fn(), + orgId: 'orgId', + mode: 'create', + category: { + _id: 'categoryId', + name: 'Category 1', + isDisabled: false, + createdAt: '2044-01-01', + creator: { _id: 'userId', firstName: 'John', lastName: 'Doe' }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + refetchCategories: jest.fn(), + orgId: 'orgId', + mode: 'edit', + category: { + _id: 'categoryId', + name: 'Category 1', + isDisabled: false, + createdAt: '2044-01-01', + creator: { _id: 'userId', firstName: 'John', lastName: 'Doe' }, + }, + }, +]; + +const renderCategoryModal = ( + link: ApolloLink, + props: InterfaceActionItemCategoryModal, +): RenderResult => { + return render( + + + + + + + + + , + ); +}; + +const fillFormAndSubmit = async ( + name: string, + isDisabled: boolean, +): Promise => { + const nameInput = screen.getByLabelText('Name *'); + const isDisabledSwitch = screen.getByTestId('isDisabledSwitch'); + const submitBtn = screen.getByTestId('formSubmitButton'); + + fireEvent.change(nameInput, { target: { value: name } }); + if (isDisabled) { + userEvent.click(isDisabledSwitch); + } + userEvent.click(submitBtn); +}; + +describe('Testing Action Item Category Modal', () => { + it('should populate form fields with correct values in edit mode', async () => { + renderCategoryModal(link1, categoryProps[1]); + await waitFor(() => + expect( + screen.getByText(translations.categoryDetails), + ).toBeInTheDocument(), + ); + + expect(screen.getByLabelText('Name *')).toHaveValue('Category 1'); + expect(screen.getByTestId('isDisabledSwitch')).not.toBeChecked(); + }); + + it('should update name when input value changes', async () => { + renderCategoryModal(link1, categoryProps[1]); + const nameInput = screen.getByLabelText('Name *'); + expect(nameInput).toHaveValue('Category 1'); + fireEvent.change(nameInput, { target: { value: 'Category 2' } }); + expect(nameInput).toHaveValue('Category 2'); + }); + + it('should update isDisabled when switch is toggled', async () => { + renderCategoryModal(link1, categoryProps[1]); + const isDisabledSwitch = screen.getByTestId('isDisabledSwitch'); + expect(isDisabledSwitch).not.toBeChecked(); + userEvent.click(isDisabledSwitch); + expect(isDisabledSwitch).toBeChecked(); + }); + + it('should edit category', async () => { + renderCategoryModal(link1, categoryProps[1]); + await fillFormAndSubmit('Category 2', true); + + await waitFor(() => { + expect(categoryProps[1].refetchCategories).toHaveBeenCalled(); + expect(categoryProps[1].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith( + translations.successfulUpdation, + ); + }); + }); + + it('Edit only Name', async () => { + renderCategoryModal(link1, categoryProps[1]); + await fillFormAndSubmit('Category 2', false); + + await waitFor(() => { + expect(categoryProps[1].refetchCategories).toHaveBeenCalled(); + expect(categoryProps[1].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith( + translations.successfulUpdation, + ); + }); + }); + + it('Edit only isDisabled', async () => { + renderCategoryModal(link1, categoryProps[1]); + await fillFormAndSubmit('Category 1', true); + + await waitFor(() => { + expect(categoryProps[1].refetchCategories).toHaveBeenCalled(); + expect(categoryProps[1].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith( + translations.successfulUpdation, + ); + }); + }); + + it('Error in updating category', async () => { + renderCategoryModal(link3, categoryProps[1]); + await fillFormAndSubmit('Category 2', true); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Mock Graphql Error'); + }); + }); + + it('should create category', async () => { + renderCategoryModal(link1, categoryProps[0]); + await fillFormAndSubmit('Category 2', true); + + await waitFor(() => { + expect(categoryProps[0].refetchCategories).toHaveBeenCalled(); + expect(categoryProps[0].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith( + translations.successfulCreation, + ); + }); + }); + + it('Error in creating category', async () => { + renderCategoryModal(link3, categoryProps[0]); + await fillFormAndSubmit('Category 2', true); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Mock Graphql Error'); + }); + }); + + it('Try to edit without changing any field', async () => { + renderCategoryModal(link1, categoryProps[1]); + const submitBtn = screen.getByTestId('formSubmitButton'); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith(translations.sameNameConflict); + }); + }); +}); diff --git a/src/components/OrgSettings/ActionItemCategories/CategoryModal.tsx b/src/components/OrgSettings/ActionItemCategories/CategoryModal.tsx new file mode 100644 index 0000000000..43018db0ab --- /dev/null +++ b/src/components/OrgSettings/ActionItemCategories/CategoryModal.tsx @@ -0,0 +1,208 @@ +import React, { type ChangeEvent, type FC, useEffect, useState } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import styles from './OrgActionItemCategories.module.css'; +import { useTranslation } from 'react-i18next'; +import type { InterfaceActionItemCategoryInfo } from 'utils/interfaces'; +import { useMutation } from '@apollo/client'; +import { + CREATE_ACTION_ITEM_CATEGORY_MUTATION, + UPDATE_ACTION_ITEM_CATEGORY_MUTATION, +} from 'GraphQl/Mutations/ActionItemCategoryMutations'; +import { toast } from 'react-toastify'; +import { FormControl, TextField } from '@mui/material'; + +/** + * Props for the `CategoryModal` component. + * + * + * isOpen - The state of the modal. + * hide - The function to hide the modal. + * refetchCategories - The function to refetch the categories. + * orgId - The organization ID. + * category - The category to be edited. + * mode - The mode of the modal. + * @returns The `CategoryModal` component. + */ +export interface InterfaceActionItemCategoryModal { + isOpen: boolean; + hide: () => void; + refetchCategories: () => void; + orgId: string; + category: InterfaceActionItemCategoryInfo | null; + mode: 'create' | 'edit'; +} + +/** + * A modal component for creating and editing action item categories. + * + * @param props - The properties passed to the component. + * @returns The `CategoryModal` component. + */ +const CategoryModal: FC = ({ + category, + hide, + isOpen, + mode, + refetchCategories, + orgId, +}) => { + const { t: tCommon } = useTranslation('common'); + const { t } = useTranslation('translation', { + keyPrefix: 'orgActionItemCategories', + }); + + const [formState, setFormState] = useState({ + name: category?.name ?? '', + isDisabled: category?.isDisabled ?? false, + }); + + const { name, isDisabled } = formState; + + useEffect(() => { + setFormState({ + name: category?.name ?? '', + isDisabled: category?.isDisabled ?? false, + }); + }, [category]); + + // Mutations for creating and updating categories + const [createActionItemCategory] = useMutation( + CREATE_ACTION_ITEM_CATEGORY_MUTATION, + ); + + const [updateActionItemCategory] = useMutation( + UPDATE_ACTION_ITEM_CATEGORY_MUTATION, + ); + + /** + * Handles category creation. + * + * @param e - The form submission event. + */ + const handleCreate = async ( + e: ChangeEvent, + ): Promise => { + e.preventDefault(); + try { + await createActionItemCategory({ + variables: { + name, + isDisabled, + organizationId: orgId, + }, + }); + + refetchCategories(); + hide(); + toast.success(t('successfulCreation')); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + + /** + * Handles category update. + * + * @param e - The form submission event. + */ + const handleEdit = async (e: ChangeEvent): Promise => { + e.preventDefault(); + if (name === category?.name && isDisabled === category?.isDisabled) { + toast.error(t('sameNameConflict')); // Show error if the name is the same + } else { + try { + const updatedFields: { [key: string]: string | boolean } = {}; + if (name != category?.name) { + updatedFields.name = name; + } + if (isDisabled != category?.isDisabled) { + updatedFields.isDisabled = isDisabled; + } + + await updateActionItemCategory({ + variables: { + actionItemCategoryId: category?._id, + ...updatedFields, + }, + }); + + setFormState({ + name: '', + isDisabled: false, + }); + refetchCategories(); + hide(); + toast.success(t('successfulUpdation')); + } catch (error: unknown) { + toast.error((error as Error).message); + } + } + }; + + return ( + + +

    {t('categoryDetails')}

    + +
    + +
    + {/* Input field to enter amount to be pledged */} + + + + setFormState({ ...formState, name: e.target.value }) + } + required + /> + + + + + setFormState({ + ...formState, + isDisabled: !isDisabled, + }) + } + /> + + + +
    +
    +
    + ); +}; + +export default CategoryModal; diff --git a/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.module.css b/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.module.css new file mode 100644 index 0000000000..919421b0f2 --- /dev/null +++ b/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.module.css @@ -0,0 +1,138 @@ +/* Button Styles */ +.addButton { + /* Position and size of the button */ + width: 7em; + position: absolute; + right: 1rem; + top: 1rem; +} + +/* Modal Styles */ +.createModal { + /* Position and size of the modal */ + margin-top: 20vh; + margin-left: 13vw; + max-width: 80vw; +} + +.icon { + /* Size and color of the icon */ + transform: scale(1.5); + color: var(--bs-danger); + margin-bottom: 1rem; +} + +.message { + /* Centering the content of the modal */ + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.titlemodal { + /* Styling for the modal title */ + color: #707070; + font-weight: 600; + font-size: 32px; + width: 65%; + margin-bottom: 0px; +} + +.modalCloseBtn { + /* Styling for the modal close button */ + width: 40px; + height: 40px; + padding: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +/* Input Styles */ +.noOutline input { + /* Removing the outline from the input */ + outline: none; +} + +/* Header and Action Item Categories Styles */ +.btnsContainer { + /* Styling for the container of the buttons */ + display: flex; + margin: 0.5rem 0 1.5rem 0; +} + +.btnsContainer .input { + /* Styling for the input field */ + flex: 1; + min-width: 18rem; + position: relative; +} + +.btnsContainer input { + /* Styling for the input border */ + outline: 1px solid var(--bs-gray-400); +} + +.btnsContainer .input button { + /* Styling for the button in the input field */ + width: 52px; +} + +.inputField { + margin-top: 10px; + margin-bottom: 10px; + background-color: white; + box-shadow: 0 1px 1px #31bb6b; +} + +.inputField > button { + padding-top: 10px; + padding-bottom: 10px; +} + +/* Dropdown Styles */ +.dropdown { + /* Styling for the dropdown */ + background-color: white; + border: 1px solid #31bb6b; + position: relative; + display: inline-block; + color: #31bb6b; +} + +/* Datagrid Styles */ +.rowBackground { + /* Styling for the row background */ + background-color: var(--bs-white); + max-height: 120px; +} + +.tableHeader { + /* Styling for the table header */ + background-color: var(--bs-primary); + color: var(--bs-white); + font-size: 1rem; +} + +.chipIcon { + /* Styling for the chip icon */ + height: 0.9rem !important; +} + +.chip { + /* Styling for the chip */ + height: 1.5rem !important; +} + +.active { + /* Styling for the active state */ + background-color: #31bb6a50 !important; +} + +.pending { + /* Styling for the pending state */ + background-color: #ffd76950 !important; + color: #bb952bd0 !important; + border-color: #bb952bd0 !important; +} diff --git a/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.test.tsx b/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.test.tsx new file mode 100644 index 0000000000..d3698bf346 --- /dev/null +++ b/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.test.tsx @@ -0,0 +1,241 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import type { ApolloLink } from '@apollo/client'; +import { MOCKS, MOCKS_EMPTY, MOCKS_ERROR } from './OrgActionItemCategoryMocks'; +import OrgActionItemCategories from './OrgActionItemCategories'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('@mui/x-date-pickers/DateTimePicker', () => { + return { + DateTimePicker: jest.requireActual( + '@mui/x-date-pickers/DesktopDateTimePicker', + ).DesktopDateTimePicker, + }; +}); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_EMPTY); +const link3 = new StaticMockLink(MOCKS_ERROR); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.orgActionItemCategories ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const renderActionItemCategories = ( + link: ApolloLink, + orgId: string, +): RenderResult => { + return render( + + + + + + + + + , + ); +}; + +describe('Testing Organisation Action Item Categories', () => { + it('should render the Action Item Categories Screen', async () => { + renderActionItemCategories(link1, 'orgId'); + await waitFor(() => { + expect(screen.getByTestId('searchByName')).toBeInTheDocument(); + expect(screen.getByText('Category 1')).toBeInTheDocument(); + expect(screen.getByText('Category 2')).toBeInTheDocument(); + }); + }); + + it('Sort the Categories (asc/desc) by createdAt', async () => { + renderActionItemCategories(link1, 'orgId'); + + const sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by createdAt_DESC + fireEvent.click(sortBtn); + await waitFor(() => { + expect(screen.getByTestId('createdAt_DESC')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('createdAt_DESC')); + await waitFor(() => { + expect(screen.getAllByTestId('categoryName')[0]).toHaveTextContent( + 'Category 1', + ); + }); + + // Sort by createdAt_ASC + fireEvent.click(sortBtn); + await waitFor(() => { + expect(screen.getByTestId('createdAt_ASC')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('createdAt_ASC')); + await waitFor(() => { + expect(screen.getAllByTestId('categoryName')[0]).toHaveTextContent( + 'Category 2', + ); + }); + }); + + it('Filter the categories by status (All/Disabled)', async () => { + renderActionItemCategories(link1, 'orgId'); + + const filterBtn = await screen.findByTestId('filter'); + expect(filterBtn).toBeInTheDocument(); + + // Filter by All + fireEvent.click(filterBtn); + await waitFor(() => { + expect(screen.getByTestId('statusAll')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('statusAll')); + + await waitFor(() => { + expect(screen.getByText('Category 1')).toBeInTheDocument(); + expect(screen.getByText('Category 2')).toBeInTheDocument(); + }); + + // Filter by Disabled + fireEvent.click(filterBtn); + await waitFor(() => { + expect(screen.getByTestId('statusDisabled')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('statusDisabled')); + await waitFor(() => { + expect(screen.queryByText('Category 1')).toBeNull(); + expect(screen.getByText('Category 2')).toBeInTheDocument(); + }); + }); + + it('Filter the categories by status (Active)', async () => { + renderActionItemCategories(link1, 'orgId'); + + const filterBtn = await screen.findByTestId('filter'); + expect(filterBtn).toBeInTheDocument(); + + fireEvent.click(filterBtn); + await waitFor(() => { + expect(screen.getByTestId('statusActive')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('statusActive')); + await waitFor(() => { + expect(screen.getByText('Category 1')).toBeInTheDocument(); + expect(screen.queryByText('Category 2')).toBeNull(); + }); + }); + + it('open and closes Create Category modal', async () => { + renderActionItemCategories(link1, 'orgId'); + + const addCategoryBtn = await screen.findByTestId( + 'createActionItemCategoryBtn', + ); + expect(addCategoryBtn).toBeInTheDocument(); + userEvent.click(addCategoryBtn); + + await waitFor(() => expect(screen.getAllByText(t.create)).toHaveLength(2)); + userEvent.click(screen.getByTestId('actionItemCategoryModalCloseBtn')); + await waitFor(() => + expect( + screen.queryByTestId('actionItemCategoryModalCloseBtn'), + ).toBeNull(), + ); + }); + + it('open and closes Edit Category modal', async () => { + renderActionItemCategories(link1, 'orgId'); + + const editCategoryBtn = await screen.findByTestId('editCategoryBtn1'); + await waitFor(() => expect(editCategoryBtn).toBeInTheDocument()); + userEvent.click(editCategoryBtn); + + await waitFor(() => + expect(screen.getByText(t.updateActionItemCategory)).toBeInTheDocument(), + ); + userEvent.click(screen.getByTestId('actionItemCategoryModalCloseBtn')); + await waitFor(() => + expect( + screen.queryByTestId('actionItemCategoryModalCloseBtn'), + ).toBeNull(), + ); + }); + + it('Search categories by name', async () => { + renderActionItemCategories(link1, 'orgId'); + + const searchInput = await screen.findByTestId('searchByName'); + expect(searchInput).toBeInTheDocument(); + + userEvent.type(searchInput, 'Category 1'); + userEvent.click(screen.getByTestId('searchBtn')); + await waitFor(() => { + expect(screen.getByText('Category 1')).toBeInTheDocument(); + expect(screen.queryByText('Category 2')).toBeNull(); + }); + }); + + it('Search categories by name and clear the input by backspace', async () => { + renderActionItemCategories(link1, 'orgId'); + + const searchInput = await screen.findByTestId('searchByName'); + expect(searchInput).toBeInTheDocument(); + + // Clear the search input by backspace + userEvent.type(searchInput, 'A{backspace}'); + await waitFor(() => { + expect(screen.getByText('Category 1')).toBeInTheDocument(); + expect(screen.getByText('Category 2')).toBeInTheDocument(); + }); + }); + + it('Search categories by name on press of ENTER', async () => { + renderActionItemCategories(link1, 'orgId'); + + const searchInput = await screen.findByTestId('searchByName'); + expect(searchInput).toBeInTheDocument(); + + userEvent.type(searchInput, 'Category 1'); + userEvent.type(searchInput, '{enter}'); + await waitFor(() => { + expect(screen.getByText('Category 1')).toBeInTheDocument(); + expect(screen.queryByText('Category 2')).toBeNull(); + }); + }); + + it('should render Empty Action Item Categories Screen', async () => { + renderActionItemCategories(link2, 'orgId'); + await waitFor(() => { + expect(screen.getByTestId('searchByName')).toBeInTheDocument(); + expect(screen.getByText(t.noActionItemCategories)).toBeInTheDocument(); + }); + }); + + it('should render the Action Item Categories Screen with error', async () => { + renderActionItemCategories(link3, 'orgId'); + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.tsx b/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.tsx new file mode 100644 index 0000000000..49cf47dd49 --- /dev/null +++ b/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.tsx @@ -0,0 +1,418 @@ +import type { FC } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import styles from './OrgActionItemCategories.module.css'; +import { useTranslation } from 'react-i18next'; + +import { useQuery } from '@apollo/client'; +import { ACTION_ITEM_CATEGORY_LIST } from 'GraphQl/Queries/Queries'; +import type { InterfaceActionItemCategoryInfo } from 'utils/interfaces'; +import Loader from 'components/Loader/Loader'; +import { + Circle, + Search, + Sort, + WarningAmberRounded, + FilterAltOutlined, +} from '@mui/icons-material'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import dayjs from 'dayjs'; +import { Chip, Stack } from '@mui/material'; +import CategoryModal from './CategoryModal'; + +enum ModalState { + SAME = 'same', + DELETE = 'delete', +} + +enum CategoryStatus { + Active = 'active', + Disabled = 'disabled', +} + +interface InterfaceActionItemCategoryProps { + orgId: string; +} + +const dataGridStyle = { + '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { + outline: 'none !important', + }, + '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-root': { + borderRadius: '0.5rem', + }, + '& .MuiDataGrid-main': { + borderRadius: '0.5rem', + }, +}; + +/** + * Represents the component for managing organization action item categories. + * This component allows creating, updating, enabling, and disabling action item categories. + */ +const OrgActionItemCategories: FC = ({ + orgId, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'orgActionItemCategories', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + const [category, setCategory] = + useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [searchValue, setSearchValue] = useState(''); + const [sortBy, setSortBy] = useState<'createdAt_ASC' | 'createdAt_DESC'>( + 'createdAt_DESC', + ); + const [status, setStatus] = useState(null); + const [categories, setCategories] = useState< + InterfaceActionItemCategoryInfo[] + >([]); + const [modalMode, setModalMode] = useState<'edit' | 'create'>('create'); + const [modalState, setModalState] = useState<{ + [key in ModalState]: boolean; + }>({ + [ModalState.SAME]: false, + [ModalState.DELETE]: false, + }); + + // Query to fetch action item categories + const { + data: catData, + loading: catLoading, + error: catError, + refetch: refetchCategories, + }: { + data?: { + actionItemCategoriesByOrganization: InterfaceActionItemCategoryInfo[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(ACTION_ITEM_CATEGORY_LIST, { + variables: { + organizationId: orgId, + where: { + name_contains: searchTerm, + is_disabled: !status ? undefined : status === CategoryStatus.Disabled, + }, + orderBy: sortBy, + }, + }); + + const openModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: true })); + + const closeModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: false })); + + const handleOpenModal = useCallback( + ( + category: InterfaceActionItemCategoryInfo | null, + mode: 'edit' | 'create', + ): void => { + setCategory(category); + setModalMode(mode); + openModal(ModalState.SAME); + }, + [openModal], + ); + + useEffect(() => { + if (catData && catData.actionItemCategoriesByOrganization) { + setCategories(catData.actionItemCategoriesByOrganization); + } + }, [catData]); + + // Show loader while data is being fetched + if (catLoading) { + return ; + } + + // Show error message if there's an error + if (catError) { + return ( +
    + +
    + {tErrors('errorLoading', { entity: 'Action Item Categories' })} +
    + {`${catError.message}`} +
    +
    + ); + } + + const columns: GridColDef[] = [ + { + field: 'id', + headerName: 'Sr. No.', + flex: 1, + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return
    {params.row.id}
    ; + }, + }, + { + field: 'categoryName', + headerName: 'Category', + flex: 2, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
    + {params.row.name} +
    + ); + }, + }, + { + field: 'status', + headerName: 'Status', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + } + label={params.row.isDisabled ? 'Disabled' : 'Active'} + variant="outlined" + color="primary" + className={`${styles.chip} ${params.row.isDisabled ? styles.pending : styles.active}`} + /> + ); + }, + }, + { + field: 'createdBy', + headerName: 'Created By', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return params.row.creator.firstName + ' ' + params.row.creator.lastName; + }, + }, + { + field: 'createdOn', + headerName: 'Created On', + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + flex: 1, + renderCell: (params: GridCellParams) => { + return ( +
    + {dayjs(params.row.createdAt).format('DD/MM/YYYY')} +
    + ); + }, + }, + { + field: 'action', + headerName: 'Action', + flex: 2, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + + ); + }, + }, + ]; + + return ( +
    + {/* Header with search, filter and Create Button */} +
    +
    + setSearchValue(e.target.value)} + onKeyUp={(e) => { + if (e.key === 'Enter') { + setSearchTerm(searchValue); + } else if (e.key === 'Backspace' && searchValue === '') { + setSearchTerm(''); + } + }} + data-testid="searchByName" + /> + +
    +
    +
    + + + + {tCommon('sort')} + + + setSortBy('createdAt_DESC')} + data-testid="createdAt_DESC" + > + {tCommon('createdLatest')} + + setSortBy('createdAt_ASC')} + data-testid="createdAt_ASC" + > + {tCommon('createdEarliest')} + + + + + + + {t('status')} + + + setStatus(null)} + data-testid="statusAll" + > + {tCommon('all')} + + setStatus(CategoryStatus.Active)} + data-testid="statusActive" + > + {tCommon('active')} + + setStatus(CategoryStatus.Disabled)} + data-testid="statusDisabled" + > + {tCommon('disabled')} + + + +
    +
    + +
    +
    +
    + + {/* Table with Action Item Categories */} + row._id} + slots={{ + noRowsOverlay: () => ( + + {t('noActionItemCategories')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={categories.map((category, index) => ({ + id: index + 1, + ...category, + }))} + columns={columns} + isRowSelectable={() => false} + /> + + closeModal(ModalState.SAME)} + refetchCategories={refetchCategories} + category={category} + orgId={orgId} + mode={modalMode} + /> +
    + ); +}; + +export default OrgActionItemCategories; diff --git a/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategoryMocks.ts b/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategoryMocks.ts new file mode 100644 index 0000000000..10310a01e4 --- /dev/null +++ b/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategoryMocks.ts @@ -0,0 +1,288 @@ +import { + CREATE_ACTION_ITEM_CATEGORY_MUTATION, + UPDATE_ACTION_ITEM_CATEGORY_MUTATION, +} from 'GraphQl/Mutations/mutations'; + +import { ACTION_ITEM_CATEGORY_LIST } from 'GraphQl/Queries/Queries'; + +export const MOCKS = [ + { + request: { + query: ACTION_ITEM_CATEGORY_LIST, + variables: { + organizationId: 'orgId', + where: { name_contains: '' }, + orderBy: 'createdAt_DESC', + }, + }, + result: { + data: { + actionItemCategoriesByOrganization: [ + { + _id: 'categoryId1', + name: 'Category 1', + isDisabled: false, + createdAt: '2024-08-26', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + { + _id: 'categoryId2', + name: 'Category 2', + isDisabled: true, + createdAt: '2024-08-25', + creator: { + _id: 'creatorId2', + firstName: 'John', + lastName: 'Doe', + }, + }, + ], + }, + }, + }, + { + request: { + query: ACTION_ITEM_CATEGORY_LIST, + variables: { + organizationId: 'orgId', + where: { name_contains: '' }, + orderBy: 'createdAt_ASC', + }, + }, + result: { + data: { + actionItemCategoriesByOrganization: [ + { + _id: 'categoryId2', + name: 'Category 2', + isDisabled: true, + createdAt: '2024-08-25', + creator: { + _id: 'creatorId2', + firstName: 'John', + lastName: 'Doe', + }, + }, + { + _id: 'categoryId1', + name: 'Category 1', + isDisabled: false, + createdAt: '2024-08-26', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + ], + }, + }, + }, + { + request: { + query: ACTION_ITEM_CATEGORY_LIST, + variables: { + organizationId: 'orgId', + where: { name_contains: '', is_disabled: false }, + orderBy: 'createdAt_DESC', + }, + }, + result: { + data: { + actionItemCategoriesByOrganization: [ + { + _id: 'categoryId1', + name: 'Category 1', + isDisabled: false, + createdAt: '2024-08-26', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + ], + }, + }, + }, + { + request: { + query: ACTION_ITEM_CATEGORY_LIST, + variables: { + organizationId: 'orgId', + where: { name_contains: '', is_disabled: true }, + orderBy: 'createdAt_DESC', + }, + }, + result: { + data: { + actionItemCategoriesByOrganization: [ + { + _id: 'categoryId2', + name: 'Category 2', + isDisabled: true, + createdAt: '2024-08-25', + creator: { + _id: 'creatorId2', + firstName: 'John', + lastName: 'Doe', + }, + }, + ], + }, + }, + }, + { + request: { + query: ACTION_ITEM_CATEGORY_LIST, + variables: { + organizationId: 'orgId', + where: { name_contains: 'Category 1' }, + orderBy: 'createdAt_DESC', + }, + }, + result: { + data: { + actionItemCategoriesByOrganization: [ + { + _id: 'categoryId1', + name: 'Category 1', + isDisabled: false, + createdAt: '2024-08-26', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + ], + }, + }, + }, + { + request: { + query: CREATE_ACTION_ITEM_CATEGORY_MUTATION, + variables: { + name: 'Category 2', + isDisabled: true, + organizationId: 'orgId', + }, + }, + result: { + data: { + createActionItemCategory: { + _id: 'categoryId3', + }, + }, + }, + }, + { + request: { + query: UPDATE_ACTION_ITEM_CATEGORY_MUTATION, + variables: { + name: 'Category 2', + isDisabled: true, + actionItemCategoryId: 'categoryId', + }, + }, + result: { + data: { + updateActionItemCategory: { + _id: 'categoryId', + }, + }, + }, + }, + { + request: { + query: UPDATE_ACTION_ITEM_CATEGORY_MUTATION, + variables: { + name: 'Category 2', + isDisabled: false, + actionItemCategoryId: 'categoryId', + }, + }, + result: { + data: { + updateActionItemCategory: { + _id: 'categoryId', + }, + }, + }, + }, + { + request: { + query: UPDATE_ACTION_ITEM_CATEGORY_MUTATION, + variables: { + name: 'Category 1', + isDisabled: true, + actionItemCategoryId: 'categoryId', + }, + }, + result: { + data: { + updateActionItemCategory: { + _id: 'categoryId', + }, + }, + }, + }, +]; + +export const MOCKS_EMPTY = [ + { + request: { + query: ACTION_ITEM_CATEGORY_LIST, + variables: { + organizationId: 'orgId', + where: { name_contains: '' }, + orderBy: 'createdAt_DESC', + }, + }, + result: { + data: { + actionItemCategoriesByOrganization: [], + }, + }, + }, +]; + +export const MOCKS_ERROR = [ + { + request: { + query: ACTION_ITEM_CATEGORY_LIST, + variables: { + organizationId: 'orgId', + where: { name_contains: '' }, + orderBy: 'createdAt_DESC', + }, + }, + error: new Error('Mock Graphql Error'), + }, + { + request: { + query: CREATE_ACTION_ITEM_CATEGORY_MUTATION, + variables: { + name: 'Category 2', + isDisabled: true, + organizationId: 'orgId', + }, + }, + error: new Error('Mock Graphql Error'), + }, + { + request: { + query: UPDATE_ACTION_ITEM_CATEGORY_MUTATION, + variables: { + name: 'Category 2', + isDisabled: true, + actionItemCategoryId: 'categoryId', + }, + }, + error: new Error('Mock Graphql Error'), + }, +]; diff --git a/src/components/OrgSettings/AgendaItemCategories/AgendaCategoryCreateModal.test.tsx b/src/components/OrgSettings/AgendaItemCategories/AgendaCategoryCreateModal.test.tsx new file mode 100644 index 0000000000..da92dfd201 --- /dev/null +++ b/src/components/OrgSettings/AgendaItemCategories/AgendaCategoryCreateModal.test.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; + +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +import AgendaCategoryCreateModal from './AgendaCategoryCreateModal'; + +const mockFormState = { + name: 'Test Name', + description: 'Test Description', + createdBy: 'Test User', +}; +const mockHideCreateModal = jest.fn(); +const mockSetFormState = jest.fn(); +const mockCreateAgendaCategoryHandler = jest.fn(); +const mockT = (key: string): string => key; + +describe('AgendaCategoryCreateModal', () => { + test('renders modal correctly', () => { + render( + + + + + + + + + + + , + ); + + expect(screen.getByText('agendaCategoryDetails')).toBeInTheDocument(); + expect( + screen.getByTestId('createAgendaCategoryFormSubmitBtn'), + ).toBeInTheDocument(); + expect( + screen.getByTestId('createAgendaCategoryModalCloseBtn'), + ).toBeInTheDocument(); + }); + test('tests the condition for formState.name and formState.description', () => { + const mockFormState = { + name: 'Test Name', + description: 'Test Description', + createdBy: 'Test User', + }; + render( + + + + + + + + + + + , + ); + const nameInput = screen.getByLabelText('name'); + fireEvent.change(nameInput, { + target: { value: 'New name' }, + }); + const descriptionInput = screen.getByLabelText('description'); + fireEvent.change(descriptionInput, { + target: { value: 'New description' }, + }); + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + name: 'New name', + }); + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + description: 'New description', + }); + }); + test('calls createAgendaCategoryHandler when form is submitted', () => { + render( + + + + + + + + + + + , + ); + + fireEvent.submit(screen.getByTestId('createAgendaCategoryFormSubmitBtn')); + expect(mockCreateAgendaCategoryHandler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/OrgSettings/AgendaItemCategories/AgendaCategoryCreateModal.tsx b/src/components/OrgSettings/AgendaItemCategories/AgendaCategoryCreateModal.tsx new file mode 100644 index 0000000000..57a3057b3f --- /dev/null +++ b/src/components/OrgSettings/AgendaItemCategories/AgendaCategoryCreateModal.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { Modal, Form, Button } from 'react-bootstrap'; +import type { ChangeEvent } from 'react'; +import styles from './OrganizationAgendaCategory.module.css'; + +/** + * InterfaceFormStateType is an object containing the form state + */ +interface InterfaceFormStateType { + name: string; + description: string; + createdBy: string; +} + +/** + * InterfaceAgendaCategoryCreateModalProps is an object containing the props for AgendaCategoryCreateModal component + */ +interface InterfaceAgendaCategoryCreateModalProps { + agendaCategoryCreateModalIsOpen: boolean; + hideCreateModal: () => void; + formState: InterfaceFormStateType; + setFormState: (state: React.SetStateAction) => void; + createAgendaCategoryHandler: ( + e: ChangeEvent, + ) => Promise; + t: (key: string) => string; +} + +/** + * AgendaCategoryCreateModal component is used to create the agenda category details like name, description + * @param agendaCategoryCreateModalIsOpen - boolean value to check if the modal is open or not + * @param hideCreateModal - function to hide the modal + * @param formState - object containing the form state + * @param setFormState - function to set the form state + * @param createAgendaCategoryHandler - function to create the agenda category + * @param t - i18n function to translate the text + * @returns returns the AgendaCategoryCreateModal component + */ +const AgendaCategoryCreateModal: React.FC< + InterfaceAgendaCategoryCreateModalProps +> = ({ + agendaCategoryCreateModalIsOpen, + hideCreateModal, + formState, + setFormState, + createAgendaCategoryHandler, + t, +}) => { + return ( + + +

    {t('agendaCategoryDetails')}

    + +
    + +
    + + {t('name')} + + setFormState({ ...formState, name: e.target.value }) + } + /> + + + {t('description')} + + setFormState({ ...formState, description: e.target.value }) + } + /> + + +
    +
    +
    + ); +}; + +export default AgendaCategoryCreateModal; diff --git a/src/components/OrgSettings/AgendaItemCategories/AgendaCategoryDeleteModal.tsx b/src/components/OrgSettings/AgendaItemCategories/AgendaCategoryDeleteModal.tsx new file mode 100644 index 0000000000..a16186bac7 --- /dev/null +++ b/src/components/OrgSettings/AgendaItemCategories/AgendaCategoryDeleteModal.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Modal, Button } from 'react-bootstrap'; +import styles from './OrganizationAgendaCategory.module.css'; + +/** + * InterfaceAgendaCategoryDeleteModalProps is an object containing the props for AgendaCategoryDeleteModal component + */ +interface InterfaceAgendaCategoryDeleteModalProps { + agendaCategoryDeleteModalIsOpen: boolean; + toggleDeleteModal: () => void; + deleteAgendaCategoryHandler: () => Promise; + t: (key: string) => string; + tCommon: (key: string) => string; +} + +/** + * AgendaCategoryDeleteModal component is used to delete the agenda category + * @param agendaCategoryDeleteModalIsOpen - boolean value to check if the modal is open or not + * @param toggleDeleteModal - function to toggle the modal + * @param deleteAgendaCategoryHandler - function to delete the agenda category + * @param t - i18n function to translate the text + * @param tCommon - i18n function to translate the text + * @returns returns the AgendaCategoryDeleteModal component + */ +const AgendaCategoryDeleteModal: React.FC< + InterfaceAgendaCategoryDeleteModalProps +> = ({ + agendaCategoryDeleteModalIsOpen, + toggleDeleteModal, + deleteAgendaCategoryHandler, + t, + tCommon, +}) => { + return ( + + + + {t('deleteAgendaCategory')} + + + +

    {t('deleteAgendaCategoryMsg')}

    +
    + + + + +
    + ); +}; + +export default AgendaCategoryDeleteModal; diff --git a/src/components/OrgSettings/AgendaItemCategories/AgendaCategoryPreviewModal.tsx b/src/components/OrgSettings/AgendaItemCategories/AgendaCategoryPreviewModal.tsx new file mode 100644 index 0000000000..794db27ad3 --- /dev/null +++ b/src/components/OrgSettings/AgendaItemCategories/AgendaCategoryPreviewModal.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { Modal, Form, Button } from 'react-bootstrap'; + +import styles from './OrganizationAgendaCategory.module.css'; + +/** + * InterfaceFormStateType is an object containing the form state + */ +interface InterfaceFormStateType { + name: string; + description: string; + createdBy: string; +} + +/** + * InterfaceAgendaCategoryPreviewModalProps is an object containing the props for AgendaCategoryPreviewModal component + */ +interface InterfaceAgendaCategoryPreviewModalProps { + agendaCategoryPreviewModalIsOpen: boolean; + hidePreviewModal: () => void; + showUpdateModal: () => void; + toggleDeleteModal: () => void; + formState: InterfaceFormStateType; + + t: (key: string) => string; +} + +/** + * AgendaCategoryPreviewModal component is used to preview the agenda category details like name, description, createdBy + * @param agendaCategoryPreviewModalIsOpen - boolean value to check if the modal is open or not + * @param hidePreviewModal - function to hide the modal + * @param showUpdateModal - function to show the update modal + * @param toggleDeleteModal - function to toggle the delete modal + * @param formState - object containing the form state + * @param t - i18n function to translate the text + * @returns returns the AgendaCategoryPreviewModal component + */ +const AgendaCategoryPreviewModal: React.FC< + InterfaceAgendaCategoryPreviewModalProps +> = ({ + agendaCategoryPreviewModalIsOpen, + hidePreviewModal, + showUpdateModal, + toggleDeleteModal, + formState, + t, +}) => { + return ( + + +

    {t('agendaCategoryDetails')}

    + +
    + +
    +
    +

    + {t('name')} + {formState.name} +

    +

    + {t('description')} + {formState.description} +

    +

    + {t('createdBy')} + {formState.createdBy} +

    +
    +
    + + +
    +
    +
    +
    + ); +}; + +export default AgendaCategoryPreviewModal; diff --git a/src/components/OrgSettings/AgendaItemCategories/AgendaCategoryUpdateModal.test.tsx b/src/components/OrgSettings/AgendaItemCategories/AgendaCategoryUpdateModal.test.tsx new file mode 100644 index 0000000000..168b97abd3 --- /dev/null +++ b/src/components/OrgSettings/AgendaItemCategories/AgendaCategoryUpdateModal.test.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; + +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +import AgendaCategoryUpdateModal from './AgendaCategoryUpdateModal'; + +const mockFormState = { + name: 'Test Name', + description: 'Test Description', + createdBy: 'Test User', +}; +const mockHideUpdateModal = jest.fn(); +const mockSetFormState = jest.fn(); +const mockUpdateAgendaCategoryHandler = jest.fn(); +const mockT = (key: string): string => key; + +describe('AgendaCategoryUpdateModal', () => { + test('renders modal correctly', () => { + render( + + + + + + + + + + + , + ); + + expect(screen.getByText('updateAgendaCategory')).toBeInTheDocument(); + expect(screen.getByTestId('editAgendaCategoryBtn')).toBeInTheDocument(); + expect( + screen.getByTestId('updateAgendaCategoryModalCloseBtn'), + ).toBeInTheDocument(); + }); + + test('calls hideUpdateModal when close button is clicked', () => { + render( + + + + + + + + + + + , + ); + + userEvent.click(screen.getByTestId('updateAgendaCategoryModalCloseBtn')); + expect(mockHideUpdateModal).toHaveBeenCalledTimes(1); + }); + + test('tests the condition for formState.name and formState.description', () => { + const mockFormState = { + name: 'Test Name', + description: 'Test Description', + createdBy: 'Test User', + }; + render( + + + + + + + + + + + , + ); + const nameInput = screen.getByLabelText('name'); + fireEvent.change(nameInput, { + target: { value: 'New name' }, + }); + const descriptionInput = screen.getByLabelText('description'); + fireEvent.change(descriptionInput, { + target: { value: 'New description' }, + }); + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + name: 'New name', + }); + expect(mockSetFormState).toHaveBeenCalledWith({ + ...mockFormState, + description: 'New description', + }); + }); + + test('calls updateAgendaCategoryHandler when form is submitted', () => { + render( + + + + + + + + + + + , + ); + + fireEvent.submit(screen.getByTestId('editAgendaCategoryBtn')); + expect(mockUpdateAgendaCategoryHandler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/OrgSettings/AgendaItemCategories/AgendaCategoryUpdateModal.tsx b/src/components/OrgSettings/AgendaItemCategories/AgendaCategoryUpdateModal.tsx new file mode 100644 index 0000000000..508f1db69b --- /dev/null +++ b/src/components/OrgSettings/AgendaItemCategories/AgendaCategoryUpdateModal.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { Modal, Form, Button } from 'react-bootstrap'; +import type { ChangeEvent } from 'react'; + +import styles from './OrganizationAgendaCategory.module.css'; + +/** + * InterfaceFormStateType is an object containing the form state + */ +interface InterfaceFormStateType { + name: string; + description: string; + createdBy: string; +} + +/** + * InterfaceAgendaCategoryUpdateModalProps is an object containing the props for AgendaCategoryUpdateModal component + */ +interface InterfaceAgendaCategoryUpdateModalProps { + agendaCategoryUpdateModalIsOpen: boolean; + hideUpdateModal: () => void; + formState: InterfaceFormStateType; + setFormState: (state: React.SetStateAction) => void; + updateAgendaCategoryHandler: ( + e: ChangeEvent, + ) => Promise; + t: (key: string) => string; +} + +/** + * AgendaCategoryUpdateModal component is used to update the agenda category details like name, description + * @param agendaCategoryUpdateModalIsOpen - boolean value to check if the modal is open or not + * @param hideUpdateModal - function to hide the modal + * @param formState - object containing the form state + * @param setFormState - function to set the form state + * @param updateAgendaCategoryHandler - function to update the agenda category + * @param t - i18n function to translate the text + * @returns returns the AgendaCategoryUpdateModal component + */ +const AgendaCategoryUpdateModal: React.FC< + InterfaceAgendaCategoryUpdateModalProps +> = ({ + agendaCategoryUpdateModalIsOpen, + hideUpdateModal, + formState, + setFormState, + updateAgendaCategoryHandler, + t, +}) => { + return ( + + +

    {t('updateAgendaCategory')}

    + +
    + +
    + + {t('name')} + + setFormState({ ...formState, name: e.target.value }) + } + /> + + + {t('description')} + + setFormState({ ...formState, description: e.target.value }) + } + /> + + +
    +
    +
    + ); +}; + +export default AgendaCategoryUpdateModal; diff --git a/src/components/OrgSettings/AgendaItemCategories/OrganizationAgendaCategory.module.css b/src/components/OrgSettings/AgendaItemCategories/OrganizationAgendaCategory.module.css new file mode 100644 index 0000000000..13e187f8b5 --- /dev/null +++ b/src/components/OrgSettings/AgendaItemCategories/OrganizationAgendaCategory.module.css @@ -0,0 +1,171 @@ +.agendaCategoryContainer { + height: 90vh; +} + +.agendaCategoryModal { + max-width: 80vw; + margin-top: 2vh; + margin-left: 13vw; +} + +.btnsContainer { + display: flex; + gap: 10px; +} + +.btnsContainer .btnsBlock { + display: flex; + gap: 10px; +} + +.btnsContainer button { + display: flex; + align-items: center; +} + +.container { + min-height: 100vh; +} + +.datediv { + display: flex; + flex-direction: row; +} + +.datebox { + width: 90%; + border-radius: 7px; + outline: none; + box-shadow: none; + padding-top: 2px; + padding-bottom: 2px; + padding-right: 5px; + padding-left: 5px; + margin-right: 5px; + margin-left: 5px; +} + +.dropdown { + display: block; +} + +.dropdownToggle { + margin-bottom: 0; + display: flex; +} + +.dropdownModalToggle { + width: 50%; +} + +.errorIcon { + transform: scale(1.5); + color: var(--bs-danger); + margin-bottom: 1rem; +} + +.greenregbtn { + margin: 1rem 0 0; + margin-top: 15px; + border: 1px solid var(--bs-gray-300); + box-shadow: 0 2px 2px var(--bs-gray-300); + padding: 10px 10px; + border-radius: 5px; + background-color: var(--bs-primary); + width: 100%; + font-size: 16px; + color: var(--bs-white); + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; +} + +h2 { + margin-top: 0.5rem; +} + +hr { + border: none; + height: 1px; + background-color: var(--bs-gray-500); + margin: 1rem; +} + +.iconContainer { + display: flex; + justify-content: flex-end; +} +.icon { + margin: 1px; +} + +.message { + margin-top: 25%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.organizationAgendaCategoryContainer h2 { + margin: 0.6rem 0; +} + +.preview { + display: flex; + flex-direction: row; + font-weight: 900; + font-size: 16px; + color: rgb(80, 80, 80); +} + +.removeFilterIcon { + cursor: pointer; +} + +.searchForm { + display: inline; +} + +.titlemodal { + color: var(--bs-gray-600); + font-weight: 600; + font-size: 20px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 3px solid var(--bs-primary); + width: 65%; +} + +.view { + margin-left: 2%; + font-weight: 600; + font-size: 16px; + color: var(--bs-gray-600); +} + +@media (max-width: 767px) { + .btnsContainer { + margin-bottom: 0; + display: flex; + flex-direction: column; + } + + .btnsContainer .btnsBlock .dropdownToggle { + flex-grow: 1; + } + + .btnsContainer button { + width: 100%; + } + + .createAgendaCategoryButton { + position: absolute; + top: 1rem; + right: 2rem; + } +} diff --git a/src/components/OrgSettings/AgendaItemCategories/OrganizationAgendaCategory.test.tsx b/src/components/OrgSettings/AgendaItemCategories/OrganizationAgendaCategory.test.tsx new file mode 100644 index 0000000000..56cb450647 --- /dev/null +++ b/src/components/OrgSettings/AgendaItemCategories/OrganizationAgendaCategory.test.tsx @@ -0,0 +1,237 @@ +import React from 'react'; +import { + render, + screen, + waitFor, + act, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import 'jest-localstorage-mock'; +import { MockedProvider } from '@apollo/client/testing'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import i18n from 'utils/i18nForTest'; +import { toast } from 'react-toastify'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +import OrganizationAgendaCategory from './OrganizationAgendaCategory'; +import { + MOCKS_ERROR_AGENDA_ITEM_CATEGORY_LIST_QUERY, + MOCKS_ERROR_MUTATION, +} from './OrganizationAgendaCategoryErrorMocks'; +import { MOCKS } from './OrganizationAgendaCategoryMocks'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: '123' }), +})); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink( + MOCKS_ERROR_AGENDA_ITEM_CATEGORY_LIST_QUERY, + true, +); +const link3 = new StaticMockLink(MOCKS_ERROR_MUTATION, true); + +const translations = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.organizationAgendaCategory ?? + {}, + ), + ), +}; + +describe('Testing Agenda Categories Component', () => { + const formData = { + name: 'Category', + description: 'Test Description', + createdBy: 'Test User', + }; + test('Component loads correctly', async () => { + const { getByText } = render( + + + + + {} + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.createAgendaCategory)).toBeInTheDocument(); + }); + }); + + test('render error component on unsuccessful agenda category list query', async () => { + const { queryByText } = render( + + + + + {} + + + + , + ); + + await wait(); + + await waitFor(() => { + expect( + queryByText(translations.createAgendaCategory), + ).not.toBeInTheDocument(); + }); + }); + + test('opens and closes the create agenda category modal', async () => { + render( + + + + + + {} + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createAgendaCategoryBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('createAgendaCategoryBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('createAgendaCategoryModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('createAgendaCategoryModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('createAgendaCategoryModalCloseBtn'), + ); + }); + test('creates new agenda cagtegory', async () => { + render( + + + + + + {} + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createAgendaCategoryBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('createAgendaCategoryBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('createAgendaCategoryModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + + userEvent.type( + screen.getByPlaceholderText(translations.name), + formData.name, + ); + + userEvent.type( + screen.getByPlaceholderText(translations.description), + formData.description, + ); + userEvent.click(screen.getByTestId('createAgendaCategoryFormSubmitBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + translations.agendaCategoryCreated, + ); + }); + }); + + // test('toasts error on unsuccessful creation', async () => { + // render( + // + // + // + // + // + // {} + // + // + // + // + // , + // ); + + // await wait(); + + // await waitFor(() => { + // expect(screen.getByTestId('createAgendaCategoryBtn')).toBeInTheDocument(); + // }); + // userEvent.click(screen.getByTestId('createAgendaCategoryBtn')); + + // await waitFor(() => { + // return expect( + // screen.findByTestId('createAgendaCategoryModalCloseBtn'), + // ).resolves.toBeInTheDocument(); + // }); + + // userEvent.type( + // screen.getByPlaceholderText(translations.name), + // formData.name, + // ); + + // userEvent.type( + // screen.getByPlaceholderText(translations.description), + // formData.description, + // ); + // userEvent.click(screen.getByTestId('createAgendaCategoryFormSubmitBtn')); + + // await waitFor(() => { + // expect(toast.error).toBeCalledWith(); + // }); + // }); +}); diff --git a/src/components/OrgSettings/AgendaItemCategories/OrganizationAgendaCategory.tsx b/src/components/OrgSettings/AgendaItemCategories/OrganizationAgendaCategory.tsx new file mode 100644 index 0000000000..884371d862 --- /dev/null +++ b/src/components/OrgSettings/AgendaItemCategories/OrganizationAgendaCategory.tsx @@ -0,0 +1,187 @@ +import React, { useState } from 'react'; +import type { ChangeEvent, FC } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from 'react-bootstrap'; + +import { WarningAmberRounded } from '@mui/icons-material'; +import { toast } from 'react-toastify'; + +import { useMutation, useQuery } from '@apollo/client'; +import { AGENDA_ITEM_CATEGORY_LIST } from 'GraphQl/Queries/Queries'; +import { CREATE_AGENDA_ITEM_CATEGORY_MUTATION } from 'GraphQl/Mutations/mutations'; + +import type { InterfaceAgendaItemCategoryList } from 'utils/interfaces'; +import AgendaCategoryContainer from 'components/AgendaCategory/AgendaCategoryContainer'; +import AgendaCategoryCreateModal from './AgendaCategoryCreateModal'; +import styles from './OrganizationAgendaCategory.module.css'; +import Loader from 'components/Loader/Loader'; + +interface InterfaceAgendaCategoryProps { + orgId: string; +} + +/** + * Component for managing and displaying agenda item categories within an organization. + * + * This component allows users to view, create, and manage agenda item categories. It includes functionality for displaying categories, handling creation, and managing modal visibility. + * + * @returns The rendered component. + */ + +const organizationAgendaCategory: FC = ({ + orgId, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'organizationAgendaCategory', + }); + + // State for managing modal visibility and form data + const [agendaCategoryCreateModalIsOpen, setAgendaCategoryCreateModalIsOpen] = + useState(false); + + const [formState, setFormState] = useState({ + name: '', + description: '', + createdBy: '', + }); + + /** + * Query to fetch agenda item categories for the organization. + */ + const { + data: agendaCategoryData, + loading: agendaCategoryLoading, + error: agendaCategoryError, + refetch: refetchAgendaCategory, + }: { + data: InterfaceAgendaItemCategoryList | undefined; + loading: boolean; + error?: unknown | undefined; + refetch: () => void; + } = useQuery(AGENDA_ITEM_CATEGORY_LIST, { + variables: { organizationId: orgId }, + notifyOnNetworkStatusChange: true, + }); + + /** + * Mutation to create a new agenda item category. + */ + const [createAgendaCategory] = useMutation( + CREATE_AGENDA_ITEM_CATEGORY_MUTATION, + ); + + /** + * Handler function to create a new agenda item category. + * + * @param e - The form submit event. + * @returns A promise that resolves when the agenda item category is created. + */ + const createAgendaCategoryHandler = async ( + e: ChangeEvent, + ): Promise => { + e.preventDefault(); + try { + await createAgendaCategory({ + variables: { + input: { + organizationId: orgId, + name: formState.name, + description: formState.description, + }, + }, + }); + toast.success(t('agendaCategoryCreated') as string); + setFormState({ name: '', description: '', createdBy: '' }); + refetchAgendaCategory(); + hideCreateModal(); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + /** + * Toggles the visibility of the create agenda item category modal. + */ + const showCreateModal = (): void => { + setAgendaCategoryCreateModalIsOpen(!agendaCategoryCreateModalIsOpen); + }; + + /** + * Hides the create agenda item category modal. + */ + const hideCreateModal = (): void => { + setAgendaCategoryCreateModalIsOpen(!agendaCategoryCreateModalIsOpen); + }; + + if (agendaCategoryLoading) return ; + + if (agendaCategoryError) { + return ( +
    +
    + +
    + Error occured while loading{' '} + {agendaCategoryError && 'Agenda Categories'} + Data +
    + {agendaCategoryError && (agendaCategoryError as Error).message} +
    +
    +
    + ); + } + + return ( +
    +
    +
    +
    +
    + {/* setSearchValue(e.target.value)} + value={searchValue} + data-testid="searchAgendaCategories" + /> */} +
    + + +
    +
    + +
    + + +
    + +
    + ); +}; + +export default organizationAgendaCategory; diff --git a/src/components/OrgSettings/AgendaItemCategories/OrganizationAgendaCategoryErrorMocks.ts b/src/components/OrgSettings/AgendaItemCategories/OrganizationAgendaCategoryErrorMocks.ts new file mode 100644 index 0000000000..10809c3da8 --- /dev/null +++ b/src/components/OrgSettings/AgendaItemCategories/OrganizationAgendaCategoryErrorMocks.ts @@ -0,0 +1,51 @@ +import { CREATE_AGENDA_ITEM_CATEGORY_MUTATION } from 'GraphQl/Mutations/AgendaCategoryMutations'; + +import { AGENDA_ITEM_CATEGORY_LIST } from 'GraphQl/Queries/AgendaCategoryQueries'; + +export const MOCKS_ERROR_AGENDA_ITEM_CATEGORY_LIST_QUERY = [ + { + request: { + query: AGENDA_ITEM_CATEGORY_LIST, + variables: { organizationId: '123' }, + }, + error: new Error('Mock Graphql Error'), + }, +]; + +export const MOCKS_ERROR_MUTATION = [ + { + request: { + query: AGENDA_ITEM_CATEGORY_LIST, + variables: { organizationId: '123' }, + }, + result: { + data: { + agendaItemCategoriesByOrganization: [ + { + _id: 'agendaItemCategory1', + name: 'Category', + description: 'Test Description', + createdBy: { + _id: 'user1', + firstName: 'Harve', + lastName: 'Lance', + }, + }, + ], + }, + }, + }, + { + request: { + query: CREATE_AGENDA_ITEM_CATEGORY_MUTATION, + variables: { + input: { + organizationId: '123', + name: 'Category', + description: 'Test Description', + }, + }, + }, + error: new Error('Mock Graphql Error'), + }, +]; diff --git a/src/components/OrgSettings/AgendaItemCategories/OrganizationAgendaCategoryMocks.ts b/src/components/OrgSettings/AgendaItemCategories/OrganizationAgendaCategoryMocks.ts new file mode 100644 index 0000000000..434b0dcad2 --- /dev/null +++ b/src/components/OrgSettings/AgendaItemCategories/OrganizationAgendaCategoryMocks.ts @@ -0,0 +1,47 @@ +import { CREATE_AGENDA_ITEM_CATEGORY_MUTATION } from 'GraphQl/Mutations/AgendaCategoryMutations'; + +import { AGENDA_ITEM_CATEGORY_LIST } from 'GraphQl/Queries/AgendaCategoryQueries'; + +export const MOCKS = [ + { + request: { + query: AGENDA_ITEM_CATEGORY_LIST, + variables: { organizationId: '123' }, + }, + result: { + data: { + agendaItemCategoriesByOrganization: [ + { + _id: 'agendaItemCategory1', + name: 'Category', + description: 'Test Description', + createdBy: { + _id: 'user1', + firstName: 'Harve', + lastName: 'Lance', + }, + }, + ], + }, + }, + }, + { + request: { + query: CREATE_AGENDA_ITEM_CATEGORY_MUTATION, + variables: { + input: { + organizationId: '123', + name: 'Category', + description: 'Test Description', + }, + }, + }, + result: { + data: { + createAgendaCategory: { + _id: 'agendaItemCategory1', + }, + }, + }, + }, +]; diff --git a/src/components/OrgSettings/General/DeleteOrg/DeleteOrg.module.css b/src/components/OrgSettings/General/DeleteOrg/DeleteOrg.module.css new file mode 100644 index 0000000000..2b15a2ac0c --- /dev/null +++ b/src/components/OrgSettings/General/DeleteOrg/DeleteOrg.module.css @@ -0,0 +1,25 @@ +.settingsBody { + margin: 2.5rem 0; +} + +.cardHeader { + padding: 1.25rem 1rem 1rem 1rem; + border-bottom: 1px solid var(--bs-gray-200); + display: flex; + justify-content: space-between; + align-items: center; +} + +.cardHeader .cardTitle { + font-size: 1.2rem; + font-weight: 600; +} + +.cardBody { + min-height: 180px; +} + +.cardBody .textBox { + margin: 0 0 3rem 0; + color: var(--bs-secondary); +} diff --git a/src/components/OrgSettings/General/DeleteOrg/DeleteOrg.test.tsx b/src/components/OrgSettings/General/DeleteOrg/DeleteOrg.test.tsx new file mode 100644 index 0000000000..77ffe65c08 --- /dev/null +++ b/src/components/OrgSettings/General/DeleteOrg/DeleteOrg.test.tsx @@ -0,0 +1,307 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { render, screen, waitFor } from '@testing-library/react'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; + +import { + DELETE_ORGANIZATION_MUTATION, + REMOVE_SAMPLE_ORGANIZATION_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18nForTest from 'utils/i18nForTest'; +import DeleteOrg from './DeleteOrg'; +import { ToastContainer, toast } from 'react-toastify'; +import { IS_SAMPLE_ORGANIZATION_QUERY } from 'GraphQl/Queries/Queries'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem } = useLocalStorage(); + +async function wait(ms = 1000): Promise { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }); +} + +const MOCKS = [ + { + request: { + query: IS_SAMPLE_ORGANIZATION_QUERY, + variables: { + isSampleOrganizationId: '123', + }, + }, + result: { + data: { + isSampleOrganization: true, + }, + }, + }, + { + request: { + query: REMOVE_SAMPLE_ORGANIZATION_MUTATION, + }, + result: { + data: { + removeSampleOrganization: true, + }, + }, + }, + { + request: { + query: DELETE_ORGANIZATION_MUTATION, + variables: { + id: '456', + }, + }, + result: { + data: { + removeOrganization: { + _id: '456', + }, + }, + }, + }, +]; + +const MOCKS_WITH_ERROR = [ + { + request: { + query: IS_SAMPLE_ORGANIZATION_QUERY, + variables: { + isSampleOrganizationId: '123', + }, + }, + result: { + data: { + isSampleOrganization: true, + }, + }, + }, + { + request: { + query: DELETE_ORGANIZATION_MUTATION, + variables: { + id: '456', + }, + }, + error: new Error('Failed to delete organization'), + }, + { + request: { + query: REMOVE_SAMPLE_ORGANIZATION_MUTATION, + }, + error: new Error('Failed to delete sample organization'), + }, +]; + +const mockNavgatePush = jest.fn(); +let mockURL = '123'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: mockURL }), + useNavigate: () => mockNavgatePush, +})); + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(MOCKS_WITH_ERROR, true); + +afterEach(() => { + localStorage.clear(); +}); + +describe('Delete Organization Component', () => { + test('should be able to Toggle Delete Organization Modal', async () => { + mockURL = '456'; + setItem('SuperAdmin', true); + await act(async () => { + render( + + + + + + + + + + , + ); + }); + act(() => { + screen.getByTestId(/openDeleteModalBtn/i).click(); + }); + expect(await screen.findByTestId(/orgDeleteModal/i)).toBeInTheDocument(); + act(() => { + screen.getByTestId(/closeDelOrgModalBtn/i).click(); + }); + await waitFor(() => { + expect(screen.queryByTestId(/orgDeleteModal/i)).not.toBeInTheDocument(); + }); + }); + + test('should be able to Toggle Delete Organization Modal When Organization is Sample Organization', async () => { + mockURL = '123'; + setItem('SuperAdmin', true); + await act(async () => { + render( + + + + + + + + + + , + ); + }); + await wait(); + act(() => { + screen.getByTestId(/openDeleteModalBtn/i).click(); + }); + expect(screen.getByTestId(/orgDeleteModal/i)).toBeInTheDocument(); + act(() => { + screen.getByTestId(/closeDelOrgModalBtn/i).click(); + }); + await waitFor(() => { + expect(screen.queryByTestId(/orgDeleteModal/i)).not.toBeInTheDocument(); + }); + }); + + test('Delete organization functionality should work properly', async () => { + mockURL = '456'; + setItem('SuperAdmin', true); + await act(async () => { + render( + + + + + + + + + , + ); + }); + screen.debug(); + act(() => { + screen.getByTestId('openDeleteModalBtn').click(); + }); + screen.debug(); + expect(await screen.findByTestId('orgDeleteModal')).toBeInTheDocument(); + const deleteButton = await screen.findByTestId('deleteOrganizationBtn'); + act(() => { + deleteButton.click(); + }); + }); + + test('Delete organization functionality should work properly for sample org', async () => { + mockURL = '123'; + setItem('SuperAdmin', true); + await act(async () => { + render( + + + + + + + + + , + ); + }); + await waitFor(() => { + expect(screen.getByTestId('openDeleteModalBtn')).toBeInTheDocument(); + }); + act(() => { + screen.getByTestId('openDeleteModalBtn').click(); + }); + await waitFor(() => { + expect(screen.getByTestId('orgDeleteModal')).toBeInTheDocument(); + }); + const deleteButton = await screen.findByTestId('deleteOrganizationBtn'); + act(() => { + deleteButton.click(); + }); + await wait(2000); + expect(mockNavgatePush).toHaveBeenCalledWith('/orglist'); + }); + + test('Error handling for IS_SAMPLE_ORGANIZATION_QUERY mock', async () => { + mockURL = '123'; + setItem('SuperAdmin', true); + jest.spyOn(toast, 'error'); + await act(async () => { + render( + + + + + + + + + , + ); + }); + await waitFor(() => { + expect(screen.getByTestId('openDeleteModalBtn')).toBeInTheDocument(); + }); + act(() => { + screen.getByTestId('openDeleteModalBtn').click(); + }); + await waitFor(() => { + expect(screen.getByTestId('orgDeleteModal')).toBeInTheDocument(); + }); + act(() => { + screen.getByTestId('deleteOrganizationBtn').click(); + }); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + 'Failed to delete sample organization', + ); + }); + }); + + test('Error handling for DELETE_ORGANIZATION_MUTATION mock', async () => { + mockURL = '456'; + setItem('SuperAdmin', true); + jest.spyOn(toast, 'error'); + + await act(async () => { + render( + + + + + + + + + , + ); + }); + await waitFor(() => { + expect(screen.getByTestId('openDeleteModalBtn')).toBeInTheDocument(); + }); + act(() => { + screen.getByTestId('openDeleteModalBtn').click(); + }); + await waitFor(() => { + expect(screen.getByTestId('orgDeleteModal')).toBeInTheDocument(); + }); + act(() => { + screen.getByTestId('deleteOrganizationBtn').click(); + }); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Failed to delete organization'); + }); + }); +}); diff --git a/src/components/OrgSettings/General/DeleteOrg/DeleteOrg.tsx b/src/components/OrgSettings/General/DeleteOrg/DeleteOrg.tsx new file mode 100644 index 0000000000..8edf11ca22 --- /dev/null +++ b/src/components/OrgSettings/General/DeleteOrg/DeleteOrg.tsx @@ -0,0 +1,150 @@ +import React, { useState } from 'react'; +import { Button, Card, Modal } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery } from '@apollo/client'; +import { errorHandler } from 'utils/errorHandler'; +import { toast } from 'react-toastify'; +import { + DELETE_ORGANIZATION_MUTATION, + REMOVE_SAMPLE_ORGANIZATION_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import { IS_SAMPLE_ORGANIZATION_QUERY } from 'GraphQl/Queries/Queries'; +import styles from './DeleteOrg.module.css'; +import { useNavigate, useParams } from 'react-router-dom'; +import useLocalStorage from 'utils/useLocalstorage'; + +/** + * A component for deleting an organization. + * + * It displays a card with a delete button. When the delete button is clicked, + * a modal appears asking for confirmation. Depending on the type of organization + * (sample or regular), it performs the delete operation and shows appropriate + * success or error messages. + * + * @returns JSX.Element - The rendered component with delete functionality. + */ +function deleteOrg(): JSX.Element { + // Translation hook for localization + const { t } = useTranslation('translation', { + keyPrefix: 'deleteOrg', + }); + const { t: tCommon } = useTranslation('common'); + + // Get the current organization ID from the URL + const { orgId: currentUrl } = useParams(); + // Navigation hook for redirecting + const navigate = useNavigate(); + // State to control the visibility of the delete confirmation modal + const [showDeleteModal, setShowDeleteModal] = useState(false); + + // Hook for accessing local storage + const { getItem } = useLocalStorage(); + // Check if the user has super admin privileges + const canDelete = getItem('SuperAdmin'); + + /** + * Toggles the visibility of the delete confirmation modal. + */ + const toggleDeleteModal = (): void => setShowDeleteModal(!showDeleteModal); + + // GraphQL mutations for deleting organizations + const [del] = useMutation(DELETE_ORGANIZATION_MUTATION); + const [removeSampleOrganization] = useMutation( + REMOVE_SAMPLE_ORGANIZATION_MUTATION, + ); + + // Query to check if the organization is a sample organization + const { data } = useQuery(IS_SAMPLE_ORGANIZATION_QUERY, { + variables: { + isSampleOrganizationId: currentUrl, + }, + }); + + /** + * Deletes the organization. It handles both sample and regular organizations. + * Displays success or error messages based on the operation result. + */ + const deleteOrg = async (): Promise => { + if (data && data.isSampleOrganization) { + // If it's a sample organization, use a specific mutation + removeSampleOrganization() + .then(() => { + toast.success(t('successfullyDeletedSampleOrganization') as string); + setTimeout(() => { + navigate('/orglist'); + }, 1000); + }) + .catch((error) => { + toast.error(error.message); + }); + } else { + // For regular organizations, use a different mutation + try { + await del({ + variables: { + id: currentUrl, + }, + }); + navigate('/orglist'); + } catch (error) { + errorHandler(t, error); + } + } + }; + + return ( + <> + {canDelete && ( + +
    +
    {t('deleteOrganization')}
    +
    + +
    {t('longDelOrgMsg')}
    + +
    +
    + )} + {/* Delete Organization Modal */} + {canDelete && ( + + +
    {t('deleteOrganization')}
    +
    + {t('deleteMsg')} + + + + +
    + )} + + ); +} + +export default deleteOrg; diff --git a/src/components/OrgSettings/General/GeneralSettings.tsx b/src/components/OrgSettings/General/GeneralSettings.tsx new file mode 100644 index 0000000000..4dbca1b6eb --- /dev/null +++ b/src/components/OrgSettings/General/GeneralSettings.tsx @@ -0,0 +1,73 @@ +import React, { type FC } from 'react'; +import { Card, Col, Form, Row } from 'react-bootstrap'; +import styles from 'screens/OrgSettings/OrgSettings.module.css'; +import OrgProfileFieldSettings from './OrgProfileFieldSettings/OrgProfileFieldSettings'; +import ChangeLanguageDropDown from 'components/ChangeLanguageDropdown/ChangeLanguageDropDown'; +import DeleteOrg from './DeleteOrg/DeleteOrg'; +import OrgUpdate from './OrgUpdate/OrgUpdate'; +import { useTranslation } from 'react-i18next'; + +/** + * Props for the `GeneralSettings` component. + */ +interface InterfaceGeneralSettingsProps { + orgId: string; +} + +/** + * A component for displaying general settings for an organization. + * + * @param props - The properties passed to the component. + * @returns The `GeneralSettings` component. + */ +const GeneralSettings: FC = ({ orgId }) => { + const { t } = useTranslation('translation', { + keyPrefix: 'orgSettings', + }); + + return ( + + + +
    +
    {t('updateOrganization')}
    +
    + + {/* Render organization update component */} + + +
    + + + + +
    +
    {t('otherSettings')}
    +
    + +
    + + {t('changeLanguage')} + + {/* Render language change dropdown component */} + +
    +
    +
    + + + +
    +
    {t('manageCustomFields')}
    +
    + + {/* Render organization profile field settings component */} + + +
    + +
    + ); +}; + +export default GeneralSettings; diff --git a/src/components/OrgSettings/General/OrgProfileFieldSettings/OrgProfileFieldSettings.module.css b/src/components/OrgSettings/General/OrgProfileFieldSettings/OrgProfileFieldSettings.module.css new file mode 100644 index 0000000000..851f70ce39 --- /dev/null +++ b/src/components/OrgSettings/General/OrgProfileFieldSettings/OrgProfileFieldSettings.module.css @@ -0,0 +1,24 @@ +.customDataTable { + width: 100%; + border-collapse: collapse; +} + +.customDataTable th, +.customDataTable td { + padding: 8px; + text-align: left; +} + +.customDataTable th { + background-color: #f2f2f2; +} +form { + display: flex; + flex-direction: column; + gap: 10px; +} + +.saveButton { + width: 10em; + align-self: self-end; +} diff --git a/src/components/OrgSettings/General/OrgProfileFieldSettings/OrgProfileFieldSettings.test.tsx b/src/components/OrgSettings/General/OrgProfileFieldSettings/OrgProfileFieldSettings.test.tsx new file mode 100644 index 0000000000..8db8773381 --- /dev/null +++ b/src/components/OrgSettings/General/OrgProfileFieldSettings/OrgProfileFieldSettings.test.tsx @@ -0,0 +1,280 @@ +import React, { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import OrgProfileFieldSettings from './OrgProfileFieldSettings'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { + ADD_CUSTOM_FIELD, + REMOVE_CUSTOM_FIELD, +} from 'GraphQl/Mutations/mutations'; +import { ORGANIZATION_CUSTOM_FIELDS } from 'GraphQl/Queries/Queries'; +import { ToastContainer, toast } from 'react-toastify'; + +const MOCKS = [ + { + request: { + query: ADD_CUSTOM_FIELD, + variables: { + type: '', + name: '', + }, + }, + result: { + data: { + addOrganizationCustomField: { + name: 'Custom Field Name', + type: 'string', + }, + }, + }, + }, + + { + request: { + query: REMOVE_CUSTOM_FIELD, + variables: { customFieldId: 'adsdasdsa334343yiu423434' }, + }, + result: { + data: { + removeOrganizationCustomField: { + type: '', + name: '', + }, + }, + }, + }, + + { + request: { + query: ORGANIZATION_CUSTOM_FIELDS, + variables: {}, + }, + result: { + data: { + customFieldsByOrganization: [ + { + _id: 'adsdasdsa334343yiu423434', + type: 'fieldType', + name: 'fieldName', + }, + ], + }, + }, + }, +]; + +const ERROR_MOCKS = [ + { + request: { + query: ADD_CUSTOM_FIELD, + variables: { + type: '', + name: '', + }, + }, + error: new Error('Failed to add custom field'), + }, + { + request: { + query: REMOVE_CUSTOM_FIELD, + variables: { + customFieldId: 'adsdasdsa334343yiu423434', + }, + }, + error: new Error('Failed to remove custom field'), + }, + { + request: { + query: ORGANIZATION_CUSTOM_FIELDS, + variables: {}, + }, + result: { + data: { + customFieldsByOrganization: [ + { + _id: 'adsdasdsa334343yiu423434', + type: 'fieldType', + name: 'fieldName', + }, + ], + }, + }, + }, +]; + +const ORGANIZATION_CUSTOM_FIELDS_ERROR_MOCKS = [ + { + request: { + query: ORGANIZATION_CUSTOM_FIELDS, + variables: {}, + }, + error: new Error('Failed to fetch custom field'), + }, +]; + +const NO_C_FIELD_MOCK = [ + { + request: { + query: ADD_CUSTOM_FIELD, + variables: { + type: 'fieldType', + name: 'fieldName', + }, + }, + result: { + data: { + addOrganizationCustomField: { + name: 'Custom Field Name', + type: 'string', + }, + }, + }, + }, + + { + request: { + query: ORGANIZATION_CUSTOM_FIELDS, + variables: {}, + }, + result: { + data: { + customFieldsByOrganization: [], + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(NO_C_FIELD_MOCK, true); +const link3 = new StaticMockLink(ERROR_MOCKS, true); +const link4 = new StaticMockLink(ORGANIZATION_CUSTOM_FIELDS_ERROR_MOCKS, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Testing Save Button', () => { + test('Testing Failure Case For Fetching Custom field', async () => { + render( + + + + + , + ); + + await wait(); + expect( + screen.queryByText('Failed to fetch custom field'), + ).toBeInTheDocument(); + }); + test('Saving Organization Custom Field', async () => { + render( + + + + + + , + ); + + await wait(); + userEvent.click(screen.getByTestId('saveChangesBtn')); + await wait(); + expect(screen.queryByText('Field added successfully')).toBeInTheDocument(); + }); + + test('Testing Failure Case For Saving Custom Field', async () => { + render( + + + + + + , + ); + await wait(); + userEvent.click(screen.getByTestId('saveChangesBtn')); + await wait(); + expect( + screen.queryByText('Failed to add custom field'), + ).toBeInTheDocument(); + await wait(); + userEvent.type(screen.getByTestId('customFieldInput'), 'Age{enter}'); + await wait(); + expect( + screen.queryByText('Failed to add custom field'), + ).toBeInTheDocument(); + }); + + test('Testing Typing Organization Custom Field Name', async () => { + const { getByTestId } = render( + + + + + , + ); + + await wait(); + + const fieldNameInput = getByTestId('customFieldInput'); + userEvent.type(fieldNameInput, 'Age'); + }); + test('When No Custom Data is Present', async () => { + const { getByText } = render( + + + + + , + ); + + await wait(); + expect(getByText('No custom fields available')).toBeInTheDocument(); + }); + test('Testing Remove Custom Field Button', async () => { + render( + + + + + + , + ); + + await wait(); + userEvent.click(screen.getByTestId('removeCustomFieldBtn')); + await wait(); + expect( + screen.queryByText('Field removed successfully'), + ).toBeInTheDocument(); + }); + + test('Testing Failure Case For Removing Custom Field', async () => { + const toastSpy = jest.spyOn(toast, 'error'); + render( + + + + + + , + ); + await wait(); + userEvent.click(screen.getByTestId('removeCustomFieldBtn')); + await wait(); + expect(toastSpy).toHaveBeenCalledWith('Failed to remove custom field'); + }); +}); diff --git a/src/components/OrgSettings/General/OrgProfileFieldSettings/OrgProfileFieldSettings.tsx b/src/components/OrgSettings/General/OrgProfileFieldSettings/OrgProfileFieldSettings.tsx new file mode 100644 index 0000000000..dcb6992e21 --- /dev/null +++ b/src/components/OrgSettings/General/OrgProfileFieldSettings/OrgProfileFieldSettings.tsx @@ -0,0 +1,191 @@ +import { useMutation, useQuery } from '@apollo/client'; +import React, { useState } from 'react'; +import { FaTrash } from 'react-icons/fa'; +import { Form } from 'react-bootstrap'; +import Button from 'react-bootstrap/Button'; +import { + ADD_CUSTOM_FIELD, + REMOVE_CUSTOM_FIELD, +} from 'GraphQl/Mutations/mutations'; +import { ORGANIZATION_CUSTOM_FIELDS } from 'GraphQl/Queries/Queries'; +import styles from './OrgProfileFieldSettings.module.css'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import EditOrgCustomFieldDropDown from 'components/EditCustomFieldDropDown/EditCustomFieldDropDown'; +import { useParams } from 'react-router-dom'; +import type { InterfaceCustomFieldData } from 'utils/interfaces'; + +/** + * Component for managing organization profile field settings + * + * This component allows adding and removing custom fields for an organization. + * It displays existing custom fields and provides a form to add new fields. + * + * @returns JSX.Element representing the organization profile field settings + */ +const OrgProfileFieldSettings = (): JSX.Element => { + const { t } = useTranslation('translation', { + keyPrefix: 'orgProfileField', + }); + const { t: tCommon } = useTranslation('common'); + + // State to hold the custom field data + const [customFieldData, setCustomFieldData] = + useState({ + type: '', + name: '', + }); + + // Get the current organization ID from the URL parameters + const { orgId: currentOrgId } = useParams(); + + // Mutation to add a custom field + const [addCustomField] = useMutation(ADD_CUSTOM_FIELD); + + // Mutation to remove a custom field + const [removeCustomField] = useMutation(REMOVE_CUSTOM_FIELD); + + // Query to fetch custom fields for the organization + const { loading, error, data, refetch } = useQuery( + ORGANIZATION_CUSTOM_FIELDS, + { + variables: { + customFieldsByOrganizationId: currentOrgId, + }, + }, + ); + + // Function to handle saving a new custom field + const handleSave = async (): Promise => { + try { + await addCustomField({ + variables: { + organizationId: currentOrgId, + ...customFieldData, + }, + }); + toast.success(t('fieldSuccessMessage') as string); + setCustomFieldData({ type: '', name: '' }); + refetch(); + } catch (error) { + toast.error((error as Error).message); + } + }; + + // Function to handle removing a custom field + const handleRemove = async (customFieldId: string): Promise => { + try { + await removeCustomField({ + variables: { + organizationId: currentOrgId, + customFieldId: customFieldId, + }, + }); + + toast.success(t('fieldRemovalSuccess') as string); + refetch(); + } catch (error) { + toast.error((error as Error).message); + } + }; + + // Render loading or error messages if needed + if (loading) return

    {tCommon('loading')}

    ; + if (error) return

    {error.message}

    ; + + return ( +
    + {/* Display existing custom fields or a message if there are none */} + {data.customFieldsByOrganization.length === 0 ? ( +

    {t('noCustomField')}

    + ) : ( + + + {data.customFieldsByOrganization.map( + ( + field: { + _id: string; + name: string; + type: string; + }, + index: number, + ) => ( + + + + + + ), + )} + +
    {field.name}{field.type} + {/* Button to remove a custom field */} + +
    + )} +
    +
    +
    +
    + {/* Form to add a new custom field */} +
    +
    + {t('customFieldName')} + { + setCustomFieldData({ + ...customFieldData, + name: event.target.value, + }); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + handleSave(); + } + }} + /> +
    + +
    + + {t('customFieldType')} + + +
    + + +
    +
    +
    +
    +
    + ); +}; + +export default OrgProfileFieldSettings; diff --git a/src/components/OrgSettings/General/OrgUpdate/OrgUpdate.module.css b/src/components/OrgSettings/General/OrgUpdate/OrgUpdate.module.css new file mode 100644 index 0000000000..fca7ac5e5b --- /dev/null +++ b/src/components/OrgSettings/General/OrgUpdate/OrgUpdate.module.css @@ -0,0 +1,13 @@ +.message { + height: 420px; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.icon { + transform: scale(1.5); + color: var(--bs-danger); + margin-bottom: 1rem; +} diff --git a/src/components/OrgSettings/General/OrgUpdate/OrgUpdate.test.tsx b/src/components/OrgSettings/General/OrgUpdate/OrgUpdate.test.tsx new file mode 100644 index 0000000000..6304bb3ec9 --- /dev/null +++ b/src/components/OrgSettings/General/OrgUpdate/OrgUpdate.test.tsx @@ -0,0 +1,242 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18nForTest from 'utils/i18nForTest'; +import OrgUpdate from './OrgUpdate'; +import { + MOCKS, + MOCKS_ERROR_ORGLIST, + MOCKS_ERROR_UPDATE_ORGLIST, +} from './OrgUpdateMocks'; + +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 500): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Testing Organization Update', () => { + const props = { + orgId: '123', + }; + + const formData = { + name: 'Palisadoes Organization', + description: 'This is a updated description', + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + displayImage: new File(['hello'], 'hello.png', { type: 'image/png' }), + userRegistrationRequired: false, + isVisible: true, + }; + + global.alert = jest.fn(); + + test('should render props and text elements test for the page component along with mock data', async () => { + act(() => { + render( + + + + + , + ); + }); + await wait(); + // Check labels are present or not + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Address')).toBeInTheDocument(); + expect(screen.getByText('Display Image:')).toBeInTheDocument(); + expect(screen.getByText(/Registration/)).toBeInTheDocument(); + expect(screen.getByText('Visible in Search:')).toBeInTheDocument(); + + // Get the input fields, and btns + const name = screen.getByPlaceholderText(/Enter Organization Name/i); + const des = screen.getByPlaceholderText(/Description/i); + const city = screen.getByPlaceholderText(/City/i); + const countryCode = screen.getByTestId('countrycode'); + const line1 = screen.getByPlaceholderText(/Line 1/i); + const line2 = screen.getByPlaceholderText(/Line 2/i); + const dependentLocality = + screen.getByPlaceholderText(/Dependent Locality/i); + const sortingCode = screen.getByPlaceholderText(/Sorting code/i); + const postalCode = screen.getByPlaceholderText(/Postal Code/i); + const userRegistrationRequired = + screen.getByPlaceholderText(/Registration/i); + const isVisible = screen.getByPlaceholderText(/Visible/i); + + // Checking if form fields got updated according to the mock data + expect(name).toHaveValue('Palisadoes'); + expect(des).toHaveValue('Equitable Access to STEM Education Jobs'); + expect(city).toHaveValue('Kingston'); + expect(countryCode).toHaveValue('JM'); + expect(dependentLocality).toHaveValue('Sample Dependent Locality'); + expect(line1).toHaveValue('123 Jamaica Street'); + expect(line2).toHaveValue('Apartment 456'); + expect(postalCode).toHaveValue('JM12345'); + expect(sortingCode).toHaveValue('ABC-123'); + expect(userRegistrationRequired).toBeChecked(); + expect(isVisible).not.toBeChecked(); + }); + + test('Should Update organization properly', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + await wait(); + + // Get the input fields, and btns + const name = screen.getByPlaceholderText(/Enter Organization Name/i); + const des = screen.getByPlaceholderText(/Description/i); + + const city = screen.getByPlaceholderText(/City/i); + const countryCode = screen.getByTestId('countrycode'); + const line1 = screen.getByPlaceholderText(/Line 1/i); + const line2 = screen.getByPlaceholderText(/Line 2/i); + const dependentLocality = + screen.getByPlaceholderText(/Dependent Locality/i); + const sortingCode = screen.getByPlaceholderText(/Sorting code/i); + const postalCode = screen.getByPlaceholderText(/Postal Code/i); + const displayImage = screen.getByPlaceholderText(/Display Image/i); + const userRegistrationRequired = + screen.getByPlaceholderText(/Registration/i); + const isVisible = screen.getByPlaceholderText(/Visible/i); + const saveChangesBtn = screen.getByText(/Save Changes/i); + + // Emptying the text fields to add updated data + fireEvent.change(name, { target: { value: '' } }); + fireEvent.change(des, { target: { value: '' } }); + fireEvent.change(city, { target: { value: '' } }); + fireEvent.change(line1, { target: { value: '' } }); + fireEvent.change(line2, { target: { value: '' } }); + fireEvent.change(postalCode, { target: { value: '' } }); + fireEvent.change(sortingCode, { target: { value: '' } }); + fireEvent.change(dependentLocality, { target: { value: '' } }); + + // Mocking filling form behaviour + userEvent.type(name, formData.name); + userEvent.type(des, formData.description); + userEvent.type(city, formData.address.city); + userEvent.selectOptions(countryCode, formData.address.countryCode); + userEvent.type(line1, formData.address.line1); + userEvent.type(line2, formData.address.line2); + userEvent.type(postalCode, formData.address.postalCode); + userEvent.type(dependentLocality, formData.address.dependentLocality); + userEvent.type(sortingCode, formData.address.sortingCode); + userEvent.upload(displayImage, formData.displayImage); + userEvent.click(userRegistrationRequired); + userEvent.click(isVisible); + + await wait(); + userEvent.click(saveChangesBtn); + + // Checking if the form got update accordingly + expect(name).toHaveValue(formData.name); + expect(des).toHaveValue(formData.description); + expect(city).toHaveValue(formData.address.city); + expect(countryCode).toHaveValue(formData.address.countryCode); + expect(dependentLocality).toHaveValue(formData.address.dependentLocality); + expect(line1).toHaveValue(formData.address.line1); + expect(line2).toHaveValue(formData.address.line2); + expect(postalCode).toHaveValue(formData.address.postalCode); + expect(sortingCode).toHaveValue(formData.address.sortingCode); + expect(displayImage).toBeTruthy(); + expect(userRegistrationRequired).not.toBeChecked(); + expect(isVisible).toBeChecked(); + }); + + test('Should render error occured text when Organization Could not be found', async () => { + act(() => { + render( + + + + + , + ); + }); + await wait(); + expect(screen.getByText(/Mock Graphql Error/i)).toBeInTheDocument(); + }); + + test('Should show error occured toast when Organization could not be updated', async () => { + await act(async () => { + render( + + + + + , + ); + }); + + await wait(); + + // Get the input fields, and btns + const name = screen.getByPlaceholderText(/Enter Organization Name/i); + const des = screen.getByPlaceholderText(/Description/i); + const city = screen.getByPlaceholderText(/City/i); + const countryCode = screen.getByTestId('countrycode'); + const line1 = screen.getByPlaceholderText(/Line 1/i); + const line2 = screen.getByPlaceholderText(/Line 2/i); + const dependentLocality = + screen.getByPlaceholderText(/Dependent Locality/i); + const sortingCode = screen.getByPlaceholderText(/Sorting code/i); + const postalCode = screen.getByPlaceholderText(/Postal Code/i); + const displayImage = screen.getByPlaceholderText(/Display Image/i); + const userRegistrationRequired = + screen.getByPlaceholderText(/Registration/i); + const isVisible = screen.getByPlaceholderText(/Visible/i); + const saveChangesBtn = screen.getByText(/Save Changes/i); + + // Emptying the text fields to add updated data + fireEvent.change(name, { target: { value: '' } }); + fireEvent.change(des, { target: { value: '' } }); + fireEvent.change(city, { target: { value: '' } }); + fireEvent.change(line1, { target: { value: '' } }); + fireEvent.change(line2, { target: { value: '' } }); + fireEvent.change(postalCode, { target: { value: '' } }); + fireEvent.change(sortingCode, { target: { value: '' } }); + fireEvent.change(dependentLocality, { target: { value: '' } }); + + // Mocking filling form behaviour + userEvent.type(name, formData.name); + userEvent.type(des, formData.description); + userEvent.type(city, formData.address.city); + userEvent.selectOptions(countryCode, formData.address.countryCode); + userEvent.type(line1, formData.address.line1); + userEvent.type(line2, formData.address.line2); + userEvent.type(postalCode, formData.address.postalCode); + userEvent.type(dependentLocality, formData.address.dependentLocality); + userEvent.type(sortingCode, formData.address.sortingCode); + userEvent.upload(displayImage, formData.displayImage); + userEvent.click(userRegistrationRequired); + userEvent.click(isVisible); + + await wait(); + userEvent.click(saveChangesBtn); + }); +}); diff --git a/src/components/OrgSettings/General/OrgUpdate/OrgUpdate.tsx b/src/components/OrgSettings/General/OrgUpdate/OrgUpdate.tsx new file mode 100644 index 0000000000..6e0be28a56 --- /dev/null +++ b/src/components/OrgSettings/General/OrgUpdate/OrgUpdate.tsx @@ -0,0 +1,359 @@ +import React, { useState, useEffect } from 'react'; +import { useMutation, useQuery } from '@apollo/client'; +import Button from 'react-bootstrap/Button'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; + +import type { ApolloError } from '@apollo/client'; +import { WarningAmberRounded } from '@mui/icons-material'; +import { UPDATE_ORGANIZATION_MUTATION } from 'GraphQl/Mutations/mutations'; +import { ORGANIZATIONS_LIST } from 'GraphQl/Queries/Queries'; +import Loader from 'components/Loader/Loader'; +import { Col, Form, Row } from 'react-bootstrap'; +import convertToBase64 from 'utils/convertToBase64'; +import { errorHandler } from 'utils/errorHandler'; +import styles from './OrgUpdate.module.css'; +import type { + InterfaceQueryOrganizationsListObject, + InterfaceAddress, +} from 'utils/interfaces'; +import { countryOptions } from 'utils/formEnumFields'; + +interface InterfaceOrgUpdateProps { + orgId: string; +} + +/** + * Component for updating organization details. + * + * This component allows users to update the organization's name, description, address, + * visibility settings, and upload an image. It uses GraphQL mutations and queries to + * fetch and update data. + * + * @param props - Component props containing the organization ID. + * @returns The rendered component. + */ +function orgUpdate(props: InterfaceOrgUpdateProps): JSX.Element { + const { orgId } = props; + + const [formState, setFormState] = useState<{ + orgName: string; + orgDescrip: string; + address: InterfaceAddress; + orgImage: string | null; + }>({ + orgName: '', + orgDescrip: '', + address: { + city: '', + countryCode: '', + dependentLocality: '', + line1: '', + line2: '', + postalCode: '', + sortingCode: '', + state: '', + }, + orgImage: null, + }); + + const handleInputChange = (fieldName: string, value: string): void => { + setFormState((prevState) => ({ + ...prevState, + address: { + ...prevState.address, + [fieldName]: value, + }, + })); + }; + + const [userRegistrationRequiredChecked, setuserRegistrationRequiredChecked] = + React.useState(false); + const [visiblechecked, setVisibleChecked] = React.useState(false); + + const [login] = useMutation(UPDATE_ORGANIZATION_MUTATION); + + const { t } = useTranslation('translation', { + keyPrefix: 'orgUpdate', + }); + const { t: tCommon } = useTranslation('common'); + + const { + data, + loading, + refetch, + error, + }: { + data?: { + organizations: InterfaceQueryOrganizationsListObject[]; + }; + loading: boolean; + refetch: (variables: { id: string }) => void; + error?: ApolloError; + } = useQuery(ORGANIZATIONS_LIST, { + variables: { id: orgId }, + notifyOnNetworkStatusChange: true, + }); + + // Update form state when data changes + useEffect(() => { + let isMounted = true; + if (data && isMounted) { + setFormState({ + ...formState, + orgName: data.organizations[0].name, + orgDescrip: data.organizations[0].description, + address: data.organizations[0].address, + }); + setuserRegistrationRequiredChecked( + data.organizations[0].userRegistrationRequired, + ); + setVisibleChecked(data.organizations[0].visibleInSearch); + } + return () => { + isMounted = false; + }; + }, [data, orgId]); + + /** + * Handles the save button click event. + * Updates the organization with the form data. + */ + const onSaveChangesClicked = async (): Promise => { + try { + const { data } = await login({ + variables: { + id: orgId, + name: formState.orgName, + description: formState.orgDescrip, + address: { + city: formState.address.city, + countryCode: formState.address.countryCode, + dependentLocality: formState.address.dependentLocality, + line1: formState.address.line1, + line2: formState.address.line2, + postalCode: formState.address.postalCode, + sortingCode: formState.address.sortingCode, + state: formState.address.state, + }, + userRegistrationRequired: userRegistrationRequiredChecked, + visibleInSearch: visiblechecked, + file: formState.orgImage, + }, + }); + // istanbul ignore next + if (data) { + refetch({ id: orgId }); + toast.success(t('successfulUpdated') as string); + } + } catch (error: unknown) { + errorHandler(t, error); + } + }; + + if (loading) { + return ; + } + + if (error) { + return ( +
    + +
    + Error occured while loading Organization Data +
    + {`${error.message}`} +
    +
    + ); + } + + return ( + <> +
    +
    + {tCommon('name')} + { + setFormState({ + ...formState, + orgName: e.target.value, + }); + }} + /> + {tCommon('description')} + { + setFormState({ + ...formState, + orgDescrip: e.target.value, + }); + }} + /> + {tCommon('address')} + + + { + const countryCode = e.target.value; + handleInputChange('countryCode', countryCode); + }} + > + + {countryOptions.map((country) => ( + + ))} + + + + handleInputChange('city', e.target.value)} + /> + + + + + handleInputChange('state', e.target.value)} + /> + + + + handleInputChange('dependentLocality', e.target.value) + } + /> + + + + + handleInputChange('line1', e.target.value)} + /> + + + handleInputChange('line2', e.target.value)} + /> + + + + + + handleInputChange('postalCode', e.target.value) + } + /> + + + + handleInputChange('sortingCode', e.target.value) + } + /> + + + + + + {t('userRegistrationRequired')}: + + + setuserRegistrationRequiredChecked( + !userRegistrationRequiredChecked, + ) + } + /> + + + + {t('isVisibleInSearch')}: + + setVisibleChecked(!visiblechecked)} + /> + + + {tCommon('displayImage')}: + => { + const target = e.target as HTMLInputElement; + const file = target.files && target.files[0]; + /* istanbul ignore else */ + if (file) + setFormState({ + ...formState, + orgImage: await convertToBase64(file), + }); + }} + data-testid="organisationImage" + /> +
    + +
    + +
    + + ); +} +export default orgUpdate; diff --git a/src/components/OrgSettings/General/OrgUpdate/OrgUpdateMocks.ts b/src/components/OrgSettings/General/OrgUpdate/OrgUpdateMocks.ts new file mode 100644 index 0000000000..4c7f704719 --- /dev/null +++ b/src/components/OrgSettings/General/OrgUpdate/OrgUpdateMocks.ts @@ -0,0 +1,204 @@ +import { UPDATE_ORGANIZATION_MUTATION } from 'GraphQl/Mutations/mutations'; +import { ORGANIZATIONS_LIST } from 'GraphQl/Queries/Queries'; + +export const MOCKS = [ + { + request: { + query: ORGANIZATIONS_LIST, + variables: { id: '123' }, + }, + result: { + data: { + organizations: [ + { + _id: '123', + image: null, + name: 'Palisadoes', + description: 'Equitable Access to STEM Education Jobs', + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + userRegistrationRequired: true, + visibleInSearch: false, + creator: { + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + }, + members: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@gmail.com', + }, + admins: [ + { + _id: '123', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@gmail.com', + createdAt: '12-03-2024', + }, + ], + membershipRequests: { + _id: '456', + user: { + firstName: 'Sam', + lastName: 'Smith', + email: 'samsmith@gmail.com', + }, + }, + blockedUsers: [], + }, + ], + }, + }, + }, + { + request: { + query: UPDATE_ORGANIZATION_MUTATION, + variables: { + id: '123', + name: 'Updated Organization', + description: 'This is an updated test organization', + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + image: new File(['hello'], 'hello.png', { type: 'image/png' }), + userRegistrationRequired: true, + visibleInSearch: false, + }, + }, + result: { + data: { + updateOrganization: { + _id: '123', + name: 'Updated Organization', + description: 'This is an updated test organization', + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + userRegistrationRequired: true, + visibleInSearch: false, + }, + }, + }, + }, +]; + +export const MOCKS_ERROR_ORGLIST = [ + { + request: { + query: ORGANIZATIONS_LIST, + variables: { id: '123' }, + }, + error: new Error('Mock Graphql Error'), + }, +]; + +export const MOCKS_ERROR_UPDATE_ORGLIST = [ + { + request: { + query: ORGANIZATIONS_LIST, + variables: { id: '123' }, + }, + result: { + data: { + organizations: [ + { + _id: '123', + image: null, + name: 'Palisadoes', + description: 'Equitable Access to STEM Education Jobs', + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + userRegistrationRequired: true, + visibleInSearch: false, + creator: { + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + }, + members: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@gmail.com', + }, + admins: [ + { + _id: '123', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@gmail.com', + createdAt: '12-03-2024', + }, + ], + membershipRequests: { + _id: '456', + user: { + firstName: 'Sam', + lastName: 'Smith', + email: 'samsmith@gmail.com', + }, + }, + blockedUsers: [], + }, + ], + }, + }, + }, + { + request: { + query: UPDATE_ORGANIZATION_MUTATION, + variables: { + id: '123', + name: 'Updated Organization', + description: 'This is an updated test organization', + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + image: new File(['hello'], 'hello.png', { type: 'image/png' }), + userRegistrationRequired: true, + visibleInSearch: false, + }, + }, + erorr: new Error('Mock Graphql Updating Organization Error'), + }, +]; diff --git a/src/components/OrganizationCard/OrganizationCard.module.css b/src/components/OrganizationCard/OrganizationCard.module.css new file mode 100644 index 0000000000..6c65b8258b --- /dev/null +++ b/src/components/OrganizationCard/OrganizationCard.module.css @@ -0,0 +1,46 @@ +.alignimg { + border-radius: 50%; + background-blend-mode: darken; + height: 65px; + width: 65px; +} + +.box { + color: #ffbd59; +} + +.box :hover { + color: #ffbd59; +} + +.first_box { + display: flex; + flex-direction: row; + padding-bottom: 10px; + padding-top: 10px; +} + +.second_box { + padding-left: 20px; + padding-top: 10px; +} + +.second_box > h4 { + font-size: 10; + font-weight: bold; + text-decoration: none; + color: black; +} + +.second_box > h5 { + text-decoration: none; + font-size: 10; + font-weight: 100; + color: #969696; +} + +.deco { + border: 1px solid #dfdfdf; + width: 65vw; + height: 0px !important; +} diff --git a/src/components/OrganizationCard/OrganizationCard.test.tsx b/src/components/OrganizationCard/OrganizationCard.test.tsx new file mode 100644 index 0000000000..e4abf3a1fd --- /dev/null +++ b/src/components/OrganizationCard/OrganizationCard.test.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import OrganizationCard from './OrganizationCard'; + +describe('Testing the Organization Card', () => { + test('should render props and text elements test for the page component', () => { + const props = { + id: '123', + image: 'https://via.placeholder.com/80', + firstName: 'John', + lastName: 'Doe', + name: 'Sample', + }; + + render(); + + expect(screen.getByText(props.name)).toBeInTheDocument(); + expect(screen.getByText(/Owner:/i)).toBeInTheDocument(); + expect(screen.getByText(props.firstName)).toBeInTheDocument(); + expect(screen.getByText(props.lastName)).toBeInTheDocument(); + }); + + test('Should render text elements when props value is not passed', () => { + const props = { + id: '123', + image: '', + firstName: 'John', + lastName: 'Doe', + name: 'Sample', + }; + + render(); + + expect(screen.getByText(props.name)).toBeInTheDocument(); + expect(screen.getByText(/Owner:/i)).toBeInTheDocument(); + expect(screen.getByText(props.firstName)).toBeInTheDocument(); + expect(screen.getByText(props.lastName)).toBeInTheDocument(); + }); +}); diff --git a/src/components/OrganizationCard/OrganizationCard.tsx b/src/components/OrganizationCard/OrganizationCard.tsx new file mode 100644 index 0000000000..ae513eff5d --- /dev/null +++ b/src/components/OrganizationCard/OrganizationCard.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import styles from './OrganizationCard.module.css'; + +interface InterfaceOrganizationCardProps { + image: string; + id: string; + name: string; + lastName: string; + firstName: string; +} + +/** + * Component to display an organization's card with its image and owner details. + * + * @param props - Properties for the organization card. + * @returns JSX element representing the organization card. + */ +function OrganizationCard(props: InterfaceOrganizationCardProps): JSX.Element { + const uri = '/superorghome/i=' + props.id; + + return ( + +
    +
    + {props.image ? ( + Organization + ) : ( + Placeholder + )} +
    +

    {props.name}

    +
    + Owner: + {props.firstName} + +   + {props.lastName} + +
    +
    +
    +
    +
    +
    + ); +} + +export default OrganizationCard; diff --git a/src/components/OrganizationCardStart/OrganizationCardStart.module.css b/src/components/OrganizationCardStart/OrganizationCardStart.module.css new file mode 100644 index 0000000000..6c65b8258b --- /dev/null +++ b/src/components/OrganizationCardStart/OrganizationCardStart.module.css @@ -0,0 +1,46 @@ +.alignimg { + border-radius: 50%; + background-blend-mode: darken; + height: 65px; + width: 65px; +} + +.box { + color: #ffbd59; +} + +.box :hover { + color: #ffbd59; +} + +.first_box { + display: flex; + flex-direction: row; + padding-bottom: 10px; + padding-top: 10px; +} + +.second_box { + padding-left: 20px; + padding-top: 10px; +} + +.second_box > h4 { + font-size: 10; + font-weight: bold; + text-decoration: none; + color: black; +} + +.second_box > h5 { + text-decoration: none; + font-size: 10; + font-weight: 100; + color: #969696; +} + +.deco { + border: 1px solid #dfdfdf; + width: 65vw; + height: 0px !important; +} diff --git a/src/components/OrganizationCardStart/OrganizationCardStart.test.tsx b/src/components/OrganizationCardStart/OrganizationCardStart.test.tsx new file mode 100644 index 0000000000..dd65c8649e --- /dev/null +++ b/src/components/OrganizationCardStart/OrganizationCardStart.test.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import OrganizationCardStart from './OrganizationCardStart'; + +describe('Testing the Organization Cards', () => { + test('should render props and text elements test for the page component', () => { + const props = { + id: '123', + image: 'https://via.placeholder.com/80', + name: 'Sample', + }; + + render(); + + expect(screen.getByText(props.name)).toBeInTheDocument(); + }); + + test('Should render text elements when props value is not passed', () => { + const props = { + id: '123', + image: '', + name: 'Sample', + }; + + render(); + + expect(screen.getByText(props.name)).toBeInTheDocument(); + }); +}); diff --git a/src/components/OrganizationCardStart/OrganizationCardStart.tsx b/src/components/OrganizationCardStart/OrganizationCardStart.tsx new file mode 100644 index 0000000000..298fb13db9 --- /dev/null +++ b/src/components/OrganizationCardStart/OrganizationCardStart.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import styles from './OrganizationCardStart.module.css'; + +interface InterfaceOrganizationCardStartProps { + image: string; + id: string; + name: string; +} + +/** + * Component to display a simplified card for an organization. + * + * @param image - URL of the organization's image. + * @param id - Unique identifier for the organization. + * @param name - Name of the organization. + * @returns JSX element representing the organization card. + */ +function organizationCardStart( + props: InterfaceOrganizationCardStartProps, +): JSX.Element { + const uri = '/orghome/i=' + props.id; + + return ( + <> + +
    +
    + {props.image ? ( + + ) : ( + + )} +
    +

    {props.name}

    +
    +
    +
    +
    +
    +
    + + ); +} + +export default organizationCardStart; diff --git a/src/components/OrganizationDashCards/CardItem.module.css b/src/components/OrganizationDashCards/CardItem.module.css new file mode 100644 index 0000000000..bfb85cb1bb --- /dev/null +++ b/src/components/OrganizationDashCards/CardItem.module.css @@ -0,0 +1,81 @@ +.cardItem { + position: relative; + display: flex; + align-items: center; + border: 1px solid var(--bs-gray-200); + border-radius: 8px; + margin-top: 20px; +} + +.cardItem .iconWrapper { + position: relative; + height: 40px; + width: 40px; + display: flex; + justify-content: center; + align-items: center; +} + +.cardItem .iconWrapper .themeOverlay { + background: var(--bs-primary); + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + opacity: 0.12; + border-radius: 50%; +} + +.cardItem .iconWrapper .dangerOverlay { + background: var(--bs-danger); + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + opacity: 0.12; + border-radius: 50%; +} + +.cardItem .title { + font-size: 1rem; + flex: 1; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + line-clamp: 1; + -webkit-box-orient: vertical; + margin-left: 3px; +} + +.cardItem .location { + font-size: 0.9rem; + color: var(--bs-primary); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + line-clamp: 1; + -webkit-box-orient: vertical; +} + +.cardItem .time { + font-size: 0.9rem; + color: var(--bs-secondary); +} + +.cardItem .creator { + font-size: 1rem; + color: rgb(33, 208, 21); +} + +.rightCard { + display: flex; + gap: 7px; + min-width: 170px; + justify-content: center; + flex-direction: column; + margin-left: 10px; + overflow-x: hidden; + width: 210px; +} diff --git a/src/components/OrganizationDashCards/CardItem.test.tsx b/src/components/OrganizationDashCards/CardItem.test.tsx new file mode 100644 index 0000000000..31f3474607 --- /dev/null +++ b/src/components/OrganizationDashCards/CardItem.test.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import CardItem from './CardItem'; +import type { InterfaceCardItem } from './CardItem'; +import dayjs from 'dayjs'; + +describe('Testing the Organization Card', () => { + test('Should render props and text elements For event card', () => { + const props: InterfaceCardItem = { + type: 'Event', + title: 'Event Title', + startdate: '2023-09-13', + enddate: '2023-09-14', + location: 'Event Location', + }; + + render(); + + expect(screen.getByText(/Event Title/i)).toBeInTheDocument(); + expect( + screen.getByText( + `${dayjs(props.startdate).format('MMM D, YYYY')} - ${dayjs( + props.enddate, + ).format('MMM D, YYYY')}`, + ), + ).toBeInTheDocument(); + expect(screen.getByText(/Event Location/i)).toBeInTheDocument(); + }); + + test('Should render props and text elements for Post card', () => { + const props: InterfaceCardItem = { + type: 'Post', + title: 'Post Title', + time: '2023-09-03', + creator: { + email: 'johndoe@example.com', + firstName: 'John', + lastName: 'Doe', + __typename: 'User', + _id: '1', + }, + }; + + render(); + + expect(screen.getByText(/Post Title/i)).toBeInTheDocument(); + expect( + screen.getByText(dayjs(props.time).format('MMM D, YYYY')), + ).toBeInTheDocument(); + expect(screen.getByText(/John Doe/i)).toBeInTheDocument(); + }); + + test('Should render props and text elements for Membership Request card', () => { + const props: InterfaceCardItem = { + type: 'MembershipRequest', + title: 'Membership Request Title', + }; + + render(); + expect(screen.getByText(/Membership Request Title/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/OrganizationDashCards/CardItem.tsx b/src/components/OrganizationDashCards/CardItem.tsx new file mode 100644 index 0000000000..a7cfaa0f57 --- /dev/null +++ b/src/components/OrganizationDashCards/CardItem.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import EventsIcon from 'assets/svgs/cardItemEvent.svg?react'; +import PostsIcon from 'assets/svgs/post.svg?react'; +import MarkerIcon from 'assets/svgs/cardItemLocation.svg?react'; +import DateIcon from 'assets/svgs/cardItemDate.svg?react'; +import UserIcon from 'assets/svgs/user.svg?react'; +import dayjs from 'dayjs'; +import styles from './CardItem.module.css'; +import { PersonAddAlt1Rounded } from '@mui/icons-material'; + +/** + * Interface for the CardItem component's props. + */ +export interface InterfaceCardItem { + type: 'Event' | 'Post' | 'MembershipRequest'; + title: string; + time?: string; + startdate?: string; + enddate?: string; + creator?: any; + location?: string; +} + +/** + * Component to display a card item with various types such as Event, Post, or MembershipRequest. + * + * @param props - Props for the CardItem component. + * @returns JSX element representing the card item. + */ +const cardItem = (props: InterfaceCardItem): JSX.Element => { + const { creator, type, title, startdate, time, enddate, location } = props; + return ( + <> +
    +
    +
    + {type == 'Event' ? ( + + ) : type == 'Post' ? ( + + ) : ( + type == 'MembershipRequest' && ( + + ) + )} +
    + +
    + {creator && ( + + {' '} + {' '} + + {creator.firstName} {creator.lastName} + + + )} + + {title && ( + + {title} + + )} + + {location && ( + + {' '} + {location} + + )} + {type == 'Event' && startdate && ( + + {type === 'Event' && ( + + )}{' '} + {dayjs(startdate).format('MMM D, YYYY')} -{' '} + {dayjs(enddate).format('MMM D, YYYY')} + + )} + {type == 'Post' && time && ( + + {type === 'Post' && ( + + )}{' '} + {dayjs(time).format('MMM D, YYYY')} + + )} +
    +
    + + ); +}; + +export default cardItem; diff --git a/src/components/OrganizationDashCards/CardItemLoading.tsx b/src/components/OrganizationDashCards/CardItemLoading.tsx new file mode 100644 index 0000000000..c7c666afb6 --- /dev/null +++ b/src/components/OrganizationDashCards/CardItemLoading.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import styles from './CardItem.module.css'; + +/** + * CardItemLoading component is a loading state for the card item. It is used when the data is being fetched. + * @returns JSX.Element + */ +const cardItemLoading = (): JSX.Element => { + return ( + <> +
    +
    +
    +
    + +   + +
    + + ); +}; + +export default cardItemLoading; diff --git a/src/components/OrganizationDashCards/DashboardCard.test.tsx b/src/components/OrganizationDashCards/DashboardCard.test.tsx new file mode 100644 index 0000000000..71e5e1fed0 --- /dev/null +++ b/src/components/OrganizationDashCards/DashboardCard.test.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import DashboardCard from './DashboardCard'; + +describe('Testing the Dashboard Card', () => { + test('should render props and text elements For event card', () => { + const props = { + icon: , + title: 'Example Title', + count: 100, + }; + + render(); + + expect(screen.getByText(/Example Title/i)).toBeInTheDocument(); + expect(screen.getByText(/100/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/OrganizationDashCards/DashboardCard.tsx b/src/components/OrganizationDashCards/DashboardCard.tsx new file mode 100644 index 0000000000..4a29eafc57 --- /dev/null +++ b/src/components/OrganizationDashCards/DashboardCard.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Card, Row } from 'react-bootstrap'; +import Col from 'react-bootstrap/Col'; +import styles from './Dashboardcard.module.css'; + +/** Dashboard card component is used to display the card with icon, title and count. + * @param icon - Icon for the card + * @param title - Title for the card + * @param count - Count for the card + * @returns Dashboard card component + * + */ +const dashBoardCard = (props: { + icon: React.ReactNode; + title: string; + count?: number; +}): JSX.Element => { + const { icon, count, title } = props; + return ( + + + + +
    +
    + {icon} +
    + + + {count ?? 0} + {title} + + + + + ); +}; + +export default dashBoardCard; diff --git a/src/components/OrganizationDashCards/DashboardCardLoading.tsx b/src/components/OrganizationDashCards/DashboardCardLoading.tsx new file mode 100644 index 0000000000..b37c1e2065 --- /dev/null +++ b/src/components/OrganizationDashCards/DashboardCardLoading.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Card, Row } from 'react-bootstrap'; +import Col from 'react-bootstrap/Col'; +import styles from './Dashboardcard.module.css'; + +/** + * Dashboard card loading component is a loading state for the dashboard card. It is used when the data is being fetched. + * @returns JSX.Element + */ +const dashBoardCardLoading = (): JSX.Element => { + return ( + + + + +
    +
    +
    + + + + + + + + + ); +}; + +export default dashBoardCardLoading; diff --git a/src/components/OrganizationDashCards/Dashboardcard.module.css b/src/components/OrganizationDashCards/Dashboardcard.module.css new file mode 100644 index 0000000000..365657fb4f --- /dev/null +++ b/src/components/OrganizationDashCards/Dashboardcard.module.css @@ -0,0 +1,60 @@ +.cardBody { + padding: 1.25rem 1.5rem; +} + +.cardBody .iconWrapper { + position: relative; + height: 48px; + width: 48px; + display: flex; + justify-content: center; + align-items: center; +} + +.cardBody .iconWrapper .themeOverlay { + background: var(--bs-primary); + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + opacity: 0.12; + border-radius: 50%; +} + +.cardBody .textWrapper .primaryText { + font-size: 24px; + font-weight: bold; + display: block; +} + +.cardBody .textWrapper .secondaryText { + font-size: 14px; + display: block; + color: var(--bs-secondary); +} + +@media (max-width: 600px) { + .cardBody { + min-height: 120px; + } + + .cardBody .iconWrapper { + position: absolute; + top: 1rem; + left: 1rem; + } + + .cardBody .textWrapper { + margin-top: calc(0.5rem + 36px); + text-align: right; + } + + .cardBody .textWrapper .primaryText { + font-size: 1.5rem; + } + + .cardBody .textWrapper .secondaryText { + font-size: 1rem; + } +} diff --git a/src/components/OrganizationScreen/OrganizationScreen.module.css b/src/components/OrganizationScreen/OrganizationScreen.module.css new file mode 100644 index 0000000000..9b8190a3ad --- /dev/null +++ b/src/components/OrganizationScreen/OrganizationScreen.module.css @@ -0,0 +1,178 @@ +.pageContainer { + display: flex; + flex-direction: column; + min-height: 100vh; + padding: 1rem 1.5rem 0 calc(300px + 2rem + 1.5rem); +} + +.expand { + padding-left: 4rem; + animation: moveLeft 0.5s ease-in-out; +} +.avatarStyle { + border-radius: 100%; +} +.profileContainer { + border: none; + padding: 2.1rem 0.5rem; + height: 52px; + border-radius: 8px 0px 0px 8px; + display: flex; + align-items: center; + background-color: white !important; + box-shadow: + 0 4px 4px 0 rgba(177, 177, 177, 0.2), + 0 6px 20px 0 rgba(151, 151, 151, 0.19); +} +.profileContainer:focus { + outline: none; + background-color: var(--bs-gray-100); +} +.imageContainer { + width: 56px; +} +.profileContainer .profileText { + flex: 1; + text-align: start; + overflow: hidden; + margin-right: 4px; +} +.angleDown { + margin-left: 4px; +} +.profileContainer .profileText .primaryText { + font-size: 1rem; + font-weight: 600; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; /* number of lines to show */ + -webkit-box-orient: vertical; + word-wrap: break-word; + white-space: normal; +} +.profileContainer .profileText .secondaryText { + font-size: 0.8rem; + font-weight: 400; + color: var(--bs-secondary); + display: block; + text-transform: capitalize; +} + +.contract { + padding-left: calc(300px + 2rem + 1.5rem); + animation: moveRight 0.5s ease-in-out; +} + +.collapseSidebarButton { + position: fixed; + height: 40px; + bottom: 0; + z-index: 9999; + width: calc(300px + 2rem); + background-color: rgba(245, 245, 245, 0.7); + color: black; + border: none; + border-radius: 0px; +} + +.collapseSidebarButton:hover, +.opendrawer:hover { + opacity: 1; + color: black !important; +} +.opendrawer { + position: fixed; + display: flex; + align-items: center; + justify-content: center; + top: 0; + left: 0; + width: 40px; + height: 100vh; + z-index: 9999; + background-color: rgba(245, 245, 245); + border: none; + border-radius: 0px; + margin-right: 20px; + color: black; +} +.profileDropdown { + background-color: transparent !important; +} +.profileDropdown .dropdown-toggle .btn .btn-normal { + display: none !important; + background-color: transparent !important; +} +.dropdownToggle { + background-image: url(/public/images/svg/angleDown.svg); + background-repeat: no-repeat; + background-position: center; + background-color: azure; +} + +.dropdownToggle::after { + border-top: none !important; + border-bottom: none !important; +} + +.opendrawer:hover { + transition: background-color 0.5s ease; + background-color: var(--bs-primary); +} +.collapseSidebarButton:hover { + transition: background-color 0.5s ease; + background-color: var(--bs-primary); +} + +@media (max-width: 1120px) { + .contract { + padding-left: calc(276px + 2rem + 1.5rem); + } + .collapseSidebarButton { + width: calc(250px + 2rem); + } +} + +@media (max-height: 900px) { + .pageContainer { + padding: 1rem 1.5rem 0 calc(300px + 2rem); + } + .collapseSidebarButton { + height: 30px; + width: calc(300px + 1rem); + } +} +@media (max-height: 650px) { + .pageContainer { + padding: 1rem 1.5rem 0 calc(270px); + } + .collapseSidebarButton { + width: 250px; + height: 20px; + } + .opendrawer { + width: 30px; + } +} + +/* For tablets */ +@media (max-width: 820px) { + .pageContainer { + padding-left: 2.5rem; + } + + .opendrawer { + width: 25px; + } + + .contract, + .expand { + animation: none; + } + + .collapseSidebarButton { + width: 100%; + left: 0; + right: 0; + } +} diff --git a/src/components/OrganizationScreen/OrganizationScreen.test.tsx b/src/components/OrganizationScreen/OrganizationScreen.test.tsx new file mode 100644 index 0000000000..cd039cc3ca --- /dev/null +++ b/src/components/OrganizationScreen/OrganizationScreen.test.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import 'jest-location-mock'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import OrganizationScreen from './OrganizationScreen'; +import { ORGANIZATION_EVENT_LIST } from 'GraphQl/Queries/Queries'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import styles from './OrganizationScreen.module.css'; + +const mockID: string | undefined = '123'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: mockID }), + useMatch: () => ({ params: { eventId: 'event123', orgId: '123' } }), +})); + +const MOCKS = [ + { + request: { + query: ORGANIZATION_EVENT_LIST, + variables: { id: '123' }, + }, + result: { + data: { + eventsByOrganization: [ + { + _id: 'event123', + title: 'Test Event Title', + description: 'Test Description', + startDate: '2024-01-01', + endDate: '2024-01-02', + location: 'Test Location', + startTime: '09:00', + endTime: '17:00', + allDay: false, + recurring: false, + isPublic: true, + isRegisterable: true, + }, + ], + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +describe('Testing OrganizationScreen', () => { + const renderComponent = (): void => { + render( + + + + + + + + + , + ); + }; + + test('renders correctly with event title', async () => { + renderComponent(); + + await waitFor(() => { + const mainPage = screen.getByTestId('mainpageright'); + expect(mainPage).toBeInTheDocument(); + }); + }); + + test('handles drawer toggle correctly', () => { + renderComponent(); + + const closeButton = screen.getByTestId('closeMenu'); + fireEvent.click(closeButton); + + // Check for contract class after closing + expect(screen.getByTestId('mainpageright')).toHaveClass('_expand_ccl5z_8'); + + const openButton = screen.getByTestId('openMenu'); + fireEvent.click(openButton); + + // Check for expand class after opening + expect(screen.getByTestId('mainpageright')).toHaveClass( + '_contract_ccl5z_61', + ); + }); + + test('handles window resize', () => { + renderComponent(); + + window.innerWidth = 800; + fireEvent(window, new Event('resize')); + + expect(screen.getByTestId('mainpageright')).toHaveClass(styles.expand); + }); +}); diff --git a/src/components/OrganizationScreen/OrganizationScreen.tsx b/src/components/OrganizationScreen/OrganizationScreen.tsx new file mode 100644 index 0000000000..85fb6ee181 --- /dev/null +++ b/src/components/OrganizationScreen/OrganizationScreen.tsx @@ -0,0 +1,189 @@ +import LeftDrawerOrg from 'components/LeftDrawerOrg/LeftDrawerOrg'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import { + Navigate, + Outlet, + useLocation, + useParams, + useMatch, +} from 'react-router-dom'; +import { updateTargets } from 'state/action-creators'; +import { useAppDispatch } from 'state/hooks'; +import type { RootState } from 'state/reducers'; +import type { TargetsType } from 'state/reducers/routesReducer'; +import styles from './OrganizationScreen.module.css'; +import ProfileDropdown from 'components/ProfileDropdown/ProfileDropdown'; +import { Button } from 'react-bootstrap'; +import type { InterfaceMapType } from 'utils/interfaces'; +import { useQuery } from '@apollo/client'; +import { ORGANIZATION_EVENT_LIST } from 'GraphQl/Queries/Queries'; +interface InterfaceEvent { + _id: string; + title: string; +} + +/** + * Component for the organization screen + * + * This component displays the organization screen and handles the layout + * including a side drawer, header, and main content area. It adjusts + * the layout based on the screen size and shows the appropriate content + * based on the route. + * + * @returns JSX.Element representing the organization screen + */ +const OrganizationScreen = (): JSX.Element => { + // Get the current location to determine the translation key + const location = useLocation(); + const titleKey: string | undefined = map[location.pathname.split('/')[1]]; + const { t } = useTranslation('translation', { keyPrefix: titleKey }); + + // State to manage visibility of the side drawer + const [hideDrawer, setHideDrawer] = useState(null); + + // Get the organization ID from the URL parameters + const { orgId } = useParams(); + const [eventName, setEventName] = useState(null); + + const isEventPath = useMatch('/event/:orgId/:eventId'); + + // If no organization ID is found, navigate back to the home page + if (!orgId) { + /*istanbul ignore next*/ + return ; + } + + // Get the application routes from the Redux store + const appRoutes: { + targets: TargetsType[]; + } = useSelector((state: RootState) => state.appRoutes); + const { targets } = appRoutes; + + const dispatch = useAppDispatch(); + + // Update targets whenever the organization ID changes + useEffect(() => { + dispatch(updateTargets(orgId)); + }, [orgId]); + + const { data: eventsData } = useQuery(ORGANIZATION_EVENT_LIST, { + variables: { id: orgId }, + }); + + useEffect(() => { + if (isEventPath?.params.eventId && eventsData?.eventsByOrganization) { + const eventId = isEventPath.params.eventId; + const event = eventsData.eventsByOrganization.find( + (e: InterfaceEvent) => e._id === eventId, + ); + /*istanbul ignore next*/ + if (!event) { + console.warn(`Event with id ${eventId} not found`); + setEventName(null); + return; + } + setEventName(event.title); + } else { + setEventName(null); + } + }, [isEventPath, eventsData]); + + // Handle screen resizing to show/hide the side drawer + const handleResize = (): void => { + if (window.innerWidth <= 820) { + setHideDrawer(!hideDrawer); + } + }; + + // Set up event listener for window resize + useEffect(() => { + handleResize(); + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + return ( + <> + {hideDrawer ? ( + + ) : ( + + )} +
    + +
    +
    +
    +
    +

    {t('title')}

    + {eventName &&

    {eventName}

    } +
    + +
    + +
    + + ); +}; + +export default OrganizationScreen; + +/** + * Mapping object to get translation keys based on route + */ +const map: InterfaceMapType = { + orgdash: 'dashboard', + orgpeople: 'organizationPeople', + orgtags: 'organizationTags', + requests: 'requests', + orgads: 'advertisement', + member: 'memberDetail', + orgevents: 'organizationEvents', + orgactionitems: 'organizationActionItems', + orgagendacategory: 'organizationAgendaCategory', + orgcontribution: 'orgContribution', + orgpost: 'orgPost', + orgfunds: 'funds', + orgfundcampaign: 'fundCampaign', + fundCampaignPledge: 'pledges', + orgsetting: 'orgSettings', + orgstore: 'addOnStore', + blockuser: 'blockUnblockUser', + orgvenues: 'organizationVenues', + event: 'eventManagement', + leaderboard: 'leaderboard', +}; diff --git a/src/components/Pagination/Pagination.test.tsx b/src/components/Pagination/Pagination.test.tsx new file mode 100644 index 0000000000..40ac2ed19a --- /dev/null +++ b/src/components/Pagination/Pagination.test.tsx @@ -0,0 +1,64 @@ +import React, { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; +import Pagination from './Pagination'; +import { store } from 'state/store'; +import userEvent from '@testing-library/user-event'; + +describe('Testing Pagination component', () => { + const props = { + count: 5, + page: 10, + rowsPerPage: 5, + onPageChange: (): number => { + return 10; + }, + }; + + test('Component should be rendered properly on rtl', async () => { + render( + + + + + , + ); + await act(async () => { + userEvent.click(screen.getByTestId(/nextPage/i)); + userEvent.click(screen.getByTestId(/previousPage/i)); + }); + }); +}); + +const props = { + count: 5, + page: 10, + rowsPerPage: 5, + onPageChange: (): number => { + return 10; + }, + theme: { direction: 'rtl' }, +}; + +test('Component should be rendered properly', async () => { + const theme = createTheme({ + direction: 'rtl', + }); + + render( + + + + + + + , + ); + + await act(async () => { + userEvent.click(screen.getByTestId(/nextPage/i)); + userEvent.click(screen.getByTestId(/previousPage/i)); + }); +}); diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx new file mode 100644 index 0000000000..637492a289 --- /dev/null +++ b/src/components/Pagination/Pagination.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { useTheme } from '@mui/material/styles'; +import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; +import FirstPageIcon from '@mui/icons-material/FirstPage'; +import KeyboardArrowLeft from '@mui/icons-material/KeyboardArrowLeft'; +import KeyboardArrowRight from '@mui/icons-material/KeyboardArrowRight'; +import LastPageIcon from '@mui/icons-material/LastPage'; + +interface InterfaceTablePaginationActionsProps { + count: number; // Total number of items + page: number; // Current page index + rowsPerPage: number; // Number of items per page + onPageChange: ( + event: React.MouseEvent, + newPage: number, // New page index to navigate to + ) => void; // Callback function for page changes +} + +/** + * Pagination component for navigating between pages in a table. + * + * This component provides buttons to navigate to the first page, previous page, + * next page, and last page of a table. The visibility and functionality of the + * buttons are controlled based on the current page and the total number of items. + * + * @param props - Component properties. + * @returns The rendered component. + */ +function pagination(props: InterfaceTablePaginationActionsProps): JSX.Element { + const theme = useTheme(); + const { count, page, rowsPerPage, onPageChange } = props; + + /** + * Handles the event when the "First Page" button is clicked. + * Navigates to the first page (page 0). + * + * @param event - The click event. + */ + /* istanbul ignore next */ + const handleFirstPageButtonClick = ( + event: React.MouseEvent, + ): void => { + onPageChange(event, 0); + }; + + /** + * Handles the event when the "Previous Page" button is clicked. + * Navigates to the previous page. + * + * @param event - The click event. + */ + const handleBackButtonClick = ( + event: React.MouseEvent, + ): void => { + onPageChange(event, page - 1); + }; + + /** + * Handles the event when the "Next Page" button is clicked. + * Navigates to the next page. + * + * @param event - The click event. + */ + + /* istanbul ignore next */ + const handleNextButtonClick = ( + event: React.MouseEvent, + ): void => { + onPageChange(event, page + 1); + }; + + /** + * Handles the event when the "Last Page" button is clicked. + * Navigates to the last page. + * + * @param event - The click event. + */ + /* istanbul ignore next */ + const handleLastPageButtonClick = ( + event: React.MouseEvent, + ): void => { + onPageChange(event, Math.max(0, Math.ceil(count / rowsPerPage) - 1)); + }; + + return ( + + + {theme.direction === 'rtl' ? : } + + + {theme.direction === 'rtl' ? ( + + ) : ( + + )} + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="next page" + data-testid="nextPage" + > + {theme.direction === 'rtl' ? ( + + ) : ( + + )} + + = Math.ceil(count / rowsPerPage) - 1} + aria-label="last page" + data-testid="lastPage" + > + {theme.direction === 'rtl' ? : } + + + ); +} + +export default pagination; diff --git a/src/components/PaginationList/PaginationList.css b/src/components/PaginationList/PaginationList.css new file mode 100644 index 0000000000..2354c9a5f9 --- /dev/null +++ b/src/components/PaginationList/PaginationList.css @@ -0,0 +1,7 @@ +.MuiTablePagination-selectLabel { + margin-top: 1rem; +} + +.MuiTablePagination-displayedRows { + margin-top: 1rem; +} diff --git a/src/components/PaginationList/PaginationList.tsx b/src/components/PaginationList/PaginationList.tsx new file mode 100644 index 0000000000..430e8ba1aa --- /dev/null +++ b/src/components/PaginationList/PaginationList.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { Hidden, TablePagination } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +import Pagination from '../Pagination/Pagination'; +import './PaginationList.css'; + +interface InterfacePropsInterface { + count: number; + rowsPerPage: number; + page: number; + onPageChange: ( + event: React.MouseEvent | null, + newPage: number, + ) => void; + onRowsPerPageChange: ( + event: React.ChangeEvent, + ) => void; +} +/** + * A component that provides pagination controls for a table. + * It uses different pagination styles based on screen size. + * + * @param count - The total number of rows in the table. + * @param rowsPerPage - The number of rows displayed per page. + * @param page - The current page number. + * @param onPageChange - Callback function to handle page changes. + * @param onRowsPerPageChange - Callback function to handle changes in rows per page. + */ + +const PaginationList = ({ + count, + rowsPerPage, + page, + onPageChange, + onRowsPerPageChange, +}: InterfacePropsInterface): JSX.Element => { + const { t } = useTranslation('translation', { + keyPrefix: 'paginationList', + }); + + return ( + <> + + + + + + + + ); +}; + +export default PaginationList; diff --git a/src/components/ProfileDropdown/ProfileDropdown.module.css b/src/components/ProfileDropdown/ProfileDropdown.module.css new file mode 100644 index 0000000000..46af582126 --- /dev/null +++ b/src/components/ProfileDropdown/ProfileDropdown.module.css @@ -0,0 +1,75 @@ +.profileContainer { + border: none; + padding: 2.1rem 0.5rem; + height: 52px; + border-radius: 8px 0px 0px 8px; + display: flex; + align-items: center; + background-color: white !important; + box-shadow: + 0 4px 4px 0 rgba(177, 177, 177, 0.2), + 0 6px 44px 0 rgba(246, 246, 246, 0.19); +} +.profileContainer:focus { + outline: none; + background-color: var(--bs-gray-100); +} +.imageContainer { + width: 56px; + height: 56px; + border-radius: 100%; + margin-right: 10px; +} +.imageContainer img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 100%; +} +.profileContainer .profileText { + flex: 1; + text-align: start; + overflow: hidden; + margin-right: 4px; +} +.angleDown { + margin-left: 4px; +} +.profileContainer .profileText .primaryText { + font-size: 1rem; + font-weight: 600; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; /* number of lines to show */ + -webkit-box-orient: vertical; + word-wrap: break-word; + white-space: normal; +} +.profileContainer .profileText .secondaryText { + font-size: 0.8rem; + font-weight: 400; + color: var(--bs-secondary); + display: block; + text-transform: capitalize; +} +.profileDropdown { + background-color: transparent !important; +} +.profileDropdown .dropdown-toggle .btn .btn-normal { + display: none !important; + background-color: transparent !important; +} +.dropdownToggle { + background-image: url(/public/images/svg/angleDown.svg); + background-repeat: no-repeat; + background-position: center; + background-color: azure; +} + +.dropdownToggle::after { + border-top: none !important; + border-bottom: none !important; +} +.avatarStyle { + border-radius: 100%; +} diff --git a/src/components/ProfileDropdown/ProfileDropdown.test.tsx b/src/components/ProfileDropdown/ProfileDropdown.test.tsx new file mode 100644 index 0000000000..785f33ee92 --- /dev/null +++ b/src/components/ProfileDropdown/ProfileDropdown.test.tsx @@ -0,0 +1,148 @@ +import React, { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BrowserRouter } from 'react-router-dom'; +import ProfileDropdown from './ProfileDropdown'; +import 'jest-localstorage-mock'; +import { MockedProvider } from '@apollo/react-testing'; +import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations'; +import useLocalStorage from 'utils/useLocalstorage'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; +import { GET_COMMUNITY_SESSION_TIMEOUT_DATA } from 'GraphQl/Queries/Queries'; + +const { setItem } = useLocalStorage(); +const MOCKS = [ + { + request: { + query: REVOKE_REFRESH_TOKEN, + }, + result: { + data: { + revokeRefreshTokenForUser: true, + }, + }, + }, + { + request: { + query: GET_COMMUNITY_SESSION_TIMEOUT_DATA, + }, + result: { + data: { + getCommunityData: { + timeout: 30, + }, + }, + }, + delay: 1000, + }, +]; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, + clear: jest.fn(), +})); + +beforeEach(() => { + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + setItem( + 'UserImage', + 'https://api.dicebear.com/5.x/initials/svg?seed=John%20Doe', + ); + setItem('SuperAdmin', false); + setItem('AdminFor', []); + setItem('id', '123'); +}); + +afterEach(() => { + jest.clearAllMocks(); + localStorage.clear(); +}); +afterEach(() => { + jest.clearAllMocks(); + localStorage.clear(); +}); + +describe('ProfileDropdown Component', () => { + test('renders with user information', () => { + render( + + + + + + + , + ); + + expect(screen.getByTestId('display-name')).toBeInTheDocument(); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('User')).toBeInTheDocument(); + expect(screen.getByTestId('display-type')).toBeInTheDocument(); + expect(screen.getByAltText('profile picture')).toBeInTheDocument(); + }); + + test('renders Super admin', () => { + setItem('SuperAdmin', true); + render( + + + + + , + ); + expect(screen.getByText('SuperAdmin')).toBeInTheDocument(); + }); + test('renders Admin', () => { + setItem('AdminFor', ['123']); + render( + + + + + , + ); + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + test('logout functionality clears local storage and redirects to home', async () => { + render( + + + + + , + ); + + await act(async () => { + userEvent.click(screen.getByTestId('togDrop')); + }); + + userEvent.click(screen.getByTestId('logoutBtn')); + + expect(global.window.location.pathname).toBe('/'); + }); + + describe('Member screen routing testing', () => { + test('member screen', async () => { + render( + + + + + , + ); + await act(async () => { + userEvent.click(screen.getByTestId('togDrop')); + }); + + userEvent.click(screen.getByTestId('profileBtn')); + expect(global.window.location.pathname).toBe('/user/settings'); + }); + }); +}); diff --git a/src/components/ProfileDropdown/ProfileDropdown.tsx b/src/components/ProfileDropdown/ProfileDropdown.tsx new file mode 100644 index 0000000000..059e5b910a --- /dev/null +++ b/src/components/ProfileDropdown/ProfileDropdown.tsx @@ -0,0 +1,124 @@ +import Avatar from 'components/Avatar/Avatar'; +import React from 'react'; +import { ButtonGroup, Dropdown } from 'react-bootstrap'; +import { useNavigate } from 'react-router-dom'; +import useLocalStorage from 'utils/useLocalstorage'; +import styles from './ProfileDropdown.module.css'; +import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations'; +import { useMutation } from '@apollo/client'; +import { useTranslation } from 'react-i18next'; +import useSession from 'utils/useSession'; + +/** + * Renders a profile dropdown menu for the user. + * + * This component displays the user's profile picture or an avatar, their name (truncated if necessary), + * and their role (SuperAdmin, Admin, or User). It provides options to view the profile or log out. + * + * - If a user image is available, it displays that; otherwise, it shows an avatar. + * - The displayed name is truncated if it exceeds a specified length. + * - The logout function revokes the refresh token and clears local storage before redirecting to the home page. + * + * @returns JSX.Element - The profile dropdown menu. + */ +const profileDropdown = (): JSX.Element => { + const { endSession } = useSession(); + const { t: tCommon } = useTranslation('common'); + const [revokeRefreshToken] = useMutation(REVOKE_REFRESH_TOKEN); + const { getItem } = useLocalStorage(); + const superAdmin = getItem('SuperAdmin'); + const adminFor = getItem('AdminFor'); + const userRole = superAdmin + ? 'SuperAdmin' + : adminFor?.length > 0 + ? 'Admin' + : 'User'; + const firstName = getItem('FirstName'); + const lastName = getItem('LastName'); + const userImage = getItem('UserImage'); + const userID = getItem('id'); + const navigate = useNavigate(); + + const logout = async (): Promise => { + try { + await revokeRefreshToken(); + } catch (error) { + /*istanbul ignore next*/ + console.error('Error revoking refresh token:', error); + } + localStorage.clear(); + endSession(); + navigate('/'); + }; + const MAX_NAME_LENGTH = 20; + const fullName = `${firstName} ${lastName}`; + const displayedName = + fullName.length > MAX_NAME_LENGTH + ? /*istanbul ignore next*/ + fullName.substring(0, MAX_NAME_LENGTH - 3) + '...' + : fullName; + + return ( + +
    +
    + {userImage && userImage !== 'null' ? ( + /*istanbul ignore next*/ + {`profile + ) : ( + + )} +
    +
    + + {displayedName} + + + {`${userRole}`} + +
    +
    + + + + userRole === 'User' + ? navigate(`/user/settings`) + : navigate(`/member/${userID}`) + } + aria-label="View Profile" + > + {tCommon('viewProfile')} + + + {tCommon('logout')} + + +
    + ); +}; + +export default profileDropdown; diff --git a/src/components/RecurrenceOptions/CustomRecurrence.test.tsx b/src/components/RecurrenceOptions/CustomRecurrence.test.tsx new file mode 100644 index 0000000000..fc0cacf5c4 --- /dev/null +++ b/src/components/RecurrenceOptions/CustomRecurrence.test.tsx @@ -0,0 +1,721 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { + act, + render, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; + +import OrganizationEvents from '../../screens/OrganizationEvents/OrganizationEvents'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import userEvent from '@testing-library/user-event'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import { createTheme } from '@mui/material'; +import { ThemeProvider } from 'react-bootstrap'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { MOCKS } from '../../screens/OrganizationEvents/OrganizationEventsMocks'; + +const theme = createTheme({ + palette: { + primary: { + main: '#31bb6b', + }, + }, +}); + +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const translations = JSON.parse( + JSON.stringify( + i18nForTest.getDataByLanguage('en')?.translation.organizationEvents, + ), +); + +jest.mock('@mui/x-date-pickers/DateTimePicker', () => { + return { + DateTimePicker: jest.requireActual( + '@mui/x-date-pickers/DesktopDateTimePicker', + ).DesktopDateTimePicker, + }; +}); + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }, +})); + +describe('Testing the creaction of recurring events with custom recurrence patterns', () => { + const formData = { + title: 'Dummy Org', + description: 'This is a dummy organization', + startDate: '03/28/2022', + endDate: '03/30/2022', + recurrenceEndDate: '04/15/2023', + location: 'New Delhi', + startTime: '09:00 AM', + endTime: '05:00 PM', + }; + + test('Changing the recurrence frequency', async () => { + render( + + + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createEventModalBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + await waitFor(() => { + expect(screen.getByTestId('recurringCheck')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('recurrenceOptions')).not.toBeInTheDocument(); + + userEvent.click(screen.getByTestId('recurringCheck')); + + await waitFor(() => { + expect(screen.getByTestId('recurrenceOptions')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect(screen.getByTestId('customRecurrence')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('customRecurrence')); + + await waitFor(() => { + expect( + screen.getByTestId('customRecurrenceFrequencyDropdown'), + ).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('customRecurrenceFrequencyDropdown')); + + await waitFor(() => { + expect(screen.getByTestId('customDailyRecurrence')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('customDailyRecurrence')); + + await waitFor(() => { + expect( + screen.getByTestId('customRecurrenceFrequencyDropdown'), + ).toHaveTextContent('Day'); + }); + + userEvent.click(screen.getByTestId('customRecurrenceFrequencyDropdown')); + + await waitFor(() => { + expect(screen.getByTestId('customWeeklyRecurrence')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('customWeeklyRecurrence')); + + await waitFor(() => { + expect( + screen.getByTestId('customRecurrenceFrequencyDropdown'), + ).toHaveTextContent('Week'); + }); + + userEvent.click(screen.getByTestId('customRecurrenceFrequencyDropdown')); + + await waitFor(() => { + expect(screen.getByTestId('customMonthlyRecurrence')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('customMonthlyRecurrence')); + + await waitFor(() => { + expect( + screen.getByTestId('customRecurrenceFrequencyDropdown'), + ).toHaveTextContent('Month'); + }); + + userEvent.click(screen.getByTestId('customRecurrenceFrequencyDropdown')); + + await waitFor(() => { + expect(screen.getByTestId('customYearlyRecurrence')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('customYearlyRecurrence')); + + await waitFor(() => { + expect( + screen.getByTestId('customRecurrenceFrequencyDropdown'), + ).toHaveTextContent('Year'); + }); + + userEvent.click(screen.getByTestId('customRecurrenceSubmitBtn')); + await waitFor(() => { + expect( + screen.queryByTestId('customRecurrenceSubmitBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('Selecting and unselecting recurrence weekdays', async () => { + render( + + + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createEventModalBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + await waitFor(() => { + expect(screen.getByTestId('recurringCheck')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('recurrenceOptions')).not.toBeInTheDocument(); + + userEvent.click(screen.getByTestId('recurringCheck')); + + await waitFor(() => { + expect(screen.getByTestId('recurrenceOptions')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect(screen.getByTestId('customRecurrence')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('customRecurrence')); + + await waitFor(() => { + expect( + screen.getByTestId('customRecurrenceSubmitBtn'), + ).toBeInTheDocument(); + }); + + const weekDaysOptions = screen.getAllByTestId('recurrenceWeekDay'); + + weekDaysOptions.forEach((weekDay) => { + userEvent.click(weekDay); + }); + + weekDaysOptions.forEach((weekDay) => { + userEvent.click(weekDay); + }); + + userEvent.click(screen.getByTestId('customRecurrenceSubmitBtn')); + await waitFor(() => { + expect( + screen.queryByTestId('customRecurrenceSubmitBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('Selecting different monthly recurrence options from the dropdown menu', async () => { + render( + + + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createEventModalBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + const startDatePicker = screen.getByLabelText('Start Date'); + fireEvent.change(startDatePicker, { + target: { value: formData.startDate }, + }); + + const endDatePicker = screen.getByLabelText('End Date'); + fireEvent.change(endDatePicker, { + target: { value: formData.endDate }, + }); + + await waitFor(() => { + expect(screen.getByTestId('recurringCheck')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('recurrenceOptions')).not.toBeInTheDocument(); + + userEvent.click(screen.getByTestId('recurringCheck')); + + await waitFor(() => { + expect(screen.getByTestId('recurrenceOptions')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect(screen.getByTestId('customRecurrence')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('customRecurrence')); + + await waitFor(() => { + expect( + screen.getByTestId('customRecurrenceFrequencyDropdown'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('customRecurrenceFrequencyDropdown')); + + await waitFor(() => { + expect(screen.getByTestId('customMonthlyRecurrence')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('customMonthlyRecurrence')); + + await waitFor(() => { + expect( + screen.getByTestId('customRecurrenceFrequencyDropdown'), + ).toHaveTextContent('Month'); + }); + + await waitFor(() => { + expect(screen.getByTestId('monthlyRecurrenceOptions')).toHaveTextContent( + 'Monthly on Day 28', + ); + }); + + userEvent.click(screen.getByTestId('monthlyRecurrenceOptions')); + + await waitFor(() => { + expect( + screen.getByTestId('monthlyRecurrenceOptionOnThatOccurence'), + ).toBeInTheDocument(); + }); + userEvent.click( + screen.getByTestId('monthlyRecurrenceOptionOnThatOccurence'), + ); + + await waitFor(() => { + expect(screen.getByTestId('monthlyRecurrenceOptions')).toHaveTextContent( + 'Monthly on Fourth Monday', + ); + }); + + userEvent.click(screen.getByTestId('monthlyRecurrenceOptions')); + + await waitFor(() => { + expect( + screen.getByTestId('monthlyRecurrenceOptionOnLastOccurence'), + ).toBeInTheDocument(); + }); + userEvent.click( + screen.getByTestId('monthlyRecurrenceOptionOnLastOccurence'), + ); + + await waitFor(() => { + expect(screen.getByTestId('monthlyRecurrenceOptions')).toHaveTextContent( + 'Monthly on Last Monday', + ); + }); + + userEvent.click(screen.getByTestId('monthlyRecurrenceOptions')); + + await waitFor(() => { + expect( + screen.getByTestId('monthlyRecurrenceOptionOnThatDay'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('monthlyRecurrenceOptionOnThatDay')); + + await waitFor(() => { + expect(screen.getByTestId('monthlyRecurrenceOptions')).toHaveTextContent( + 'Monthly on Day 28', + ); + }); + }); + + test('Selecting the "Ends on" option for specifying the end of recurrence', async () => { + // i.e. when would the recurring event end: never, on a certain date, or after a certain number of occurences + render( + + + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createEventModalBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + await waitFor(() => { + expect(screen.getByTestId('recurringCheck')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('recurrenceOptions')).not.toBeInTheDocument(); + + userEvent.click(screen.getByTestId('recurringCheck')); + + await waitFor(() => { + expect(screen.getByTestId('recurrenceOptions')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect(screen.getByTestId('customRecurrence')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('customRecurrence')); + + await waitFor(() => { + expect(screen.getByTestId('never')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('never')); + userEvent.click(screen.getByTestId('on')); + userEvent.click(screen.getByTestId('after')); + userEvent.click(screen.getByTestId('never')); + + userEvent.click(screen.getByTestId('customRecurrenceSubmitBtn')); + await waitFor(() => { + expect( + screen.queryByTestId('customRecurrenceSubmitBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('Creating a bi monthly recurring event through custom recurrence modal', async () => { + render( + + + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createEventModalBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + await waitFor(() => { + expect(screen.getByPlaceholderText(/Enter Title/i)).toBeInTheDocument(); + }); + + userEvent.type(screen.getByPlaceholderText(/Enter Title/i), formData.title); + + userEvent.type( + screen.getByPlaceholderText(/Enter Description/i), + formData.description, + ); + + userEvent.type( + screen.getByPlaceholderText(/Enter Location/i), + formData.location, + ); + + const startDatePicker = screen.getByLabelText('Start Date'); + fireEvent.change(startDatePicker, { + target: { value: formData.startDate }, + }); + + const eventEndDatePicker = screen.getByLabelText('End Date'); + fireEvent.change(eventEndDatePicker, { + target: { value: formData.endDate }, + }); + + userEvent.click(screen.getByTestId('recurringCheck')); + + await waitFor(() => { + expect(screen.getByTestId('recurrenceOptions')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect(screen.getByTestId('customRecurrence')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('customRecurrence')); + + userEvent.click(screen.getByTestId('customRecurrenceFrequencyDropdown')); + + await waitFor(() => { + expect(screen.getByTestId('customMonthlyRecurrence')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('customMonthlyRecurrence')); + + await waitFor(() => { + expect( + screen.getByTestId('customRecurrenceFrequencyDropdown'), + ).toHaveTextContent('Month'); + }); + + userEvent.click(screen.getByTestId('monthlyRecurrenceOptions')); + + await waitFor(() => { + expect( + screen.getByTestId('monthlyRecurrenceOptionOnThatOccurence'), + ).toBeInTheDocument(); + }); + userEvent.click( + screen.getByTestId('monthlyRecurrenceOptionOnThatOccurence'), + ); + + await waitFor(() => { + expect(screen.getByTestId('on')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('on')); + + await waitFor(() => { + expect(screen.getByTestId('on')).toBeChecked(); + }); + + await waitFor(() => { + expect(screen.getAllByLabelText('End Date')[1]).toBeEnabled(); + }); + + const recurrenceEndDatePicker = screen.getAllByLabelText('End Date')[1]; + fireEvent.change(recurrenceEndDatePicker, { + target: { value: formData.recurrenceEndDate }, + }); + + await waitFor(() => { + expect( + screen.getByTestId('customRecurrenceIntervalInput'), + ).toBeInTheDocument(); + }); + + const recurrenceCount = screen.getByTestId('customRecurrenceIntervalInput'); + fireEvent.change(recurrenceCount, { + target: { value: 2 }, + }); + + userEvent.click(screen.getByTestId('customRecurrenceSubmitBtn')); + await waitFor(() => { + expect( + screen.queryByTestId('customRecurrenceSubmitBtn'), + ).not.toBeInTheDocument(); + }); + + expect(screen.getByTestId('recurrenceOptions')).toHaveTextContent( + 'Every 2 months on Fourth Monday, until April...', + // "..." because of the overlay component that would trim the recurrence rule text at 45 characters + ); + + userEvent.click(screen.getByTestId('createEventBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(translations.eventCreated); + }); + + await waitFor(() => { + expect( + screen.queryByTestId('createEventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('Creating a daily recurring event with a certain number of occurences', async () => { + render( + + + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createEventModalBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + await waitFor(() => { + expect(screen.getByPlaceholderText(/Enter Title/i)).toBeInTheDocument(); + }); + + userEvent.type(screen.getByPlaceholderText(/Enter Title/i), formData.title); + + userEvent.type( + screen.getByPlaceholderText(/Enter Description/i), + formData.description, + ); + + userEvent.type( + screen.getByPlaceholderText(/Enter Location/i), + formData.location, + ); + + const startDatePicker = screen.getByLabelText('Start Date'); + const endDatePicker = screen.getByLabelText('End Date'); + + fireEvent.change(startDatePicker, { + target: { value: formData.startDate }, + }); + + fireEvent.change(endDatePicker, { + target: { value: formData.endDate }, + }); + + userEvent.click(screen.getByTestId('recurringCheck')); + + await waitFor(() => { + expect(screen.getByTestId('recurrenceOptions')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect(screen.getByTestId('customRecurrence')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('customRecurrence')); + + userEvent.click(screen.getByTestId('customRecurrenceFrequencyDropdown')); + + await waitFor(() => { + expect(screen.getByTestId('customDailyRecurrence')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('customDailyRecurrence')); + + await waitFor(() => { + expect( + screen.getByTestId('customRecurrenceFrequencyDropdown'), + ).toHaveTextContent('Day'); + }); + + await waitFor(() => { + expect(screen.getByTestId('after')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('after')); + + await waitFor(() => { + expect(screen.getByTestId('after')).toBeChecked(); + }); + + await waitFor(() => { + expect( + screen.getByTestId('customRecurrenceCountInput'), + ).toBeInTheDocument(); + }); + + const recurrenceCount = screen.getByTestId('customRecurrenceCountInput'); + fireEvent.change(recurrenceCount, { + target: { value: 100 }, + }); + + await waitFor(() => { + expect(screen.getByTestId('customRecurrenceCountInput')).toHaveValue(100); + }); + + userEvent.click(screen.getByTestId('customRecurrenceSubmitBtn')); + await waitFor(() => { + expect( + screen.queryByTestId('customRecurrenceSubmitBtn'), + ).not.toBeInTheDocument(); + }); + + expect(screen.getByTestId('recurrenceOptions')).toHaveTextContent( + 'Daily, 100 times', + ); + + userEvent.click(screen.getByTestId('createEventBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(translations.eventCreated); + }); + + await waitFor(() => { + expect( + screen.queryByTestId('createEventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/RecurrenceOptions/CustomRecurrenceModal.module.css b/src/components/RecurrenceOptions/CustomRecurrenceModal.module.css new file mode 100644 index 0000000000..5fe5d33c47 --- /dev/null +++ b/src/components/RecurrenceOptions/CustomRecurrenceModal.module.css @@ -0,0 +1,59 @@ +.titlemodal { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 65%; +} + +.recurrenceRuleNumberInput { + width: 70px; +} + +.recurrenceRuleDateBox { + width: 70%; +} + +.recurrenceDayButton { + width: 33px; + height: 33px; + border: 1px solid var(--bs-gray); + cursor: pointer; + transition: background-color 0.3s; + display: inline-flex; + justify-content: center; + align-items: center; + margin-right: 0.5rem; + border-radius: 50%; +} + +.recurrenceDayButton:hover { + background-color: var(--bs-gray); +} + +.recurrenceDayButton.selected { + background-color: var(--bs-primary); + border-color: var(--bs-primary); + color: var(--bs-white); +} + +.recurrenceDayButton span { + color: var(--bs-gray); + padding: 0.25rem; + text-align: center; +} + +.recurrenceDayButton:hover span { + color: var(--bs-white); +} + +.recurrenceDayButton.selected span { + color: var(--bs-white); +} + +.recurrenceRuleSubmitBtn { + margin-left: 82%; + padding: 7px 15px; +} diff --git a/src/components/RecurrenceOptions/CustomRecurrenceModal.tsx b/src/components/RecurrenceOptions/CustomRecurrenceModal.tsx new file mode 100644 index 0000000000..deb1a03dab --- /dev/null +++ b/src/components/RecurrenceOptions/CustomRecurrenceModal.tsx @@ -0,0 +1,430 @@ +import React, { useEffect, useState } from 'react'; +import { Button, Dropdown, Form, FormControl, Modal } from 'react-bootstrap'; +import styles from './CustomRecurrenceModal.module.css'; +import { DatePicker } from '@mui/x-date-pickers'; +import { + Days, + Frequency, + daysOptions, + endsAfter, + endsNever, + endsOn, + frequencies, + getRecurrenceRuleText, + getWeekDayOccurenceInMonth, + isLastOccurenceOfWeekDay, + recurrenceEndOptions, +} from 'utils/recurrenceUtils'; +import type { + InterfaceRecurrenceRuleState, + RecurrenceEndOption, + WeekDays, +} from 'utils/recurrenceUtils'; +import type { Dayjs } from 'dayjs'; +import dayjs from 'dayjs'; + +/** + * Props for the CustomRecurrenceModal component. + */ +interface InterfaceCustomRecurrenceModalProps { + recurrenceRuleState: InterfaceRecurrenceRuleState; + recurrenceRuleText: string; + setRecurrenceRuleState: ( + state: React.SetStateAction, + ) => void; + customRecurrenceModalIsOpen: boolean; + hideCustomRecurrenceModal: () => void; + setCustomRecurrenceModalIsOpen: ( + state: React.SetStateAction, + ) => void; + t: (key: string) => string; + tCommon: (key: string) => string; +} + +/** + * A modal for setting up custom recurrence rules. + * + * This component allows users to configure how often an event should repeat, and + * when it should end. It includes options for daily, weekly, monthly, and yearly + * recurrence, as well as specific end options. + * + * @param props - The props object containing various configurations and state management functions. + * @returns The JSX element representing the CustomRecurrenceModal. + */ +const CustomRecurrenceModal: React.FC = ({ + recurrenceRuleState, + recurrenceRuleText, + setRecurrenceRuleState, + customRecurrenceModalIsOpen, + hideCustomRecurrenceModal, + setCustomRecurrenceModalIsOpen, + t, + tCommon, +}) => { + const { + recurrenceStartDate, + recurrenceEndDate, + frequency, + weekDays, + interval, + count, + } = recurrenceRuleState; + const [selectedRecurrenceEndOption, setSelectedRecurrenceEndOption] = + useState(endsNever); + + useEffect(() => { + if (recurrenceEndDate) { + setSelectedRecurrenceEndOption(endsOn); + } else if (count) { + setSelectedRecurrenceEndOption(endsAfter); + } + }, [recurrenceRuleState]); + + /** + * Handles changes to the recurrence end option. + * + * Updates the recurrence rule state based on the selected option. + * + * @param e - The event object from the radio button change. + */ + const handleRecurrenceEndOptionChange = ( + e: React.ChangeEvent, + ): void => { + const selectedRecurrenceEndOption = e.target.value as RecurrenceEndOption; + setSelectedRecurrenceEndOption(selectedRecurrenceEndOption); + if (selectedRecurrenceEndOption === endsNever) { + setRecurrenceRuleState({ + ...recurrenceRuleState, + recurrenceEndDate: null, + count: undefined, + }); + } + if (selectedRecurrenceEndOption === endsOn) { + setRecurrenceRuleState({ + ...recurrenceRuleState, + recurrenceEndDate: dayjs().add(1, 'month').toDate(), + count: undefined, + }); + } + if (selectedRecurrenceEndOption === endsAfter) { + setRecurrenceRuleState({ + ...recurrenceRuleState, + recurrenceEndDate: null, + count: 10, + }); + } + }; + + /** + * Handles clicks on day buttons for weekly recurrence. + * + * Toggles the selected state of a day button in the weekly recurrence setup. + * + * @param day - The day of the week to toggle. + */ + const handleDayClick = (day: WeekDays): void => { + if (weekDays !== undefined && weekDays.includes(day)) { + setRecurrenceRuleState({ + ...recurrenceRuleState, + weekDays: weekDays.filter((d) => d !== day), + weekDayOccurenceInMonth: undefined, + }); + } else { + setRecurrenceRuleState({ + ...recurrenceRuleState, + weekDays: [...(weekDays ?? []), day], + weekDayOccurenceInMonth: undefined, + }); + } + }; + + /** + * Toggles the visibility of the custom recurrence modal. + */ + const handleCustomRecurrenceSubmit = (): void => { + setCustomRecurrenceModalIsOpen(!customRecurrenceModalIsOpen); + }; + + return ( + <> + + +

    {t('customRecurrence')}

    + +
    + +
    + + {t('repeatsEvery')} + {' '} + + setRecurrenceRuleState({ + ...recurrenceRuleState, + interval: Number(e.target.value), + }) + } + /> + + + {`${frequencies[frequency]}${interval && interval > 1 ? 's' : ''}`} + + + + + setRecurrenceRuleState({ + ...recurrenceRuleState, + frequency: Frequency.DAILY, + weekDayOccurenceInMonth: undefined, + }) + } + data-testid="customDailyRecurrence" + > + {interval && interval > 1 ? 'Days' : 'Day'} + + + setRecurrenceRuleState({ + ...recurrenceRuleState, + frequency: Frequency.WEEKLY, + weekDays: [Days[recurrenceStartDate.getDay()]], + weekDayOccurenceInMonth: undefined, + }) + } + data-testid="customWeeklyRecurrence" + > + {interval && interval > 1 ? 'Weeks' : 'Week'} + + + setRecurrenceRuleState({ + ...recurrenceRuleState, + frequency: Frequency.MONTHLY, + weekDayOccurenceInMonth: undefined, + }) + } + data-testid="customMonthlyRecurrence" + > + {interval && interval > 1 ? 'Months' : 'Month'} + + + setRecurrenceRuleState({ + ...recurrenceRuleState, + frequency: Frequency.YEARLY, + weekDayOccurenceInMonth: undefined, + }) + } + data-testid="customYearlyRecurrence" + > + {interval && interval > 1 ? 'Years' : 'Year'} + + + +
    + + {frequency === Frequency.WEEKLY && ( +
    + + {t('repeatsOn')} + +
    +
    + {daysOptions.map((day, index) => ( +
    handleDayClick(Days[index])} + data-testid="recurrenceWeekDay" + > + {day} +
    + ))} +
    +
    + )} + + {frequency === Frequency.MONTHLY && ( +
    + + + {recurrenceRuleText} + + + + setRecurrenceRuleState({ + ...recurrenceRuleState, + frequency: Frequency.MONTHLY, + weekDayOccurenceInMonth: undefined, + }) + } + data-testid="monthlyRecurrenceOptionOnThatDay" + > + + {getRecurrenceRuleText({ + ...recurrenceRuleState, + frequency: Frequency.MONTHLY, + weekDayOccurenceInMonth: undefined, + })} + + + {getWeekDayOccurenceInMonth(recurrenceStartDate) !== 5 && ( + + setRecurrenceRuleState({ + ...recurrenceRuleState, + frequency: Frequency.MONTHLY, + weekDays: [Days[recurrenceStartDate.getDay()]], + weekDayOccurenceInMonth: + getWeekDayOccurenceInMonth(recurrenceStartDate), + }) + } + data-testid="monthlyRecurrenceOptionOnThatOccurence" + > + + {getRecurrenceRuleText({ + ...recurrenceRuleState, + frequency: Frequency.MONTHLY, + weekDays: [Days[recurrenceStartDate.getDay()]], + weekDayOccurenceInMonth: + getWeekDayOccurenceInMonth(recurrenceStartDate), + })} + + + )} + {isLastOccurenceOfWeekDay(recurrenceStartDate) && ( + + setRecurrenceRuleState({ + ...recurrenceRuleState, + frequency: Frequency.MONTHLY, + weekDays: [Days[recurrenceStartDate.getDay()]], + weekDayOccurenceInMonth: -1, + }) + } + data-testid="monthlyRecurrenceOptionOnLastOccurence" + > + + {getRecurrenceRuleText({ + ...recurrenceRuleState, + frequency: Frequency.MONTHLY, + weekDays: [Days[recurrenceStartDate.getDay()]], + weekDayOccurenceInMonth: -1, + })} + + + )} + + +
    + )} + +
    + {t('ends')} +
    +
    + {recurrenceEndOptions.map((option, index) => ( +
    + + + {option === endsOn && ( +
    + { + /* istanbul ignore next */ + if (date) { + setRecurrenceRuleState({ + ...recurrenceRuleState, + recurrenceEndDate: date?.toDate(), + }); + } + }} + /> +
    + )} + {option === endsAfter && ( + <> + + setRecurrenceRuleState({ + ...recurrenceRuleState, + count: Number(e.target.value), + }) + } + className={`${styles.recurrenceRuleNumberInput} ms-1 me-2 d-inline-block py-2`} + disabled={selectedRecurrenceEndOption !== endsAfter} + data-testid="customRecurrenceCountInput" + />{' '} + {t('occurences')} + + )} +
    + ))} +
    +
    +
    + +
    + +
    + +
    +
    +
    + + ); +}; + +export default CustomRecurrenceModal; diff --git a/src/components/RecurrenceOptions/RecurrenceOptions.test.tsx b/src/components/RecurrenceOptions/RecurrenceOptions.test.tsx new file mode 100644 index 0000000000..2d283460da --- /dev/null +++ b/src/components/RecurrenceOptions/RecurrenceOptions.test.tsx @@ -0,0 +1,587 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { + act, + render, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; + +import OrganizationEvents from '../../screens/OrganizationEvents/OrganizationEvents'; +import { store } from 'state/store'; +import i18n from 'utils/i18nForTest'; +import userEvent from '@testing-library/user-event'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import { createTheme } from '@mui/material'; +import { ThemeProvider } from 'react-bootstrap'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { MOCKS } from '../../screens/OrganizationEvents/OrganizationEventsMocks'; + +const theme = createTheme({ + palette: { + primary: { + main: '#31bb6b', + }, + }, +}); + +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const translations = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.organizationEvents ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +jest.mock('@mui/x-date-pickers/DateTimePicker', () => { + return { + DateTimePicker: jest.requireActual( + '@mui/x-date-pickers/DesktopDateTimePicker', + ).DesktopDateTimePicker, + }; +}); + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }, +})); + +describe('Testing the creaction of recurring events through recurrence options', () => { + const formData = { + title: 'Dummy Org', + description: 'This is a dummy organization', + startDate: '03/28/2022', + endDate: '03/30/2022', + location: 'New Delhi', + startTime: '09:00 AM', + endTime: '05:00 PM', + }; + + test('Recurrence options Dropdown shows up after checking the Recurring switch', async () => { + render( + + + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createEventModalBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + await waitFor(() => { + expect(screen.getByTestId('recurringCheck')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('recurrenceOptions')).not.toBeInTheDocument(); + + userEvent.click(screen.getByTestId('recurringCheck')); + + await waitFor(() => { + expect(screen.getByTestId('recurrenceOptions')).toBeInTheDocument(); + }); + }); + + test('Showing different recurrence options through the Dropdown', async () => { + render( + + + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createEventModalBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + await waitFor(() => { + expect(screen.getByTestId('recurringCheck')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('recurrenceOptions')).not.toBeInTheDocument(); + + userEvent.click(screen.getByTestId('recurringCheck')); + + await waitFor(() => { + expect(screen.getByTestId('recurrenceOptions')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect(screen.getByTestId('customRecurrence')).toBeInTheDocument(); + }); + }); + + test('Toggling of custom recurrence modal', async () => { + render( + + + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createEventModalBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + await waitFor(() => { + expect(screen.getByTestId('recurringCheck')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('recurrenceOptions')).not.toBeInTheDocument(); + + userEvent.click(screen.getByTestId('recurringCheck')); + + await waitFor(() => { + expect(screen.getByTestId('recurrenceOptions')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect(screen.getByTestId('customRecurrence')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('customRecurrence')); + + await waitFor(() => { + expect( + screen.getByTestId('customRecurrenceModalCloseBtn'), + ).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('customRecurrenceModalCloseBtn')); + + await waitFor(() => { + expect( + screen.queryByTestId('customRecurrenceModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('Selecting different recurrence options from the dropdown menu', async () => { + render( + + + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createEventModalBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + const startDatePicker = screen.getByLabelText('Start Date'); + const endDatePicker = screen.getByLabelText('End Date'); + + fireEvent.change(startDatePicker, { + target: { value: formData.startDate }, + }); + + fireEvent.change(endDatePicker, { + target: { value: formData.endDate }, + }); + + await waitFor(() => { + expect(screen.getByTestId('recurringCheck')).toBeInTheDocument(); + }); + + expect(screen.queryByTestId('recurrenceOptions')).not.toBeInTheDocument(); + + userEvent.click(screen.getByTestId('recurringCheck')); + + await waitFor(() => { + expect(screen.getByTestId('recurrenceOptions')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect(screen.getByTestId('dailyRecurrence')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('dailyRecurrence')); + + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect(screen.getByTestId('weeklyRecurrence')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('weeklyRecurrence')); + + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect( + screen.getByTestId('monthlyRecurrenceOnThatDay'), + ).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('monthlyRecurrenceOnThatDay')); + + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect( + screen.getByTestId('monthlyRecurrenceOnThatOccurence'), + ).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('monthlyRecurrenceOnThatOccurence')); + + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect( + screen.getByTestId('monthlyRecurrenceOnLastOccurence'), + ).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('monthlyRecurrenceOnLastOccurence')); + + // changing the startDate would change the weekDayOccurenceInMonth, if it is defined + fireEvent.change(startDatePicker, { + target: { value: formData.endDate }, + }); + + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect(screen.getByTestId('yearlyRecurrence')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('yearlyRecurrence')); + + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect( + screen.getByTestId('mondayToFridayRecurrence'), + ).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('mondayToFridayRecurrence')); + + await waitFor(() => { + expect(screen.getByTestId('recurrenceOptions')).toHaveTextContent( + 'Monday to Friday', + ); + }); + }, 30000); + + test('Creating a recurring event with the daily recurrence option', async () => { + render( + + + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createEventModalBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + await waitFor(() => { + expect(screen.getByPlaceholderText(/Enter Title/i)).toBeInTheDocument(); + }); + + userEvent.type(screen.getByPlaceholderText(/Enter Title/i), formData.title); + + userEvent.type( + screen.getByPlaceholderText(/Enter Description/i), + formData.description, + ); + + userEvent.type( + screen.getByPlaceholderText(/Enter Location/i), + formData.location, + ); + + const startDatePicker = screen.getByLabelText('Start Date'); + const endDatePicker = screen.getByLabelText('End Date'); + + fireEvent.change(startDatePicker, { + target: { value: formData.startDate }, + }); + + fireEvent.change(endDatePicker, { + target: { value: formData.endDate }, + }); + + userEvent.click(screen.getByTestId('alldayCheck')); + + await waitFor(() => { + expect(screen.getByLabelText(translations.startTime)).toBeInTheDocument(); + }); + + const startTimePicker = screen.getByLabelText(translations.startTime); + const endTimePicker = screen.getByLabelText(translations.endTime); + + fireEvent.change(startTimePicker, { + target: { value: formData.startTime }, + }); + + fireEvent.change(endTimePicker, { + target: { value: formData.endTime }, + }); + + await waitFor(() => { + expect(screen.getByTestId('recurringCheck')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('recurringCheck')); + + await waitFor(() => { + expect(screen.getByTestId('recurrenceOptions')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect(screen.getByTestId('dailyRecurrence')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('dailyRecurrence')); + + expect(screen.getByPlaceholderText(/Enter Title/i)).toHaveValue( + formData.title, + ); + expect(screen.getByPlaceholderText(/Enter Location/i)).toHaveValue( + formData.location, + ); + expect(screen.getByPlaceholderText(/Enter Description/i)).toHaveValue( + formData.description, + ); + expect(startDatePicker).toHaveValue(formData.startDate); + expect(endDatePicker).toHaveValue(formData.endDate); + expect(startTimePicker).toHaveValue(formData.startTime); + expect(endTimePicker).toHaveValue(formData.endTime); + expect(screen.getByTestId('alldayCheck')).not.toBeChecked(); + expect(screen.getByTestId('recurringCheck')).toBeChecked(); + + expect(screen.getByTestId('recurrenceOptions')).toHaveTextContent('Daily'); + + userEvent.click(screen.getByTestId('createEventBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(translations.eventCreated); + }); + + await waitFor(() => { + expect( + screen.queryByTestId('createEventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('Creating a recurring event with the monday to friday recurrence option', async () => { + render( + + + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createEventModalBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + await waitFor(() => { + expect(screen.getByPlaceholderText(/Enter Title/i)).toBeInTheDocument(); + }); + + userEvent.type(screen.getByPlaceholderText(/Enter Title/i), formData.title); + + userEvent.type( + screen.getByPlaceholderText(/Enter Description/i), + formData.description, + ); + + userEvent.type( + screen.getByPlaceholderText(/Enter Location/i), + formData.location, + ); + + const startDatePicker = screen.getByLabelText('Start Date'); + const endDatePicker = screen.getByLabelText('End Date'); + + fireEvent.change(startDatePicker, { + target: { value: formData.startDate }, + }); + + fireEvent.change(endDatePicker, { + target: { value: formData.endDate }, + }); + + userEvent.click(screen.getByTestId('alldayCheck')); + + await waitFor(() => { + expect(screen.getByLabelText(translations.startTime)).toBeInTheDocument(); + }); + + const startTimePicker = screen.getByLabelText(translations.startTime); + const endTimePicker = screen.getByLabelText(translations.endTime); + + fireEvent.change(startTimePicker, { + target: { value: formData.startTime }, + }); + + fireEvent.change(endTimePicker, { + target: { value: formData.endTime }, + }); + + await waitFor(() => { + expect(screen.getByTestId('recurringCheck')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('recurringCheck')); + + await waitFor(() => { + expect(screen.getByTestId('recurrenceOptions')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('recurrenceOptions')); + + await waitFor(() => { + expect( + screen.getByTestId('mondayToFridayRecurrence'), + ).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('mondayToFridayRecurrence')); + + expect(screen.getByPlaceholderText(/Enter Title/i)).toHaveValue( + formData.title, + ); + expect(screen.getByPlaceholderText(/Enter Location/i)).toHaveValue( + formData.location, + ); + expect(screen.getByPlaceholderText(/Enter Description/i)).toHaveValue( + formData.description, + ); + expect(startDatePicker).toHaveValue(formData.startDate); + expect(endDatePicker).toHaveValue(formData.endDate); + expect(startTimePicker).toHaveValue(formData.startTime); + expect(endTimePicker).toHaveValue(formData.endTime); + expect(screen.getByTestId('alldayCheck')).not.toBeChecked(); + expect(screen.getByTestId('recurringCheck')).toBeChecked(); + + expect(screen.getByTestId('recurrenceOptions')).toHaveTextContent( + 'Monday to Friday', + ); + + userEvent.click(screen.getByTestId('createEventBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(translations.eventCreated); + }); + + await waitFor(() => { + expect( + screen.queryByTestId('createEventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/RecurrenceOptions/RecurrenceOptions.tsx b/src/components/RecurrenceOptions/RecurrenceOptions.tsx new file mode 100644 index 0000000000..f1772f1b08 --- /dev/null +++ b/src/components/RecurrenceOptions/RecurrenceOptions.tsx @@ -0,0 +1,255 @@ +import React, { useState } from 'react'; +import { Dropdown, OverlayTrigger } from 'react-bootstrap'; +import { + Days, + Frequency, + type InterfaceRecurrenceRuleState, + getRecurrenceRuleText, + getWeekDayOccurenceInMonth, + isLastOccurenceOfWeekDay, + mondayToFriday, +} from 'utils/recurrenceUtils'; +import CustomRecurrenceModal from './CustomRecurrenceModal'; + +interface InterfaceRecurrenceOptionsProps { + recurrenceRuleState: InterfaceRecurrenceRuleState; + recurrenceRuleText: string; + setRecurrenceRuleState: ( + state: React.SetStateAction, + ) => void; + popover: JSX.Element; + t: (key: string) => string; + tCommon: (key: string) => string; +} +/** + * Renders a dropdown menu for selecting recurrence options. + * + * This component allows users to choose various recurrence rules (daily, weekly, monthly, yearly) for a given event. + * It displays the current recurrence rule text and provides options to modify it, including a custom recurrence modal. + * + * The dropdown menu includes options for: + * - Daily recurrence + * - Weekly recurrence (including a specific day of the week or Monday to Friday) + * - Monthly recurrence (on a specific day or occurrence) + * - Yearly recurrence + * - Custom recurrence (opens a modal for advanced settings) + * + * The displayed recurrence rule text is truncated if it exceeds a specified length, with an overlay showing the full text on hover. + * + * @param props - The properties to configure the recurrence options dropdown. + * @param recurrenceRuleState - The current state of the recurrence rule. + * @param recurrenceRuleText - The text describing the current recurrence rule. + * @param setRecurrenceRuleState - A function to update the recurrence rule state. + * @param popover - A JSX element used for displaying additional information on hover. + * @param t - A function for translating text. + * @param tCommon - A function for translating common text. + * + * @returns JSX.Element - The recurrence options dropdown and the custom recurrence modal. + */ +const RecurrenceOptions: React.FC = ({ + recurrenceRuleState, + recurrenceRuleText, + setRecurrenceRuleState, + popover, + t, + tCommon, +}) => { + const [customRecurrenceModalIsOpen, setCustomRecurrenceModalIsOpen] = + useState(false); + + const { recurrenceStartDate } = recurrenceRuleState; + + const hideCustomRecurrenceModal = (): void => { + setCustomRecurrenceModalIsOpen(false); + }; + + return ( + <> + + + {recurrenceRuleText.length > 45 ? ( + + + {`${recurrenceRuleText.substring(0, 45)}...`} + + + ) : ( + {recurrenceRuleText} + )} + + + + + setRecurrenceRuleState({ + ...recurrenceRuleState, + frequency: Frequency.DAILY, + weekDayOccurenceInMonth: undefined, + }) + } + data-testid="dailyRecurrence" + > + + {getRecurrenceRuleText({ + ...recurrenceRuleState, + frequency: Frequency.DAILY, + })} + + + + setRecurrenceRuleState({ + ...recurrenceRuleState, + frequency: Frequency.WEEKLY, + weekDays: [Days[recurrenceStartDate.getDay()]], + weekDayOccurenceInMonth: undefined, + }) + } + data-testid="weeklyRecurrence" + > + + {getRecurrenceRuleText({ + ...recurrenceRuleState, + frequency: Frequency.WEEKLY, + weekDays: [Days[recurrenceStartDate.getDay()]], + })} + + + + setRecurrenceRuleState({ + ...recurrenceRuleState, + frequency: Frequency.MONTHLY, + weekDayOccurenceInMonth: undefined, + }) + } + data-testid="monthlyRecurrenceOnThatDay" + > + + {getRecurrenceRuleText({ + ...recurrenceRuleState, + frequency: Frequency.MONTHLY, + weekDayOccurenceInMonth: undefined, + })} + + + {getWeekDayOccurenceInMonth(recurrenceStartDate) !== 5 && ( + + setRecurrenceRuleState({ + ...recurrenceRuleState, + frequency: Frequency.MONTHLY, + weekDays: [Days[recurrenceStartDate.getDay()]], + weekDayOccurenceInMonth: + getWeekDayOccurenceInMonth(recurrenceStartDate), + }) + } + data-testid="monthlyRecurrenceOnThatOccurence" + > + + {getRecurrenceRuleText({ + ...recurrenceRuleState, + frequency: Frequency.MONTHLY, + weekDays: [Days[recurrenceStartDate.getDay()]], + weekDayOccurenceInMonth: + getWeekDayOccurenceInMonth(recurrenceStartDate), + })} + + + )} + {isLastOccurenceOfWeekDay(recurrenceStartDate) && ( + + setRecurrenceRuleState({ + ...recurrenceRuleState, + frequency: Frequency.MONTHLY, + weekDays: [Days[recurrenceStartDate.getDay()]], + weekDayOccurenceInMonth: -1, + }) + } + data-testid="monthlyRecurrenceOnLastOccurence" + > + + {getRecurrenceRuleText({ + ...recurrenceRuleState, + frequency: Frequency.MONTHLY, + weekDays: [Days[recurrenceStartDate.getDay()]], + weekDayOccurenceInMonth: -1, + })} + + + )} + + setRecurrenceRuleState({ + ...recurrenceRuleState, + frequency: Frequency.YEARLY, + weekDayOccurenceInMonth: undefined, + }) + } + data-testid="yearlyRecurrence" + > + + {getRecurrenceRuleText({ + ...recurrenceRuleState, + frequency: Frequency.YEARLY, + weekDayOccurenceInMonth: undefined, + })} + + + + setRecurrenceRuleState({ + ...recurrenceRuleState, + frequency: Frequency.WEEKLY, + weekDays: mondayToFriday, + weekDayOccurenceInMonth: undefined, + }) + } + data-testid="mondayToFridayRecurrence" + > + + {getRecurrenceRuleText({ + ...recurrenceRuleState, + frequency: Frequency.WEEKLY, + weekDays: mondayToFriday, + })} + + + setCustomRecurrenceModalIsOpen(true)} + data-testid="customRecurrence" + > + Custom... + + + + + {/* Custom Recurrence Modal */} + + + ); +}; + +export default RecurrenceOptions; diff --git a/src/components/RequestsTableItem/RequestsTableItem.module.css b/src/components/RequestsTableItem/RequestsTableItem.module.css new file mode 100644 index 0000000000..9f12bfe996 --- /dev/null +++ b/src/components/RequestsTableItem/RequestsTableItem.module.css @@ -0,0 +1,66 @@ +.tableItem { + width: 100%; +} + +.tableItem td { + padding: 0.5rem; +} + +.tableItem .index { + padding-left: 2.5rem; + padding-top: 1rem; +} + +.tableItem .name { + padding-left: 1.5rem; + padding-top: 1rem; +} + +.tableItem .email { + padding-left: 1.5rem; + padding-top: 1rem; +} + +.acceptButton { + background: #31bb6b; + width: 120px; + height: 46px; + margin-left: -1rem; +} + +.rejectButton { + background: #dc3545; + width: 120px; + height: 46px; + margin-left: -1rem; +} + +@media (max-width: 1020px) { + .tableItem .index { + padding-left: 2rem; + } + + .tableItem .name, + .tableItem .email { + padding-left: 1rem; + } + + .acceptButton, + .rejectButton { + margin-left: -0.25rem; + } +} + +@media (max-width: 520px) { + .tableItem .index, + .tableItem .name, + .tableItem .email { + padding-left: 1rem; + } + + .acceptButton, + .rejectButton { + margin-left: 0; + width: 100%; + } +} diff --git a/src/components/RequestsTableItem/RequestsTableItem.test.tsx b/src/components/RequestsTableItem/RequestsTableItem.test.tsx new file mode 100644 index 0000000000..bbd895300b --- /dev/null +++ b/src/components/RequestsTableItem/RequestsTableItem.test.tsx @@ -0,0 +1,147 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { render, screen } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18nForTest from 'utils/i18nForTest'; +import type { InterfaceRequestsListItem } from './RequestsTableItem'; +import { MOCKS } from './RequestsTableItemMocks'; +import RequestsTableItem from './RequestsTableItem'; +import { BrowserRouter } from 'react-router-dom'; +const link = new StaticMockLink(MOCKS, true); +import useLocalStorage from 'utils/useLocalstorage'; +import userEvent from '@testing-library/user-event'; + +const { setItem } = useLocalStorage(); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} +const resetAndRefetchMock = jest.fn(); + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + warning: jest.fn(), + }, +})); + +beforeEach(() => { + setItem('id', '123'); +}); + +afterEach(() => { + localStorage.clear(); + jest.clearAllMocks(); +}); + +describe('Testing User Table Item', () => { + console.error = jest.fn((message) => { + if (message.includes('validateDOMNesting')) { + return; + } + console.warn(message); + }); + test('Should render props and text elements test for the page component', async () => { + const props: { + request: InterfaceRequestsListItem; + index: number; + resetAndRefetch: () => void; + } = { + request: { + _id: '123', + user: { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + }, + }, + index: 1, + resetAndRefetch: resetAndRefetchMock, + }; + + render( + + + + + + + , + ); + + await wait(); + expect(screen.getByText(/2./i)).toBeInTheDocument(); + expect(screen.getByText(/John Doe/i)).toBeInTheDocument(); + expect(screen.getByText(/john@example.com/i)).toBeInTheDocument(); + }); + + test('Accept MembershipRequest Button works properly', async () => { + const props: { + request: InterfaceRequestsListItem; + index: number; + resetAndRefetch: () => void; + } = { + request: { + _id: '123', + user: { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + }, + }, + index: 1, + resetAndRefetch: resetAndRefetchMock, + }; + + render( + + + + + + + , + ); + + await wait(); + userEvent.click(screen.getByTestId('acceptMembershipRequestBtn123')); + }); + + test('Reject MembershipRequest Button works properly', async () => { + const props: { + request: InterfaceRequestsListItem; + index: number; + resetAndRefetch: () => void; + } = { + request: { + _id: '123', + user: { + firstName: 'John', + lastName: 'Doe', + email: 'john@example.com', + }, + }, + index: 1, + resetAndRefetch: resetAndRefetchMock, + }; + + render( + + + + + + + , + ); + + await wait(); + userEvent.click(screen.getByTestId('rejectMembershipRequestBtn123')); + }); +}); diff --git a/src/components/RequestsTableItem/RequestsTableItem.tsx b/src/components/RequestsTableItem/RequestsTableItem.tsx new file mode 100644 index 0000000000..07feb5d289 --- /dev/null +++ b/src/components/RequestsTableItem/RequestsTableItem.tsx @@ -0,0 +1,142 @@ +import { useMutation } from '@apollo/client'; +import { + ACCEPT_ORGANIZATION_REQUEST_MUTATION, + REJECT_ORGANIZATION_REQUEST_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import React from 'react'; +import { Button } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { errorHandler } from 'utils/errorHandler'; +import styles from './RequestsTableItem.module.css'; + +/** + * Represents a membership request in the requests table. + */ +export interface InterfaceRequestsListItem { + _id: string; + user: { + firstName: string; + lastName: string; + email: string; + }; +} + +/** + * Props for the RequestsTableItem component. + * + */ +type Props = { + request: InterfaceRequestsListItem; + index: number; + resetAndRefetch: () => void; +}; + +/** + * Renders a table row item for a membership request. + * + * This component displays user details and provides buttons to accept or reject + * the membership request. It also handles showing success or error messages using + * toast notifications. + * + * @param props - The props object containing request details, index, and state reset function. + * @returns The JSX element representing the RequestsTableItem. + */ +const RequestsTableItem = (props: Props): JSX.Element => { + const { t } = useTranslation('translation', { keyPrefix: 'requests' }); + const { request, index, resetAndRefetch } = props; + const [acceptUser] = useMutation(ACCEPT_ORGANIZATION_REQUEST_MUTATION); + const [rejectUser] = useMutation(REJECT_ORGANIZATION_REQUEST_MUTATION); + + /** + * Handles the acceptance of a membership request. + * + * Sends a mutation request to accept the user and shows a success message if successful. + * It also triggers a state reset and refetch. + * + * @param membershipRequestId - The ID of the membership request to accept. + */ + const handleAcceptUser = async ( + membershipRequestId: string, + ): Promise => { + try { + const { data } = await acceptUser({ + variables: { + id: membershipRequestId, + }, + }); + /* istanbul ignore next */ + if (data) { + toast.success(t('acceptedSuccessfully') as string); + resetAndRefetch(); + } + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }; + + /** + * Handles the rejection of a membership request. + * + * Sends a mutation request to reject the user and shows a success message if successful. + * It also triggers a state reset and refetch. + * + * @param membershipRequestId - The ID of the membership request to reject. + */ + const handleRejectUser = async ( + membershipRequestId: string, + ): Promise => { + try { + const { data } = await rejectUser({ + variables: { + id: membershipRequestId, + }, + }); + /* istanbul ignore next */ + if (data) { + toast.success(t('rejectedSuccessfully') as string); + resetAndRefetch(); + } + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }; + + return ( + + {index + 1}. + {`${request.user.firstName} ${request.user.lastName}`} + {request.user.email} + + + + + + + + ); +}; + +export default RequestsTableItem; diff --git a/src/components/RequestsTableItem/RequestsTableItemMocks.ts b/src/components/RequestsTableItem/RequestsTableItemMocks.ts new file mode 100644 index 0000000000..22ea245d3a --- /dev/null +++ b/src/components/RequestsTableItem/RequestsTableItemMocks.ts @@ -0,0 +1,37 @@ +import { + ACCEPT_ORGANIZATION_REQUEST_MUTATION, + REJECT_ORGANIZATION_REQUEST_MUTATION, +} from 'GraphQl/Mutations/mutations'; + +export const MOCKS = [ + { + request: { + query: ACCEPT_ORGANIZATION_REQUEST_MUTATION, + variables: { + id: '1', + }, + }, + result: { + data: { + acceptMembershipRequest: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: REJECT_ORGANIZATION_REQUEST_MUTATION, + variables: { + id: '1', + }, + }, + result: { + data: { + rejectMembershipRequest: { + _id: '1', + }, + }, + }, + }, +]; diff --git a/src/components/SecuredRoute/SecuredRoute.tsx b/src/components/SecuredRoute/SecuredRoute.tsx new file mode 100644 index 0000000000..1119800215 --- /dev/null +++ b/src/components/SecuredRoute/SecuredRoute.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Navigate, Outlet } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import PageNotFound from 'screens/PageNotFound/PageNotFound'; +import useLocalStorage from 'utils/useLocalstorage'; +const { getItem, setItem } = useLocalStorage(); + +/** + * A route guard that checks if the user is logged in and has the necessary permissions. + * + * If the user is logged in and has an admin role set, it renders the child routes. + * Otherwise, it redirects to the home page or shows a 404 page if admin role is not set. + * + * @returns The JSX element representing the secured route. + */ +const SecuredRoute = (): JSX.Element => { + const isLoggedIn = getItem('IsLoggedIn'); + const adminFor = getItem('AdminFor'); + + return isLoggedIn === 'TRUE' ? ( + <>{adminFor != null ? : } + ) : ( + + ); +}; + +// Time constants for session timeout and inactivity interval +const timeoutMinutes = 15; +const timeoutMilliseconds = timeoutMinutes * 60 * 1000; + +const inactiveIntervalMin = 1; +const inactiveIntervalMilsec = inactiveIntervalMin * 60 * 1000; + +let lastActive: number = Date.now(); + +// Update lastActive timestamp on mouse movement +document.addEventListener('mousemove', () => { + lastActive = Date.now(); +}); + +// Check for inactivity and handle session timeout +setInterval(() => { + const currentTime = Date.now(); + const timeSinceLastActive = currentTime - lastActive; + + // If inactive for longer than the timeout period, show a warning and log out + if (timeSinceLastActive > timeoutMilliseconds) { + toast.warn('Kindly relogin as sessison has expired'); + + window.location.href = '/'; + setItem('IsLoggedIn', 'FALSE'); + } +}, inactiveIntervalMilsec); + +export default SecuredRoute; diff --git a/src/components/SuperAdminScreen/SuperAdminScreen.module.css b/src/components/SuperAdminScreen/SuperAdminScreen.module.css new file mode 100644 index 0000000000..9496ef95fa --- /dev/null +++ b/src/components/SuperAdminScreen/SuperAdminScreen.module.css @@ -0,0 +1,101 @@ +.pageContainer { + display: flex; + flex-direction: column; + min-height: 100vh; + padding: 1rem 1.5rem 0 calc(300px + 2rem + 1.5rem); +} + +.expand { + padding-left: 4rem; + animation: moveLeft 0.5s ease-in-out; +} + +.contract { + padding-left: calc(300px + 2rem + 1.5rem); + animation: moveRight 0.5s ease-in-out; +} + +.collapseSidebarButton { + position: fixed; + height: 40px; + bottom: 0; + z-index: 9999; + width: calc(300px + 2rem); + background-color: rgba(245, 245, 245, 0.7); + color: black; + border: none; + border-radius: 0px; +} + +.collapseSidebarButton:hover, +.opendrawer:hover { + opacity: 1; + color: black !important; +} +.opendrawer { + position: fixed; + display: flex; + align-items: center; + justify-content: center; + top: 0; + left: 0; + width: 40px; + height: 100vh; + z-index: 9999; + background-color: rgba(245, 245, 245); + border: none; + border-radius: 0px; + margin-right: 20px; + color: black; +} + +@media (max-width: 1120px) { + .contract { + padding-left: calc(250px + 2rem + 1.5rem); + } + .collapseSidebarButton { + width: calc(250px + 2rem); + } +} + +/* For tablets */ +@media (max-width: 820px) { + .pageContainer { + padding-left: 2.5rem; + } + + .opendrawer { + width: 25px; + } + + .contract, + .expand { + animation: none; + } + + .collapseSidebarButton { + width: 100%; + left: 0; + right: 0; + } +} + +@keyframes moveLeft { + from { + padding-left: calc(300px + 2rem + 1.5rem); + } + + to { + padding-left: 1.5rem; + } +} + +@keyframes moveRight { + from { + padding-left: 1.5rem; + } + + to { + padding-left: calc(300px + 2rem + 1.5rem); + } +} diff --git a/src/components/SuperAdminScreen/SuperAdminScreen.test.tsx b/src/components/SuperAdminScreen/SuperAdminScreen.test.tsx new file mode 100644 index 0000000000..84b740ab12 --- /dev/null +++ b/src/components/SuperAdminScreen/SuperAdminScreen.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { fireEvent, render, screen } from '@testing-library/react'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import SuperAdminScreen from './SuperAdminScreen'; + +const resizeWindow = (width: number): void => { + window.innerWidth = width; + fireEvent(window, new Event('resize')); +}; + +const clickToggleMenuBtn = (toggleButton: HTMLElement): void => { + fireEvent.click(toggleButton); +}; + +describe('Testing LeftDrawer in SuperAdminScreen', () => { + test('Testing LeftDrawer in page functionality', async () => { + render( + + + + + + + + + , + ); + + const toggleButton = screen.getByTestId('closeMenu') as HTMLElement; + const icon = toggleButton.querySelector('i'); + + // Resize window to a smaller width + resizeWindow(800); + clickToggleMenuBtn(toggleButton); + expect(icon).toHaveClass('fa fa-angle-double-left'); + + // Resize window back to a larger width + resizeWindow(1000); + clickToggleMenuBtn(toggleButton); + expect(icon).toHaveClass('fa fa-angle-double-right'); + + clickToggleMenuBtn(toggleButton); + expect(icon).toHaveClass('fa fa-angle-double-left'); + }); +}); diff --git a/src/components/SuperAdminScreen/SuperAdminScreen.tsx b/src/components/SuperAdminScreen/SuperAdminScreen.tsx new file mode 100644 index 0000000000..948dc334f7 --- /dev/null +++ b/src/components/SuperAdminScreen/SuperAdminScreen.tsx @@ -0,0 +1,98 @@ +import LeftDrawer from 'components/LeftDrawer/LeftDrawer'; +import React, { useEffect, useState } from 'react'; +import Button from 'react-bootstrap/Button'; +import { useTranslation } from 'react-i18next'; +import { Outlet, useLocation } from 'react-router-dom'; +import styles from './SuperAdminScreen.module.css'; +import ProfileDropdown from 'components/ProfileDropdown/ProfileDropdown'; + +/** + * The SuperAdminScreen component manages the layout for the Super Admin screen, + * including handling the sidebar visibility and page title based on the current route. + * + * @returns The JSX element representing the Super Admin screen layout. + */ +const superAdminScreen = (): JSX.Element => { + const location = useLocation(); + const titleKey = map[location.pathname.split('/')[1]]; + const { t } = useTranslation('translation', { keyPrefix: titleKey }); + const [hideDrawer, setHideDrawer] = useState(null); + + /** + * Handles resizing of the window to show or hide the sidebar. + */ + const handleResize = (): void => { + if (window.innerWidth <= 820) { + setHideDrawer(!hideDrawer); + } + }; + + useEffect(() => { + handleResize(); + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + return ( + <> + {hideDrawer ? ( + + ) : ( + + )} + +
    +
    +
    +

    {t('title')}

    +
    + +
    + +
    + + ); +}; + +export default superAdminScreen; + +/** + * Map of route segments to translation keys for page titles. + */ +const map: Record< + string, + 'orgList' | 'requests' | 'users' | 'memberDetail' | 'communityProfile' +> = { + orglist: 'orgList', + requests: 'requests', + users: 'users', + member: 'memberDetail', + communityProfile: 'communityProfile', +}; diff --git a/src/components/TableLoader/TableLoader.module.css b/src/components/TableLoader/TableLoader.module.css new file mode 100644 index 0000000000..66349e2aa9 --- /dev/null +++ b/src/components/TableLoader/TableLoader.module.css @@ -0,0 +1,3 @@ +.loadingItem { + height: 30px; +} diff --git a/src/components/TableLoader/TableLoader.test.tsx b/src/components/TableLoader/TableLoader.test.tsx new file mode 100644 index 0000000000..a7d334a1c7 --- /dev/null +++ b/src/components/TableLoader/TableLoader.test.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { BrowserRouter } from 'react-router-dom'; + +import type { InterfaceTableLoader } from './TableLoader'; +import TableLoader from './TableLoader'; + +beforeAll(() => { + console.error = jest.fn(); +}); + +describe('Testing Loader component', () => { + test('Component should be rendered properly only headerTitles is provided', () => { + const props: InterfaceTableLoader = { + noOfRows: 10, + headerTitles: ['header1', 'header2', 'header3'], + }; + render( + + + , + ); + // Check if header titles are rendered properly + const data = props.headerTitles as string[]; + data.forEach((title) => { + expect(screen.getByText(title)).toBeInTheDocument(); + }); + + // Check if elements are rendered properly + for (let rowIndex = 0; rowIndex < props.noOfRows; rowIndex++) { + expect( + screen.getByTestId(`row-${rowIndex}-tableLoading`), + ).toBeInTheDocument(); + for (let colIndex = 0; colIndex < data.length; colIndex++) { + expect( + screen.getByTestId(`row-${rowIndex}-col-${colIndex}-tableLoading`), + ).toBeInTheDocument(); + } + } + }); + test('Component should be rendered properly only noCols is provided', () => { + const props: InterfaceTableLoader = { + noOfRows: 10, + noOfCols: 3, + }; + render( + + + , + ); + // Check if header titles are rendered properly + const data = [...Array(props.noOfCols)]; + + // Check if elements are rendered properly + for (let rowIndex = 0; rowIndex < props.noOfRows; rowIndex++) { + expect( + screen.getByTestId(`row-${rowIndex}-tableLoading`), + ).toBeInTheDocument(); + for (let colIndex = 0; colIndex < data.length; colIndex++) { + expect( + screen.getByTestId(`row-${rowIndex}-col-${colIndex}-tableLoading`), + ).toBeInTheDocument(); + } + } + }); + test('Component should be throw error when noOfCols and headerTitles are undefined', () => { + const props = { + noOfRows: 10, + }; + expect(() => { + render( + + + , + ); + }).toThrow(); + }); +}); diff --git a/src/components/TableLoader/TableLoader.tsx b/src/components/TableLoader/TableLoader.tsx new file mode 100644 index 0000000000..631b13d514 --- /dev/null +++ b/src/components/TableLoader/TableLoader.tsx @@ -0,0 +1,79 @@ +import React, { useEffect } from 'react'; +import styles from './TableLoader.module.css'; +import { Table } from 'react-bootstrap'; + +export interface InterfaceTableLoader { + noOfRows: number; + headerTitles?: string[]; + noOfCols?: number; +} + +/** + * The TableLoader component displays a loading skeleton for tables. + * It shows a specified number of rows and columns as placeholders + * with a shimmering effect to indicate loading content. + * + * @param props - The properties for the TableLoader component. + * @param noOfRows - The number of rows to display. + * @param headerTitles - Optional. The titles for the table headers. + * @param noOfCols - Optional. The number of columns if headerTitles is not provided. + * + * @returns The JSX element representing the table loader. + */ +const tableLoader = (props: InterfaceTableLoader): JSX.Element => { + const { noOfRows, headerTitles, noOfCols } = props; + + useEffect(() => { + if (headerTitles == undefined && noOfCols == undefined) { + throw new Error( + 'TableLoader error Either headerTitles or noOfCols is required !', + ); + } + }, []); + + return ( + <> + + + + {headerTitles + ? headerTitles.map((title, index) => { + return ; + }) + : noOfCols && + [...Array(noOfCols)].map((_, index) => { + return + + + + {[...Array(noOfRows)].map((_, rowIndex) => { + return ( + + {[...Array(headerTitles ? headerTitles?.length : noOfCols)].map( + (_, colIndex) => { + return ( + + ); + }, + )} + + ); + })} + +
    {title}; + })} +
    +
    +
    + + ); +}; + +export default tableLoader; diff --git a/src/components/TagActions/TagActions.module.css b/src/components/TagActions/TagActions.module.css new file mode 100644 index 0000000000..079dffea65 --- /dev/null +++ b/src/components/TagActions/TagActions.module.css @@ -0,0 +1,42 @@ +.errorContainer { + min-height: 100vh; +} + +.errorMessage { + margin-top: 25%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.errorIcon { + transform: scale(1.5); + color: var(--bs-danger); + margin-bottom: 1rem; +} + +.scrollContainer { + height: 100px; + overflow-y: auto; +} + +.tagBadge { + display: flex; + align-items: center; + padding: 5px 10px; + border-radius: 12px; + box-shadow: 0 1px 3px var(--bs-gray-400); + max-width: calc(100% - 2rem); +} + +.removeFilterIcon { + cursor: pointer; +} + +.loadingDiv { + min-height: 300px; + display: flex; + justify-content: center; + align-items: center; +} diff --git a/src/components/TagActions/TagActions.test.tsx b/src/components/TagActions/TagActions.test.tsx new file mode 100644 index 0000000000..d27f177ebe --- /dev/null +++ b/src/components/TagActions/TagActions.test.tsx @@ -0,0 +1,400 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import type { RenderResult } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + cleanup, + waitFor, + act, +} from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; + +import { store } from 'state/store'; +import userEvent from '@testing-library/user-event'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { ApolloLink } from '@apollo/client'; +import type { InterfaceTagActionsProps } from './TagActions'; +import TagActions from './TagActions'; +import i18n from 'utils/i18nForTest'; +import { + MOCKS, + MOCKS_ERROR_ORGANIZATION_TAGS_QUERY, + MOCKS_ERROR_SUBTAGS_QUERY, +} from './TagActionsMocks'; +import type { TFunction } from 'i18next'; + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(MOCKS_ERROR_ORGANIZATION_TAGS_QUERY, true); +const link3 = new StaticMockLink(MOCKS_ERROR_SUBTAGS_QUERY, true); + +async function wait(ms = 500): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const translations = { + ...JSON.parse( + JSON.stringify(i18n.getDataByLanguage('en')?.translation.manageTag ?? {}), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const props: InterfaceTagActionsProps[] = [ + { + tagActionsModalIsOpen: true, + hideTagActionsModal: () => {}, + tagActionType: 'assignToTags', + t: ((key: string) => translations[key]) as TFunction< + 'translation', + 'manageTag' + >, + tCommon: ((key: string) => translations[key]) as TFunction< + 'common', + undefined + >, + }, + { + tagActionsModalIsOpen: true, + hideTagActionsModal: () => {}, + tagActionType: 'removeFromTags', + t: ((key: string) => translations[key]) as TFunction< + 'translation', + 'manageTag' + >, + tCommon: ((key: string) => translations[key]) as TFunction< + 'common', + undefined + >, + }, +]; + +const renderTagActionsModal = ( + props: InterfaceTagActionsProps, + link: ApolloLink, +): RenderResult => { + return render( + + + + + + } + /> + + + + + , + ); +}; + +describe('Organisation Tags Page', () => { + beforeEach(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + test('Component loads correctly and opens assignToTags modal', async () => { + const { getByText } = renderTagActionsModal(props[0], link); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.assign)).toBeInTheDocument(); + }); + }); + + test('Component loads correctly and opens removeFromTags modal', async () => { + const { getByText } = renderTagActionsModal(props[1], link); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.remove)).toBeInTheDocument(); + }); + }); + + test('Component calls hideTagActionsModal when modal is closed', async () => { + const hideTagActionsModalMock = jest.fn(); + + const props2: InterfaceTagActionsProps = { + tagActionsModalIsOpen: true, + hideTagActionsModal: hideTagActionsModalMock, + tagActionType: 'assignToTags', + t: ((key: string) => translations[key]) as TFunction< + 'translation', + 'manageTag' + >, + tCommon: ((key: string) => translations[key]) as TFunction< + 'common', + undefined + >, + }; + + renderTagActionsModal(props2, link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('closeTagActionsModalBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('closeTagActionsModalBtn')); + + await waitFor(() => { + expect(hideTagActionsModalMock).toHaveBeenCalled(); + }); + }); + + test('Renders error component when when query is unsuccessful', async () => { + const { queryByText } = renderTagActionsModal(props[0], link2); + + await wait(); + + await waitFor(() => { + expect(queryByText(translations.assign)).not.toBeInTheDocument(); + }); + }); + + test('Renders error component when when subTags query is unsuccessful', async () => { + const { getByText } = renderTagActionsModal(props[0], link3); + + await wait(); + + // expand tag 1 to list its subtags + await waitFor(() => { + expect(screen.getByTestId('expandSubTags1')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('expandSubTags1')); + + await waitFor(() => { + expect( + getByText(translations.errorOccurredWhileLoadingSubTags), + ).toBeInTheDocument(); + }); + }); + + test('searchs for tags where the name matches the provided search input', async () => { + renderTagActionsModal(props[0], link); + + await wait(); + + await waitFor(() => { + expect( + screen.getByPlaceholderText(translations.searchByName), + ).toBeInTheDocument(); + }); + const input = screen.getByPlaceholderText(translations.searchByName); + fireEvent.change(input, { target: { value: 'searchUserTag' } }); + + // should render the two searched tags from the mock data + // where name starts with "searchUserTag" + await waitFor(() => { + const tags = screen.getAllByTestId('orgUserTag'); + expect(tags.length).toEqual(2); + }); + }); + + test('Renders more members with infinite scroll', async () => { + const { getByText } = renderTagActionsModal(props[0], link); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.assign)).toBeInTheDocument(); + }); + + // Find the infinite scroll div by test ID or another selector + const scrollableDiv = screen.getByTestId('scrollableDiv'); + + const initialTagsDataLength = screen.getAllByTestId('orgUserTag').length; + + // Set scroll position to the bottom + fireEvent.scroll(scrollableDiv, { + target: { scrollY: scrollableDiv.scrollHeight }, + }); + + await waitFor(() => { + const finalTagsDataLength = screen.getAllByTestId('orgUserTag').length; + expect(finalTagsDataLength).toBeGreaterThan(initialTagsDataLength); + + expect(getByText(translations.assign)).toBeInTheDocument(); + }); + }); + + test('Selects and deselects tags', async () => { + renderTagActionsModal(props[0], link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('checkTag1')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTag1')); + + await waitFor(() => { + expect(screen.getByTestId('checkTag2')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTag2')); + + await waitFor(() => { + expect(screen.getByTestId('checkTag1')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTag1')); + + await waitFor(() => { + expect(screen.getByTestId('clearSelectedTag2')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('clearSelectedTag2')); + }); + + test('fetches and lists the child tags and then selects and deselects them', async () => { + renderTagActionsModal(props[0], link); + + await wait(); + + // expand tag 1 to list its subtags + await waitFor(() => { + expect(screen.getByTestId('expandSubTags1')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('expandSubTags1')); + + await waitFor(() => { + expect(screen.getByTestId('subTagsScrollableDiv1')).toBeInTheDocument(); + }); + // Find the infinite scroll div for subtags by test ID or another selector + const subTagsScrollableDiv1 = screen.getByTestId('subTagsScrollableDiv1'); + + const initialTagsDataLength = + screen.getAllByTestId('orgUserSubTags').length; + + // Set scroll position to the bottom + fireEvent.scroll(subTagsScrollableDiv1, { + target: { scrollY: subTagsScrollableDiv1.scrollHeight }, + }); + + await waitFor(() => { + const finalTagsDataLength = + screen.getAllByTestId('orgUserSubTags').length; + expect(finalTagsDataLength).toBeGreaterThan(initialTagsDataLength); + }); + + // select subtags 1 & 2 + await waitFor(() => { + expect(screen.getByTestId('checkTagsubTag1')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTagsubTag1')); + + await waitFor(() => { + expect(screen.getByTestId('checkTagsubTag2')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTagsubTag2')); + + await waitFor(() => { + expect(screen.getByTestId('checkTag1')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTag1')); + + // deselect subtags 1 & 2 + await waitFor(() => { + expect(screen.getByTestId('checkTagsubTag1')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTagsubTag1')); + + await waitFor(() => { + expect(screen.getByTestId('checkTagsubTag2')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTagsubTag2')); + + // hide subtags of tag 1 + await waitFor(() => { + expect(screen.getByTestId('expandSubTags1')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('expandSubTags1')); + }); + + test('Toasts error when no tag is selected while assigning', async () => { + renderTagActionsModal(props[0], link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('tagActionSubmitBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('tagActionSubmitBtn')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith(translations.noTagSelected); + }); + }); + + test('Successfully assigns to tags', async () => { + renderTagActionsModal(props[0], link); + + await wait(); + + // select userTags 2 & 3 and assign them + await waitFor(() => { + expect(screen.getByTestId('checkTag2')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTag2')); + + await waitFor(() => { + expect(screen.getByTestId('checkTag3')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTag3')); + + userEvent.click(screen.getByTestId('tagActionSubmitBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + translations.successfullyAssignedToTags, + ); + }); + }); + + test('Successfully removes from tags', async () => { + renderTagActionsModal(props[1], link); + + await wait(); + + // select userTag 2 and remove people from it + await waitFor(() => { + expect(screen.getByTestId('checkTag2')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('checkTag2')); + + userEvent.click(screen.getByTestId('tagActionSubmitBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + translations.successfullyRemovedFromTags, + ); + }); + }); +}); diff --git a/src/components/TagActions/TagActions.tsx b/src/components/TagActions/TagActions.tsx new file mode 100644 index 0000000000..083341372f --- /dev/null +++ b/src/components/TagActions/TagActions.tsx @@ -0,0 +1,407 @@ +import { useMutation, useQuery } from '@apollo/client'; +import type { FormEvent } from 'react'; +import React, { useEffect, useState } from 'react'; +import { Modal, Form, Button } from 'react-bootstrap'; +import { useParams } from 'react-router-dom'; +import type { + InterfaceQueryOrganizationUserTags, + InterfaceTagData, +} from 'utils/interfaces'; +import styles from './TagActions.module.css'; +import { ORGANIZATION_USER_TAGS_LIST } from 'GraphQl/Queries/OrganizationQueries'; +import { + ASSIGN_TO_TAGS, + REMOVE_FROM_TAGS, +} from 'GraphQl/Mutations/TagMutations'; +import { toast } from 'react-toastify'; +import type { + InterfaceOrganizationTagsQuery, + TagActionType, +} from 'utils/organizationTagsUtils'; +import { TAGS_QUERY_DATA_CHUNK_SIZE } from 'utils/organizationTagsUtils'; +import InfiniteScroll from 'react-infinite-scroll-component'; +import { WarningAmberRounded } from '@mui/icons-material'; +import TagNode from './TagNode'; +import InfiniteScrollLoader from 'components/InfiniteScrollLoader/InfiniteScrollLoader'; +import type { TFunction } from 'i18next'; + +interface InterfaceUserTagsAncestorData { + _id: string; + name: string; +} + +/** + * Props for the `AssignToTags` component. + */ +export interface InterfaceTagActionsProps { + tagActionsModalIsOpen: boolean; + hideTagActionsModal: () => void; + tagActionType: TagActionType; + t: TFunction<'translation', 'manageTag'>; + tCommon: TFunction<'common', undefined>; +} + +const TagActions: React.FC = ({ + tagActionsModalIsOpen, + hideTagActionsModal, + tagActionType, + t, + tCommon, +}) => { + const { orgId, tagId: currentTagId } = useParams(); + + const [tagSearchName, setTagSearchName] = useState(''); + + const { + data: orgUserTagsData, + loading: orgUserTagsLoading, + error: orgUserTagsError, + fetchMore: orgUserTagsFetchMore, + }: InterfaceOrganizationTagsQuery = useQuery(ORGANIZATION_USER_TAGS_LIST, { + variables: { + id: orgId, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: tagSearchName } }, + }, + skip: !tagActionsModalIsOpen, + }); + + const loadMoreUserTags = (): void => { + orgUserTagsFetchMore({ + variables: { + first: TAGS_QUERY_DATA_CHUNK_SIZE, + after: orgUserTagsData?.organizations[0].userTags.pageInfo.endCursor, + }, + updateQuery: ( + prevResult: { organizations: InterfaceQueryOrganizationUserTags[] }, + { + fetchMoreResult, + }: { + fetchMoreResult?: { + organizations: InterfaceQueryOrganizationUserTags[]; + }; + }, + ) => { + if (!fetchMoreResult) /* istanbul ignore next */ return prevResult; + + return { + organizations: [ + { + ...prevResult.organizations[0], + userTags: { + ...prevResult.organizations[0].userTags, + edges: [ + ...prevResult.organizations[0].userTags.edges, + ...fetchMoreResult.organizations[0].userTags.edges, + ], + pageInfo: fetchMoreResult.organizations[0].userTags.pageInfo, + }, + }, + ], + }; + }, + }); + }; + + const userTagsList = + orgUserTagsData?.organizations[0]?.userTags.edges.map( + (edge) => edge.node, + ) ?? /* istanbul ignore next */ []; + + // tags that we have selected to assigned + const [selectedTags, setSelectedTags] = useState([]); + + // tags that we have checked, it is there to differentiate between the selected tags and all the checked tags + // i.e. selected tags would only be the ones we select, but checked tags will also include the selected tag's ancestors + const [checkedTags, setCheckedTags] = useState>(new Set()); + + // next 3 states are there to keep track of the ancestor tags of the the tags that we have selected + // i.e. when we check a tag, all of it's ancestor tags will be checked too + // indicating that the users will be assigned all of the ancestor tags as well + const [addAncestorTagsData, setAddAncestorTagsData] = useState< + Set + >(new Set()); + const [removeAncestorTagsData, setRemoveAncestorTagsData] = useState< + Set + >(new Set()); + const [ancestorTagsDataMap, setAncestorTagsDataMap] = useState(new Map()); + + useEffect(() => { + const newCheckedTags = new Set(checkedTags); + const newAncestorTagsDataMap = new Map(ancestorTagsDataMap); + /* istanbul ignore next */ + addAncestorTagsData.forEach( + (ancestorTag: InterfaceUserTagsAncestorData) => { + const prevAncestorTagValue = ancestorTagsDataMap.get(ancestorTag._id); + newAncestorTagsDataMap.set( + ancestorTag._id, + prevAncestorTagValue ? prevAncestorTagValue + 1 : 1, + ); + newCheckedTags.add(ancestorTag._id); + }, + ); + + setCheckedTags(newCheckedTags); + setAncestorTagsDataMap(newAncestorTagsDataMap); + }, [addAncestorTagsData]); + + useEffect(() => { + const newCheckedTags = new Set(checkedTags); + const newAncestorTagsDataMap = new Map(ancestorTagsDataMap); + /* istanbul ignore next */ + removeAncestorTagsData.forEach( + (ancestorTag: InterfaceUserTagsAncestorData) => { + const prevAncestorTagValue = ancestorTagsDataMap.get(ancestorTag._id); + if (prevAncestorTagValue === 1) { + newCheckedTags.delete(ancestorTag._id); + newAncestorTagsDataMap.delete(ancestorTag._id); + } else { + newAncestorTagsDataMap.set(ancestorTag._id, prevAncestorTagValue - 1); + } + }, + ); + + setCheckedTags(newCheckedTags); + setAncestorTagsDataMap(newAncestorTagsDataMap); + }, [removeAncestorTagsData]); + + const selectTag = (tag: InterfaceTagData): void => { + const newCheckedTags = new Set(checkedTags); + + setSelectedTags((selectedTags) => [...selectedTags, tag]); + newCheckedTags.add(tag._id); + + setAddAncestorTagsData(new Set(tag.ancestorTags)); + + setCheckedTags(newCheckedTags); + }; + + const deSelectTag = (tag: InterfaceTagData): void => { + if (!selectedTags.some((selectedTag) => selectedTag._id === tag._id)) { + /* istanbul ignore next */ + return; + } + + const newCheckedTags = new Set(checkedTags); + + setSelectedTags( + selectedTags.filter((selectedTag) => selectedTag._id !== tag._id), + ); + newCheckedTags.delete(tag._id); + + setRemoveAncestorTagsData(new Set(tag.ancestorTags)); + + setCheckedTags(newCheckedTags); + }; + + const toggleTagSelection = ( + tag: InterfaceTagData, + isSelected: boolean, + ): void => { + if (isSelected) { + selectTag(tag); + } else { + deSelectTag(tag); + } + }; + + const [assignToTags] = useMutation(ASSIGN_TO_TAGS); + const [removeFromTags] = useMutation(REMOVE_FROM_TAGS); + + const handleTagAction = async ( + e: FormEvent, + ): Promise => { + e.preventDefault(); + + if (!selectedTags.length) { + toast.error(t('noTagSelected')); + return; + } + + const mutationObject = { + variables: { + currentTagId, + selectedTagIds: selectedTags.map((selectedTag) => selectedTag._id), + }, + }; + + try { + const { data } = + tagActionType === 'assignToTags' + ? await assignToTags(mutationObject) + : await removeFromTags(mutationObject); + + if (data) { + if (tagActionType === 'assignToTags') { + toast.success(t('successfullyAssignedToTags')); + } else { + toast.success(t('successfullyRemovedFromTags')); + } + hideTagActionsModal(); + } + } catch (error: unknown) { + /* istanbul ignore next */ + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + if (orgUserTagsError) { + return ( +
    +
    + +
    + {t('errorOccurredWhileLoadingOrganizationUserTags')} +
    +
    +
    + ); + } + + return ( + <> + + + + {tagActionType === 'assignToTags' + ? t('assignToTags') + : t('removeFromTags')} + + +
    + +
    + {selectedTags.length === 0 ? ( +
    + {t('noTagSelected')} +
    + ) : ( + selectedTags.map((tag: InterfaceTagData) => ( +
    + {tag.name} +
    + )) + )} +
    + +
    + + setTagSearchName(e.target.value.trim())} + data-testid="searchByName" + autoComplete="off" + /> +
    + +
    + {t('allTags')} +
    + {orgUserTagsLoading ? ( +
    + +
    + ) : ( + <> +
    + } + scrollableTarget="scrollableDiv" + > + {userTagsList?.map((tag) => ( +
    +
    + +
    + + {/* Ancestor tags breadcrumbs positioned at the end of TagNode */} + {tag.parentTag && ( +
    + <>{'('} + {tag.ancestorTags?.map((ancestorTag) => ( + + {ancestorTag.name} + + + ))} + <>{')'} +
    + )} +
    + ))} +
    +
    + + )} +
    + + + + + + +
    + + ); +}; + +export default TagActions; diff --git a/src/components/TagActions/TagActionsMocks.ts b/src/components/TagActions/TagActionsMocks.ts new file mode 100644 index 0000000000..f22458fa53 --- /dev/null +++ b/src/components/TagActions/TagActionsMocks.ts @@ -0,0 +1,706 @@ +import { + ASSIGN_TO_TAGS, + REMOVE_FROM_TAGS, +} from 'GraphQl/Mutations/TagMutations'; +import { ORGANIZATION_USER_TAGS_LIST } from 'GraphQl/Queries/OrganizationQueries'; +import { USER_TAG_SUB_TAGS } from 'GraphQl/Queries/userTagQueries'; +import { TAGS_QUERY_DATA_CHUNK_SIZE } from 'utils/organizationTagsUtils'; + +export const MOCKS = [ + { + request: { + query: ORGANIZATION_USER_TAGS_LIST, + variables: { + id: '123', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: '' } }, + }, + }, + result: { + data: { + organizations: [ + { + userTags: { + edges: [ + { + node: { + _id: '1', + name: 'userTag 1', + parentTag: null, + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 11, + }, + ancestorTags: [], + }, + cursor: '1', + }, + { + node: { + _id: '2', + name: 'userTag 2', + parentTag: null, + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [], + }, + cursor: '2', + }, + { + node: { + _id: '3', + name: 'userTag 3', + parentTag: null, + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [], + }, + cursor: '3', + }, + { + node: { + _id: '4', + name: 'userTag 4', + parentTag: null, + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [], + }, + cursor: '4', + }, + { + node: { + _id: '5', + name: 'userTag 5', + parentTag: null, + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [], + }, + cursor: '5', + }, + { + node: { + _id: '6', + name: 'userTag 6', + parentTag: null, + usersAssignedTo: { + totalCount: 6, + }, + childTags: { + totalCount: 6, + }, + ancestorTags: [], + }, + cursor: '6', + }, + { + node: { + _id: '7', + name: 'userTag 7', + parentTag: null, + usersAssignedTo: { + totalCount: 7, + }, + childTags: { + totalCount: 7, + }, + ancestorTags: [], + }, + cursor: '7', + }, + { + node: { + _id: '8', + name: 'userTag 8', + parentTag: null, + usersAssignedTo: { + totalCount: 8, + }, + childTags: { + totalCount: 8, + }, + ancestorTags: [], + }, + cursor: '8', + }, + { + node: { + _id: '9', + name: 'userTag 9', + parentTag: null, + usersAssignedTo: { + totalCount: 9, + }, + childTags: { + totalCount: 9, + }, + ancestorTags: [], + }, + cursor: '9', + }, + { + node: { + _id: '10', + name: 'userTag 10', + parentTag: null, + usersAssignedTo: { + totalCount: 10, + }, + childTags: { + totalCount: 10, + }, + ancestorTags: [], + }, + cursor: '10', + }, + ], + pageInfo: { + startCursor: '1', + endCursor: '10', + hasNextPage: true, + hasPreviousPage: false, + }, + totalCount: 12, + }, + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_USER_TAGS_LIST, + variables: { + id: '123', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + after: '10', + where: { name: { starts_with: '' } }, + }, + }, + result: { + data: { + organizations: [ + { + userTags: { + edges: [ + { + node: { + _id: '11', + name: 'userTag 11', + parentTag: null, + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [], + }, + cursor: '11', + }, + { + node: { + _id: '12', + name: 'userTag 12', + parentTag: null, + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [], + }, + cursor: '12', + }, + ], + pageInfo: { + startCursor: '11', + endCursor: '12', + hasNextPage: false, + hasPreviousPage: true, + }, + totalCount: 12, + }, + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_USER_TAGS_LIST, + variables: { + id: '123', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: 'searchUserTag' } }, + }, + }, + result: { + data: { + organizations: [ + { + userTags: { + edges: [ + { + node: { + _id: '1', + name: 'searchUserTag 1', + parentTag: { + _id: '1', + }, + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: '1', + }, + { + node: { + _id: '2', + name: 'searchUserTag 2', + parentTag: { + _id: '1', + }, + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: '2', + }, + ], + pageInfo: { + startCursor: '1', + endCursor: '2', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 2, + }, + }, + ], + }, + }, + }, + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + }, + }, + result: { + data: { + getChildTags: { + name: 'userTag 1', + childTags: { + edges: [ + { + node: { + _id: 'subTag1', + name: 'subTag 1', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag1', + }, + { + node: { + _id: 'subTag2', + name: 'subTag 2', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag2', + }, + { + node: { + _id: 'subTag3', + name: 'subTag 3', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag3', + }, + { + node: { + _id: 'subTag4', + name: 'subTag 4', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag4', + }, + { + node: { + _id: 'subTag5', + name: 'subTag 5', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag5', + }, + { + node: { + _id: 'subTag6', + name: 'subTag 6', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag6', + }, + { + node: { + _id: 'subTag7', + name: 'subTag 7', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag7', + }, + { + node: { + _id: 'subTag8', + name: 'subTag 8', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag8', + }, + { + node: { + _id: 'subTag9', + name: 'subTag 9', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag9', + }, + { + node: { + _id: 'subTag10', + name: 'subTag 10', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag10', + }, + ], + pageInfo: { + startCursor: 'subTag1', + endCursor: 'subTag10', + hasNextPage: true, + hasPreviousPage: false, + }, + totalCount: 11, + }, + ancestorTags: [], + }, + }, + }, + }, + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: '1', + after: 'subTag10', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + }, + }, + result: { + data: { + getChildTags: { + name: 'userTag 1', + childTags: { + edges: [ + { + node: { + _id: 'subTag11', + name: 'subTag 11', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag11', + }, + ], + pageInfo: { + startCursor: 'subTag11', + endCursor: 'subTag11', + hasNextPage: false, + hasPreviousPage: true, + }, + totalCount: 11, + }, + ancestorTags: [], + }, + }, + }, + }, + { + request: { + query: ASSIGN_TO_TAGS, + variables: { + currentTagId: '1', + selectedTagIds: ['2', '3'], + }, + }, + result: { + data: { + assignToUserTags: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: REMOVE_FROM_TAGS, + variables: { + currentTagId: '1', + selectedTagIds: ['2'], + }, + }, + result: { + data: { + removeFromUserTags: { + _id: '1', + }, + }, + }, + }, +]; + +export const MOCKS_ERROR_ORGANIZATION_TAGS_QUERY = [ + { + request: { + query: ORGANIZATION_USER_TAGS_LIST, + variables: { + id: '123', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: '' } }, + }, + }, + error: new Error('Mock Graphql Error for organization root tags query'), + }, +]; + +export const MOCKS_ERROR_SUBTAGS_QUERY = [ + { + request: { + query: ORGANIZATION_USER_TAGS_LIST, + variables: { + id: '123', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: '' } }, + }, + }, + result: { + data: { + organizations: [ + { + userTags: { + edges: [ + { + node: { + _id: '1', + name: 'userTag 1', + parentTag: null, + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 11, + }, + ancestorTags: [], + }, + cursor: '1', + }, + { + node: { + _id: '2', + name: 'userTag 2', + parentTag: null, + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [], + }, + cursor: '2', + }, + ], + pageInfo: { + startCursor: '1', + endCursor: '2', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 2, + }, + }, + ], + }, + }, + }, + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + }, + }, + error: new Error('Mock Graphql Error for subTags query'), + }, +]; diff --git a/src/components/TagActions/TagNode.tsx b/src/components/TagActions/TagNode.tsx new file mode 100644 index 0000000000..4a085ecaf9 --- /dev/null +++ b/src/components/TagActions/TagNode.tsx @@ -0,0 +1,199 @@ +import { useQuery } from '@apollo/client'; +import { USER_TAG_SUB_TAGS } from 'GraphQl/Queries/userTagQueries'; +import React, { useState } from 'react'; +import type { + InterfaceQueryUserTagChildTags, + InterfaceTagData, +} from 'utils/interfaces'; +import type { InterfaceOrganizationSubTagsQuery } from 'utils/organizationTagsUtils'; +import { TAGS_QUERY_DATA_CHUNK_SIZE } from 'utils/organizationTagsUtils'; +import styles from './TagActions.module.css'; +import InfiniteScroll from 'react-infinite-scroll-component'; +import InfiniteScrollLoader from 'components/InfiniteScrollLoader/InfiniteScrollLoader'; +import { WarningAmberRounded } from '@mui/icons-material'; +import type { TFunction } from 'i18next'; + +/** + * Props for the `TagNode` component. + */ +interface InterfaceTagNodeProps { + tag: InterfaceTagData; + checkedTags: Set; + toggleTagSelection: (tag: InterfaceTagData, isSelected: boolean) => void; + t: TFunction<'translation', 'manageTag'>; +} + +/** + * Renders the Tags which can be expanded to list subtags. + */ +const TagNode: React.FC = ({ + tag, + checkedTags, + toggleTagSelection, + t, +}) => { + const [expanded, setExpanded] = useState(false); + + const { + data: subTagsData, + loading: subTagsLoading, + error: subTagsError, + fetchMore: fetchMoreSubTags, + }: InterfaceOrganizationSubTagsQuery = useQuery(USER_TAG_SUB_TAGS, { + variables: { + id: tag._id, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + }, + skip: !expanded, + }); + + const loadMoreSubTags = (): void => { + fetchMoreSubTags({ + variables: { + first: TAGS_QUERY_DATA_CHUNK_SIZE, + after: subTagsData?.getChildTags.childTags.pageInfo.endCursor, + }, + updateQuery: ( + prevResult: { getChildTags: InterfaceQueryUserTagChildTags }, + { + fetchMoreResult, + }: { + fetchMoreResult?: { getChildTags: InterfaceQueryUserTagChildTags }; + }, + ) => { + if (!fetchMoreResult) /* istanbul ignore next */ return prevResult; + + return { + getChildTags: { + ...fetchMoreResult.getChildTags, + childTags: { + ...fetchMoreResult.getChildTags.childTags, + edges: [ + ...prevResult.getChildTags.childTags.edges, + ...fetchMoreResult.getChildTags.childTags.edges, + ], + }, + }, + }; + }, + }); + }; + + if (subTagsError) { + return ( +
    +
    + +
    + {t('errorOccurredWhileLoadingSubTags')} +
    +
    +
    + ); + } + + const subTagsList = + subTagsData?.getChildTags.childTags.edges.map((edge) => edge.node) ?? + /* istanbul ignore next */ []; + + const handleTagClick = (): void => { + setExpanded(!expanded); + }; + + const handleCheckboxChange = ( + e: React.ChangeEvent, + ): void => { + toggleTagSelection(tag, e.target.checked); + }; + + return ( +
    +
    + {tag.childTags.totalCount ? ( + <> + + {expanded ? '▼' : '▶'} + + + {' '} + + ) : ( + <> + + + {' '} + + )} + + {tag.name} +
    + + {expanded && subTagsLoading && ( +
    +
    +
    +
    +
    + )} + {expanded && subTagsList?.length && ( +
    +
    + } + scrollableTarget={`subTagsScrollableDiv${tag._id}`} + > + {subTagsList.map((tag: InterfaceTagData) => ( +
    + +
    + ))} +
    +
    +
    + )} +
    + ); +}; + +export default TagNode; diff --git a/src/components/UpdateSession/UpdateSession.css b/src/components/UpdateSession/UpdateSession.css new file mode 100644 index 0000000000..073bbf973d --- /dev/null +++ b/src/components/UpdateSession/UpdateSession.css @@ -0,0 +1,96 @@ +/* Card styles */ +.update-timeout-card { + width: 700px; + background: #ffffff; + border: none; + border-radius: 16px; + filter: drop-shadow(0px 4px 15.3px rgba(0, 0, 0, 0.08)); + padding: 20px; +} + +.update-timeout-card-header { + background: none; + padding: 16px; + border-bottom: none; +} + +.update-timeout-card-title { + font-family: 'Lato', sans-serif; + font-weight: 600; + font-size: 24px; + color: #000000; +} + +.update-timeout-card-body { + padding: 20px; +} + +.update-timeout-current { + font-family: 'Lato', sans-serif; + font-weight: 400; + font-size: 16px; + color: #000000; + margin-bottom: 20px; /* Increased margin to create more space */ +} + +.update-timeout-label { + font-family: 'Lato', sans-serif; + font-weight: 400; + font-size: 16px; + color: #000000; + margin-bottom: 10px; /* Keep the same margin to maintain spacing with the slider */ +} + +.update-timeout-labels-container { + display: flex; + flex-direction: column; + align-items: start; +} + +.update-timeout-value { + color: #14ae5c; + font-weight: bold; +} + +.update-timeout-slider-labels { + display: flex; + justify-content: space-between; + font-size: 0.9rem; + color: #757575; +} + +.update-timeout-button-container { + display: flex; + justify-content: right; + margin-top: 20px; +} + +.update-timeout-button { + width: 112px; + height: 36px; + background: #31bb6b; + border-radius: 6px; + font-family: 'Lato', sans-serif; + font-weight: 500; + font-size: 16px; + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + border: none; + box-shadow: none; +} + +.update-timeout-button:hover { + background-color: #28a745; + border-color: #28a745; + box-shadow: none; +} + +.update-timeout-button:active { + transform: scale(0.98); +} + +.update-timeout-slider-container { + position: relative; +} diff --git a/src/components/UpdateSession/UpdateSession.test.tsx b/src/components/UpdateSession/UpdateSession.test.tsx new file mode 100644 index 0000000000..ce0a868820 --- /dev/null +++ b/src/components/UpdateSession/UpdateSession.test.tsx @@ -0,0 +1,346 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/client/testing'; +import { + render, + screen, + act, + within, + fireEvent, + waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import 'jest-localstorage-mock'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { BrowserRouter } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import UpdateTimeout from './UpdateSession'; +import i18n from 'utils/i18nForTest'; +import { GET_COMMUNITY_SESSION_TIMEOUT_DATA } from 'GraphQl/Queries/Queries'; +import { UPDATE_SESSION_TIMEOUT } from 'GraphQl/Mutations/mutations'; +import { errorHandler } from 'utils/errorHandler'; + +const MOCKS = [ + { + request: { + query: GET_COMMUNITY_SESSION_TIMEOUT_DATA, + }, + result: { + data: { + getCommunityData: { + timeout: 30, + }, + }, + }, + }, + { + request: { + query: GET_COMMUNITY_SESSION_TIMEOUT_DATA, + }, + result: { + data: { + getCommunityData: null, + }, + }, + }, + { + request: { + query: UPDATE_SESSION_TIMEOUT, + variables: { + timeout: 30, + }, + }, + result: { + data: { + updateSessionTimeout: true, + }, + }, + }, +]; + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('utils/errorHandler', () => ({ + errorHandler: jest.fn(), +})); + +describe('Testing UpdateTimeout Component', () => { + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('Should handle minimum slider value correctly', async () => { + const mockOnValueChange = jest.fn(); + + render( + + + , + ); + + const slider = await screen.findByTestId('slider-thumb'); + + // Simulate dragging to minimum value + userEvent.click(slider, { + // Simulate clicking on the slider to focus + clientX: -999, // Adjust the clientX to simulate different slider positions + }); + + expect(mockOnValueChange).toHaveBeenCalledWith(15); // Adjust based on slider min value + }); + + test('Should handle maximum slider value correctly', async () => { + const mockOnValueChange = jest.fn(); + + render( + + + , + ); + + const slider = await screen.findByTestId('slider-thumb'); + + // Simulate dragging to maximum value + userEvent.click(slider, { + // Simulate clicking on the slider to focus + clientX: 999, // Adjust the clientX to simulate different slider positions + }); + + expect(mockOnValueChange).toHaveBeenCalledWith(60); // Adjust based on slider max value + }); + + test('Should not update value if an invalid value is passed', async () => { + const mockOnValueChange = jest.fn(); + + render( + + + , + ); + + const slider = await screen.findByTestId('slider-thumb'); + + // Simulate invalid value handling + userEvent.click(slider, { + // Simulate clicking on the slider to focus + clientX: 0, // Adjust the clientX to simulate different slider positions + }); + + // Ensure onValueChange is not called with invalid values + expect(mockOnValueChange).not.toHaveBeenCalled(); + }); + + test('Should update slider value on user interaction', async () => { + const mockOnValueChange = jest.fn(); + + render( + + + , + ); + + // Wait for the slider to be present + const slider = await screen.findByTestId('slider-thumb'); + + // Simulate slider interaction + userEvent.type(slider, '45'); // Simulate typing value + + // Assert that the callback was called with the expected value + expect(mockOnValueChange).toHaveBeenCalledWith(expect.any(Number)); // Adjust as needed + }); + + test('Components should render properly', async () => { + render( + + + + + + + , + ); + + await wait(); + + // Use getAllByText to get all elements with "Update Timeout" text + const updateTimeoutElements = screen.getAllByText(/Update Timeout/i); + expect(updateTimeoutElements).toHaveLength(1); // Check if there are exactly 2 elements with this text + + expect(screen.getByText(/Current Timeout/i)).toBeInTheDocument(); + expect(screen.getByText(/15 min/i)).toBeInTheDocument(); + + // Locate the parent element first + const sliderLabelsContainer = screen.getByTestId('slider-labels'); + + // Use within to query inside the parent element + const sliderLabels = within(sliderLabelsContainer); + + // Check for the specific text within the parent element + expect(sliderLabels.getByText('30 min')).toBeInTheDocument(); + + expect(screen.getByText(/45 min/i)).toBeInTheDocument(); + expect(screen.getByText(/60 min/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Update/i })).toBeInTheDocument(); + }); + + test('Should update session timeout', async () => { + render( + + + + + + + , + ); + + await wait(); + + const submitButton = screen.getByTestId('update-button'); + userEvent.click(submitButton); + + // Wait for the toast success call + + await wait(); + + expect(toast.success).toHaveBeenCalledWith( + expect.stringContaining('Successfully updated the Profile Details.'), + ); + }); + + test('Should handle query errors', async () => { + const errorMocks = [ + { + request: { + query: GET_COMMUNITY_SESSION_TIMEOUT_DATA, + }, + error: new Error('An error occurred'), + }, + ]; + + render( + + + + + + + , + ); + + await wait(); + + expect(errorHandler).toHaveBeenCalled(); + }); + + test('Should handle update errors', async () => { + const errorMocks = [ + { + request: { + query: GET_COMMUNITY_SESSION_TIMEOUT_DATA, + }, + result: { + data: { + getCommunityData: { + timeout: 30, + }, + }, + }, + }, + { + request: { + query: GET_COMMUNITY_SESSION_TIMEOUT_DATA, + }, + result: { + data: { + getCommunityData: null, + }, + }, + }, + { + request: { + query: UPDATE_SESSION_TIMEOUT, + variables: { timeout: 30 }, + }, + error: new Error('An error occurred'), + }, + ]; + + render( + + + + + + + , + ); + + await wait(); + + const submitButton = screen.getByTestId('update-button'); + userEvent.click(submitButton); + + await wait(); + + expect(errorHandler).toHaveBeenCalled(); + }); + + test('Should handle null community object gracefully', async () => { + render( + + + + + + + , + ); + + await wait(); + + // Assertions to verify the component handles null community object correctly + // Use getAllByText to get all elements with "Update Timeout" text + const updateTimeoutElements = screen.getAllByText(/Update Timeout/i); + expect(updateTimeoutElements).toHaveLength(1); // Check if there are exactly 2 elements with this text + + expect(screen.getByText(/Current Timeout/i)).toBeInTheDocument(); + + // Locate the parent element first + const sliderLabelsContainer = screen.getByTestId('slider-labels'); + + // Use within to query inside the parent element + const sliderLabels = within(sliderLabelsContainer); + + // Check for the specific text within the parent element + expect(sliderLabels.getByText('15 min')).toBeInTheDocument(); + + expect(screen.getByText(/30 min/i)).toBeInTheDocument(); + expect(screen.getByText(/45 min/i)).toBeInTheDocument(); + expect(screen.getByText(/60 min/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Update/i })).toBeInTheDocument(); + + // Check if the component displays a default value or handles the null state appropriately + expect(screen.getByText(/No timeout set/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/UpdateSession/UpdateSession.tsx b/src/components/UpdateSession/UpdateSession.tsx new file mode 100644 index 0000000000..f49970ebaa --- /dev/null +++ b/src/components/UpdateSession/UpdateSession.tsx @@ -0,0 +1,202 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Card, Button, Form } from 'react-bootstrap'; +import Box from '@mui/material/Box'; +import Slider from '@mui/material/Slider'; +import { useMutation, useQuery } from '@apollo/client'; +import { GET_COMMUNITY_SESSION_TIMEOUT_DATA } from 'GraphQl/Queries/Queries'; +import { toast } from 'react-toastify'; +import { errorHandler } from 'utils/errorHandler'; +import { UPDATE_SESSION_TIMEOUT } from 'GraphQl/Mutations/mutations'; +import './UpdateSession.css'; +import Loader from 'components/Loader/Loader'; + +/** + * Component for updating the session timeout for a community. + * + * This component fetches the current session timeout value from the server + * and allows the user to update it using a slider. + * + * The component also handles form submission, making a mutation request to update the session timeout. + * + * @returns JSX.Element - The rendered component. + */ + +interface TestInterfaceUpdateTimeoutProps { + onValueChange?: (value: number) => void; +} + +const UpdateTimeout: React.FC = ({ + onValueChange, +}): JSX.Element => { + const { t } = useTranslation('translation', { + keyPrefix: 'communityProfile', + }); + + const [timeout, setTimeout] = useState(30); + const [communityTimeout, setCommunityTimeout] = useState( + 30, + ); // Timeout from database for the community + + const { + data, + loading, + error: queryError, + } = useQuery(GET_COMMUNITY_SESSION_TIMEOUT_DATA); + const [uploadSessionTimeout] = useMutation(UPDATE_SESSION_TIMEOUT); + + type TimeoutDataType = { + timeout: number; + }; + + /** + * Effect that fetches the current session timeout from the server and sets the initial state. + * If there is an error in fetching the data, it is handled using the error handler. + */ + React.useEffect(() => { + if (queryError) { + errorHandler(t, queryError as Error); + } + + const SessionTimeoutData: TimeoutDataType | undefined = + data?.getCommunityData; + + if (SessionTimeoutData && SessionTimeoutData.timeout !== null) { + setCommunityTimeout(SessionTimeoutData.timeout); + setTimeout(SessionTimeoutData.timeout); + } else { + setCommunityTimeout(undefined); // Handle null or undefined data + } + }, [data, queryError]); + + /** + * Handles changes to the slider value and updates the timeout state. + * + * @param e - The event triggered by slider movement. + */ + const handleOnChange = ( + e: Event | React.ChangeEvent, + ): void => { + if ('target' in e && e.target) { + const target = e.target as HTMLInputElement; + // Ensure the value is a number and not NaN + const value = parseInt(target.value, 10); + if (!Number.isNaN(value)) { + setTimeout(value); + if (onValueChange) { + onValueChange(value); + } + } else { + console.warn('Invalid timeout value:', target.value); + } + } + }; + + /** + * Handles form submission to update the session timeout. + * It makes a mutation request to update the timeout value on the server. + * If the update is successful, a success toast is shown, and the state is updated. + * + * @param e - The event triggered by form submission. + */ + const handleOnSubmit = async ( + e: React.FormEvent, + ): Promise => { + e.preventDefault(); + try { + await uploadSessionTimeout({ + variables: { + timeout: timeout, + }, + }); + + toast.success(t('profileChangedMsg')); + setCommunityTimeout(timeout); + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error as Error); + } + }; + + // Show a loader while the data is being fetched + if (loading) { + return ; + } + + return ( + <> + + +
    Login Session Timeout
    +
    + +
    +
    + + Current Timeout: + + {communityTimeout !== undefined + ? ` ${communityTimeout} minutes` + : ' No timeout set'} + + + + + Update Timeout + +
    + + + + + +
    + 15 min + 30 min + 45 min + 60 min +
    +
    + +
    +
    +
    +
    + + ); +}; + +export default UpdateTimeout; diff --git a/src/components/UserListCard/UserListCard.module.css b/src/components/UserListCard/UserListCard.module.css new file mode 100644 index 0000000000..187757a531 --- /dev/null +++ b/src/components/UserListCard/UserListCard.module.css @@ -0,0 +1,74 @@ +.memberlist { + margin-top: -1px; +} +.memberimg { + width: 200px; + height: 100px; + border-radius: 7px; + margin-left: 20px; +} +.singledetails { + display: flex; + flex-direction: row; + justify-content: space-between; +} +.singledetails p { + margin-bottom: -5px; +} +.singledetails_data_left { + margin-top: 10px; + margin-left: 10px; + color: #707070; +} +.singledetails_data_right { + justify-content: right; + margin-top: 10px; + text-align: right; + color: #707070; +} +.membername { + font-size: 16px; + font-weight: bold; +} +.memberfont { + margin-top: 3px; +} +.memberfont > span { + width: 80%; +} +.memberfontcreated { + margin-top: 18px; +} +.memberfontcreatedbtn { + margin-top: 33px; + border-radius: 7px; + border-color: #31bb6b; + background-color: #31bb6b; + color: white; + padding-right: 10px; + padding-left: 10px; + justify-content: flex-end; + float: right; + text-align: right; + box-shadow: none; +} +#grid_wrapper { + align-items: left; +} +.peoplelistdiv { + margin-right: 50px; +} +@media only screen and (max-width: 600px) { + .singledetails { + margin-left: 20px; + } + .memberimg { + margin: auto; + } + .singledetails_data_right { + margin-right: -52px; + } + .singledetails_data_left { + margin-left: 0px; + } +} diff --git a/src/components/UserListCard/UserListCard.test.tsx b/src/components/UserListCard/UserListCard.test.tsx new file mode 100644 index 0000000000..e2ad552507 --- /dev/null +++ b/src/components/UserListCard/UserListCard.test.tsx @@ -0,0 +1,81 @@ +import React, { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; + +import UserListCard from './UserListCard'; +import { ADD_ADMIN_MUTATION } from 'GraphQl/Mutations/mutations'; +import i18nForTest from 'utils/i18nForTest'; +import { BrowserRouter } from 'react-router-dom'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +const MOCKS = [ + { + request: { + query: ADD_ADMIN_MUTATION, + variables: { userid: '784', orgid: '554' }, + }, + result: { + data: { + organizations: [ + { + _id: '1', + }, + ], + }, + }, + }, +]; +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Testing User List Card', () => { + global.alert = jest.fn(); + + test('Should render props and text elements test for the page component', async () => { + const props = { + id: '456', + }; + + render( + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByText(/Add Admin/i)); + }); + + test('Should render text elements when props value is not passed', async () => { + const props = { + id: '456', + }; + + render( + + + + + + + , + ); + + await wait(); + userEvent.click(screen.getByText(/Add Admin/i)); + }); +}); diff --git a/src/components/UserListCard/UserListCard.tsx b/src/components/UserListCard/UserListCard.tsx new file mode 100644 index 0000000000..76ad110ea1 --- /dev/null +++ b/src/components/UserListCard/UserListCard.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import Button from 'react-bootstrap/Button'; +import { useMutation } from '@apollo/client'; +import { toast } from 'react-toastify'; +import { useTranslation } from 'react-i18next'; + +import { ADD_ADMIN_MUTATION } from 'GraphQl/Mutations/mutations'; +import styles from './UserListCard.module.css'; +import { useParams } from 'react-router-dom'; +import { errorHandler } from 'utils/errorHandler'; + +interface InterfaceUserListCardProps { + key: number; + id: string; +} + +/** + * The UserListCard component allows for adding a user as an admin in a specific organization. + * It uses a button to trigger a mutation for updating the user's role. + * + * @param props - The properties for the UserListCard component. + * @param key - The unique key for the component (although not used here). + * @param id - The ID of the user to be promoted to admin. + * + * @returns The JSX element representing the user list card. + */ +function userListCard(props: InterfaceUserListCardProps): JSX.Element { + const { orgId: currentUrl } = useParams(); + const [adda] = useMutation(ADD_ADMIN_MUTATION); + + const { t } = useTranslation('translation', { + keyPrefix: 'userListCard', + }); + + /** + * Handles adding a user as an admin. + * It performs a mutation and handles success or failure accordingly. + */ + const addAdmin = async (): Promise => { + try { + const { data } = await adda({ + variables: { + userid: props.id, + orgid: currentUrl, + }, + }); + + /* istanbul ignore next */ + if (data) { + toast.success(t('addedAsAdmin') as string); + setTimeout(() => { + window.location.reload(); + }, 2000); + } + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }; + + return ( + <> + + + ); +} +export {}; +export default userListCard; diff --git a/src/components/UserPasswordUpdate/UserPasswordUpdate.module.css b/src/components/UserPasswordUpdate/UserPasswordUpdate.module.css new file mode 100644 index 0000000000..90b5c09be3 --- /dev/null +++ b/src/components/UserPasswordUpdate/UserPasswordUpdate.module.css @@ -0,0 +1,97 @@ +/* .userupdatediv{ + border: 1px solid #e8e5e5; + box-shadow: 2px 1px #e8e5e5; + padding:25px 16px; + border-radius: 5px; + background:#fdfdfd; +} */ +.settingstitle { + color: #707070; + font-size: 20px; + margin-bottom: 30px; + text-align: center; + margin-top: -10px; +} +.dispflex { + display: flex; + justify-content: flex-start; + margin: 0 auto; +} +.dispbtnflex { + width: 90%; + margin-top: 20px; + display: flex; + margin: 0 30%; +} +.dispflex > div { + width: 50%; + margin-right: 50px; +} + +.radio_buttons > input { + margin-bottom: 20px; + border: none; + box-shadow: none; + padding: 0 0; + border-radius: 5px; + background: none; + width: 50%; +} + +.whitebtn { + margin: 1rem 0 0; + margin-top: 10px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 20px; + border-radius: 5px; + background: none; + width: 20%; + font-size: 16px; + color: #31bb6b; + outline: none; + font-weight: 600; + cursor: pointer; + float: left; + transition: + transform 0.2s, + box-shadow 0.2s; +} +.greenregbtn { + margin: 1rem 0 0; + margin-top: 10px; + margin-right: 30px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 10px; + border-radius: 5px; + background-color: #31bb6b; + width: 20%; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; +} +.radio_buttons { + width: 55%; + margin-top: 10px; + display: flex; + color: #707070; + font-weight: 600; + font-size: 14px; +} +.radio_buttons > input { + transform: scale(1.2); +} +.radio_buttons > label { + margin-top: -4px; + margin-left: 0px; + margin-right: 7px; +} +.idtitle { + width: 88%; +} diff --git a/src/components/UserPasswordUpdate/UserPasswordUpdate.test.tsx b/src/components/UserPasswordUpdate/UserPasswordUpdate.test.tsx new file mode 100644 index 0000000000..65f5e40f76 --- /dev/null +++ b/src/components/UserPasswordUpdate/UserPasswordUpdate.test.tsx @@ -0,0 +1,143 @@ +import React, { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { UPDATE_USER_PASSWORD_MUTATION } from 'GraphQl/Mutations/mutations'; +import i18nForTest from 'utils/i18nForTest'; +import UserPasswordUpdate from './UserPasswordUpdate'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast as mockToast } from 'react-toastify'; + +jest.mock('react-toastify', () => ({ + toast: { + error: jest.fn(), + success: jest.fn(), + }, +})); + +const MOCKS = [ + { + request: { + query: UPDATE_USER_PASSWORD_MUTATION, + variables: { + previousPassword: 'anshgoyal', + newPassword: 'anshgoyalansh', + confirmNewPassword: 'anshgoyalansh', + }, + }, + result: { + data: { + users: [ + { + _id: '1', + }, + ], + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 5): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Testing User Password Update', () => { + const formData = { + previousPassword: 'Palisadoes', + newPassword: 'ThePalisadoesFoundation', + wrongPassword: 'This is wrong password', + confirmNewPassword: 'ThePalisadoesFoundation', + }; + + global.alert = jest.fn(); + + test('should render props and text elements test for the page component', async () => { + render( + + + + + , + ); + + await wait(); + + userEvent.type( + screen.getByPlaceholderText(/Previous Password/i), + formData.previousPassword, + ); + userEvent.type( + screen.getAllByPlaceholderText(/New Password/i)[0], + formData.newPassword, + ); + userEvent.type( + screen.getByPlaceholderText(/Confirm New Password/i), + formData.confirmNewPassword, + ); + + userEvent.click(screen.getByText(/Save Changes/i)); + + expect(screen.getByText(/Cancel/i)).toBeTruthy(); + expect( + screen.getByPlaceholderText(/Previous Password/i), + ).toBeInTheDocument(); + expect( + screen.getByPlaceholderText(/Confirm New Password/i), + ).toBeInTheDocument(); + }); + + test('displays an error when the password field is empty', async () => { + render( + + + + + , + ); + + userEvent.click(screen.getByText(/Save Changes/i)); + + await wait(); + expect(mockToast.error).toHaveBeenCalledWith(`Password can't be empty`); + }); + + test('displays an error when new and confirm password field does not match', async () => { + render( + + + + + , + ); + + await wait(); + + userEvent.type( + screen.getByPlaceholderText(/Previous Password/i), + formData.previousPassword, + ); + userEvent.type( + screen.getAllByPlaceholderText(/New Password/i)[0], + formData.wrongPassword, + ); + userEvent.type( + screen.getByPlaceholderText(/Confirm New Password/i), + formData.confirmNewPassword, + ); + + userEvent.click(screen.getByText(/Save Changes/i)); + + expect(screen.getByText(/Cancel/i)).toBeTruthy(); + await wait(); + expect(mockToast.error).toHaveBeenCalledWith( + 'New and Confirm password do not match.', + ); + }); +}); diff --git a/src/components/UserPasswordUpdate/UserPasswordUpdate.tsx b/src/components/UserPasswordUpdate/UserPasswordUpdate.tsx new file mode 100644 index 0000000000..1ea75811c1 --- /dev/null +++ b/src/components/UserPasswordUpdate/UserPasswordUpdate.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import { useMutation } from '@apollo/client'; +import { UPDATE_USER_PASSWORD_MUTATION } from 'GraphQl/Mutations/mutations'; +import { useTranslation } from 'react-i18next'; +import Button from 'react-bootstrap/Button'; +import styles from './UserPasswordUpdate.module.css'; +import { toast } from 'react-toastify'; +import { Form } from 'react-bootstrap'; + +interface InterfaceUserPasswordUpdateProps { + id: string; +} + +/** + * UserUpdate component allows users to update their passwords. + * It handles form submission and communicates with the backend to update the user's password. + * + * @param props - The properties for the UserUpdate component. + * @param id - The ID of the user whose password is being updated. + * + * @returns The JSX element for updating user password. + */ +const UserUpdate: React.FC< + InterfaceUserPasswordUpdateProps +> = (): JSX.Element => { + const { t } = useTranslation('translation', { + keyPrefix: 'userPasswordUpdate', + }); + const { t: tCommon } = useTranslation('common'); + const [formState, setFormState] = React.useState({ + previousPassword: '', + newPassword: '', + confirmNewPassword: '', + }); + + const [login] = useMutation(UPDATE_USER_PASSWORD_MUTATION); + + /** + * Handles the password update process. + * It validates the form inputs and performs the mutation to update the password. + */ + const loginLink = async (): Promise => { + if ( + !formState.previousPassword || + !formState.newPassword || + !formState.confirmNewPassword + ) { + toast.error(t('passCantBeEmpty') as string); + return; + } + + if (formState.newPassword !== formState.confirmNewPassword) { + toast.error(t('passNoMatch') as string); + return; + } + + try { + const { data } = await login({ + variables: { + previousPassword: formState.previousPassword, + newPassword: formState.newPassword, + confirmNewPassword: formState.confirmNewPassword, + }, + }); + /* istanbul ignore next */ + if (data) { + toast.success( + tCommon('updatedSuccessfully', { item: 'Password' }) as string, + ); + setTimeout(() => { + window.location.reload(); + }, 2000); + } + } catch (error: unknown) { + /* istanbul ignore next */ + if (error instanceof Error) { + toast.error(error.toString()); + } + } + }; + + /** + * Handles canceling the update process. + * It reloads the page to reset any changes. + */ + /* istanbul ignore next */ + const cancelUpdate = (): void => { + window.location.reload(); + }; + + return ( + <> +
    +
    + {/*

    Update Your Details

    */} +
    +
    + + { + setFormState({ + ...formState, + previousPassword: e.target.value, + }); + }} + /> +
    +
    +
    +
    + + { + setFormState({ + ...formState, + newPassword: e.target.value, + }); + }} + /> +
    +
    +
    +
    + + { + setFormState({ + ...formState, + confirmNewPassword: e.target.value, + }); + }} + /> +
    +
    +
    + + +
    +
    +
    + + ); +}; +export default UserUpdate; diff --git a/src/components/UserPortal/ChatRoom/ChatRoom.module.css b/src/components/UserPortal/ChatRoom/ChatRoom.module.css new file mode 100644 index 0000000000..5fc98351cd --- /dev/null +++ b/src/components/UserPortal/ChatRoom/ChatRoom.module.css @@ -0,0 +1,250 @@ +.chatAreaContainer { + padding: 10px; + flex-grow: 1; + /* background-color: rgba(196, 255, 211, 0.3); */ +} + +.backgroundWhite { + background-color: white; +} + +.grey { + color: grey; +} + +.header { + position: sticky; + top: 0px; + background: white; +} + +.userInfo { + display: flex; + border-bottom: 1px solid black; + padding-bottom: 5px; + align-items: center; + margin-top: 5px; + gap: 10px; +} + +.contactImage { + width: 45px !important; + height: auto !important; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; +} + +.messageSentContainer { + display: flex; + justify-content: flex-end; +} + +.messageReceivedContainer { + display: flex; + align-items: flex-end; +} + +.messageReceived { + border: 1px solid #c2c2c2; + border-radius: 8px 8px 8px 0px; + padding: 4px 6px; + margin: 4px 6px; + width: fit-content; + max-width: 75%; + min-width: 80px; + padding-bottom: 0; + display: flex; + justify-content: space-between; +} + +.messageSent { + border: 1px solid #c2c2c2; + border-radius: 8px 8px 0px 8px; + padding: 4px 6px; + margin: 4px 6px; + width: fit-content; + max-width: 75%; + background-color: rgba(196, 255, 211, 0.3); + min-width: 80px; + padding-bottom: 0; + display: flex; + justify-content: space-between; +} + +.userDetails { + display: flex; + align-items: center; + font-size: 14px; + gap: 6px; +} + +.userDetails .userImage { + height: 20px; + width: 20px; +} + +.replyTo { + border-left: 4px solid pink; + display: flex; + justify-content: space-between; + background-color: rgb(249, 249, 250); + padding: 6px 0px 4px 4px; + border-radius: 6px 6px 6px 6px; +} + +.replyToMessageContainer { + padding-left: 4px; +} + +.replyToMessageContainer p { + margin: 4px 0px 0px; +} + +.replyToMessage { + border-left: 4px solid pink; + border-radius: 6px; + margin: 6px 0px; + padding: 6px; + background-color: #dbf6db; +} + +.messageReceived .replyToMessage { + background-color: #f2f2f2; +} + +.messageTime { + font-size: 10px; + display: flex; + align-items: flex-end; + justify-content: flex-end; + margin-bottom: 0px; + margin-left: 6px; +} + +.messageContent { + margin-bottom: 0.5px; + display: flex; + align-items: flex-start; + flex-direction: column; + justify-content: center; +} + +.createChat { + border: none; + background-color: white; +} + +.chatMessages { + margin: 10px 0; +} + +.userDetails .subtitle { + font-size: 12px; + color: #959595; + margin: 0; +} + +.userDetails .title { + font-size: 18px; + margin: 0; +} + +.contactImage { + height: fit-content; +} + +.senderInfo { + margin: 2px 2px 0px 2px; + font-size: 12px; + font-weight: 600; +} + +.messageAttributes { + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: flex-end; +} + +.customToggle { + display: none; +} + +.customToggle, +.closeBtn { + padding: 0; + background: none; + border: none; + --bs-btn-active-bg: none; +} +.customToggle svg { + color: black; + font-size: 12px; +} + +.customToggle::after, +.closeBtn::after { + content: none; +} + +.closeBtn svg { + color: black; + font-size: 18px; +} + +.closeBtn { + padding: 2px 10px; +} + +.closeBtn:hover { + background-color: transparent; + border-color: transparent; +} + +.customToggle:hover, +.customToggle:focus, +.customToggle:active { + background: none; + border: none; +} + +.messageReceived:hover .customToggle { + display: block; +} + +.messageSent:hover .customToggle { + display: block; +} + +.messageSent:hover, +.messageReceived:hover { + padding: 0px 6px; +} + +.messageSent:target { + scroll-margin-top: 100px; + animation-name: test; + animation-duration: 1s; +} + +.messageReceived:target { + scroll-margin-top: 100px; + animation-name: test; + animation-duration: 1s; +} + +@keyframes test { + from { + background-color: white; + } + to { + background-color: rgb(82, 83, 81); + } +} + +a { + color: currentColor; + width: 100%; +} diff --git a/src/components/UserPortal/ChatRoom/ChatRoom.test.tsx b/src/components/UserPortal/ChatRoom/ChatRoom.test.tsx new file mode 100644 index 0000000000..c808485132 --- /dev/null +++ b/src/components/UserPortal/ChatRoom/ChatRoom.test.tsx @@ -0,0 +1,1586 @@ +import React from 'react'; + +import { + act, + render, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import { MockSubscriptionLink, MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { CHATS_LIST, CHAT_BY_ID } from 'GraphQl/Queries/PlugInQueries'; +import { + MESSAGE_SENT_TO_CHAT, + SEND_MESSAGE_TO_CHAT, +} from 'GraphQl/Mutations/OrganizationMutations'; +import ChatRoom from './ChatRoom'; +import { useLocalStorage } from 'utils/useLocalstorage'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +const { setItem } = useLocalStorage(); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const MESSAGE_SENT_TO_CHAT_MOCK = [ + { + request: { + query: MESSAGE_SENT_TO_CHAT, + variables: { + userId: null, + }, + }, + result: { + data: { + messageSentToChat: { + _id: '668ec1f1364e03ac47a151', + createdAt: '2024-07-10T17:16:33.248Z', + chatMessageBelongsTo: { + _id: '1', + }, + messageContent: 'Test ', + replyTo: null, + sender: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + updatedAt: '2024-07-10', + }, + }, + }, + }, + { + request: { + query: MESSAGE_SENT_TO_CHAT, + variables: { + userId: '2', + }, + }, + result: { + data: { + messageSentToChat: { + _id: '668ec1f1df364e03ac47a151', + createdAt: '2024-07-10T17:16:33.248Z', + messageContent: 'Test ', + chatMessageBelongsTo: { + _id: '1', + }, + replyTo: null, + sender: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + updatedAt: '2024-07-10', + }, + }, + }, + }, + { + request: { + query: MESSAGE_SENT_TO_CHAT, + variables: { + userId: '1', + }, + }, + result: { + data: { + messageSentToChat: { + _id: '668ec1f13603ac4697a151', + createdAt: '2024-07-10T17:16:33.248Z', + messageContent: 'Test ', + chatMessageBelongsTo: { + _id: '1', + }, + replyTo: null, + sender: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + updatedAt: '2024-07-10', + }, + }, + }, + }, +]; + +const CHAT_BY_ID_QUERY_MOCK = [ + { + request: { + query: CHAT_BY_ID, + variables: { + id: '1', + }, + }, + result: { + data: { + chatById: { + _id: '1', + createdAt: '2345678903456', + isGroup: false, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: null, + name: '', + messages: [ + { + _id: '4', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + ], + }, + }, + }, + }, + { + request: { + query: CHAT_BY_ID, + variables: { + id: '1', + }, + }, + result: { + data: { + chatById: { + _id: '1', + isGroup: false, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: null, + name: '', + createdAt: '2345678903456', + messages: [ + { + _id: '4', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + ], + }, + }, + }, + }, + { + request: { + query: CHAT_BY_ID, + variables: { + id: '', + }, + }, + result: { + data: { + chatById: { + _id: '1', + isGroup: false, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: null, + name: '', + createdAt: '2345678903456', + messages: [ + { + _id: '4', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + ], + }, + }, + }, + }, +]; + +const CHATS_LIST_MOCK = [ + { + request: { + query: CHATS_LIST, + variables: { + id: null, + }, + }, + result: { + data: { + chatsByUserId: [ + { + _id: '65844efc814dd40fgh03db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + { + _id: '65844efc814ddgh4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: CHATS_LIST, + variables: { + id: '', + }, + }, + result: { + data: { + chatsByUserId: [ + { + _id: '65844ghjefc814dd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + { + _id: 'ujhgtrdtyuiop', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: CHATS_LIST, + variables: { + id: '1', + }, + }, + result: { + data: { + chatsByUserId: [ + { + _id: '65844efc814dhjmkdftyd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + { + _id: '65844ewsedrffc814dd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + ], + }, + }, + }, +]; + +const GROUP_CHAT_BY_ID_QUERY_MOCK = [ + { + request: { + query: CHAT_BY_ID, + variables: { + id: '', + }, + }, + result: { + data: { + chatById: { + _id: '65844efc814dd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '2', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + }, + }, + }, + { + request: { + query: CHAT_BY_ID, + variables: { + id: '1', + }, + }, + result: { + data: { + chatById: { + _id: '65844efc814dd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '1', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + }, + }, + }, + { + request: { + query: CHAT_BY_ID, + variables: { + id: '1', + }, + }, + result: { + data: { + chatById: { + _id: '65844efc814dd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '1', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + }, + }, + }, +]; + +const SEND_MESSAGE_TO_CHAT_MOCK = [ + { + request: { + query: SEND_MESSAGE_TO_CHAT, + variables: { + chatId: '1', + replyTo: '4', + messageContent: 'Test reply message', + }, + }, + result: { + data: { + sendMessageToChat: { + _id: '668ec1f1364e03ac47a151', + createdAt: '2024-07-10T17:16:33.248Z', + messageContent: 'Test ', + replyTo: null, + sender: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + updatedAt: '2024-07-10', + }, + }, + }, + }, + { + request: { + query: SEND_MESSAGE_TO_CHAT, + variables: { + chatId: '1', + replyTo: '4', + messageContent: 'Test reply message', + }, + }, + result: { + data: { + sendMessageToChat: { + _id: '668ec1f1364e03ac47a151', + createdAt: '2024-07-10T17:16:33.248Z', + messageContent: 'Test ', + replyTo: null, + sender: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + updatedAt: '2024-07-10', + }, + }, + }, + }, + { + request: { + query: SEND_MESSAGE_TO_CHAT, + variables: { + chatId: '1', + replyTo: '1', + messageContent: 'Test reply message', + }, + }, + result: { + data: { + sendMessageToChat: { + _id: '668ec1f1364e03ac47a151', + createdAt: '2024-07-10T17:16:33.248Z', + messageContent: 'Test ', + replyTo: null, + sender: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + updatedAt: '2024-07-10', + }, + }, + }, + }, + { + request: { + query: SEND_MESSAGE_TO_CHAT, + variables: { + chatId: '1', + replyTo: undefined, + messageContent: 'Hello', + }, + }, + result: { + data: { + sendMessageToChat: { + _id: '668ec1f1364e03ac47a151', + createdAt: '2024-07-10T17:16:33.248Z', + messageContent: 'Test ', + replyTo: null, + sender: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + updatedAt: '2024-07-10', + }, + }, + }, + }, + { + request: { + query: SEND_MESSAGE_TO_CHAT, + variables: { + chatId: '1', + replyTo: '345678', + messageContent: 'Test reply message', + }, + }, + result: { + data: { + sendMessageToChat: { + _id: '668ec1f1364e03ac47a151', + createdAt: '2024-07-10T17:16:33.248Z', + messageContent: 'Test ', + replyTo: null, + sender: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + updatedAt: '2024-07-10', + }, + }, + }, + }, + { + request: { + query: SEND_MESSAGE_TO_CHAT, + variables: { + chatId: '1', + replyTo: undefined, + messageContent: 'Test message', + }, + }, + result: { + data: { + sendMessageToChat: { + _id: '668ec1f1364e03ac47a151', + createdAt: '2024-07-10T17:16:33.248Z', + messageContent: 'Test ', + replyTo: null, + sender: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + updatedAt: '2024-07-10', + }, + }, + }, + }, +]; + +describe('Testing Chatroom Component [User Portal]', () => { + window.HTMLElement.prototype.scrollIntoView = jest.fn(); + + test('Chat room should display fallback content if no chat is active', async () => { + const mocks = [ + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...CHAT_BY_ID_QUERY_MOCK, + ...CHATS_LIST_MOCK, + ...SEND_MESSAGE_TO_CHAT_MOCK, + ]; + render( + + + + + + + + + , + ); + await wait(); + expect(await screen.findByTestId('noChatSelected')).toBeInTheDocument(); + }); + + test('Selected contact is direct chat', async () => { + const link = new MockSubscriptionLink(); + const mocks = [ + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...CHAT_BY_ID_QUERY_MOCK, + ...CHATS_LIST_MOCK, + ...SEND_MESSAGE_TO_CHAT_MOCK, + ]; + render( + + + + + + + + + , + ); + await wait(); + }); + + test('send message direct chat', async () => { + setItem('userId', '2'); + const mocks = [ + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...CHAT_BY_ID_QUERY_MOCK, + ...CHATS_LIST_MOCK, + ...GROUP_CHAT_BY_ID_QUERY_MOCK, + ...SEND_MESSAGE_TO_CHAT_MOCK, + ]; + const link2 = new StaticMockLink(mocks, true); + render( + + + + + + + + + , + ); + await wait(); + + const input = (await screen.findByTestId( + 'messageInput', + )) as HTMLInputElement; + + act(() => { + fireEvent.change(input, { target: { value: 'Hello' } }); + }); + expect(input.value).toBe('Hello'); + + const sendBtn = await screen.findByTestId('sendMessage'); + + expect(sendBtn).toBeInTheDocument(); + act(() => { + fireEvent.click(sendBtn); + }); + await waitFor(() => { + expect(input.value).toBeFalsy(); + }); + + const messages = await screen.findAllByTestId('message'); + + console.log('MESSAGES', messages); + + expect(messages.length).not.toBe(0); + + act(() => { + fireEvent.mouseOver(messages[0]); + }); + + await waitFor(async () => { + expect(await screen.findByTestId('moreOptions')).toBeInTheDocument(); + }); + + const moreOptionsBtn = await screen.findByTestId('dropdown'); + act(() => { + fireEvent.click(moreOptionsBtn); + }); + + const replyBtn = await screen.findByTestId('replyBtn'); + + act(() => { + fireEvent.click(replyBtn); + }); + + const replyMsg = await screen.findByTestId('replyMsg'); + + await waitFor(() => { + expect(replyMsg).toBeInTheDocument(); + }); + + act(() => { + fireEvent.change(input, { target: { value: 'Test reply message' } }); + }); + expect(input.value).toBe('Test reply message'); + + act(() => { + fireEvent.click(sendBtn); + }); + + await wait(400); + }); + + test('send message direct chat when userId is different', async () => { + setItem('userId', '8'); + const mocks = [ + ...GROUP_CHAT_BY_ID_QUERY_MOCK, + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...CHAT_BY_ID_QUERY_MOCK, + ...CHATS_LIST_MOCK, + ...SEND_MESSAGE_TO_CHAT_MOCK, + ]; + const link2 = new StaticMockLink(mocks, true); + render( + + + + + + + + + , + ); + await wait(); + + const input = (await screen.findByTestId( + 'messageInput', + )) as HTMLInputElement; + + act(() => { + fireEvent.change(input, { target: { value: 'Hello' } }); + }); + expect(input.value).toBe('Hello'); + + const sendBtn = await screen.findByTestId('sendMessage'); + + expect(sendBtn).toBeInTheDocument(); + act(() => { + fireEvent.click(sendBtn); + }); + await waitFor(() => { + expect(input.value).toBeFalsy(); + }); + + const messages = await screen.findAllByTestId('message'); + + console.log('MESSAGES', messages); + + expect(messages.length).not.toBe(0); + + act(() => { + fireEvent.mouseOver(messages[0]); + }); + + await waitFor(async () => { + expect(await screen.findByTestId('moreOptions')).toBeInTheDocument(); + }); + + const moreOptionsBtn = await screen.findByTestId('dropdown'); + act(() => { + fireEvent.click(moreOptionsBtn); + }); + + const replyBtn = await screen.findByTestId('replyBtn'); + + act(() => { + fireEvent.click(replyBtn); + }); + + const replyMsg = await screen.findByTestId('replyMsg'); + + await waitFor(() => { + expect(replyMsg).toBeInTheDocument(); + }); + + act(() => { + fireEvent.change(input, { target: { value: 'Test reply message' } }); + }); + expect(input.value).toBe('Test reply message'); + + act(() => { + fireEvent.click(sendBtn); + }); + + await wait(400); + }); + + test('Selected contact is group chat', async () => { + const mocks = [ + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...CHAT_BY_ID_QUERY_MOCK, + ...CHATS_LIST_MOCK, + ...SEND_MESSAGE_TO_CHAT_MOCK, + ]; + render( + + + + + + + + + , + ); + await wait(); + }); + + test('send message group chat', async () => { + const mocks = [ + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...CHAT_BY_ID_QUERY_MOCK, + ...CHATS_LIST_MOCK, + ...SEND_MESSAGE_TO_CHAT_MOCK, + ]; + render( + + + + + + + + + , + ); + await wait(); + + const input = (await screen.findByTestId( + 'messageInput', + )) as HTMLInputElement; + + act(() => { + fireEvent.change(input, { target: { value: 'Test message' } }); + }); + expect(input.value).toBe('Test message'); + + const sendBtn = await screen.findByTestId('sendMessage'); + + expect(sendBtn).toBeInTheDocument(); + act(() => { + fireEvent.click(sendBtn); + }); + await waitFor(() => { + expect(input.value).toBeFalsy(); + }); + + const messages = await screen.findAllByTestId('message'); + + expect(messages.length).not.toBe(0); + + act(() => { + fireEvent.mouseOver(messages[0]); + }); + + expect(await screen.findByTestId('moreOptions')).toBeInTheDocument(); + + const moreOptionsBtn = await screen.findByTestId('dropdown'); + act(() => { + fireEvent.click(moreOptionsBtn); + }); + + const replyBtn = await screen.findByTestId('replyBtn'); + + act(() => { + fireEvent.click(replyBtn); + }); + + const replyMsg = await screen.findByTestId('replyMsg'); + + await waitFor(() => { + expect(replyMsg).toBeInTheDocument(); + }); + + act(() => { + fireEvent.change(input, { target: { value: 'Test reply message' } }); + }); + expect(input.value).toBe('Test reply message'); + + const closeReplyBtn = await screen.findByTestId('closeReply'); + + expect(closeReplyBtn).toBeInTheDocument(); + + fireEvent.click(closeReplyBtn); + + await wait(500); + }); + + test('reply to message', async () => { + const mocks = [ + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...CHAT_BY_ID_QUERY_MOCK, + ...CHATS_LIST_MOCK, + ...SEND_MESSAGE_TO_CHAT_MOCK, + ]; + const link2 = new StaticMockLink(mocks, true); + render( + + + + + + + + + , + ); + await wait(); + + const input = (await screen.findByTestId( + 'messageInput', + )) as HTMLInputElement; + + act(() => { + fireEvent.change(input, { target: { value: 'Test message' } }); + }); + expect(input.value).toBe('Test message'); + + const sendBtn = await screen.findByTestId('sendMessage'); + + expect(sendBtn).toBeInTheDocument(); + act(() => { + fireEvent.click(sendBtn); + }); + await waitFor(() => { + expect(input.value).toBeFalsy(); + }); + + const messages = await screen.findAllByTestId('message'); + + expect(messages.length).not.toBe(0); + + act(() => { + fireEvent.mouseOver(messages[0]); + }); + + expect(await screen.findByTestId('moreOptions')).toBeInTheDocument(); + + const moreOptionsBtn = await screen.findByTestId('dropdown'); + act(() => { + fireEvent.click(moreOptionsBtn); + }); + + const replyBtn = await screen.findByTestId('replyBtn'); + + act(() => { + fireEvent.click(replyBtn); + }); + + const replyMsg = await screen.findByTestId('replyMsg'); + + await waitFor(() => { + expect(replyMsg).toBeInTheDocument(); + }); + + act(() => { + fireEvent.change(input, { target: { value: 'Test reply message' } }); + }); + expect(input.value).toBe('Test reply message'); + + act(() => { + fireEvent.click(sendBtn); + }); + + await wait(400); + }); +}); diff --git a/src/components/UserPortal/ChatRoom/ChatRoom.tsx b/src/components/UserPortal/ChatRoom/ChatRoom.tsx new file mode 100644 index 0000000000..c23244a314 --- /dev/null +++ b/src/components/UserPortal/ChatRoom/ChatRoom.tsx @@ -0,0 +1,378 @@ +import React, { useEffect, useRef, useState } from 'react'; +import type { ChangeEvent } from 'react'; +import SendIcon from '@mui/icons-material/Send'; +import { Button, Dropdown, Form, InputGroup } from 'react-bootstrap'; +import styles from './ChatRoom.module.css'; +import PermContactCalendarIcon from '@mui/icons-material/PermContactCalendar'; +import { useTranslation } from 'react-i18next'; +import { CHAT_BY_ID } from 'GraphQl/Queries/PlugInQueries'; +import { useMutation, useQuery, useSubscription } from '@apollo/client'; +import { + MESSAGE_SENT_TO_CHAT, + SEND_MESSAGE_TO_CHAT, +} from 'GraphQl/Mutations/OrganizationMutations'; +import useLocalStorage from 'utils/useLocalstorage'; +import Avatar from 'components/Avatar/Avatar'; +import { MoreVert, Close } from '@mui/icons-material'; + +interface InterfaceChatRoomProps { + selectedContact: string; +} + +/** + * A chat room component that displays messages and a message input field. + * + * This component shows a list of messages between the user and a selected contact. + * If no contact is selected, it displays a placeholder with an icon and a message asking the user to select a contact. + * + * @param props - The properties passed to the component. + * @param selectedContact - The ID or name of the currently selected contact. If empty, a placeholder is shown. + * + * @returns The rendered chat room component. + */ + +type DirectMessage = { + _id: string; + createdAt: Date; + sender: { + _id: string; + firstName: string; + lastName: string; + image: string; + }; + replyTo: + | { + _id: string; + createdAt: Date; + sender: { + _id: string; + firstName: string; + lastName: string; + image: string; + }; + messageContent: string; + receiver: { + _id: string; + firstName: string; + lastName: string; + }; + } + | undefined; + messageContent: string; + type: string; +}; + +type Chat = { + _id: string; + isGroup: boolean; + name?: string; + image?: string; + messages: DirectMessage[]; + users: { + _id: string; + firstName: string; + lastName: string; + email: string; + }[]; +}; + +export default function chatRoom(props: InterfaceChatRoomProps): JSX.Element { + // Translation hook for text in different languages + const { t } = useTranslation('translation', { + keyPrefix: 'userChatRoom', + }); + const isMountedRef = useRef(true); + + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + + const { getItem } = useLocalStorage(); + const userId = getItem('userId'); + const [chatTitle, setChatTitle] = useState(''); + const [chatSubtitle, setChatSubtitle] = useState(''); + const [newMessage, setNewMessage] = useState(''); + const [chat, setChat] = useState(); + const [replyToDirectMessage, setReplyToDirectMessage] = + useState(null); + + /** + * Handles changes to the new message input field. + * + * Updates the state with the current value of the input field whenever it changes. + * + * @param e - The event triggered by the input field change. + */ + const handleNewMessageChange = (e: ChangeEvent): void => { + const newMessageValue = e.target.value; + setNewMessage(newMessageValue); + }; + + const [sendMessageToChat] = useMutation(SEND_MESSAGE_TO_CHAT, { + variables: { + chatId: props.selectedContact, + replyTo: replyToDirectMessage?._id, + messageContent: newMessage, + }, + }); + + const { data: chatData, refetch: chatRefetch } = useQuery(CHAT_BY_ID, { + variables: { + id: props.selectedContact, + }, + }); + + useEffect(() => { + chatRefetch(); + }, [props.selectedContact]); + + useEffect(() => { + if (chatData) { + const chat = chatData.chatById; + setChat(chat); + if (chat.isGroup) { + setChatTitle(chat.name); + setChatSubtitle(`${chat.users.length} members`); + } else { + const otherUser = chat.users.find( + (user: { _id: string }) => user._id !== userId, + ); + if (otherUser) { + setChatTitle(`${otherUser.firstName} ${otherUser.lastName}`); + setChatSubtitle(otherUser.email); + } + } + } + }, [chatData]); + + const sendMessage = async (): Promise => { + await sendMessageToChat(); + await chatRefetch(); + setReplyToDirectMessage(null); + setNewMessage(''); + }; + + useSubscription(MESSAGE_SENT_TO_CHAT, { + variables: { + userId: userId, + }, + onData: (messageSubscriptionData) => { + if ( + messageSubscriptionData?.data.data.messageSentToChat && + messageSubscriptionData?.data.data.messageSentToChat + .chatMessageBelongsTo['_id'] == props.selectedContact + ) { + chatRefetch(); + } else { + chatRefetch({ + id: messageSubscriptionData?.data.data.messageSentToChat + .chatMessageBelongsTo['_id'], + }); + } + }, + }); + + useEffect(() => { + document + .getElementById('chat-area') + ?.lastElementChild?.scrollIntoView({ block: 'end' }); + }); + + return ( +
    + {!props.selectedContact ? ( +
    + +
    {t('selectContact')}
    +
    + ) : ( + <> +
    +
    + +
    +

    {chatTitle}

    +

    {chatSubtitle}

    +
    +
    +
    +
    +
    + {!!chat?.messages.length && ( +
    + {chat?.messages.map((message: DirectMessage) => { + return ( +
    + {chat.isGroup && + message.sender._id !== userId && + (message.sender?.image ? ( + {message.sender.image} + ) : ( + + ))} +
    + + {chat.isGroup && message.sender._id !== userId && ( +

    + {message.sender.firstName + + ' ' + + message.sender.lastName} +

    + )} + {message.replyTo && ( + +
    +

    + {message.replyTo.sender.firstName + + ' ' + + message.replyTo.sender.lastName} +

    + {message.replyTo.messageContent} +
    +
    + )} + {message.messageContent} +
    +
    + + + + + + { + setReplyToDirectMessage(message); + }} + data-testid="replyBtn" + > + Reply + + + + + {new Date(message?.createdAt).toLocaleTimeString( + 'it-IT', + { + hour: '2-digit', + minute: '2-digit', + }, + )} + +
    +
    +
    + ); + })} +
    + )} +
    +
    +
    + {!!replyToDirectMessage?._id && ( +
    +
    +
    + + + {replyToDirectMessage.sender.firstName + + ' ' + + replyToDirectMessage.sender.lastName} + +
    +

    {replyToDirectMessage.messageContent}

    +
    + + +
    + )} + + + + +
    + + )} +
    + ); +} diff --git a/src/components/UserPortal/CommentCard/CommentCard.module.css b/src/components/UserPortal/CommentCard/CommentCard.module.css new file mode 100644 index 0000000000..b765d60bbd --- /dev/null +++ b/src/components/UserPortal/CommentCard/CommentCard.module.css @@ -0,0 +1,44 @@ +.mainContainer { + width: auto; + overflow: hidden; + background-color: white; + margin-top: 1rem; + padding: 0.5rem; + border: 1px solid #dddddd; + border-radius: 10px; +} + +.personDetails { + display: flex; + flex-direction: column; + justify-content: center; +} + +.personImage { + border-radius: 50%; + margin-right: 20px; +} + +.cardActions { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + margin-top: 10px; +} + +.cardActionBtn { + background-color: rgba(0, 0, 0, 0); + border: none; + color: black; +} + +.cardActionBtn:hover { + background-color: ghostwhite; + border: none; + color: green !important; +} + +.likeIcon { + width: 20px; +} diff --git a/src/components/UserPortal/CommentCard/CommentCard.test.tsx b/src/components/UserPortal/CommentCard/CommentCard.test.tsx new file mode 100644 index 0000000000..f02e5a606a --- /dev/null +++ b/src/components/UserPortal/CommentCard/CommentCard.test.tsx @@ -0,0 +1,241 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { render, screen } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +import CommentCard from './CommentCard'; +import userEvent from '@testing-library/user-event'; +import { LIKE_COMMENT, UNLIKE_COMMENT } from 'GraphQl/Mutations/mutations'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { getItem, setItem } = useLocalStorage(); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const MOCKS = [ + { + request: { + query: LIKE_COMMENT, + variables: { + commentId: '1', + }, + result: { + data: { + likeComment: { + _id: '1', + }, + }, + }, + }, + }, + { + request: { + query: UNLIKE_COMMENT, + variables: { + commentId: '1', + }, + result: { + data: { + unlikeComment: { + _id: '1', + }, + }, + }, + }, + }, +]; + +const handleLikeComment = jest.fn(); +const handleDislikeComment = jest.fn(); +const link = new StaticMockLink(MOCKS, true); + +describe('Testing CommentCard Component [User Portal]', () => { + afterEach(async () => { + await act(async () => { + await i18nForTest.changeLanguage('en'); + }); + }); + + test('Component should be rendered properly if comment is already liked by the user.', async () => { + const cardProps = { + id: '1', + creator: { + id: '1', + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + }, + likeCount: 1, + likedBy: [ + { + id: '1', + }, + ], + text: 'testComment', + handleLikeComment: handleLikeComment, + handleDislikeComment: handleDislikeComment, + }; + + const beforeUserId = getItem('userId'); + setItem('userId', '2'); + + render( + + + + + + + + + , + ); + + await wait(); + if (beforeUserId) { + setItem('userId', beforeUserId); + } + }); + + test('Component should be rendered properly if comment is not already liked by the user.', async () => { + const cardProps = { + id: '1', + creator: { + id: '1', + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + }, + likeCount: 1, + likedBy: [ + { + id: '1', + }, + ], + text: 'testComment', + handleLikeComment: handleLikeComment, + handleDislikeComment: handleDislikeComment, + }; + + const beforeUserId = getItem('userId'); + setItem('userId', '1'); + + render( + + + + + + + + + , + ); + + await wait(); + if (beforeUserId) { + setItem('userId', beforeUserId); + } + }); + + test('Component renders as expected if user likes the comment.', async () => { + const cardProps = { + id: '1', + creator: { + id: '1', + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + }, + likeCount: 1, + likedBy: [ + { + id: '1', + }, + ], + text: 'testComment', + handleLikeComment: handleLikeComment, + handleDislikeComment: handleDislikeComment, + }; + + const beforeUserId = getItem('userId'); + setItem('userId', '2'); + + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('likeCommentBtn')); + + await wait(); + + if (beforeUserId) { + setItem('userId', beforeUserId); + } + }); + + test('Component renders as expected if user unlikes the comment.', async () => { + const cardProps = { + id: '1', + creator: { + id: '1', + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + }, + likeCount: 1, + likedBy: [ + { + id: '1', + }, + ], + text: 'testComment', + handleLikeComment: handleLikeComment, + handleDislikeComment: handleDislikeComment, + }; + + const beforeUserId = getItem('userId'); + setItem('userId', '1'); + + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('likeCommentBtn')); + + if (beforeUserId) { + setItem('userId', beforeUserId); + } + }); +}); diff --git a/src/components/UserPortal/CommentCard/CommentCard.tsx b/src/components/UserPortal/CommentCard/CommentCard.tsx new file mode 100644 index 0000000000..9e8c46d241 --- /dev/null +++ b/src/components/UserPortal/CommentCard/CommentCard.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { Button } from 'react-bootstrap'; +import styles from './CommentCard.module.css'; +import ThumbUpIcon from '@mui/icons-material/ThumbUp'; +import { useMutation } from '@apollo/client'; +import { LIKE_COMMENT, UNLIKE_COMMENT } from 'GraphQl/Mutations/mutations'; +import { toast } from 'react-toastify'; +import HourglassBottomIcon from '@mui/icons-material/HourglassBottom'; +import ThumbUpOffAltIcon from '@mui/icons-material/ThumbUpOffAlt'; +import useLocalStorage from 'utils/useLocalstorage'; +import AccountCircleIcon from '@mui/icons-material/AccountCircle'; + +interface InterfaceCommentCardProps { + id: string; + creator: { + id: string; + firstName: string; + lastName: string; + email: string; + }; + likeCount: number; + likedBy: { + id: string; + }[]; + text: string; + handleLikeComment: (commentId: string) => void; + handleDislikeComment: (commentId: string) => void; +} + +/** + * Displays a card for a single comment with options to like or dislike the comment. + * + * Shows the commenter's name, the comment text, and the number of likes. + * Allows the user to like or dislike the comment. The button icon changes based on whether the comment is liked by the user. + * + * @param props - The properties passed to the component. + * @param id - The unique identifier of the comment. + * @param creator - Information about the creator of the comment. + * @param id - The unique identifier of the creator. + * @param firstName - The first name of the creator. + * @param lastName - The last name of the creator. + * @param email - The email address of the creator. + * @param likeCount - The current number of likes for the comment. + * @param likedBy - An array of users who have liked the comment. + * @param text - The text content of the comment. + * @param handleLikeComment - Function to call when the user likes the comment. + * @param handleDislikeComment - Function to call when the user dislikes the comment. + * + * @returns The rendered comment card component. + */ +function commentCard(props: InterfaceCommentCardProps): JSX.Element { + // Full name of the comment creator + const creatorName = `${props.creator.firstName} ${props.creator.lastName}`; + + // Hook to get user ID from local storage + const { getItem } = useLocalStorage(); + const userId = getItem('userId'); + + // Check if the current user has liked the comment + const likedByUser = props.likedBy.some((likedBy) => likedBy.id === userId); + + // State to track the number of likes and if the comment is liked by the user + const [likes, setLikes] = React.useState(props.likeCount); + const [isLikedByUser, setIsLikedByUser] = React.useState(likedByUser); + + // Mutation hooks for liking and unliking comments + const [likeComment, { loading: likeLoading }] = useMutation(LIKE_COMMENT); + const [unlikeComment, { loading: unlikeLoading }] = + useMutation(UNLIKE_COMMENT); + + /** + * Toggles the like status of the comment. + * + * If the comment is already liked by the user, it will be unliked. Otherwise, it will be liked. + * Updates the number of likes and the like status accordingly. + * + * @returns A promise that resolves when the like/unlike operation is complete. + */ + const handleToggleLike = async (): Promise => { + if (isLikedByUser) { + try { + const { data } = await unlikeComment({ + variables: { + commentId: props.id, + }, + }); + /* istanbul ignore next */ + if (data) { + setLikes((likes) => likes - 1); + setIsLikedByUser(false); + props.handleDislikeComment(props.id); + } + } catch (error: unknown) { + /* istanbul ignore next */ + toast.error(error as string); + } + } else { + try { + const { data } = await likeComment({ + variables: { + commentId: props.id, + }, + }); + /* istanbul ignore next */ + if (data) { + setLikes((likes) => likes + 1); + setIsLikedByUser(true); + props.handleLikeComment(props.id); + } + } catch (error: unknown) { + /* istanbul ignore next */ + toast.error(error as string); + } + } + }; + + return ( +
    +
    +
    + + {creatorName} +
    + {props.text} +
    + + {`${likes} Likes`} +
    +
    +
    + ); +} + +export default commentCard; diff --git a/src/components/UserPortal/ContactCard/ContactCard.module.css b/src/components/UserPortal/ContactCard/ContactCard.module.css new file mode 100644 index 0000000000..19d66596c9 --- /dev/null +++ b/src/components/UserPortal/ContactCard/ContactCard.module.css @@ -0,0 +1,37 @@ +.contact { + display: flex; + flex-direction: row; + padding: 5px 10px; + cursor: pointer; + border-radius: 10px; + /* margin-bottom: 10px; */ + /* border: 2px solid #f5f5f5; */ +} + +.contactImage { + width: 45px !important; + height: auto !important; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; +} + +.contactNameContainer { + display: flex; + flex-direction: column; + padding: 0px 10px; + justify-content: center; +} + +.grey { + color: grey; +} + +.bgGreen { + background-color: rgba(196, 255, 211, 0.3); +} + +.bgWhite { + background-color: white; +} diff --git a/src/components/UserPortal/ContactCard/ContactCard.test.tsx b/src/components/UserPortal/ContactCard/ContactCard.test.tsx new file mode 100644 index 0000000000..05a6a0a530 --- /dev/null +++ b/src/components/UserPortal/ContactCard/ContactCard.test.tsx @@ -0,0 +1,119 @@ +import React, { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; + +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import ContactCard from './ContactCard'; +import userEvent from '@testing-library/user-event'; + +const link = new StaticMockLink([], true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +let props = { + id: '1', + title: 'Disha Talreja', + subtitle: 'disha@example.com', + email: 'noble@mittal.com', + isGroup: false, + image: '', + selectedContact: '', + type: '', + setSelectedContact: jest.fn(), + setSelectedChatType: jest.fn(), +}; + +describe('Testing ContactCard Component [User Portal]', () => { + test('Component should be rendered properly if person image is undefined', async () => { + render( + + + + + + + + + , + ); + + await wait(); + }); + + test('Component should be rendered properly if person image is not undefined', async () => { + props = { + ...props, + image: 'personImage', + }; + + render( + + + + + + + + + , + ); + + await wait(); + }); + + test('Contact gets selectected when component is clicked', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('contactContainer')); + + await wait(); + }); + + test('Component is rendered with background color grey if the contact is selected', async () => { + props = { + ...props, + selectedContact: '1', + isGroup: true, + }; + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('contactContainer')); + + await wait(); + }); +}); diff --git a/src/components/UserPortal/ContactCard/ContactCard.tsx b/src/components/UserPortal/ContactCard/ContactCard.tsx new file mode 100644 index 0000000000..ef6bf2c9d5 --- /dev/null +++ b/src/components/UserPortal/ContactCard/ContactCard.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import styles from './ContactCard.module.css'; +import Avatar from 'components/Avatar/Avatar'; + +interface InterfaceContactCardProps { + id: string; + title: string; + image: string; + selectedContact: string; + setSelectedContact: React.Dispatch>; + isGroup: boolean; +} + +/** + * Displays a card for a contact in a contact list. + * + * Shows the contact's name, email, and an image or avatar. + * The card changes background color based on whether it is selected. + * Clicking on the card sets it as the selected contact and updates the contact name. + * + * @param props - The properties passed to the component. + * @param id - The unique identifier of the contact. + * @param firstName - The first name of the contact. + * @param lastName - The last name of the contact. + * @param email - The email address of the contact. + * @param image - The URL of the contact's image. + * @param selectedContact - The ID of the currently selected contact. + * @param setSelectedContact - Function to set the ID of the selected contact. + * @param setSelectedContactName - Function to set the name of the selected contact. + * + * @returns The rendered contact card component. + */ +function contactCard(props: InterfaceContactCardProps): JSX.Element { + const handleSelectedContactChange = (): void => { + props.setSelectedContact(props.id); + }; + const [isSelected, setIsSelected] = React.useState( + props.selectedContact === props.id, + ); + + // Update selection state when the selected contact changes + React.useEffect(() => { + setIsSelected(props.selectedContact === props.id); + }, [props.selectedContact]); + + return ( + <> +
    + {props.image ? ( + {props.title} + ) : ( + + )} +
    + {props.title} +
    +
    + + ); +} + +export default contactCard; diff --git a/src/components/UserPortal/CreateDirectChat/CreateDirectChat.module.css b/src/components/UserPortal/CreateDirectChat/CreateDirectChat.module.css new file mode 100644 index 0000000000..3795e402fa --- /dev/null +++ b/src/components/UserPortal/CreateDirectChat/CreateDirectChat.module.css @@ -0,0 +1,9 @@ +.userData { + height: 400px; + overflow-y: scroll; + overflow-x: hidden !important; +} + +.modalContent { + width: 530px; +} diff --git a/src/components/UserPortal/CreateDirectChat/CreateDirectChat.test.tsx b/src/components/UserPortal/CreateDirectChat/CreateDirectChat.test.tsx new file mode 100644 index 0000000000..d8b3466207 --- /dev/null +++ b/src/components/UserPortal/CreateDirectChat/CreateDirectChat.test.tsx @@ -0,0 +1,1496 @@ +import React from 'react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import { USERS_CONNECTION_LIST } from 'GraphQl/Queries/Queries'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import Chat from '../../../screens/UserPortal/Chat/Chat'; +import { + CREATE_CHAT, + MESSAGE_SENT_TO_CHAT, +} from 'GraphQl/Mutations/OrganizationMutations'; +import { CHATS_LIST, CHAT_BY_ID } from 'GraphQl/Queries/PlugInQueries'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem } = useLocalStorage(); + +const UserConnectionListMock = [ + { + request: { + query: USERS_CONNECTION_LIST, + variables: { + firstName_contains: '', + lastName_contains: '', + }, + }, + result: { + data: { + users: [ + { + user: { + firstName: 'Deanne', + lastName: 'Marks', + image: null, + _id: '6589389d2caa9d8d69087487', + email: 'testuser8@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + organizationsBlockedBy: [], + joinedOrganizations: [ + { + _id: '6537904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Queens', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 Coffee Street', + line2: 'Apartment 501', + postalCode: '11427', + sortingCode: 'ABC-133', + state: 'NYC', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + { + _id: '6637904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Staten Island', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 church Street', + line2: 'Apartment 499', + postalCode: '10301', + sortingCode: 'ABC-122', + state: 'NYC', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + { + _id: '6737904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Brooklyn', + countryCode: 'US', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Main Street', + line2: 'Apt 456', + postalCode: '10004', + sortingCode: 'ABC-789', + state: 'NY', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + { + _id: '6437904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Bronx', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 Random Street', + line2: 'Apartment 456', + postalCode: '10451', + sortingCode: 'ABC-123', + state: 'NYC', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + ], + __typename: 'User', + }, + appUserProfile: { + _id: '64378abd85308f171cf2993d', + adminFor: [], + isSuperAdmin: false, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + __typename: 'AppUserProfile', + }, + __typename: 'UserData', + }, + ], + }, + }, + }, + { + request: { + query: USERS_CONNECTION_LIST, + variables: { + firstName_contains: 'Disha', + lastName_contains: '', + }, + }, + result: { + data: { + users: [ + { + user: { + firstName: 'Deanne', + lastName: 'Marks', + image: null, + _id: '6589389d2caa9d8d69087487', + email: 'testuser8@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + organizationsBlockedBy: [], + joinedOrganizations: [ + { + _id: '6537904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Queens', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 Coffee Street', + line2: 'Apartment 501', + postalCode: '11427', + sortingCode: 'ABC-133', + state: 'NYC', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + { + _id: '6637904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Staten Island', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 church Street', + line2: 'Apartment 499', + postalCode: '10301', + sortingCode: 'ABC-122', + state: 'NYC', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + { + _id: '6737904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Brooklyn', + countryCode: 'US', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Main Street', + line2: 'Apt 456', + postalCode: '10004', + sortingCode: 'ABC-789', + state: 'NY', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + { + _id: '6437904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Bronx', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 Random Street', + line2: 'Apartment 456', + postalCode: '10451', + sortingCode: 'ABC-123', + state: 'NYC', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + ], + __typename: 'User', + }, + appUserProfile: { + _id: '64378abd85308f171cf2993d', + adminFor: [], + isSuperAdmin: false, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + __typename: 'AppUserProfile', + }, + __typename: 'UserData', + }, + ], + }, + }, + }, +]; + +const MESSAGE_SENT_TO_CHAT_MOCK = [ + { + request: { + query: MESSAGE_SENT_TO_CHAT, + variables: { + userId: null, + }, + }, + result: { + data: { + messageSentToChat: { + _id: '668ec1f1364e03ac47a151', + createdAt: '2024-07-10T17:16:33.248Z', + messageContent: 'Test ', + type: 'STRING', + replyTo: null, + sender: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + updatedAt: '2024-07-10', + }, + }, + }, + }, + { + request: { + query: MESSAGE_SENT_TO_CHAT, + variables: { + userId: '2', + }, + }, + result: { + data: { + messageSentToChat: { + _id: '668ec1f1df364e03ac47a151', + createdAt: '2024-07-10T17:16:33.248Z', + messageContent: 'Test ', + replyTo: null, + type: 'STRING', + sender: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + updatedAt: '2024-07-10', + }, + }, + }, + }, + { + request: { + query: MESSAGE_SENT_TO_CHAT, + variables: { + userId: '1', + }, + }, + result: { + data: { + messageSentToChat: { + _id: '668ec1f13603ac4697a151', + createdAt: '2024-07-10T17:16:33.248Z', + messageContent: 'Test ', + replyTo: null, + type: 'STRING', + sender: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + updatedAt: '2024-07-10', + }, + }, + }, + }, +]; + +const CHAT_BY_ID_QUERY_MOCK = [ + { + request: { + query: CHAT_BY_ID, + variables: { + id: '1', + }, + }, + result: { + data: { + chatById: { + _id: '1', + createdAt: '2345678903456', + isGroup: false, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: null, + name: '', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + ], + }, + }, + }, + }, + { + request: { + query: CHAT_BY_ID, + variables: { + id: '1', + }, + }, + result: { + data: { + chatById: { + _id: '1', + isGroup: false, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: null, + name: '', + createdAt: '2345678903456', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + ], + }, + }, + }, + }, + { + request: { + query: CHAT_BY_ID, + variables: { + id: '', + }, + }, + result: { + data: { + chatById: { + _id: '1', + isGroup: false, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: null, + name: '', + createdAt: '2345678903456', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + ], + }, + }, + }, + }, +]; + +const CHATS_LIST_MOCK = [ + { + request: { + query: CHATS_LIST, + variables: { + id: null, + }, + }, + result: { + data: { + chatsByUserId: [ + { + _id: '65844efc814dd40fgh03db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + { + _id: '65844efc814ddgh4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: CHATS_LIST, + variables: { + id: '', + }, + }, + result: { + data: { + chatsByUserId: [ + { + _id: '65844ghjefc814dd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + { + _id: 'ujhgtrdtyuiop', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: CHATS_LIST, + variables: { + id: '1', + }, + }, + result: { + data: { + chatsByUserId: [ + { + _id: '65844efc814dhjmkdftyd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + { + _id: '65844ewsedrffc814dd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: CHATS_LIST, + variables: { + id: '1', + }, + }, + result: { + data: { + chatsByUserId: [ + { + _id: '65844efc814dhjmkdftyd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + { + _id: '65844ewsedrffc814dd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + ], + }, + }, + }, +]; + +const GROUP_CHAT_BY_ID_QUERY_MOCK = [ + { + request: { + query: CHAT_BY_ID, + variables: { + id: '', + }, + }, + result: { + data: { + chatById: { + _id: '65844efc814dd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + }, + }, + }, +]; + +const CREATE_CHAT_MUTATION_MOCK = [ + { + request: { + query: CREATE_CHAT, + variables: { + organizationId: undefined, + userIds: ['1', '6589389d2caa9d8d69087487'], + isGroup: false, + }, + }, + result: { + data: { + createChat: { + _id: '1', + createdAt: '2345678903456', + isGroup: false, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: null, + name: '', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + ], + }, + }, + }, + }, +]; + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Testing Create Direct Chat Modal [User Portal]', () => { + window.HTMLElement.prototype.scrollIntoView = jest.fn(); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); + + test('Open and close create new direct chat modal', async () => { + const mock = [ + ...GROUP_CHAT_BY_ID_QUERY_MOCK, + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...UserConnectionListMock, + ...CHATS_LIST_MOCK, + ...CHAT_BY_ID_QUERY_MOCK, + ...CREATE_CHAT_MUTATION_MOCK, + ]; + render( + + + + + + + + + , + ); + + await wait(); + + const dropdown = await screen.findByTestId('dropdown'); + expect(dropdown).toBeInTheDocument(); + fireEvent.click(dropdown); + const newDirectChatBtn = await screen.findByTestId('newDirectChat'); + expect(newDirectChatBtn).toBeInTheDocument(); + fireEvent.click(newDirectChatBtn); + + const submitBtn = await screen.findByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + + const searchInput = (await screen.findByTestId( + 'searchUser', + )) as HTMLInputElement; + expect(searchInput).toBeInTheDocument(); + + fireEvent.change(searchInput, { target: { value: 'Disha' } }); + + expect(searchInput.value).toBe('Disha'); + + fireEvent.click(submitBtn); + + const closeButton = screen.getByRole('button', { name: /close/i }); + expect(closeButton).toBeInTheDocument(); + + fireEvent.click(closeButton); + }); + + test('create new direct chat', async () => { + setItem('userId', '1'); + const mock = [ + ...GROUP_CHAT_BY_ID_QUERY_MOCK, + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...UserConnectionListMock, + ...CHATS_LIST_MOCK, + ...CHAT_BY_ID_QUERY_MOCK, + ...CREATE_CHAT_MUTATION_MOCK, + ]; + render( + + + + + + + + + , + ); + + await wait(); + + const dropdown = await screen.findByTestId('dropdown'); + expect(dropdown).toBeInTheDocument(); + fireEvent.click(dropdown); + const newDirectChatBtn = await screen.findByTestId('newDirectChat'); + expect(newDirectChatBtn).toBeInTheDocument(); + fireEvent.click(newDirectChatBtn); + + const addBtn = await screen.findAllByTestId('addBtn'); + waitFor(() => { + expect(addBtn[0]).toBeInTheDocument(); + }); + + fireEvent.click(addBtn[0]); + + const closeButton = screen.getByRole('button', { name: /close/i }); + expect(closeButton).toBeInTheDocument(); + + fireEvent.click(closeButton); + + await new Promise(process.nextTick); + + await wait(); + }); +}); diff --git a/src/components/UserPortal/CreateDirectChat/CreateDirectChat.tsx b/src/components/UserPortal/CreateDirectChat/CreateDirectChat.tsx new file mode 100644 index 0000000000..24c853fcdd --- /dev/null +++ b/src/components/UserPortal/CreateDirectChat/CreateDirectChat.tsx @@ -0,0 +1,205 @@ +import { Paper, TableBody } from '@mui/material'; +import React, { useState } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import styles from './CreateDirectChat.module.css'; +import type { ApolloQueryResult } from '@apollo/client'; +import { useMutation, useQuery } from '@apollo/client'; +import useLocalStorage from 'utils/useLocalstorage'; +import { CREATE_CHAT } from 'GraphQl/Mutations/OrganizationMutations'; +import Table from '@mui/material/Table'; +import TableCell, { tableCellClasses } from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import { styled } from '@mui/material/styles'; +import type { InterfaceQueryUserListItem } from 'utils/interfaces'; +import { USERS_CONNECTION_LIST } from 'GraphQl/Queries/Queries'; +import Loader from 'components/Loader/Loader'; +import { Search } from '@mui/icons-material'; +import { useParams } from 'react-router-dom'; + +interface InterfaceCreateDirectChatProps { + toggleCreateDirectChatModal: () => void; + createDirectChatModalisOpen: boolean; + chatsListRefetch: ( + variables?: + | Partial<{ + id: string; + }> + | undefined, + ) => Promise>; +} + +/** + * Styled table cell with custom styles. + */ + +const StyledTableCell = styled(TableCell)(({ theme }) => ({ + [`&.${tableCellClasses.head}`]: { + backgroundColor: ['#31bb6b', '!important'], + color: theme.palette.common.white, + }, + [`&.${tableCellClasses.body}`]: { + fontSize: 14, + }, +})); + +/** + * Styled table row with custom styles. + */ + +const StyledTableRow = styled(TableRow)(() => ({ + '&:last-child td, &:last-child th': { + border: 0, + }, +})); + +const { getItem } = useLocalStorage(); + +export default function createDirectChatModal({ + toggleCreateDirectChatModal, + createDirectChatModalisOpen, + chatsListRefetch, +}: InterfaceCreateDirectChatProps): JSX.Element { + const { orgId: organizationId } = useParams(); + + const userId: string | null = getItem('userId'); + + const [userName, setUserName] = useState(''); + + const [createChat] = useMutation(CREATE_CHAT); + + const handleCreateDirectChat = async (id: string): Promise => { + await createChat({ + variables: { + organizationId, + userIds: [userId, id], + isGroup: false, + }, + }); + await chatsListRefetch(); + toggleCreateDirectChatModal(); + }; + + const { + data: allUsersData, + loading: allUsersLoading, + refetch: allUsersRefetch, + } = useQuery(USERS_CONNECTION_LIST, { + variables: { + firstName_contains: '', + lastName_contains: '', + }, + }); + + const handleUserModalSearchChange = (e: React.FormEvent): void => { + e.preventDefault(); + /* istanbul ignore next */ + const [firstName, lastName] = userName.split(' '); + + const newFilterData = { + firstName_contains: firstName || '', + lastName_contains: lastName || '', + }; + + allUsersRefetch({ + ...newFilterData, + }); + }; + + return ( + <> + + + {'Chat'} + + + {allUsersLoading ? ( + <> + + + ) : ( + <> +
    +
    + { + const { value } = e.target; + setUserName(value); + }} + /> + + +
    + + + + + # + {'user'} + {'Chat'} + + + + {allUsersData && + allUsersData.users.length > 0 && + allUsersData.users.map( + ( + userDetails: InterfaceQueryUserListItem, + index: number, + ) => ( + + + {index + 1} + + + {userDetails.user.firstName + + ' ' + + userDetails.user.lastName} +
    + {userDetails.user.email} +
    + + + +
    + ), + )} +
    +
    +
    + + )} +
    +
    + + ); +} diff --git a/src/components/UserPortal/CreateGroupChat/CreateGroupChat.module.css b/src/components/UserPortal/CreateGroupChat/CreateGroupChat.module.css new file mode 100644 index 0000000000..3795e402fa --- /dev/null +++ b/src/components/UserPortal/CreateGroupChat/CreateGroupChat.module.css @@ -0,0 +1,9 @@ +.userData { + height: 400px; + overflow-y: scroll; + overflow-x: hidden !important; +} + +.modalContent { + width: 530px; +} diff --git a/src/components/UserPortal/CreateGroupChat/CreateGroupChat.test.tsx b/src/components/UserPortal/CreateGroupChat/CreateGroupChat.test.tsx new file mode 100644 index 0000000000..055985d5fb --- /dev/null +++ b/src/components/UserPortal/CreateGroupChat/CreateGroupChat.test.tsx @@ -0,0 +1,2455 @@ +import React from 'react'; +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; + +import { + USERS_CONNECTION_LIST, + USER_JOINED_ORGANIZATIONS, +} from 'GraphQl/Queries/Queries'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import Chat from '../../../screens/UserPortal/Chat/Chat'; +import { + CREATE_CHAT, + MESSAGE_SENT_TO_CHAT, +} from 'GraphQl/Mutations/OrganizationMutations'; +import { CHATS_LIST, CHAT_BY_ID } from 'GraphQl/Queries/PlugInQueries'; +import useLocalStorage from 'utils/useLocalstorage'; +import userEvent from '@testing-library/user-event'; + +const { setItem } = useLocalStorage(); + +const USER_JOINED_ORG_MOCK = [ + { + request: { + query: USER_JOINED_ORGANIZATIONS, + variables: { + id: '1', + }, + }, + result: { + data: { + users: [ + { + user: { + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + name: 'Test Org 1', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + name: 'Test Org 1', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + name: 'Test Org 1', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + name: 'Test Org 1', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }, + { + request: { + query: USER_JOINED_ORGANIZATIONS, + variables: { + id: '1', + }, + }, + result: { + data: { + users: [ + { + user: { + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + name: 'Test org', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + { + __typename: 'Organization', + _id: 'qsxhgjhbmnbkhlk,njgjfhgv', + name: 'Any Organization', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }, + { + request: { + query: USER_JOINED_ORGANIZATIONS, + variables: { + id: '1', + }, + }, + result: { + data: { + users: [ + { + user: { + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8fhgjhnm07af2', + name: 'Test org', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + { + __typename: 'Organization', + _id: '6401ff65ce8egfhbn8406b8f07af2', + name: 'Any Organization', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }, + { + request: { + query: USER_JOINED_ORGANIZATIONS, + variables: { + id: null, + }, + }, + result: { + data: { + users: [ + { + user: { + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65fghce8e8406b8f07af2', + name: 'Test org', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8jygjgf07af2', + name: 'Any Organization', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }, + { + request: { + query: USER_JOINED_ORGANIZATIONS, + variables: { + id: null, + }, + }, + result: { + data: { + users: [ + { + user: { + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65cehgh8e8406b8f07af2', + name: 'Test org', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + name: 'Any Organization', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }, + { + request: { + query: USER_JOINED_ORGANIZATIONS, + variables: { + id: null, + }, + }, + result: { + data: { + users: [ + { + user: { + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406nbmnmb8f07af2', + name: 'Test org', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8fnnmm07af2', + name: 'Any Organization', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }, +]; + +const UserConnectionListMock = [ + { + request: { + query: USERS_CONNECTION_LIST, + variables: { + firstName_contains: '', + lastName_contains: '', + }, + }, + result: { + data: { + users: [ + { + user: { + firstName: 'Deanne', + lastName: 'Marks', + image: null, + _id: '6589389d2caa9d8d69087487', + email: 'testuser8@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + organizationsBlockedBy: [], + joinedOrganizations: [ + { + _id: '6537904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Queens', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 Coffee Street', + line2: 'Apartment 501', + postalCode: '11427', + sortingCode: 'ABC-133', + state: 'NYC', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + { + _id: '6637904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Staten Island', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 church Street', + line2: 'Apartment 499', + postalCode: '10301', + sortingCode: 'ABC-122', + state: 'NYC', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + { + _id: '6737904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Brooklyn', + countryCode: 'US', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Main Street', + line2: 'Apt 456', + postalCode: '10004', + sortingCode: 'ABC-789', + state: 'NY', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + { + _id: '6437904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Bronx', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 Random Street', + line2: 'Apartment 456', + postalCode: '10451', + sortingCode: 'ABC-123', + state: 'NYC', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + ], + __typename: 'User', + }, + appUserProfile: { + _id: '64378abd85308f171cf2993d', + adminFor: [], + isSuperAdmin: false, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + __typename: 'AppUserProfile', + }, + __typename: 'UserData', + }, + ], + }, + }, + }, + { + request: { + query: USERS_CONNECTION_LIST, + variables: { + firstName_contains: '', + lastName_contains: '', + }, + }, + result: { + data: { + users: { + user: [ + { + firstName: 'Disha', + lastName: 'Talreja', + image: 'img', + _id: '1', + email: 'disha@email.com', + createdAt: '', + appUserProfile: { + _id: '12', + isSuperAdmin: 'false', + createdOrganizations: { + _id: '345678', + }, + createdEvents: { + _id: '34567890', + }, + }, + organizationsBlockedBy: [], + joinedOrganizations: [], + }, + { + firstName: 'Disha', + lastName: 'Talreja', + image: 'img', + _id: '1', + email: 'disha@email.com', + createdAt: '', + appUserProfile: { + _id: '12', + isSuperAdmin: 'false', + createdOrganizations: { + _id: '345678', + }, + createdEvents: { + _id: '34567890', + }, + }, + organizationsBlockedBy: [], + joinedOrganizations: [], + }, + { + firstName: 'Disha', + lastName: 'Talreja', + image: 'img', + _id: '1', + email: 'disha@email.com', + createdAt: '', + appUserProfile: { + _id: '12', + isSuperAdmin: 'false', + createdOrganizations: { + _id: '345678', + }, + createdEvents: { + _id: '34567890', + }, + }, + organizationsBlockedBy: [], + joinedOrganizations: [], + }, + ], + }, + }, + }, + }, + { + request: { + query: USERS_CONNECTION_LIST, + variables: { + firstName_contains: 'Disha', + lastName_contains: '', + }, + }, + result: { + data: { + users: [ + { + user: { + firstName: 'Deanne', + lastName: 'Marks', + image: null, + _id: '6589389d2caa9d8d69087487', + email: 'testuser8@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + organizationsBlockedBy: [], + joinedOrganizations: [ + { + _id: '6537904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Queens', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 Coffee Street', + line2: 'Apartment 501', + postalCode: '11427', + sortingCode: 'ABC-133', + state: 'NYC', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + { + _id: '6637904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Staten Island', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 church Street', + line2: 'Apartment 499', + postalCode: '10301', + sortingCode: 'ABC-122', + state: 'NYC', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + { + _id: '6737904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Brooklyn', + countryCode: 'US', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Main Street', + line2: 'Apt 456', + postalCode: '10004', + sortingCode: 'ABC-789', + state: 'NY', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + { + _id: '6437904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Bronx', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 Random Street', + line2: 'Apartment 456', + postalCode: '10451', + sortingCode: 'ABC-123', + state: 'NYC', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + ], + __typename: 'User', + }, + appUserProfile: { + _id: '64378abd85308f171cf2993d', + adminFor: [], + isSuperAdmin: false, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + __typename: 'AppUserProfile', + }, + __typename: 'UserData', + }, + ], + }, + }, + }, +]; + +const MESSAGE_SENT_TO_CHAT_MOCK = [ + { + request: { + query: MESSAGE_SENT_TO_CHAT, + variables: { + userId: null, + }, + }, + result: { + data: { + messageSentToChat: { + _id: '668ec1f1364e03ac47a151', + createdAt: '2024-07-10T17:16:33.248Z', + messageContent: 'Test ', + type: 'STRING', + replyTo: null, + sender: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + updatedAt: '2024-07-10', + }, + }, + }, + }, + { + request: { + query: MESSAGE_SENT_TO_CHAT, + variables: { + userId: '2', + }, + }, + result: { + data: { + messageSentToChat: { + _id: '668ec1f1df364e03ac47a151', + createdAt: '2024-07-10T17:16:33.248Z', + messageContent: 'Test ', + replyTo: null, + type: 'STRING', + sender: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + updatedAt: '2024-07-10', + }, + }, + }, + }, + { + request: { + query: MESSAGE_SENT_TO_CHAT, + variables: { + userId: '1', + }, + }, + result: { + data: { + messageSentToChat: { + _id: '668ec1f13603ac4697a151', + createdAt: '2024-07-10T17:16:33.248Z', + messageContent: 'Test ', + replyTo: null, + type: 'STRING', + sender: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + updatedAt: '2024-07-10', + }, + }, + }, + }, +]; + +const CHAT_BY_ID_QUERY_MOCK = [ + { + request: { + query: CHAT_BY_ID, + variables: { + id: '1', + }, + }, + result: { + data: { + chatById: { + _id: '1', + createdAt: '2345678903456', + isGroup: false, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: null, + name: '', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + ], + }, + }, + }, + }, + { + request: { + query: CHAT_BY_ID, + variables: { + id: '1', + }, + }, + result: { + data: { + chatById: { + _id: '1', + isGroup: false, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: null, + name: '', + createdAt: '2345678903456', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + ], + }, + }, + }, + }, + { + request: { + query: CHAT_BY_ID, + variables: { + id: '', + }, + }, + result: { + data: { + chatById: { + _id: '1', + isGroup: false, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: null, + name: '', + createdAt: '2345678903456', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + ], + }, + }, + }, + }, +]; + +const CHATS_LIST_MOCK = [ + { + request: { + query: CHATS_LIST, + variables: { + id: null, + }, + }, + result: { + data: { + chatsByUserId: [ + { + _id: '65844efc814dd40fgh03db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + { + _id: '65844efc814ddgh4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: CHATS_LIST, + variables: { + id: '', + }, + }, + result: { + data: { + chatsByUserId: [ + { + _id: '65844ghjefc814dd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + { + _id: 'ujhgtrdtyuiop', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: CHATS_LIST, + variables: { + id: '1', + }, + }, + result: { + data: { + chatsByUserId: [ + { + _id: '65844efc814dhjmkdftyd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + { + _id: '65844ewsedrffc814dd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + ], + }, + }, + }, +]; + +const GROUP_CHAT_BY_ID_QUERY_MOCK = [ + { + request: { + query: CHAT_BY_ID, + variables: { + id: '', + }, + }, + result: { + data: { + chatById: { + _id: '65844efc814dd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + }, + }, + }, +]; + +const CREATE_CHAT_MUTATION = [ + { + request: { + query: CREATE_CHAT, + variables: { + organizationId: '6401ff65ce8e8406b8jygjgf07af2', + userIds: [null], + name: 'Test Group', + isGroup: true, + }, + }, + result: { + data: { + createChat: { + _id: '65844efc814dd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + }, + }, + }, + { + request: { + query: CREATE_CHAT, + variables: { + organizationId: '', + userIds: [null], + name: 'Test Group', + isGroup: true, + }, + }, + result: { + data: { + createChat: { + _id: '65844efc814dd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + }, + }, + }, +]; + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Testing Create Group Chat Modal [User Portal]', () => { + window.HTMLElement.prototype.scrollIntoView = jest.fn(); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); + + test('open and close create new direct chat modal', async () => { + const mock = [ + ...USER_JOINED_ORG_MOCK, + ...GROUP_CHAT_BY_ID_QUERY_MOCK, + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...CHAT_BY_ID_QUERY_MOCK, + ...CHATS_LIST_MOCK, + ...UserConnectionListMock, + ...CREATE_CHAT_MUTATION, + ...CHATS_LIST_MOCK, + ]; + render( + + + + + + + + + , + ); + + await wait(); + + const dropdown = await screen.findByTestId('dropdown'); + expect(dropdown).toBeInTheDocument(); + fireEvent.click(dropdown); + const newGroupChatBtn = await screen.findByTestId('newGroupChat'); + expect(newGroupChatBtn).toBeInTheDocument(); + fireEvent.click(newGroupChatBtn); + + const closeButton = screen.getByRole('button', { name: /close/i }); + expect(closeButton).toBeInTheDocument(); + + fireEvent.click(closeButton); + }); + + test('create new group chat', async () => { + const mock = [ + ...USER_JOINED_ORG_MOCK, + ...GROUP_CHAT_BY_ID_QUERY_MOCK, + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...CHAT_BY_ID_QUERY_MOCK, + ...CHATS_LIST_MOCK, + ...UserConnectionListMock, + ...CREATE_CHAT_MUTATION, + ...CHATS_LIST_MOCK, + ]; + render( + + + + + + + + + , + ); + + await wait(); + + const dropdown = await screen.findByTestId('dropdown'); + expect(dropdown).toBeInTheDocument(); + fireEvent.click(dropdown); + + const newGroupChatBtn = await screen.findByTestId('newGroupChat'); + expect(newGroupChatBtn).toBeInTheDocument(); + + fireEvent.click(newGroupChatBtn); + + await waitFor(async () => { + expect( + await screen.findByTestId('createGroupChatModal'), + ).toBeInTheDocument(); + }); + + const groupTitleInput = screen.getByLabelText( + 'Group name', + ) as HTMLInputElement; + + expect(groupTitleInput).toBeInTheDocument(); + + fireEvent.change(groupTitleInput, { target: { value: 'Test Group' } }); + await waitFor(() => { + expect(groupTitleInput.value).toBe('Test Group'); + }); + + const selectLabel = /select organization/i; + const orgSelect = await screen.findByLabelText('Select Organization'); + // console.log(prettyDOM(document)); + + // act(() => { + // fireEvent.change(orgSelect, { + // target: { value: '6401ff65ce8e8406b8f07af2' }, + // }); + // }) + // fireEvent.change(orgSelect, { + // target: { value: '6401ff65ce8e8406b8f07af2' }, + // }); + + // act(() => { + userEvent.click(orgSelect); + + const optionsPopupEl = await screen.findByRole('listbox', { + name: selectLabel, + }); + + userEvent.click(within(optionsPopupEl).getByText(/any organization/i)); + // }); + + // await waitFor(async () => { + // const option = await screen.findAllByText('Any Organization'); + + // console.log("option", option); + // }); + + const nextBtn = await screen.findByTestId('nextBtn'); + + act(() => { + fireEvent.click(nextBtn); + }); + + const createBtn = await screen.findByTestId('createBtn'); + await waitFor(async () => { + expect(createBtn).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(await screen.findByTestId('createBtn')); + }); + + // await waitFor(() => { + // expect(createBtn).not.toBeInTheDocument(); + // }); + }, 3000); + + test('add and remove user', async () => { + setItem('userId', '1'); + const mock = [ + ...USER_JOINED_ORG_MOCK, + ...GROUP_CHAT_BY_ID_QUERY_MOCK, + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...CHAT_BY_ID_QUERY_MOCK, + ...CHATS_LIST_MOCK, + ...UserConnectionListMock, + ...CREATE_CHAT_MUTATION, + ...CHATS_LIST_MOCK, + ]; + render( + + + + + + + + + , + ); + + await wait(); + + const dropdown = await screen.findByTestId('dropdown'); + expect(dropdown).toBeInTheDocument(); + fireEvent.click(dropdown); + const newGroupChatBtn = await screen.findByTestId('newGroupChat'); + expect(newGroupChatBtn).toBeInTheDocument(); + fireEvent.click(newGroupChatBtn); + + await waitFor(async () => { + expect( + await screen.findByTestId('createGroupChatModal'), + ).toBeInTheDocument(); + }); + + const nextBtn = await screen.findByTestId('nextBtn'); + + act(() => { + fireEvent.click(nextBtn); + }); + + await waitFor(async () => { + const addBtn = await screen.findAllByTestId('addBtn'); + expect(addBtn[0]).toBeInTheDocument(); + }); + + const addBtn = await screen.findAllByTestId('addBtn'); + + fireEvent.click(addBtn[0]); + + const removeBtn = await screen.findAllByText('Remove'); + await waitFor(async () => { + expect(removeBtn[0]).toBeInTheDocument(); + }); + fireEvent.click(removeBtn[0]); + + await waitFor(() => { + expect(addBtn[0]).toBeInTheDocument(); + }); + + const submitBtn = await screen.findByTestId('submitBtn'); + + expect(submitBtn).toBeInTheDocument(); + + const searchInput = (await screen.findByTestId( + 'searchUser', + )) as HTMLInputElement; + expect(searchInput).toBeInTheDocument(); + + fireEvent.change(searchInput, { target: { value: 'Disha' } }); + + expect(searchInput.value).toBe('Disha'); + + fireEvent.click(submitBtn); + + const closeButton = screen.getAllByRole('button', { name: /close/i }); + expect(closeButton[0]).toBeInTheDocument(); + + fireEvent.click(closeButton[0]); + + await wait(500); + }); +}); diff --git a/src/components/UserPortal/CreateGroupChat/CreateGroupChat.tsx b/src/components/UserPortal/CreateGroupChat/CreateGroupChat.tsx new file mode 100644 index 0000000000..e293675a03 --- /dev/null +++ b/src/components/UserPortal/CreateGroupChat/CreateGroupChat.tsx @@ -0,0 +1,367 @@ +import { + FormControl, + InputLabel, + MenuItem, + Paper, + Select, + TableBody, +} from '@mui/material'; +import type { SelectChangeEvent } from '@mui/material/Select'; +import React, { useEffect, useState } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import styles from './CreateGroupChat.module.css'; +import type { ApolloQueryResult } from '@apollo/client'; +import { useMutation, useQuery } from '@apollo/client'; +import { USER_JOINED_ORGANIZATIONS } from 'GraphQl/Queries/OrganizationQueries'; +import useLocalStorage from 'utils/useLocalstorage'; +import { CREATE_CHAT } from 'GraphQl/Mutations/OrganizationMutations'; +import Table from '@mui/material/Table'; +import TableCell, { tableCellClasses } from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import { styled } from '@mui/material/styles'; +import type { InterfaceQueryUserListItem } from 'utils/interfaces'; +import { USERS_CONNECTION_LIST } from 'GraphQl/Queries/Queries'; +import Loader from 'components/Loader/Loader'; +import { Search } from '@mui/icons-material'; + +interface InterfaceCreateGroupChatProps { + toggleCreateGroupChatModal: () => void; + createGroupChatModalisOpen: boolean; + chatsListRefetch: ( + variables?: + | Partial<{ + id: string; + }> + | undefined, + ) => Promise>; +} + +interface InterfaceOrganization { + _id: string; + name: string; + image: string; + description: string; + admins: []; + members: []; + address: { + city: string; + countryCode: string; + line1: string; + postalCode: string; + state: string; + }; + membershipRequestStatus: string; + userRegistrationRequired: boolean; + membershipRequests: { + _id: string; + user: { + _id: string; + }; + }[]; +} + +/** + * Styled table cell with custom styles. + */ + +const StyledTableCell = styled(TableCell)(({ theme }) => ({ + [`&.${tableCellClasses.head}`]: { + backgroundColor: ['#31bb6b', '!important'], + color: theme.palette.common.white, + }, + [`&.${tableCellClasses.body}`]: { + fontSize: 14, + }, +})); + +/** + * Styled table row with custom styles. + */ + +const StyledTableRow = styled(TableRow)(() => ({ + '&:last-child td, &:last-child th': { + border: 0, + }, +})); + +const { getItem } = useLocalStorage(); + +export default function CreateGroupChat({ + toggleCreateGroupChatModal, + createGroupChatModalisOpen, + chatsListRefetch, +}: InterfaceCreateGroupChatProps): JSX.Element { + const userId: string | null = getItem('userId'); + + const [createChat] = useMutation(CREATE_CHAT); + + const [organizations, setOrganizations] = useState([]); + const [selectedOrganization, setSelectedOrganization] = useState(''); + const [title, setTitle] = useState(''); + const [userIds, setUserIds] = useState([]); + + const [addUserModalisOpen, setAddUserModalisOpen] = useState(false); + + function openAddUserModal(): void { + setAddUserModalisOpen(true); + } + + const toggleAddUserModal = /* istanbul ignore next */ (): void => + setAddUserModalisOpen(!addUserModalisOpen); + + const handleChange = (event: SelectChangeEvent): void => { + setSelectedOrganization(event.target.value as string); + }; + + const { data: joinedOrganizationsData } = useQuery( + USER_JOINED_ORGANIZATIONS, + { + variables: { id: userId }, + }, + ); + + function reset(): void { + setTitle(''); + setUserIds([]); + setSelectedOrganization(''); + } + + useEffect(() => { + setUserIds(userIds); + }, [userIds]); + + async function handleCreateGroupChat(): Promise { + await createChat({ + variables: { + organizationId: selectedOrganization, + userIds: [userId, ...userIds], + name: title, + isGroup: true, + }, + }); + chatsListRefetch(); + toggleAddUserModal(); + toggleCreateGroupChatModal(); + reset(); + } + + const [userName, setUserName] = useState(''); + + const { + data: allUsersData, + loading: allUsersLoading, + refetch: allUsersRefetch, + } = useQuery(USERS_CONNECTION_LIST, { + variables: { + firstName_contains: '', + lastName_contains: '', + }, + }); + + const handleUserModalSearchChange = (e: React.FormEvent): void => { + e.preventDefault(); + /* istanbul ignore next */ + const [firstName, lastName] = userName.split(' '); + + const newFilterData = { + firstName_contains: firstName || '', + lastName_contains: lastName || '', + }; + + allUsersRefetch({ + ...newFilterData, + }); + }; + + useEffect(() => { + if (joinedOrganizationsData && joinedOrganizationsData.users.length > 0) { + const organizations = + joinedOrganizationsData.users[0]?.user?.joinedOrganizations || []; + setOrganizations(organizations); + } + }, [joinedOrganizationsData]); + + return ( + <> + + + New Group + + +
    + + Select Organization + + + + Group name + { + setTitle(e.target.value); + }} + /> + + +
    +
    +
    + + + {'Chat'} + + + {allUsersLoading ? ( + <> + + + ) : ( + <> +
    +
    + { + const { value } = e.target; + setUserName(value); + }} + /> + + +
    + + + + + + # + {'user'} + {'Chat'} + + + + {allUsersData && + allUsersData.users.length > 0 && + allUsersData.users.map( + ( + userDetails: InterfaceQueryUserListItem, + index: number, + ) => ( + + + {index + 1} + + + {userDetails.user.firstName + + ' ' + + userDetails.user.lastName} +
    + {userDetails.user.email} +
    + + {userIds.includes(userDetails.user._id) ? ( + + ) : ( + + )} + +
    + ), + )} +
    +
    +
    + + )} + +
    +
    + + ); +} diff --git a/src/components/UserPortal/DonationCard/DonationCard.module.css b/src/components/UserPortal/DonationCard/DonationCard.module.css new file mode 100644 index 0000000000..3fa737ada2 --- /dev/null +++ b/src/components/UserPortal/DonationCard/DonationCard.module.css @@ -0,0 +1,41 @@ +.mainContainer { + width: 49%; + height: 8rem; + min-width: max-content; + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + background-color: white; + border: 1px solid #dddddd; + border-radius: 10px; + overflow: hidden; +} + +.img { + height: 100%; + aspect-ratio: 1/1; + background-color: rgba(49, 187, 107, 12%); +} + +.btn { + display: flex; + align-items: flex-end; +} + +.btn button { + padding-inline: 2rem !important; + border-radius: 5px; +} + +.personDetails { + display: flex; + flex-direction: column; + justify-content: center; + min-width: max-content; +} + +.personImage { + border-radius: 50%; + margin-right: 20px; +} diff --git a/src/components/UserPortal/DonationCard/DonationCard.test.tsx b/src/components/UserPortal/DonationCard/DonationCard.test.tsx new file mode 100644 index 0000000000..cddf62dd6c --- /dev/null +++ b/src/components/UserPortal/DonationCard/DonationCard.test.tsx @@ -0,0 +1,49 @@ +import React, { act } from 'react'; +import { render } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; + +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import DonationCard from './DonationCard'; +import { type InterfaceDonationCardProps } from 'screens/UserPortal/Donate/Donate'; + +const link = new StaticMockLink([], true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const props: InterfaceDonationCardProps = { + id: '1', + name: 'John Doe', + amount: '20', + userId: '1234', + payPalId: 'id', + updatedAt: String(new Date()), +}; + +describe('Testing ContactCard Component [User Portal]', () => { + test('Component should be rendered properly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + }); +}); diff --git a/src/components/UserPortal/DonationCard/DonationCard.tsx b/src/components/UserPortal/DonationCard/DonationCard.tsx new file mode 100644 index 0000000000..63082f6acf --- /dev/null +++ b/src/components/UserPortal/DonationCard/DonationCard.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import styles from './DonationCard.module.css'; +import { type InterfaceDonationCardProps } from 'screens/UserPortal/Donate/Donate'; +import { Button } from 'react-bootstrap'; + +/** + * Displays a card with details about a donation. + * + * Shows the donor's name, the amount donated, and the date of the donation. + * Includes a button to view more details about the donation. + * + * @param props - The properties passed to the component. + * @param name - The name of the donor. + * @param amount - The amount donated. + * @param updatedAt - The date of the donation, in ISO format. + * + * @returns The rendered donation card component. + */ +function donationCard(props: InterfaceDonationCardProps): JSX.Element { + // Create a date object from the donation date string + const date = new Date(props.updatedAt); + + // Format the date into a readable string + const formattedDate = new Intl.DateTimeFormat('en-US', { + weekday: 'short', + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(date); + + return ( +
    +
    +
    + + {props.name} + + Amount: {props.amount} + Date: {formattedDate} +
    +
    + +
    +
    + ); +} + +export default donationCard; diff --git a/src/components/UserPortal/EventCard/EventCard.module.css b/src/components/UserPortal/EventCard/EventCard.module.css new file mode 100644 index 0000000000..28278dd5a6 --- /dev/null +++ b/src/components/UserPortal/EventCard/EventCard.module.css @@ -0,0 +1,26 @@ +.mainContainer { + width: 100%; + display: flex; + flex-direction: column; + padding: 10px; + cursor: pointer; + background-color: white; + border-radius: 10px; + box-shadow: 2px 2px 8px 0px #c8c8c8; + overflow: hidden; +} + +.eventDetails { + gap: 5px; +} + +.personImage { + border-radius: 50%; + margin-right: 20px; +} + +.eventActions { + display: flex; + flex-direction: row; + justify-content: right; +} diff --git a/src/components/UserPortal/EventCard/EventCard.test.tsx b/src/components/UserPortal/EventCard/EventCard.test.tsx new file mode 100644 index 0000000000..78aa32abcf --- /dev/null +++ b/src/components/UserPortal/EventCard/EventCard.test.tsx @@ -0,0 +1,194 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import { BrowserRouter } from 'react-router-dom'; +import { ToastContainer } from 'react-toastify'; +import i18nForTest from 'utils/i18nForTest'; +import EventCard from './EventCard'; +import { render, screen, waitFor } from '@testing-library/react'; +import { REGISTER_EVENT } from 'GraphQl/Mutations/mutations'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import userEvent from '@testing-library/user-event'; +import { debug } from 'jest-preview'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem } = useLocalStorage(); + +const MOCKS = [ + { + request: { + query: REGISTER_EVENT, + variables: { eventId: '123' }, + }, + result: { + data: { + registerForEvent: [ + { + _id: '123', + }, + ], + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +afterEach(() => { + localStorage.clear(); +}); + +describe('Testing Event Card In User portal', () => { + const props = { + id: '123', + title: 'Test Event', + description: 'This is a test event', + location: 'Virtual', + startDate: '2023-04-13', + endDate: '2023-04-15', + isRegisterable: true, + isPublic: true, + endTime: '19:49:12', + startTime: '17:49:12', + recurring: false, + allDay: true, + creator: { + firstName: 'Joe', + lastName: 'David', + id: '123', + }, + registrants: [ + { + id: '234', + }, + ], + }; + + test('The card should be rendered properly, and all the details should be displayed correct', async () => { + const { queryByText } = render( + + + + + + + + + + , + ); + debug(); + await waitFor(() => expect(queryByText('Test Event')).toBeInTheDocument()); + await waitFor(() => + expect(queryByText('This is a test event')).toBeInTheDocument(), + ); + await waitFor(() => expect(queryByText('Location')).toBeInTheDocument()); + await waitFor(() => expect(queryByText('Virtual')).toBeInTheDocument()); + await waitFor(() => expect(queryByText('Starts')).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByTestId('startTime')).toBeInTheDocument(), + ); + await waitFor(() => + expect(queryByText(`13 April '23`)).toBeInTheDocument(), + ); + await waitFor(() => expect(queryByText('Ends')).toBeInTheDocument()); + await waitFor(() => + expect(screen.getByTestId('endTime')).toBeInTheDocument(), + ); + await waitFor(() => + expect(queryByText(`15 April '23`)).toBeInTheDocument(), + ); + await waitFor(() => expect(queryByText('Creator')).toBeInTheDocument()); + await waitFor(() => expect(queryByText('Joe David')).toBeInTheDocument()); + await waitFor(() => expect(queryByText('Register')).toBeInTheDocument()); + }); + + test('When the user is already registered', async () => { + setItem('userId', '234'); + const { queryByText } = render( + + + + + + + + + + , + ); + await waitFor(() => + expect(queryByText('Already registered')).toBeInTheDocument(), + ); + }); + + test('Handle register should work properly', async () => { + setItem('userId', '456'); + const { queryByText } = render( + + + + + + + + + + , + ); + userEvent.click(screen.getByText('Register')); + await waitFor(() => + expect( + queryByText('Successfully registered for Test Event'), + ).toBeInTheDocument(), + ); + }); +}); + +describe('Event card when start and end time are not given', () => { + const props = { + id: '123', + title: 'Test Event', + description: 'This is a test event', + location: 'Virtual', + startDate: '2023-04-13', + endDate: '2023-04-15', + isRegisterable: true, + isPublic: true, + endTime: '', + startTime: '', + recurring: false, + allDay: true, + creator: { + firstName: 'Joe', + lastName: 'David', + id: '123', + }, + registrants: [ + { + id: '234', + }, + ], + }; + + test('Card is rendered correctly', async () => { + const { container } = render( + + + + + + + + + + , + ); + + await waitFor(() => + expect(container.querySelector(':empty')).toBeInTheDocument(), + ); + }); +}); diff --git a/src/components/UserPortal/EventCard/EventCard.tsx b/src/components/UserPortal/EventCard/EventCard.tsx new file mode 100644 index 0000000000..e93138ce42 --- /dev/null +++ b/src/components/UserPortal/EventCard/EventCard.tsx @@ -0,0 +1,170 @@ +import React from 'react'; +import styles from './EventCard.module.css'; +import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; +import dayjs from 'dayjs'; +import { Button } from 'react-bootstrap'; +import { useMutation } from '@apollo/client'; +import { toast } from 'react-toastify'; +import HourglassBottomIcon from '@mui/icons-material/HourglassBottom'; + +import { REGISTER_EVENT } from 'GraphQl/Mutations/mutations'; +import { useTranslation } from 'react-i18next'; + +import useLocalStorage from 'utils/useLocalstorage'; + +interface InterfaceEventCardProps { + id: string; + title: string; + description: string; + location: string; + startDate: string; + endDate: string; + isRegisterable: boolean; + isPublic: boolean; + endTime: string; + startTime: string; + recurring: boolean; + allDay: boolean; + creator: { + firstName: string; + lastName: string; + id: string; + }; + registrants: { + id: string; + }[]; +} + +/** + * Displays information about an event and provides an option to register for it. + * + * Shows the event's title, description, location, start and end dates and times, + * creator's name, and registration status. Includes a button to register for the event + * if the user is not already registered. + * + * @param props - The properties for the event card. + * @param id - The unique identifier of the event. + * @param title - The title of the event. + * @param description - A description of the event. + * @param location - The location where the event will take place. + * @param startDate - The start date of the event in ISO format. + * @param endDate - The end date of the event in ISO format. + * @param isRegisterable - Indicates if the event can be registered for. + * @param isPublic - Indicates if the event is public. + * @param endTime - The end time of the event in HH:mm:ss format. + * @param startTime - The start time of the event in HH:mm:ss format. + * @param recurring - Indicates if the event is recurring. + * @param allDay - Indicates if the event lasts all day. + * @param creator - The creator of the event with their name and ID. + * @param registrants - A list of registrants with their IDs. + * + * @returns The event card component. + */ +function eventCard(props: InterfaceEventCardProps): JSX.Element { + // Extract the translation functions + const { t } = useTranslation('translation', { + keyPrefix: 'userEventCard', + }); + const { t: tCommon } = useTranslation('common'); + + // Get user ID from local storage + const { getItem } = useLocalStorage(); + const userId = getItem('userId'); + + // Create a full name for the event creator + const creatorName = `${props.creator.firstName} ${props.creator.lastName}`; + + // Check if the user is initially registered for the event + const isInitiallyRegistered = props.registrants.some( + (registrant) => registrant.id === userId, + ); + + // Set up the mutation for registering for the event + const [registerEventMutation, { loading }] = useMutation(REGISTER_EVENT); + const [isRegistered, setIsRegistered] = React.useState(isInitiallyRegistered); + + /** + * Handles registering for the event. + * If the user is not already registered, sends a mutation request to register. + * Displays a success or error message based on the result. + */ + const handleRegister = async (): Promise => { + if (!isRegistered) { + try { + const { data } = await registerEventMutation({ + variables: { + eventId: props.id, + }, + }); + /* istanbul ignore next */ + if (data) { + setIsRegistered(true); + toast.success(`Successfully registered for ${props.title}`); + } + } catch (error: unknown) { + /* istanbul ignore next */ + toast.error(error as string); + } + } + }; + + return ( +
    +
    +
    + {props.title} +
    +
    + +
    +
    + {props.description} + + {`${tCommon('location')} `} + {props.location} + +
    + {`${t('starts')} `} + {props.startTime ? ( + + {dayjs(`2015-03-04T${props.startTime}`).format('h:mm:ss A')} + + ) : ( + <> + )} + {dayjs(props.startDate).format("D MMMM 'YY")} +
    +
    + {`${t('ends')} `} + {props.endTime ? ( + + {dayjs(`2015-03-04T${props.endTime}`).format('h:mm:ss A')} + + ) : ( + <> + )}{' '} + {dayjs(props.endDate).format("D MMMM 'YY")} +
    + + {`${t('creator')} `} + {creatorName} + + +
    + {loading ? ( + + ) : isRegistered ? ( + + ) : ( + + )} +
    +
    + ); +} + +export default eventCard; diff --git a/src/components/UserPortal/OrganizationCard/OrganizationCard.module.css b/src/components/UserPortal/OrganizationCard/OrganizationCard.module.css new file mode 100644 index 0000000000..15634f7ad0 --- /dev/null +++ b/src/components/UserPortal/OrganizationCard/OrganizationCard.module.css @@ -0,0 +1,149 @@ +.orgCard { + background-color: var(--bs-white); + margin: 0.5rem; + height: calc(120px + 2rem); + padding: 1rem; + border-radius: 8px; + outline: 1px solid var(--bs-gray-200); + position: relative; +} + +.orgCard .innerContainer { + display: flex; +} + +.orgCard .innerContainer .orgImgContainer { + display: flex; + justify-content: center; + align-items: center; + position: relative; + overflow: hidden; + border-radius: 4px; + width: 125px; + height: 120px; + object-fit: contain; + background-color: var(--bs-gray-200); +} + +.orgCard .innerContainer .content { + flex: 1; + margin-left: 1rem; + width: 70%; + margin-top: 0.7rem; +} + +.orgCard button { + position: absolute; + bottom: 1rem; + right: 1rem; + z-index: 1; +} + +.joinBtn { + display: flex; + justify-content: space-around; + width: 8rem; + border-width: medium; +} + +.joinedBtn { + display: flex; + justify-content: space-around; + width: 8rem; +} + +.withdrawBtn { + display: flex; + justify-content: space-around; + width: 8rem; +} + +.orgName { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + font-size: 1rem; +} + +.orgdesc { + font-size: 0.9rem; + color: var(--bs-gray-600); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + line-clamp: 1; + -webkit-box-orient: vertical; + max-width: 20rem; +} + +.orgadmin { + font-size: 0.9rem; +} + +.orgmember { + font-size: 0.9rem; +} + +.address { + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 1; + line-clamp: 1; + -webkit-box-orient: vertical; + align-items: center; +} + +.address h6 { + font-size: 0.9rem; + color: var(--bs-gray-600); +} + +@media (max-width: 1420px) { + .orgCard { + width: 100%; + } +} + +@media (max-width: 550px) { + .orgCard { + width: 100%; + } + + .orgCard { + height: unset; + margin: 0.5rem 0; + padding: 1.25rem 1.5rem; + } + + .orgCard .innerContainer .orgImgContainer { + margin-bottom: 0.8rem; + } + + .orgCard .innerContainer { + flex-direction: column; + } + + .orgCard .innerContainer .orgImgContainer img { + height: auto; + width: 100%; + } + + .orgCard .innerContainer .content { + margin-left: 0; + } + + .orgCard button { + bottom: 0; + right: 0; + position: relative; + margin-left: auto; + display: block; + } + .joinBtn, + .joinedBtn, + .withdrawBtn { + display: flex; + justify-content: space-around; + width: 100%; + } +} diff --git a/src/components/UserPortal/OrganizationCard/OrganizationCard.test.tsx b/src/components/UserPortal/OrganizationCard/OrganizationCard.test.tsx new file mode 100644 index 0000000000..77516ddc7a --- /dev/null +++ b/src/components/UserPortal/OrganizationCard/OrganizationCard.test.tsx @@ -0,0 +1,346 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; + +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; + +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import OrganizationCard from './OrganizationCard'; +import { + USER_JOINED_ORGANIZATIONS, + USER_ORGANIZATION_CONNECTION, +} from 'GraphQl/Queries/OrganizationQueries'; +import useLocalStorage from 'utils/useLocalstorage'; +import { + SEND_MEMBERSHIP_REQUEST, + JOIN_PUBLIC_ORGANIZATION, +} from 'GraphQl/Mutations/OrganizationMutations'; +import { toast } from 'react-toastify'; + +const { getItem } = useLocalStorage(); + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const MOCKS = [ + { + request: { + query: SEND_MEMBERSHIP_REQUEST, + variables: { + organizationId: '1', + }, + }, + result: { + data: { + sendMembershipRequest: { + _id: 'edgwrgui4y28urfejwiwfw', + organization: { + _id: '1', + name: 'organizationName', + }, + user: { + _id: '1', + }, + }, + }, + }, + }, + { + request: { + query: JOIN_PUBLIC_ORGANIZATION, + variables: { + organizationId: '2', + }, + }, + result: { + data: { + joinPublicOrganization: { + _id: 'edgwrgui4y28urfejwiwfw', + }, + }, + }, + }, + { + request: { + query: USER_JOINED_ORGANIZATIONS, + variables: { + id: '1', + }, + }, + result: { + data: { + users: { + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '2', + image: 'organizationImage', + name: 'organizationName', + description: 'organizationDescription', + }, + ], + }, + }, + }, + }, + { + request: { + query: USER_ORGANIZATION_CONNECTION, + variables: { + id: '1', + }, + }, + result: { + data: { + organizationsConnection: [ + { + __typename: 'Organization', + _id: '2', + image: 'organizationImage', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + name: 'organizationName', + description: 'organizationDescription', + userRegistrationRequired: false, + createdAt: '12345678900', + creator: { __typename: 'User', firstName: 'John', lastName: 'Doe' }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: getItem('userId'), + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +let props = { + id: '1', + name: 'organizationName', + image: '', + description: 'organizationDescription', + admins: [ + { + id: '123', + }, + ], + members: [], + address: { + city: 'Sample City', + countryCode: 'US', + line1: '123 Sample Street', + postalCode: '', + state: '', + }, + membershipRequestStatus: '', + userRegistrationRequired: true, + membershipRequests: [ + { + _id: '', + user: { + _id: '', + }, + }, + ], +}; + +describe('Testing OrganizationCard Component [User Portal]', () => { + test('Component should be rendered properly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + }); + + test('Component should be rendered properly if organization Image is not undefined', async () => { + props = { + ...props, + image: 'organizationImage', + }; + + render( + + + + + + + + + , + ); + + await wait(); + }); + + test('Visit organization', async () => { + const cardProps = { + ...props, + id: '3', + image: 'organizationImage', + userRegistrationRequired: true, + membershipRequestStatus: 'accepted', + }; + + render( + + + + + + + + + , + ); + + await wait(); + + expect(screen.getByTestId('manageBtn')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('manageBtn')); + + await wait(); + + expect(window.location.pathname).toBe(`/user/organization/${cardProps.id}`); + }); + + test('Send membership request', async () => { + props = { + ...props, + image: 'organizationImage', + }; + + render( + + + + + + + + + , + ); + + await wait(); + + expect(screen.getByTestId('joinBtn')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('joinBtn')); + await wait(); + + expect(toast.success).toHaveBeenCalledWith('MembershipRequestSent'); + }); + + test('send membership request to public org', async () => { + const cardProps = { + ...props, + id: '2', + image: 'organizationImage', + userRegistrationRequired: false, + }; + + render( + + + + + + + + + , + ); + + await wait(); + + expect(screen.getByTestId('joinBtn')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('joinBtn')); + await wait(); + + expect(toast.success).toHaveBeenCalledTimes(2); + }); + + test('withdraw membership request', async () => { + const cardProps = { + ...props, + id: '3', + image: 'organizationImage', + userRegistrationRequired: true, + membershipRequestStatus: 'pending', + }; + + render( + + + + + + + + + , + ); + + await wait(); + + expect(screen.getByTestId('withdrawBtn')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('withdrawBtn')); + }); +}); diff --git a/src/components/UserPortal/OrganizationCard/OrganizationCard.tsx b/src/components/UserPortal/OrganizationCard/OrganizationCard.tsx new file mode 100644 index 0000000000..e1c2c23beb --- /dev/null +++ b/src/components/UserPortal/OrganizationCard/OrganizationCard.tsx @@ -0,0 +1,228 @@ +import React from 'react'; +import styles from './OrganizationCard.module.css'; +import { Button } from 'react-bootstrap'; +import { Tooltip } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { + CANCEL_MEMBERSHIP_REQUEST, + JOIN_PUBLIC_ORGANIZATION, + SEND_MEMBERSHIP_REQUEST, +} from 'GraphQl/Mutations/OrganizationMutations'; +import { useMutation, useQuery } from '@apollo/client'; +import { + USER_JOINED_ORGANIZATIONS, + USER_ORGANIZATION_CONNECTION, +} from 'GraphQl/Queries/OrganizationQueries'; +import useLocalStorage from 'utils/useLocalstorage'; +import Avatar from 'components/Avatar/Avatar'; +import { useNavigate } from 'react-router-dom'; + +const { getItem } = useLocalStorage(); + +interface InterfaceOrganizationCardProps { + id: string; + name: string; + image: string; + description: string; + admins: { + id: string; + }[]; + members: { + id: string; + }[]; + address: { + city: string; + countryCode: string; + line1: string; + postalCode: string; + state: string; + }; + membershipRequestStatus: string; + userRegistrationRequired: boolean; + membershipRequests: { + _id: string; + user: { + _id: string; + }; + }[]; +} + +/** + * Displays an organization card with options to join or manage membership. + * + * Shows the organization's name, image, description, address, number of admins and members, + * and provides buttons for joining, withdrawing membership requests, or visiting the organization page. + * + * @param props - The properties for the organization card. + * @param id - The unique identifier of the organization. + * @param name - The name of the organization. + * @param image - The URL of the organization's image. + * @param description - A description of the organization. + * @param admins - The list of admins with their IDs. + * @param members - The list of members with their IDs. + * @param address - The address of the organization including city, country code, line1, postal code, and state. + * @param membershipRequestStatus - The status of the membership request (accepted, pending, or empty). + * @param userRegistrationRequired - Indicates if user registration is required to join the organization. + * @param membershipRequests - The list of membership requests with user IDs. + * + * @returns The organization card component. + */ +const userId: string | null = getItem('userId'); + +function organizationCard(props: InterfaceOrganizationCardProps): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'users', + }); + const { t: tCommon } = useTranslation('common'); + + const navigate = useNavigate(); + + // Mutations for handling organization memberships + const [sendMembershipRequest] = useMutation(SEND_MEMBERSHIP_REQUEST, { + refetchQueries: [ + { query: USER_ORGANIZATION_CONNECTION, variables: { id: props.id } }, + ], + }); + const [joinPublicOrganization] = useMutation(JOIN_PUBLIC_ORGANIZATION, { + refetchQueries: [ + { query: USER_ORGANIZATION_CONNECTION, variables: { id: props.id } }, + ], + }); + const [cancelMembershipRequest] = useMutation(CANCEL_MEMBERSHIP_REQUEST, { + refetchQueries: [ + { query: USER_ORGANIZATION_CONNECTION, variables: { id: props.id } }, + ], + }); + const { refetch } = useQuery(USER_JOINED_ORGANIZATIONS, { + variables: { id: userId }, + }); + + /** + * Handles joining the organization. Sends a membership request if registration is required, + * otherwise joins the public organization directly. Displays success or error messages. + */ + async function joinOrganization(): Promise { + try { + if (props.userRegistrationRequired) { + await sendMembershipRequest({ + variables: { + organizationId: props.id, + }, + }); + toast.success(t('MembershipRequestSent') as string); + } else { + await joinPublicOrganization({ + variables: { + organizationId: props.id, + }, + }); + toast.success(t('orgJoined') as string); + } + refetch(); + } catch (error: unknown) { + /* istanbul ignore next */ + if (error instanceof Error) { + if (error.message === 'User is already a member') { + toast.error(t('AlreadyJoined') as string); + } else { + toast.error(t('errorOccured') as string); + } + } + } + } + + /** + * Handles withdrawing a membership request. Finds the request for the current user and cancels it. + */ + async function withdrawMembershipRequest(): Promise { + const membershipRequest = props.membershipRequests.find( + (request) => request.user._id === userId, + ); + + await cancelMembershipRequest({ + variables: { + membershipRequestId: membershipRequest?._id, + }, + }); + } + + return ( + <> +
    +
    +
    + {props.image ? ( + {`${props.name} + ) : ( + + )} +
    +
    + +

    {props.name}

    +
    +
    + {props.description} +
    + {props.address && props.address.city && ( +
    +
    + {props.address.line1}, + {props.address.city}, + + {props.address.countryCode} + +
    +
    + )} +
    + {tCommon('admins')}: {props.admins?.length}   +     {tCommon('members')}:{' '} + {props.members?.length} +
    +
    +
    + {props.membershipRequestStatus === 'accepted' && ( + + )} + + {props.membershipRequestStatus === 'pending' && ( + + )} + {props.membershipRequestStatus === '' && ( + + )} +
    + + ); +} + +export default organizationCard; diff --git a/src/components/UserPortal/OrganizationNavbar/OrganizationNavbar.module.css b/src/components/UserPortal/OrganizationNavbar/OrganizationNavbar.module.css new file mode 100644 index 0000000000..761b389541 --- /dev/null +++ b/src/components/UserPortal/OrganizationNavbar/OrganizationNavbar.module.css @@ -0,0 +1,27 @@ +.talawaImage { + width: 40px; + height: auto; + margin-top: -5px; + border: 2px solid white; + margin-right: 10px; + background-color: white; + border-radius: 10px; +} + +.colorWhite { + color: white; +} + +.colorPrimary { + background: #31bb6b; +} + +.offcanvasContainer { + background-color: #31bb6b; + color: white; +} + +.link { + text-decoration: none !important; + color: inherit; +} diff --git a/src/components/UserPortal/OrganizationNavbar/OrganizationNavbar.test.tsx b/src/components/UserPortal/OrganizationNavbar/OrganizationNavbar.test.tsx new file mode 100644 index 0000000000..038ff626df --- /dev/null +++ b/src/components/UserPortal/OrganizationNavbar/OrganizationNavbar.test.tsx @@ -0,0 +1,452 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import 'jest-localstorage-mock'; +import { render, screen } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter, Router } from 'react-router-dom'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import cookies from 'js-cookie'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +import OrganizationNavbar from './OrganizationNavbar'; +import userEvent from '@testing-library/user-event'; +import { USER_ORGANIZATION_CONNECTION } from 'GraphQl/Queries/Queries'; +import { PLUGIN_SUBSCRIPTION } from 'GraphQl/Mutations/mutations'; + +import { createMemoryHistory } from 'history'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem, removeItem } = useLocalStorage(); + +const organizationId = 'org1234'; + +const MOCK_ORGANIZATION_CONNECTION = { + request: { + query: USER_ORGANIZATION_CONNECTION, + variables: { + id: organizationId, + }, + }, + result: { + data: { + organizationsConnection: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + image: '', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + name: 'anyOrganization1', + description: 'desc', + userRegistrationRequired: true, + createdAt: '12345678900', + creator: { __typename: 'User', firstName: 'John', lastName: 'Doe' }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, +}; + +const MOCKS = [MOCK_ORGANIZATION_CONNECTION]; + +const PLUGIN_SUBSCRIPTION_1 = [ + MOCK_ORGANIZATION_CONNECTION, + { + request: { + query: PLUGIN_SUBSCRIPTION, + }, + result: { + data: { + onPluginUpdate: { + pluginName: 'TestPlugin1', + _id: '123', + pluginDesc: 'desc', + uninstalledOrgs: [organizationId], + }, + }, + _loadingSub: false, + }, + }, +]; + +const PLUGIN_SUBSCRIPTION_2 = [ + MOCK_ORGANIZATION_CONNECTION, + { + request: { + query: PLUGIN_SUBSCRIPTION, + }, + result: { + data: { + onPluginUpdate: { + pluginName: 'TestPlugin1', + _id: '123', + pluginDesc: 'desc', + uninstalledOrgs: [], + }, + }, + _loadingSub: false, + }, + }, +]; + +const PLUGIN_SUBSCRIPTION_3 = [ + MOCK_ORGANIZATION_CONNECTION, + { + request: { + query: PLUGIN_SUBSCRIPTION, + }, + result: { + data: { + onPluginUpdate: { + pluginName: 'TestPlugin100', + _id: '123', + pluginDesc: 'desc', + uninstalledOrgs: [organizationId], + }, + }, + _loadingSub: false, + }, + }, +]; + +const testPlugins = [ + { + pluginName: 'TestPlugin1', + alias: 'testPlugin1', + link: '/testPlugin1', + translated: 'Test Plugin 1', + view: true, + }, +]; + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(PLUGIN_SUBSCRIPTION_1, true); +const link3 = new StaticMockLink(PLUGIN_SUBSCRIPTION_2, true); +const link4 = new StaticMockLink(PLUGIN_SUBSCRIPTION_3, true); + +const navbarProps = { + currentPage: 'home', +}; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: organizationId }), +})); + +describe('Testing OrganizationNavbar Component [User Portal]', () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); + + afterEach(async () => { + await act(async () => { + await i18nForTest.changeLanguage('en'); + }); + }); + + test('Component should be rendered properly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + expect(screen.queryByText('anyOrganization1')).toBeInTheDocument(); + // Check if navigation links are rendered + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('People')).toBeInTheDocument(); + expect(screen.getByText('Events')).toBeInTheDocument(); + expect(screen.getByText('Donate')).toBeInTheDocument(); + // expect(screen.getByText('Chat')).toBeInTheDocument(); + }); + + test('should navigate correctly on clicking a plugin', async () => { + const history = createMemoryHistory(); + render( + + + + + + + + + , + ); + + const peoplePlugin = screen.getByText('People'); + expect(peoplePlugin).toBeInTheDocument(); + + userEvent.click(peoplePlugin); + + await wait(); + expect(history.location.pathname).toBe(`/user/people/${organizationId}`); + }); + + test('The language is switched to English', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('languageIcon')); + + userEvent.click(screen.getByTestId('changeLanguageBtn0')); + + await wait(); + + expect(cookies.get('i18next')).toBe('en'); + // Check if navigation links are rendered + expect(screen.getByText('Home')).toBeInTheDocument(); + expect(screen.getByText('People')).toBeInTheDocument(); + expect(screen.getByText('Events')).toBeInTheDocument(); + expect(screen.getByText('Donate')).toBeInTheDocument(); + // expect(screen.getByText('Chat')).toBeInTheDocument(); + }); + + test('The language is switched to fr', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('languageIcon')); + + userEvent.click(screen.getByTestId('changeLanguageBtn1')); + + await wait(); + + expect(cookies.get('i18next')).toBe('fr'); + }); + + test('The language is switched to hi', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('languageIcon')); + + userEvent.click(screen.getByTestId('changeLanguageBtn2')); + + await wait(); + + expect(cookies.get('i18next')).toBe('hi'); + }); + + test('The language is switched to sp', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('languageIcon')); + + userEvent.click(screen.getByTestId('changeLanguageBtn3')); + + await wait(); + + expect(cookies.get('i18next')).toBe('sp'); + }); + + test('The language is switched to zh', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('languageIcon')); + + userEvent.click(screen.getByTestId('changeLanguageBtn4')); + + await wait(); + + expect(cookies.get('i18next')).toBe('zh'); + }); + + test('Component should be rendered properly if plugins are present in localStorage', async () => { + setItem('talawaPlugins', JSON.stringify(testPlugins)); + + render( + + + + + + + + + , + ); + + await wait(); + + testPlugins.forEach((plugin) => { + expect(screen.queryByText(plugin.translated)).toBeInTheDocument(); + }); + + removeItem('talawaPlugins'); + }); + + test('should remove plugin if uninstalledOrgs contains organizationId', async () => { + setItem('talawaPlugins', JSON.stringify(testPlugins)); + + render( + + + + + + + + + , + ); + + await wait(); + + testPlugins.forEach((plugin) => { + expect(screen.queryByText(plugin.translated)).not.toBeInTheDocument(); + }); + }); + + test('should render plugin if uninstalledOrgs does not contain organizationId', async () => { + setItem('talawaPlugins', JSON.stringify(testPlugins)); + + render( + + + + + + + + + , + ); + + await wait(); + + testPlugins.forEach((plugin) => { + expect(screen.queryByText(plugin.translated)).toBeInTheDocument(); + }); + }); + + test('should do nothing if pluginName is not found in the rendered plugins', async () => { + render( + + + + + + + + + , + ); + + await wait(); + }); +}); diff --git a/src/components/UserPortal/OrganizationNavbar/OrganizationNavbar.tsx b/src/components/UserPortal/OrganizationNavbar/OrganizationNavbar.tsx new file mode 100644 index 0000000000..34022fcfcf --- /dev/null +++ b/src/components/UserPortal/OrganizationNavbar/OrganizationNavbar.tsx @@ -0,0 +1,267 @@ +import React from 'react'; +import styles from './OrganizationNavbar.module.css'; +import TalawaImage from 'assets/images/talawa-logo-600x600.png'; +import { Container, Dropdown, Nav, Navbar, Offcanvas } from 'react-bootstrap'; +import { languages } from 'utils/languages'; +import i18next from 'i18next'; +import cookies from 'js-cookie'; +import PermIdentityIcon from '@mui/icons-material/PermIdentity'; +import LanguageIcon from '@mui/icons-material/Language'; +import { useTranslation } from 'react-i18next'; +import { useQuery, useSubscription } from '@apollo/client'; +import { USER_ORGANIZATION_CONNECTION } from 'GraphQl/Queries/Queries'; +import type { DropDirection } from 'react-bootstrap/esm/DropdownContext'; +import { Link, useNavigate, useParams } from 'react-router-dom'; +import { PLUGIN_SUBSCRIPTION } from 'GraphQl/Mutations/mutations'; +import useLocalStorage from 'utils/useLocalstorage'; +interface InterfaceNavbarProps { + currentPage: string | null; +} + +type Plugin = { + pluginName: string; + + alias: string; + link: string; + translated: string; + view: boolean; +}; + +/** + * Displays the organization navbar with navigation options, user settings, and language selection. + * + * The navbar includes: + * - Organization branding and name. + * - Navigation links for various plugins based on user permissions. + * - Language dropdown for changing the interface language. + * - User dropdown for accessing settings and logging out. + * + * @param props - The properties for the navbar. + * @param currentPage - The current page identifier for highlighting the active navigation link. + * + * @returns The organization navbar component. + */ +function organizationNavbar(props: InterfaceNavbarProps): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'userNavbar', + }); + const { t: tCommon } = useTranslation('common'); + + const navigate = useNavigate(); + + const [organizationDetails, setOrganizationDetails] = React.useState<{ + name: string; + }>({ name: '' }); + + const dropDirection: DropDirection = 'start'; + + const { orgId: organizationId } = useParams(); + + const { data } = useQuery(USER_ORGANIZATION_CONNECTION, { + variables: { id: organizationId }, + }); + + const [currentLanguageCode, setCurrentLanguageCode] = React.useState( + /* istanbul ignore next */ + cookies.get('i18next') || 'en', + ); + + const { getItem, setItem } = useLocalStorage(); + + /** + * Handles user logout by clearing local storage and redirecting to the home page. + */ + /* istanbul ignore next */ + const handleLogout = (): void => { + localStorage.clear(); + window.location.replace('/'); + }; + + const userName = getItem('name'); + + React.useEffect(() => { + if (data) { + setOrganizationDetails({ name: data.organizationsConnection[0].name }); + } + }, [data]); + + const homeLink = `/user/organization/${organizationId}`; + + let plugins: Plugin[] = [ + { + pluginName: 'People', + alias: 'people', + link: `/user/people/${organizationId}`, + translated: t('people'), + view: true, + }, + { + pluginName: 'Events', + alias: 'events', + link: `/user/events/${organizationId}`, + translated: t('events'), + view: true, + }, + { + pluginName: 'Donation', + alias: 'donate', + link: `/user/donate/${organizationId}`, + translated: t('donate'), + view: true, + }, + // { + // pluginName: 'Chats', + // alias: 'chat', + // link: `/user/chat/id=${organizationId}`, + // translated: t('chat'), + // view: true, + // }, + ]; + + if (getItem('talawaPlugins')) { + const talawaPlugins: string = getItem('talawaPlugins') || '{}'; + plugins = JSON.parse(talawaPlugins); + } + + const { data: updatedPluginData } = useSubscription(PLUGIN_SUBSCRIPTION); + + function getPluginIndex(pluginName: string, pluginsArray: Plugin[]): number { + return pluginsArray.findIndex((plugin) => plugin.pluginName === pluginName); + } + + if (updatedPluginData != undefined) { + const pluginName = updatedPluginData.onPluginUpdate.pluginName; + const uninstalledOrgs = updatedPluginData.onPluginUpdate.uninstalledOrgs; + const pluginIndexToRemove = getPluginIndex(pluginName, plugins); + if (uninstalledOrgs.includes(organizationId)) { + if (pluginIndexToRemove != -1) { + plugins[pluginIndexToRemove].view = false; + setItem('talawaPlugins', JSON.stringify(plugins)); + console.log(`Plugin ${pluginName} has been removed.`); + } else { + console.log(`Plugin ${pluginName} is not present.`); + } + } else { + if (pluginIndexToRemove != -1) { + plugins[pluginIndexToRemove].view = true; + setItem('talawaPlugins', JSON.stringify(plugins)); + } + } + } + + return ( + + + + Talawa Branding + {organizationDetails.name} + + + + + Talawa + + + + + + + + + + {languages.map((language, index: number) => ( + => { + setCurrentLanguageCode(language.code); + await i18next.changeLanguage(language.code); + }} + disabled={currentLanguageCode === language.code} + data-testid={`changeLanguageBtn${index}`} + > + {' '} + {language.name} + + ))} + + + + + + + + + + {userName} + + + + {tCommon('settings')} + + + + {tCommon('logout')} + + + + + + + + + ); +} + +export default organizationNavbar; diff --git a/src/components/UserPortal/OrganizationSidebar/OrganizationSidebar.module.css b/src/components/UserPortal/OrganizationSidebar/OrganizationSidebar.module.css new file mode 100644 index 0000000000..cf8bc84dda --- /dev/null +++ b/src/components/UserPortal/OrganizationSidebar/OrganizationSidebar.module.css @@ -0,0 +1,76 @@ +.mainContainer { + display: flex; + overflow: auto; + flex-direction: column; + /* align-items: center; */ + padding: 20px; + /* padding-top: 20px; */ + width: 250px; + flex-grow: 1; + background-color: var(--bs-white); +} + +@media screen and (max-width: 900px) { + .mainContainer { + display: none; + } +} + +.userDetails { + display: flex; + flex-direction: column; + align-items: center; + padding-top: 20px; +} + +.boxShadow { + box-shadow: 4px 4px 8px 4px #c8c8c8; +} + +.organizationsConatiner { + width: 100%; + padding-top: 50px; +} + +.heading { + padding: 10px 0px; +} + +.orgName { + font-size: 16px; + font-weight: 600; + margin-top: 4px; + margin-left: 5px; +} + +.alignRight { + width: 100%; + text-align: right; + padding: 5px; +} + +.link { + text-decoration: none !important; + color: black; +} + +.rounded { + border-radius: 10px !important; +} + +.colorLight { + background-color: #f5f5f5; +} + +.marginTop { + margin-top: -2px; +} + +.eventDetails { + font-size: small; + gap: 5px; +} + +.memberImage { + border-radius: 50%; +} diff --git a/src/components/UserPortal/OrganizationSidebar/OrganizationSidebar.test.tsx b/src/components/UserPortal/OrganizationSidebar/OrganizationSidebar.test.tsx new file mode 100644 index 0000000000..cddac285fd --- /dev/null +++ b/src/components/UserPortal/OrganizationSidebar/OrganizationSidebar.test.tsx @@ -0,0 +1,160 @@ +import React, { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; + +import { + ORGANIZATIONS_MEMBER_CONNECTION_LIST, + ORGANIZATION_EVENT_CONNECTION_LIST, +} from 'GraphQl/Queries/Queries'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import OrganizationSidebar from './OrganizationSidebar'; + +const MOCKS = [ + { + request: { + query: ORGANIZATION_EVENT_CONNECTION_LIST, + variables: { + organization_id: 'events', + first: 3, + skip: 0, + }, + }, + result: { + data: { + eventsByOrganizationConnection: [ + { + _id: 1, + title: 'Event', + description: 'Event Test', + startDate: '2024-01-01', + endDate: '2024-01-02', + location: 'New Delhi', + startTime: '02:00', + endTime: '06:00', + allDay: false, + recurring: false, + attendees: [], + recurrenceRule: null, + isRecurringEventException: false, + isPublic: true, + isRegisterable: true, + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATIONS_MEMBER_CONNECTION_LIST, + variables: { + orgId: 'members', + first: 3, + skip: 0, + }, + }, + result: { + data: { + organizationsMemberConnection: { + edges: [ + { + _id: '64001660a711c62d5b4076a2', + firstName: 'Noble', + lastName: 'Mittal', + image: null, + email: 'noble@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + }, + { + _id: '64001660a711c62d5b4076a3', + firstName: 'Noble', + lastName: 'Mittal', + image: 'mockImage', + email: 'noble@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + }, + ], + }, + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} +let mockId = ''; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: mockId }), +})); + +describe('Testing OrganizationSidebar Component [User Portal]', () => { + test('Component should be rendered properly when members and events list is empty', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + expect(screen.queryByText('No Members to show')).toBeInTheDocument(); + expect(screen.queryByText('No Events to show')).toBeInTheDocument(); + }); + + test('Component should be rendered properly when events list is not empty', async () => { + mockId = 'events'; + render( + + + + + + + + + , + ); + + await wait(); + expect(screen.queryByText('No Members to show')).toBeInTheDocument(); + expect(screen.queryByText('No Events to show')).not.toBeInTheDocument(); + expect(screen.queryByText('Event')).toBeInTheDocument(); + }); + + test('Component should be rendered properly when members list is not empty', async () => { + mockId = 'members'; + render( + + + + + + + + + , + ); + + await wait(); + expect(screen.queryByText('No Members to show')).not.toBeInTheDocument(); + expect(screen.queryByText('No Events to show')).toBeInTheDocument(); + }); +}); diff --git a/src/components/UserPortal/OrganizationSidebar/OrganizationSidebar.tsx b/src/components/UserPortal/OrganizationSidebar/OrganizationSidebar.tsx new file mode 100644 index 0000000000..796d30d568 --- /dev/null +++ b/src/components/UserPortal/OrganizationSidebar/OrganizationSidebar.tsx @@ -0,0 +1,200 @@ +import React, { useEffect } from 'react'; +import { ListGroup } from 'react-bootstrap'; +import AboutImg from 'assets/images/defaultImg.png'; +import styles from './OrganizationSidebar.module.css'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import { Link, useParams } from 'react-router-dom'; +import { useQuery } from '@apollo/client'; +import { + ORGANIZATIONS_MEMBER_CONNECTION_LIST, + ORGANIZATION_EVENT_CONNECTION_LIST, +} from 'GraphQl/Queries/Queries'; +import HourglassBottomIcon from '@mui/icons-material/HourglassBottom'; +import CalendarMonthIcon from '@mui/icons-material/CalendarMonth'; +import dayjs from 'dayjs'; +import { useTranslation } from 'react-i18next'; +import type { + InterfaceQueryOrganizationEventListItem, + InterfaceMemberInfo, +} from 'utils/interfaces'; + +/** + * OrganizationSidebar displays the sidebar for an organization, showing a list of members and events. + * + * This component fetches and displays: + * - The top 3 members of the organization with their images and names. + * - The top 3 upcoming events for the organization with their titles, start, and end dates. + * + * It includes: + * - A link to view all members. + * - A link to view all events. + * + * The sidebar handles loading states and displays appropriate messages while data is being fetched. + * + * @returns JSX.Element representing the organization sidebar. + */ +export default function organizationSidebar(): JSX.Element { + // Translation functions for different namespaces + const { t } = useTranslation('translation', { + keyPrefix: 'organizationSidebar', + }); + const { t: tCommon } = useTranslation('common'); + + // Extract the organization ID from the URL parameters + const { orgId: organizationId } = useParams(); + const [members, setMembers] = React.useState< + InterfaceMemberInfo[] | undefined + >(undefined); + const [events, setEvents] = React.useState< + InterfaceQueryOrganizationEventListItem[] | undefined + >(undefined); + const eventsLink = `/user/events/${organizationId}`; + const peopleLink = `/user/people/${organizationId}`; + + // Query to fetch members of the organization + const { data: memberData, loading: memberLoading } = useQuery( + ORGANIZATIONS_MEMBER_CONNECTION_LIST, + { + variables: { + orgId: organizationId, + first: 3, // Fetch top 3 members + skip: 0, // No offset + }, + }, + ); + + // Query to fetch events of the organization + const { data: eventsData, loading: eventsLoading } = useQuery( + ORGANIZATION_EVENT_CONNECTION_LIST, + { + variables: { + organization_id: organizationId, + first: 3, // Fetch top 3 upcoming events + skip: 0, // No offset + }, + }, + ); + + /** + * Effect hook to update members state when memberData is fetched. + * + * Sets the members state with the data from the query. + */ + /* istanbul ignore next */ + useEffect(() => { + if (memberData) { + setMembers(memberData.organizationsMemberConnection.edges); + } + }, [memberData]); + + /** + * Effect hook to update events state when eventsData is fetched. + * + * Sets the events state with the data from the query. + */ + /* istanbul ignore next */ + useEffect(() => { + if (eventsData) { + setEvents(eventsData.eventsByOrganizationConnection); + } + }, [eventsData]); + + return ( +
    + {/* Members section */} +
    + {tCommon('members')} +
    + {memberLoading ? ( +
    + Loading... +
    + ) : ( + + {members && members.length ? ( + members.map((member: InterfaceMemberInfo) => { + const memberName = `${member.firstName} ${member.lastName}`; + return ( + +
    + +
    {memberName}
    +
    +
    + ); + }) + ) : ( +
    {t('noMembers')}
    + )} +
    + )} + + {/* Link to view all members */} +
    + + {t('viewAll')} + + +
    + + {/* Events section */} +
    + {t('events')} +
    + {eventsLoading ? ( +
    + Loading... +
    + ) : ( + + {events && events.length ? ( + events.map((event: InterfaceQueryOrganizationEventListItem) => { + return ( + +
    +
    +
    {event.title}
    +
    + +
    +
    +
    + Starts{' '} + {dayjs(event.startDate).format("D MMMM 'YY")} +
    +
    + Ends {dayjs(event.endDate).format("D MMMM 'YY")} +
    +
    +
    + ); + }) + ) : ( +
    {t('noEvents')}
    + )} +
    + )} + + {/* Link to view all events */} +
    + + {t('viewAll')} + + +
    +
    + ); +} diff --git a/src/components/UserPortal/PeopleCard/PeopleCard.module.css b/src/components/UserPortal/PeopleCard/PeopleCard.module.css new file mode 100644 index 0000000000..fe9fc3bcbd --- /dev/null +++ b/src/components/UserPortal/PeopleCard/PeopleCard.module.css @@ -0,0 +1,33 @@ +.mainContainer { + width: 100%; + display: flex; + flex-direction: row; + padding: 10px; + cursor: pointer; + background-color: white; + border-radius: 10px; + box-shadow: 2px 2px 8px 0px #c8c8c8; + overflow: hidden; +} + +.personDetails { + display: flex; + flex-direction: column; + justify-content: center; +} + +.personImage { + border-radius: 50%; + margin-right: 20px; + max-width: 70px; +} + +.borderBox { + border: 1px solid #dddddd; + box-shadow: 5px 5px 4px 0px #31bb6b1f; + border-radius: 4px; +} + +.greenText { + color: #31bb6b; +} diff --git a/src/components/UserPortal/PeopleCard/PeopleCard.test.tsx b/src/components/UserPortal/PeopleCard/PeopleCard.test.tsx new file mode 100644 index 0000000000..fd5d6c7f93 --- /dev/null +++ b/src/components/UserPortal/PeopleCard/PeopleCard.test.tsx @@ -0,0 +1,69 @@ +import React, { act } from 'react'; +import { render } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; + +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import PeopleCard from './PeopleCard'; + +const link = new StaticMockLink([], true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +let props = { + id: '1', + name: 'First Last', + image: '', + email: 'first@last.com', + role: 'Admin', + sno: '1', +}; + +describe('Testing PeopleCard Component [User Portal]', () => { + test('Component should be rendered properly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + }); + + test('Component should be rendered properly if person image is not undefined', async () => { + props = { + ...props, + image: 'personImage', + }; + + render( + + + + + + + + + , + ); + + await wait(); + }); +}); diff --git a/src/components/UserPortal/PeopleCard/PeopleCard.tsx b/src/components/UserPortal/PeopleCard/PeopleCard.tsx new file mode 100644 index 0000000000..e6b6088801 --- /dev/null +++ b/src/components/UserPortal/PeopleCard/PeopleCard.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import aboutImg from 'assets/images/defaultImg.png'; +import styles from './PeopleCard.module.css'; + +/** + * Props interface for the PeopleCard component. + */ +interface InterfaceOrganizationCardProps { + id: string; + name: string; + image: string; + email: string; + role: string; + sno: string; +} + +/** + * PeopleCard component displays information about a person within an organization. + * + * It includes: + * - An image of the person or a default image if none is provided. + * - The serial number of the person. + * - The person's name. + * - The person's email address. + * - The person's role within the organization, styled with a border. + * + * @param props - The properties passed to the component. + * @returns JSX.Element representing a card with the person's details. + */ +function peopleCard(props: InterfaceOrganizationCardProps): JSX.Element { + // Determine the image URL; use default image if no image URL is provided + const imageUrl = props.image ? props.image : aboutImg; + + return ( +
    + {/* Container for serial number and image */} + + {/* Serial number */} + + {props.sno} + + {/* Person's image */} + + + + + {/* Person's name */} + + {props.name} + + {/* Person's email */} + + {props.email} + + {/* Person's role with additional styling */} +
    +
    + {props.role} +
    +
    +
    + ); +} + +export default peopleCard; diff --git a/src/components/UserPortal/PostCard/PostCard.module.css b/src/components/UserPortal/PostCard/PostCard.module.css new file mode 100644 index 0000000000..e8edc2d82c --- /dev/null +++ b/src/components/UserPortal/PostCard/PostCard.module.css @@ -0,0 +1,183 @@ +.cardStyles { + width: 20rem; + background-color: white; + padding: 0; + border: none !important; + outline: none !important; +} + +.cardHeader { + display: flex; + width: 100%; + padding-inline: 0; + padding-block: 0; + flex-direction: row; + gap: 0.5rem; + align-items: center; + background-color: white; + border-bottom: 1px solid #dddddd; +} + +.creator { + display: flex; + width: 100%; + padding-inline: 1rem; + padding-block: 0; + flex-direction: row; + gap: 0.5rem; + align-items: center; +} +.creator p { + margin-bottom: 0; + font-weight: 500; +} +.creator svg { + width: 2rem; + height: 2rem; +} + +.customToggle { + padding: 0; + background: none; + border: none; + margin-right: 1rem; + --bs-btn-active-bg: none; +} +.customToggle svg { + color: black; +} + +.customToggle::after { + content: none; +} +.customToggle:hover, +.customToggle:focus, +.customToggle:active { + background: none; + border: none; +} +.customToggle svg { + color: black; +} + +.cardBody div { + padding: 0.5rem; +} + +.imageContainer { + max-width: 100%; +} + +.cardTitle { + --max-lines: 1; + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: var(--max-lines); + + font-size: 1.3rem !important; + font-weight: 600; +} + +.date { + font-weight: 600; +} + +.cardText { + --max-lines: 2; + display: -webkit-box; + overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: var(--max-lines); + + padding-top: 0; + font-weight: 300; + margin-top: 0.7rem !important; + text-align: justify; +} + +.viewBtn { + display: flex; + justify-content: flex-end; + margin: 0.5rem; +} +.viewBtn Button { + padding-inline: 1rem; +} + +.cardActions { + display: flex; + flex-direction: row; + align-items: center; + gap: 1px; + justify-content: flex-end; +} + +.cardActionBtn { + background-color: rgba(0, 0, 0, 0); + padding: 0; + border: none; + color: black; +} + +.cardActionBtn:hover { + background-color: ghostwhite; + border: none; + color: black !important; +} + +.creatorNameModal { + display: flex; + flex-direction: row; + gap: 5px; + align-items: center; + margin-bottom: 10px; +} + +.modalActions { + display: flex; + flex-direction: row; + align-items: center; + gap: 1rem; + margin: 5px 0px; +} + +.textModal { + margin-top: 10px; +} + +.colorPrimary { + background: #31bb6b; + color: white; + cursor: pointer; +} + +.commentContainer { + overflow: auto; + max-height: 18rem; + padding-bottom: 1rem; +} + +.modalFooter { + background-color: white; + position: absolute; + width: calc(100% - 1rem); + padding-block: 0.5rem; + display: flex; + flex-direction: column; + border-top: 1px solid #dddddd; + bottom: 0; + right: 0.5rem; + margin-left: 1rem; +} + +.inputArea { + border: none; + outline: none; + background-color: #f1f3f6; +} + +.postImage { + height: 300px; + object-fit: cover; +} diff --git a/src/components/UserPortal/PostCard/PostCard.test.tsx b/src/components/UserPortal/PostCard/PostCard.test.tsx new file mode 100644 index 0000000000..1b7708b384 --- /dev/null +++ b/src/components/UserPortal/PostCard/PostCard.test.tsx @@ -0,0 +1,863 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { render, screen } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; + +import PostCard from './PostCard'; +import userEvent from '@testing-library/user-event'; +import { + CREATE_COMMENT_POST, + LIKE_POST, + UNLIKE_POST, + LIKE_COMMENT, + UNLIKE_COMMENT, + DELETE_POST_MUTATION, + UPDATE_POST_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem, getItem } = useLocalStorage(); + +jest.mock('react-toastify', () => ({ + toast: { + error: jest.fn(), + info: jest.fn(), + success: jest.fn(), + }, +})); + +const MOCKS = [ + { + request: { + query: LIKE_POST, + variables: { + postId: '', + }, + result: { + data: { + likePost: { + _id: '', + }, + }, + }, + }, + }, + { + request: { + query: UNLIKE_POST, + variables: { + post: '', + }, + result: { + data: { + unlikePost: { + _id: '', + }, + }, + }, + }, + }, + { + request: { + query: CREATE_COMMENT_POST, + variables: { + postId: '1', + comment: 'testComment', + }, + result: { + data: { + createComment: { + _id: '64ef885bca85de60ebe0f304', + creator: { + _id: '63d6064458fce20ee25c3bf7', + firstName: 'Noble', + lastName: 'Mittal', + email: 'test@gmail.com', + __typename: 'User', + }, + likeCount: 0, + likedBy: [], + text: 'testComment', + __typename: 'Comment', + }, + }, + }, + }, + }, + { + request: { + query: LIKE_COMMENT, + variables: { + commentId: '1', + }, + }, + result: { + data: { + likeComment: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: UNLIKE_COMMENT, + variables: { + commentId: '1', + }, + }, + result: { + data: { + unlikeComment: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: UPDATE_POST_MUTATION, + variables: { + id: 'postId', + text: 'Edited Post', + }, + }, + result: { + data: { + updatePost: { + _id: '', + }, + }, + }, + }, + { + request: { + query: DELETE_POST_MUTATION, + variables: { + id: 'postId', + }, + }, + result: { + data: { + removePost: { + _id: '', + }, + }, + }, + }, +]; + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const link = new StaticMockLink(MOCKS, true); + +describe('Testing PostCard Component [User Portal]', () => { + test('Component should be rendered properly', async () => { + const cardProps = { + id: 'postId', + userImage: 'image.png', + creator: { + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + id: '1', + }, + postedAt: '', + image: '', + video: '', + text: 'This is post test text', + title: 'This is post test title', + likeCount: 1, + commentCount: 1, + comments: [ + { + id: '64eb13beca85de60ebe0ed0e', + creator: { + id: '63d6064458fce20ee25c3bf7', + firstName: 'Noble', + lastName: 'Mittal', + email: 'test@gmail.com', + __typename: 'User', + }, + likeCount: 0, + likedBy: [], + text: 'First comment from Talawa user portal.', + __typename: 'Comment', + }, + { + id: '64eb13beca85de60ebe0ed0b', + creator: { + id: '63d6064458fce20ee25c3bf8', + firstName: 'Priyanshu', + lastName: 'Bartwal', + email: 'test1@gmail.com', + __typename: 'User', + }, + likeCount: 0, + likedBy: [], + text: 'First comment from Talawa user portal.', + __typename: 'Comment', + }, + ], + likedBy: [ + { + firstName: '', + lastName: '', + id: '2', + }, + ], + fetchPosts: jest.fn(), + }; + + render( + + + + + + + + + , + ); + + await wait(); + }); + + test('Dropdown component should be rendered properly', async () => { + setItem('userId', '2'); + + const cardProps = { + id: '', + userImage: 'image.png', + creator: { + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + id: '1', + }, + postedAt: '', + image: '', + video: '', + text: 'This is post test text', + title: 'This is post test title', + likeCount: 1, + commentCount: 0, + comments: [], + likedBy: [ + { + firstName: 'test', + lastName: 'user', + id: '2', + }, + ], + fetchPosts: jest.fn(), + }; + + render( + + + + + + + + + , + ); + await wait(); + + userEvent.click(screen.getByTestId('dropdown')); + await wait(); + expect(screen.getByText('Edit')).toBeInTheDocument(); + expect(screen.getByText('Delete')).toBeInTheDocument(); + }); + + test('Edit post should work properly', async () => { + setItem('userId', '2'); + + const cardProps = { + id: 'postId', + userImage: 'image.png', + creator: { + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + id: '1', + }, + postedAt: '', + image: '', + video: '', + text: 'test Post', + title: 'This is post test title', + likeCount: 1, + commentCount: 0, + comments: [], + likedBy: [ + { + firstName: 'test', + lastName: 'user', + id: '2', + }, + ], + fetchPosts: jest.fn(), + }; + + render( + + + + + + + + + , + ); + await wait(); + + userEvent.click(screen.getByTestId('dropdown')); + userEvent.click(screen.getByTestId('editPost')); + await wait(); + + expect(screen.getByTestId('editPostModalTitle')).toBeInTheDocument(); + userEvent.clear(screen.getByTestId('postInput')); + userEvent.type(screen.getByTestId('postInput'), 'Edited Post'); + userEvent.click(screen.getByTestId('editPostBtn')); + await wait(); + + expect(toast.success).toHaveBeenCalledWith('Post updated Successfully'); + }); + + test('Delete post should work properly', async () => { + setItem('userId', '2'); + + const cardProps = { + id: 'postId', + userImage: 'image.png', + creator: { + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + id: '1', + }, + postedAt: '', + image: '', + video: '', + text: 'test Post', + title: 'This is post test title', + likeCount: 1, + commentCount: 0, + comments: [], + likedBy: [ + { + firstName: 'test', + lastName: 'user', + id: '2', + }, + ], + fetchPosts: jest.fn(), + }; + + render( + + + + + + + + + , + ); + await wait(); + + userEvent.click(screen.getByTestId('dropdown')); + userEvent.click(screen.getByTestId('deletePost')); + await wait(); + + expect(toast.success).toHaveBeenCalledWith( + 'Successfully deleted the Post.', + ); + }); + + test('Component should be rendered properly if user has liked the post', async () => { + const beforeUserId = getItem('userId'); + setItem('userId', '2'); + + const cardProps = { + id: '', + userImage: 'image.png', + creator: { + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + id: '1', + }, + postedAt: '', + image: '', + video: '', + text: 'This is post test text', + title: 'This is post test title', + likeCount: 1, + commentCount: 0, + comments: [], + likedBy: [ + { + firstName: 'test', + lastName: 'user', + id: '2', + }, + ], + fetchPosts: jest.fn(), + }; + + render( + + + + + + + + + , + ); + + await wait(); + + if (beforeUserId) { + setItem('userId', beforeUserId); + } + }); + + test('Component should be rendered properly if user unlikes a post', async () => { + const beforeUserId = getItem('userId'); + setItem('userId', '2'); + + const cardProps = { + id: '', + userImage: 'image.png', + creator: { + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + id: '1', + }, + postedAt: '', + image: '', + video: '', + text: 'This is post test text', + title: 'This is post test title', + likeCount: 1, + commentCount: 0, + comments: [], + likedBy: [ + { + firstName: 'test', + lastName: 'user', + id: '2', + }, + ], + fetchPosts: jest.fn(), + }; + + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('viewPostBtn')); + userEvent.click(screen.getByTestId('likePostBtn')); + + if (beforeUserId) { + setItem('userId', beforeUserId); + } + }); + + test('Component should be rendered properly if user likes a post', async () => { + const beforeUserId = getItem('userId'); + setItem('userId', '2'); + + const cardProps = { + id: '', + userImage: 'image.png', + creator: { + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + id: '1', + }, + postedAt: '', + image: '', + video: '', + text: 'This is post test text', + title: 'This is post test title', + likeCount: 1, + commentCount: 0, + comments: [], + likedBy: [ + { + firstName: 'test', + lastName: 'user', + id: '1', + }, + ], + fetchPosts: jest.fn(), + }; + + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('viewPostBtn')); + userEvent.click(screen.getByTestId('likePostBtn')); + + if (beforeUserId) { + setItem('userId', beforeUserId); + } + }); + + test('Component should be rendered properly if post image is defined', async () => { + const cardProps = { + id: '', + userImage: 'image.png', + creator: { + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + id: '1', + }, + postedAt: '', + image: 'testImage', + video: '', + text: 'This is post test text', + title: 'This is post test title', + likeCount: 1, + commentCount: 0, + comments: [], + likedBy: [ + { + firstName: 'test', + lastName: 'user', + id: '1', + }, + ], + fetchPosts: jest.fn(), + }; + + render( + + + + + + + + + , + ); + + await wait(); + }); + + test('Comment is created successfully after create comment button is clicked.', async () => { + const cardProps = { + id: '1', + userImage: 'image.png', + creator: { + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + id: '1', + }, + postedAt: '', + image: 'testImage', + video: '', + text: 'This is post test text', + title: 'This is post test title', + likeCount: 1, + commentCount: 0, + comments: [], + likedBy: [ + { + firstName: 'test', + lastName: 'user', + id: '1', + }, + ], + fetchPosts: jest.fn(), + }; + + render( + + + + + + + + + , + ); + + const randomComment = 'testComment'; + + userEvent.click(screen.getByTestId('viewPostBtn')); + + userEvent.type(screen.getByTestId('commentInput'), randomComment); + userEvent.click(screen.getByTestId('createCommentBtn')); + + await wait(); + }); + + test(`Comment should be liked when like button is clicked`, async () => { + const cardProps = { + id: '1', + creator: { + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + id: '1', + }, + image: 'testImage', + video: '', + text: 'This is post test text', + title: 'This is post test title', + likeCount: 1, + commentCount: 1, + postedAt: '', + comments: [ + { + id: '1', + creator: { + _id: '1', + id: '1', + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + }, + likeCount: 1, + likedBy: [ + { + id: '1', + }, + ], + text: 'testComment', + }, + { + id: '2', + creator: { + _id: '1', + id: '1', + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + }, + likeCount: 1, + likedBy: [ + { + id: '2', + }, + ], + text: 'testComment', + }, + ], + likedBy: [ + { + firstName: 'test', + lastName: 'user', + id: '1', + }, + ], + fetchPosts: jest.fn(), + }; + const beforeUserId = getItem('userId'); + setItem('userId', '2'); + + render( + + + + + + + + + , + ); + + userEvent.click(screen.getByTestId('viewPostBtn')); + + userEvent.click(screen.getAllByTestId('likeCommentBtn')[0]); + + await wait(); + + if (beforeUserId) { + setItem('userId', beforeUserId); + } + }); + + test(`Comment should be unliked when like button is clicked, if already liked`, async () => { + const cardProps = { + id: '1', + creator: { + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + id: '1', + }, + image: 'testImage', + video: '', + text: 'This is post test text', + title: 'This is post test title', + likeCount: 1, + commentCount: 1, + postedAt: '', + comments: [ + { + id: '1', + creator: { + _id: '1', + id: '1', + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + }, + likeCount: 1, + likedBy: [ + { + id: '1', + }, + ], + text: 'testComment', + }, + { + id: '2', + creator: { + _id: '1', + id: '1', + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + }, + likeCount: 1, + likedBy: [ + { + id: '2', + }, + ], + text: 'testComment', + }, + ], + likedBy: [ + { + firstName: 'test', + lastName: 'user', + id: '1', + }, + ], + fetchPosts: jest.fn(), + }; + const beforeUserId = getItem('userId'); + setItem('userId', '1'); + + render( + + + + + + + + + , + ); + + userEvent.click(screen.getByTestId('viewPostBtn')); + + userEvent.click(screen.getAllByTestId('likeCommentBtn')[0]); + + await wait(); + + if (beforeUserId) { + setItem('userId', beforeUserId); + } + }); + test('Comment modal pops when show comments button is clicked.', async () => { + const cardProps = { + id: '', + userImage: 'image.png', + creator: { + firstName: 'test', + lastName: 'user', + email: 'test@user.com', + id: '1', + }, + postedAt: '', + image: 'testImage', + video: '', + text: 'This is post test text', + title: 'This is post test title', + likeCount: 1, + commentCount: 0, + comments: [], + likedBy: [ + { + firstName: 'test', + lastName: 'user', + id: '1', + }, + ], + fetchPosts: jest.fn(), + }; + + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('viewPostBtn')); + expect(screen.findAllByText('Comments')).not.toBeNull(); + }); +}); diff --git a/src/components/UserPortal/PostCard/PostCard.tsx b/src/components/UserPortal/PostCard/PostCard.tsx new file mode 100644 index 0000000000..f8fcdaebca --- /dev/null +++ b/src/components/UserPortal/PostCard/PostCard.tsx @@ -0,0 +1,481 @@ +import React from 'react'; +import { useMutation } from '@apollo/client'; +import { toast } from 'react-toastify'; +import { useTranslation } from 'react-i18next'; +import { + Col, + Button, + Card, + Dropdown, + Form, + InputGroup, + Modal, + ModalFooter, +} from 'react-bootstrap'; +import AccountCircleIcon from '@mui/icons-material/AccountCircle'; +import HourglassBottomIcon from '@mui/icons-material/HourglassBottom'; +import ThumbUpOffAltIcon from '@mui/icons-material/ThumbUpOffAlt'; +import SendIcon from '@mui/icons-material/Send'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import ThumbUpIcon from '@mui/icons-material/ThumbUp'; +import CommentIcon from '@mui/icons-material/Comment'; +import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'; +import EditOutlinedIcon from '@mui/icons-material/EditOutlined'; + +import type { InterfacePostCard } from 'utils/interfaces'; +import { + CREATE_COMMENT_POST, + DELETE_POST_MUTATION, + LIKE_POST, + UNLIKE_POST, + UPDATE_POST_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import CommentCard from '../CommentCard/CommentCard'; +import { errorHandler } from 'utils/errorHandler'; +import useLocalStorage from 'utils/useLocalstorage'; +import styles from './PostCard.module.css'; +import UserDefault from '../../../assets/images/defaultImg.png'; + +interface InterfaceCommentCardProps { + id: string; + creator: { + id: string; + firstName: string; + lastName: string; + email: string; + }; + likeCount: number; + likedBy: { + id: string; + }[]; + text: string; + handleLikeComment: (commentId: string) => void; + handleDislikeComment: (commentId: string) => void; +} + +/** + * PostCard component displays an individual post, including its details, interactions, and comments. + * + * The component allows users to: + * - View the post's details in a modal. + * - Edit or delete the post. + * - Like or unlike the post. + * - Add comments to the post. + * - Like or dislike individual comments. + * + * @param props - The properties passed to the component including post details, comments, and related actions. + * @returns JSX.Element representing a post card with interactive features. + */ +export default function postCard(props: InterfacePostCard): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'postCard', + }); + const { t: tCommon } = useTranslation('common'); + + const { getItem } = useLocalStorage(); + + // Retrieve user ID from local storage + const userId = getItem('userId'); + // Check if the post is liked by the current user + const likedByUser = props.likedBy.some((likedBy) => likedBy.id === userId); + + // State variables + const [comments, setComments] = React.useState(props.comments); + const [numComments, setNumComments] = React.useState(props.commentCount); + + const [likes, setLikes] = React.useState(props.likeCount); + const [isLikedByUser, setIsLikedByUser] = React.useState(likedByUser); + const [commentInput, setCommentInput] = React.useState(''); + const [viewPost, setViewPost] = React.useState(false); + const [showEditPost, setShowEditPost] = React.useState(false); + const [postContent, setPostContent] = React.useState(props.text); + + // Post creator's full name + const postCreator = `${props.creator.firstName} ${props.creator.lastName}`; + + // GraphQL mutations + const [likePost, { loading: likeLoading }] = useMutation(LIKE_POST); + const [unLikePost, { loading: unlikeLoading }] = useMutation(UNLIKE_POST); + const [create, { loading: commentLoading }] = + useMutation(CREATE_COMMENT_POST); + const [editPost] = useMutation(UPDATE_POST_MUTATION); + const [deletePost] = useMutation(DELETE_POST_MUTATION); + + // Toggle the view post modal + const toggleViewPost = (): void => setViewPost(!viewPost); + + // Toggle the edit post modal + const toggleEditPost = (): void => setShowEditPost(!showEditPost); + + // Handle input changes for the post content + const handlePostInput = (e: React.ChangeEvent): void => { + setPostContent(e.target.value); + }; + + // Toggle like or unlike the post + const handleToggleLike = async (): Promise => { + if (isLikedByUser) { + try { + const { data } = await unLikePost({ + variables: { + postId: props.id, + }, + }); + /* istanbul ignore next */ + if (data) { + setLikes((likes) => likes - 1); + setIsLikedByUser(false); + } + } catch (error: unknown) { + /* istanbul ignore next */ + toast.error(error as string); + } + } else { + try { + const { data } = await likePost({ + variables: { + postId: props.id, + }, + }); + /* istanbul ignore next */ + if (data) { + setLikes((likes) => likes + 1); + setIsLikedByUser(true); + } + } catch (error: unknown) { + /* istanbul ignore next */ + toast.error(error as string); + } + } + }; + + // Handle changes to the comment input field + const handleCommentInput = ( + event: React.ChangeEvent, + ): void => { + const comment = event.target.value; + setCommentInput(comment); + }; + + // Dislike a comment + const handleDislikeComment = (commentId: string): void => { + const updatedComments = comments.map((comment) => { + let updatedComment = { ...comment }; + if ( + comment.id === commentId && + comment.likedBy.some((user) => user.id === userId) + ) { + updatedComment = { + ...comment, + likedBy: comment.likedBy.filter((user) => user.id !== userId), + likeCount: comment.likeCount - 1, + }; + } + return updatedComment; + }); + setComments(updatedComments); + }; + + // Like a comment + const handleLikeComment = (commentId: string): void => { + const updatedComments = comments.map((comment) => { + let updatedComment = { ...comment }; + if ( + comment.id === commentId && + !comment.likedBy.some((user) => user.id === userId) + ) { + updatedComment = { + ...comment, + likedBy: [...comment.likedBy, { id: userId }], + likeCount: comment.likeCount + 1, + }; + } + return updatedComment; + }); + setComments(updatedComments); + }; + + // Create a new comment + const createComment = async (): Promise => { + try { + const { data: createEventData } = await create({ + variables: { + postId: props.id, + comment: commentInput, + }, + }); + + /* istanbul ignore next */ + if (createEventData) { + setCommentInput(''); + setNumComments((numComments) => numComments + 1); + + const newComment: InterfaceCommentCardProps = { + id: createEventData.createComment.id, + creator: { + id: createEventData.createComment.creator._id, + firstName: createEventData.createComment.creator.firstName, + lastName: createEventData.createComment.creator.lastName, + email: createEventData.createComment.creator.email, + }, + likeCount: createEventData.createComment.likeCount, + likedBy: createEventData.createComment.likedBy, + text: createEventData.createComment.text, + handleLikeComment: handleLikeComment, + handleDislikeComment: handleDislikeComment, + }; + + setComments([...comments, newComment]); + } + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }; + + // Edit the post + const handleEditPost = (): void => { + try { + editPost({ + variables: { + id: props.id, + text: postContent, + }, + }); + + props.fetchPosts(); // Refresh the posts + toggleEditPost(); + toast.success(tCommon('updatedSuccessfully', { item: 'Post' }) as string); + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }; + + // Delete the post + const handleDeletePost = (): void => { + try { + deletePost({ + variables: { + id: props.id, + }, + }); + + props.fetchPosts(); // Refresh the posts + toast.success('Successfully deleted the Post.'); + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }; + + return ( + + + +
    + +

    {postCreator}

    +
    + + + + + + + + {tCommon('edit')} + + + + {tCommon('delete')} + + {/* Pin Post + Report + Share */} + + +
    + + + + {props.title} + + + {t('postedOn', { date: props.postedAt })} + + + {props.text} + + + +
    + +
    +
    +
    + + +
    + postImg +
    +
    +
    +
    + +

    {postCreator}

    +
    +
    + +
    +
    +
    +

    + {props.title} +

    +

    {props.text}

    +
    +

    Comments

    +
    + {numComments ? ( + comments.map((comment, index: number) => { + const cardProps: InterfaceCommentCardProps = { + id: comment.id, + creator: { + id: comment.creator.id, + firstName: comment.creator.firstName, + lastName: comment.creator.lastName, + email: comment.creator.email, + }, + likeCount: comment.likeCount, + likedBy: comment.likedBy, + text: comment.text, + handleLikeComment: handleLikeComment, + handleDislikeComment: handleDislikeComment, + }; + return ; + }) + ) : ( +

    No comments to show.

    + )} +
    +
    +
    +
    + + {likes} + {` ${t('likes')}`} +
    +
    + + {numComments} + {` ${t('comments')}`} +
    +
    + + + + {commentLoading ? ( + + ) : ( + + )} + + +
    +
    +
    +
    + + +

    + {t('editPost')} +

    +
    + + + + + + +
    + + ); +} diff --git a/src/components/UserPortal/PromotedPost/PromotedPost.module.css b/src/components/UserPortal/PromotedPost/PromotedPost.module.css new file mode 100644 index 0000000000..676d30a83a --- /dev/null +++ b/src/components/UserPortal/PromotedPost/PromotedPost.module.css @@ -0,0 +1,61 @@ +.cardActions { + display: flex; + flex-direction: row; + align-items: center; + gap: 1px; +} + +.cardActionBtn { + background-color: rgba(0, 0, 0, 0); + border: none; + color: black; +} + +.cardActionBtn:hover { + background-color: ghostwhite; + border: none; + color: black !important; +} + +.imageContainer { + max-width: 100%; +} + +.cardHeader { + display: flex; + flex-direction: row; + gap: 10px; + align-items: center; + color: #50c878; +} + +.creatorNameModal { + display: flex; + flex-direction: row; + gap: 5px; + align-items: center; + margin-bottom: 10px; +} + +.modalActions { + display: flex; + flex-direction: row; + align-items: center; + gap: 1px; + margin: 5px 0px; +} + +.textModal { + margin-top: 10px; +} + +.colorPrimary { + background: #31bb6b; + color: white; + cursor: pointer; +} + +.admedia { + object-fit: cover; + height: 30rem; +} diff --git a/src/components/UserPortal/PromotedPost/PromotedPost.test.tsx b/src/components/UserPortal/PromotedPost/PromotedPost.test.tsx new file mode 100644 index 0000000000..6ec8ec5de7 --- /dev/null +++ b/src/components/UserPortal/PromotedPost/PromotedPost.test.tsx @@ -0,0 +1,127 @@ +import React, { act } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; + +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import PromotedPost from './PromotedPost'; + +const link = new StaticMockLink([], true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +let props = { + id: '1', + image: '', + title: 'Test Post', +}; + +describe('Testing PromotedPost Test', () => { + test('Component should be rendered properly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + }); + + test('Component should be rendered properly if prop image is not undefined', async () => { + props = { + ...props, + image: 'promotedPostImage', + }; + + render( + + + + + + + + + , + ); + + await wait(); + }); +}); + +test('Component should display the icon correctly', async () => { + const { queryByTestId } = render( + + + + + + + + + , + ); + + await waitFor(() => { + const icon = queryByTestId('StarPurple500Icon'); + expect(icon).toBeInTheDocument(); + }); +}); + +test('Component should display the text correctly', async () => { + const { queryAllByText } = render( + + + + + + + + + , + ); + + await waitFor(() => { + const title = queryAllByText('Test Post') as HTMLElement[]; + expect(title[0]).toBeInTheDocument(); + }); +}); + +test('Component should display the image correctly', async () => { + props = { + ...props, + image: 'promotedPostImage', + }; + const { queryByRole } = render( + + + + + + + + + , + ); + + await waitFor(() => { + const image = queryByRole('img'); + expect(image).toHaveAttribute('src', 'promotedPostImage'); + }); +}); diff --git a/src/components/UserPortal/PromotedPost/PromotedPost.tsx b/src/components/UserPortal/PromotedPost/PromotedPost.tsx new file mode 100644 index 0000000000..c1af98f367 --- /dev/null +++ b/src/components/UserPortal/PromotedPost/PromotedPost.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Card } from 'react-bootstrap'; +import styles from './PromotedPost.module.css'; +import StarPurple500Icon from '@mui/icons-material/StarPurple500'; + +interface InterfacePostCardProps { + id: string; + image: string; + title: string; +} + +/** + * PromotedPost component displays a card representing promoted content. + * + * This component includes: + * - A header with a star icon indicating the content is promoted. + * - A title and description of the promoted content. + * - An optional image associated with the promoted content. + * + * @param props - Properties passed to the component including an image, title, and ID. + * @returns JSX.Element representing a card with promoted content. + */ +export default function promotedPost( + props: InterfacePostCardProps, +): JSX.Element { + return ( + <> + + +
    + {/* Icon indicating promoted content */} + + {'Promoted Content'} +
    +
    + + {/* Display the title of the promoted content */} + {props.title} + {/* Display a brief description or the title again */} + {props.title} + {/* Conditionally render the image if provided */} + {props.image && ( + + )} + +
    + + ); +} diff --git a/src/components/UserPortal/Register/Register.module.css b/src/components/UserPortal/Register/Register.module.css new file mode 100644 index 0000000000..1fc2a34af2 --- /dev/null +++ b/src/components/UserPortal/Register/Register.module.css @@ -0,0 +1,15 @@ +.loginText { + cursor: pointer; +} + +.borderNone { + border: none; +} + +.colorWhite { + color: white; +} + +.colorPrimary { + background: #31bb6b; +} diff --git a/src/components/UserPortal/Register/Register.test.tsx b/src/components/UserPortal/Register/Register.test.tsx new file mode 100644 index 0000000000..1883d60da3 --- /dev/null +++ b/src/components/UserPortal/Register/Register.test.tsx @@ -0,0 +1,267 @@ +import type { SetStateAction } from 'react'; +import React, { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { SIGNUP_MUTATION } from 'GraphQl/Mutations/mutations'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import Register from './Register'; +import { toast } from 'react-toastify'; + +const MOCKS = [ + { + request: { + query: SIGNUP_MUTATION, + variables: { + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@gmail.com', + password: 'johnDoe', + }, + }, + result: { + data: { + signUp: { + user: { + _id: '1', + }, + accessToken: 'accessToken', + refreshToken: 'refreshToken', + }, + }, + }, + }, +]; + +const formData = { + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@gmail.com', + password: 'johnDoe', + confirmPassword: 'johnDoe', +}; + +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +const setCurrentMode: React.Dispatch> = jest.fn(); + +const props = { + setCurrentMode, +}; + +describe('Testing Register Component [User Portal]', () => { + test('Component should be rendered properly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + }); + + test('Expect the mode to be changed to Login', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('setLoginBtn')); + + expect(setCurrentMode).toHaveBeenCalledWith('login'); + }); + + test('Expect toast.error to be called if email input is empty', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('registerBtn')); + + expect(toast.error).toHaveBeenCalledWith('Please enter valid details.'); + }); + + test('Expect toast.error to be called if password input is empty', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.type(screen.getByTestId('emailInput'), formData.email); + userEvent.click(screen.getByTestId('registerBtn')); + + expect(toast.error).toHaveBeenCalledWith('Please enter valid details.'); + }); + + test('Expect toast.error to be called if first name input is empty', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.type(screen.getByTestId('passwordInput'), formData.password); + + userEvent.type(screen.getByTestId('emailInput'), formData.email); + + userEvent.click(screen.getByTestId('registerBtn')); + + expect(toast.error).toHaveBeenCalledWith('Please enter valid details.'); + }); + + test('Expect toast.error to be called if last name input is empty', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.type(screen.getByTestId('passwordInput'), formData.password); + + userEvent.type(screen.getByTestId('emailInput'), formData.email); + + userEvent.type(screen.getByTestId('firstNameInput'), formData.firstName); + + userEvent.click(screen.getByTestId('registerBtn')); + + expect(toast.error).toHaveBeenCalledWith('Please enter valid details.'); + }); + + test("Expect toast.error to be called if confirmPassword doesn't match with password", async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.type(screen.getByTestId('passwordInput'), formData.password); + + userEvent.type(screen.getByTestId('emailInput'), formData.email); + + userEvent.type(screen.getByTestId('firstNameInput'), formData.firstName); + + userEvent.type(screen.getByTestId('lastNameInput'), formData.lastName); + + userEvent.click(screen.getByTestId('registerBtn')); + + expect(toast.error).toHaveBeenCalledWith( + "Password doesn't match. Confirm Password and try again.", + ); + }); + + test('Expect toast.success to be called if valid credentials are entered.', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.type(screen.getByTestId('passwordInput'), formData.password); + + userEvent.type( + screen.getByTestId('confirmPasswordInput'), + formData.confirmPassword, + ); + + userEvent.type(screen.getByTestId('emailInput'), formData.email); + + userEvent.type(screen.getByTestId('firstNameInput'), formData.firstName); + + userEvent.type(screen.getByTestId('lastNameInput'), formData.lastName); + + userEvent.click(screen.getByTestId('registerBtn')); + + await wait(); + + expect(toast.success).toHaveBeenCalledWith( + 'Successfully registered. Please wait for admin to approve your request.', + ); + }); +}); diff --git a/src/components/UserPortal/Register/Register.tsx b/src/components/UserPortal/Register/Register.tsx new file mode 100644 index 0000000000..11a810c955 --- /dev/null +++ b/src/components/UserPortal/Register/Register.tsx @@ -0,0 +1,253 @@ +import type { ChangeEvent, SetStateAction } from 'react'; +import React from 'react'; +import { Button, Form, InputGroup } from 'react-bootstrap'; +import EmailOutlinedIcon from '@mui/icons-material/EmailOutlined'; +import BadgeOutlinedIcon from '@mui/icons-material/BadgeOutlined'; +import { LockOutlined } from '@mui/icons-material'; +import { useTranslation } from 'react-i18next'; +import { SIGNUP_MUTATION } from 'GraphQl/Mutations/mutations'; + +import styles from './Register.module.css'; +import { useMutation } from '@apollo/client'; +import { toast } from 'react-toastify'; +import { errorHandler } from 'utils/errorHandler'; + +interface InterfaceRegisterProps { + /** + * Function to change the current mode (e.g., from register to login). + */ + setCurrentMode: React.Dispatch>; +} + +export default function register(props: InterfaceRegisterProps): JSX.Element { + const { setCurrentMode } = props; + + // Translation hooks for user registration and common text + const { t } = useTranslation('translation', { keyPrefix: 'userRegister' }); + const { t: tCommon } = useTranslation('common'); + + /** + * Changes the mode to login when invoked. + */ + const handleModeChangeToLogin = (): void => { + setCurrentMode('login'); + }; + + // Mutation hook for user registration + const [registerMutation] = useMutation(SIGNUP_MUTATION); + + // State to manage the registration form variables + const [registerVariables, setRegisterVariables] = React.useState({ + firstName: '', + lastName: '', + email: '', + password: '', + confirmPassword: '', + }); + + /** + * Handles the registration process by validating inputs and invoking the mutation. + */ + const handleRegister = async (): Promise => { + if ( + !( + registerVariables.email && + registerVariables.password && + registerVariables.firstName && + registerVariables.lastName + ) + ) { + toast.error(t('invalidDetailsMessage') as string); // Error if fields are missing + } else if ( + registerVariables.password !== registerVariables.confirmPassword + ) { + toast.error(t('passwordNotMatch') as string); // Error if passwords do not match + } else { + try { + await registerMutation({ + variables: { + firstName: registerVariables.firstName, + lastName: registerVariables.lastName, + email: registerVariables.email, + password: registerVariables.password, + }, + }); + + toast.success(t('afterRegister') as string); // Success message + + // Reset form fields + /* istanbul ignore next */ + setRegisterVariables({ + firstName: '', + lastName: '', + email: '', + password: '', + confirmPassword: '', + }); + } catch (error: unknown) { + // Handle any errors during registration + /* istanbul ignore next */ + errorHandler(t, error); + } + } + }; + + /** + * Updates the state with the first name input value. + * @param e - Change event from the input element + */ + /* istanbul ignore next */ + const handleFirstName = (e: ChangeEvent): void => { + const firstName = e.target.value; + setRegisterVariables({ ...registerVariables, firstName }); + }; + + /** + * Updates the state with the last name input value. + * @param e - Change event from the input element + */ + /* istanbul ignore next */ + const handleLastName = (e: ChangeEvent): void => { + const lastName = e.target.value; + setRegisterVariables({ ...registerVariables, lastName }); + }; + + /** + * Updates the state with the email input value. + * @param e - Change event from the input element + */ + /* istanbul ignore next */ + const handleEmailChange = (e: ChangeEvent): void => { + const email = e.target.value; + setRegisterVariables({ ...registerVariables, email }); + }; + + /** + * Updates the state with the password input value. + * @param e - Change event from the input element + */ + /* istanbul ignore next */ + const handlePasswordChange = (e: ChangeEvent): void => { + const password = e.target.value; + + setRegisterVariables({ ...registerVariables, password }); + }; + + /** + * Updates the state with the confirm password input value. + * @param e - Change event from the input element + */ + /* istanbul ignore next */ + const handleConfirmPasswordChange = ( + e: ChangeEvent, + ): void => { + const confirmPassword = e.target.value; + + setRegisterVariables({ ...registerVariables, confirmPassword }); + }; + + return ( + <> +

    {tCommon('register')}

    +
    +
    {tCommon('firstName')}
    + + + + + + +
    {tCommon('lastName')}
    + + + + + + +
    {tCommon('emailAddress')}
    + + + + + + +
    {tCommon('password')}
    + + + + + + +
    {tCommon('confirmPassword')}
    + + + + + + +
    + + +
    + {t('alreadyhaveAnAccount')}{' '} + + {tCommon('login')} + +
    + + ); +} diff --git a/src/components/UserPortal/SecuredRouteForUser/SecuredRouteForUser.test.tsx b/src/components/UserPortal/SecuredRouteForUser/SecuredRouteForUser.test.tsx new file mode 100644 index 0000000000..93b71b14f1 --- /dev/null +++ b/src/components/UserPortal/SecuredRouteForUser/SecuredRouteForUser.test.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { render, screen, waitFor } from '@testing-library/react'; +import SecuredRouteForUser from './SecuredRouteForUser'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem } = useLocalStorage(); + +describe('SecuredRouteForUser', () => { + test('renders the route when the user is logged in', () => { + // Set the 'IsLoggedIn' value to 'TRUE' in localStorage to simulate a logged-in user and do not set 'AdminFor' so that it remains undefined. + setItem('IsLoggedIn', 'TRUE'); + + render( + + + }> + + Organizations Component +
    + } + /> + + + , + ); + + expect(screen.getByTestId('organizations-content')).toBeInTheDocument(); + }); + + test('redirects to /user when the user is not logged in', async () => { + // Set the user as not logged in in local storage + setItem('IsLoggedIn', 'FALSE'); + + render( + + + User Login Page
    } /> + }> + + Organizations Component +
    + } + /> + + + , + ); + + await waitFor(() => { + expect(screen.getByText('User Login Page')).toBeInTheDocument(); + }); + }); + + test('renders the route when the user is logged in and user is ADMIN', () => { + // Set the 'IsLoggedIn' value to 'TRUE' in localStorage to simulate a logged-in user and set 'AdminFor' to simulate ADMIN of some Organization. + setItem('IsLoggedIn', 'TRUE'); + setItem('AdminFor', [ + { + _id: '6537904485008f171cf29924', + __typename: 'Organization', + }, + ]); + + render( + + + Oops! The Page you requested was not found!
    } + /> + }> + + Organizations Component +
    + } + /> + + + , + ); + + expect( + screen.getByText(/Oops! The Page you requested was not found!/i), + ).toBeTruthy(); + }); +}); diff --git a/src/components/UserPortal/SecuredRouteForUser/SecuredRouteForUser.tsx b/src/components/UserPortal/SecuredRouteForUser/SecuredRouteForUser.tsx new file mode 100644 index 0000000000..21d091dad6 --- /dev/null +++ b/src/components/UserPortal/SecuredRouteForUser/SecuredRouteForUser.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Navigate, Outlet } from 'react-router-dom'; +import PageNotFound from 'screens/PageNotFound/PageNotFound'; +import useLocalStorage from 'utils/useLocalstorage'; + +/** + * A component that guards routes by checking if the user is logged in. + * If the user is logged in and does not have 'AdminFor' set, the child routes are rendered. + * If the user is not logged in, they are redirected to the homepage. + * If the user is logged in but has 'AdminFor' set, a 404 page is shown. + * + * @returns JSX.Element - Rendered component based on user authentication and role. + */ +const SecuredRouteForUser = (): JSX.Element => { + // Custom hook to interact with local storage + const { getItem } = useLocalStorage(); + + // Check if the user is logged in and the role of the user + const isLoggedIn = getItem('IsLoggedIn'); + const adminFor = getItem('AdminFor'); + + // Conditional rendering based on authentication status and role + return isLoggedIn === 'TRUE' ? ( + <>{adminFor == undefined ? : } + ) : ( + + ); +}; + +export default SecuredRouteForUser; diff --git a/src/components/UserPortal/StartPostModal/StartPostModal.module.css b/src/components/UserPortal/StartPostModal/StartPostModal.module.css new file mode 100644 index 0000000000..8b709f584d --- /dev/null +++ b/src/components/UserPortal/StartPostModal/StartPostModal.module.css @@ -0,0 +1,50 @@ +.userImage { + display: flex; + width: 50px; + height: 50px; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 50%; + position: relative; + border: 2px solid #31bb6b; +} + +.userImage img { + position: absolute; + top: 0; + left: 0; + width: 100%; + scale: 1.5; +} + +.previewImage { + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; + margin-bottom: 1rem; +} + +.previewImage img { + border-radius: 8px; +} + +.icons { + width: 25px; +} + +.icons svg { + stroke: #000; +} + +.icons.dark { + cursor: pointer; + border: none; + outline: none; + background-color: transparent; +} + +.icons.dark svg { + stroke: #000; +} diff --git a/src/components/UserPortal/StartPostModal/StartPostModal.test.tsx b/src/components/UserPortal/StartPostModal/StartPostModal.test.tsx new file mode 100644 index 0000000000..c34f3a2e9e --- /dev/null +++ b/src/components/UserPortal/StartPostModal/StartPostModal.test.tsx @@ -0,0 +1,175 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import userEvent from '@testing-library/user-event'; +import { CREATE_POST_MUTATION } from 'GraphQl/Mutations/mutations'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18nForTest from 'utils/i18nForTest'; +import StartPostModal from './StartPostModal'; + +jest.mock('react-toastify', () => ({ + toast: { + error: jest.fn(), + info: jest.fn(), + success: jest.fn(), + }, +})); + +const MOCKS = [ + { + request: { + query: CREATE_POST_MUTATION, + variables: { + title: '', + text: 'This is dummy text', + organizationId: '123', + file: '', + }, + result: { + data: { + createPost: { + _id: '453', + }, + }, + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +afterEach(() => { + localStorage.clear(); +}); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const renderStartPostModal = ( + visibility: boolean, + image: string | null, +): RenderResult => { + const cardProps = { + show: visibility, + onHide: jest.fn(), + fetchPosts: jest.fn(), + userData: { + user: { + __typename: 'User', + _id: '123', + image: image, + firstName: 'Glen', + lastName: 'dsza', + email: 'glen@dsza.com', + appLanguageCode: 'en', + pluginCreationAllowed: true, + createdAt: '2023-02-18T09:22:27.969Z', + adminFor: [], + createdOrganizations: [], + joinedOrganizations: [], + createdEvents: [], + registeredEvents: [], + eventAdmin: [], + membershipRequests: [], + organizationsBlockedBy: [], + }, + appUserProfile: { + __typename: 'AppUserProfile', + _id: '123', + isSuperAdmin: true, + adminFor: [], + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + }, + }, + organizationId: '123', + img: '', + }; + + return render( + + + + + + + + + , + ); +}; + +describe('Testing StartPostModal Component: User Portal', () => { + afterAll(() => { + jest.clearAllMocks(); + }); + + test('Check if StartPostModal renders properly', async () => { + renderStartPostModal(true, null); + + const modal = await screen.findByTestId('startPostModal'); + expect(modal).toBeInTheDocument(); + }); + + test('On invalid post submission with empty body Error toast should be shown', async () => { + const toastSpy = jest.spyOn(toast, 'error'); + renderStartPostModal(true, null); + await wait(); + + userEvent.click(screen.getByTestId('createPostBtn')); + expect(toastSpy).toHaveBeenCalledWith( + "Can't create a post with an empty body.", + ); + }); + + test('On valid post submission Info toast should be shown', async () => { + renderStartPostModal(true, null); + await wait(); + + const randomPostInput = 'This is dummy text'; + userEvent.type(screen.getByTestId('postInput'), randomPostInput); + expect(screen.queryByText(randomPostInput)).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('createPostBtn')); + + expect(toast.error).not.toHaveBeenCalledWith(); + expect(toast.info).toHaveBeenCalledWith( + 'Processing your post. Please wait.', + ); + // await wait(); + // expect(toast.success).toBeCalledWith( + // 'Your post is now visible in the feed.', + // ); + }); + + test('If user image is null then default image should be shown', async () => { + renderStartPostModal(true, null); + await wait(); + + const userImage = screen.getByTestId('userImage'); + expect(userImage).toHaveAttribute( + 'src', + '/src/assets/images/defaultImg.png', + ); + }); + + test('If user image is not null then user image should be shown', async () => { + renderStartPostModal(true, 'image.png'); + await wait(); + + const userImage = screen.getByTestId('userImage'); + expect(userImage).toHaveAttribute('src', 'image.png'); + }); +}); diff --git a/src/components/UserPortal/StartPostModal/StartPostModal.tsx b/src/components/UserPortal/StartPostModal/StartPostModal.tsx new file mode 100644 index 0000000000..d48b8425fa --- /dev/null +++ b/src/components/UserPortal/StartPostModal/StartPostModal.tsx @@ -0,0 +1,173 @@ +import React, { useState } from 'react'; +import type { ChangeEvent } from 'react'; +import { Button, Form, Image, Modal } from 'react-bootstrap'; +import { toast } from 'react-toastify'; +import { useMutation } from '@apollo/client'; +import { useTranslation } from 'react-i18next'; + +import { errorHandler } from 'utils/errorHandler'; +import UserDefault from '../../../assets/images/defaultImg.png'; +import styles from './StartPostModal.module.css'; +import { CREATE_POST_MUTATION } from 'GraphQl/Mutations/mutations'; +import type { InterfaceQueryUserListItem } from 'utils/interfaces'; + +interface InterfaceStartPostModalProps { + show: boolean; + onHide: () => void; + fetchPosts: () => void; + userData: InterfaceQueryUserListItem | undefined; + organizationId: string; + img: string | null; +} + +/** + * A modal component for creating a new post. + * + * This modal includes: + * - A form where users can input the content of the post. + * - A preview of the image if provided. + * - User's profile image and name displayed in the modal header. + * + * @param show - Whether the modal is visible. + * @param onHide - Function to call when the modal is hidden. + * @param fetchPosts - Function to refresh the posts after creating a new one. + * @param userData - User data to display in the modal header. + * @param organizationId - The ID of the organization for the post. + * @param img - The URL of the image to be included in the post. + * + * @returns JSX.Element - The rendered modal component. + */ +const startPostModal = ({ + show, + onHide, + fetchPosts, + userData, + organizationId, + img, +}: InterfaceStartPostModalProps): JSX.Element => { + // Translation hook for internationalization + const { t } = useTranslation('translation', { keyPrefix: 'home' }); + + // State to manage the content of the post + const [postContent, setPostContent] = useState(''); + + // Mutation hook for creating a new post + const [createPost] = useMutation(CREATE_POST_MUTATION); + + /** + * Updates the state with the content of the post as the user types. + * + * @param e - Change event from the textarea input. + */ + const handlePostInput = (e: ChangeEvent): void => { + setPostContent(e.target.value); + }; + + /** + * Hides the modal and clears the post content. + */ + const handleHide = (): void => { + setPostContent(''); + onHide(); + }; + + /** + * Handles the creation of a new post by calling the mutation. + * Displays a toast notification based on the outcome. + */ + const handlePost = async (): Promise => { + try { + if (!postContent) { + throw new Error("Can't create a post with an empty body."); + } + toast.info('Processing your post. Please wait.'); + + const { data } = await createPost({ + variables: { + title: '', + text: postContent, + organizationId: organizationId, + file: img, + }, + }); + /* istanbul ignore next */ + if (data) { + toast.dismiss(); + toast.success(t('postNowVisibleInFeed') as string); + fetchPosts(); + handleHide(); + } + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }; + + return ( + + + + + + + + {`${userData?.user?.firstName} ${userData?.user?.lastName}`} + + + +
    + + + {img && ( +
    + Post Image Preview +
    + )} +
    + + + +
    +
    + ); +}; + +export default startPostModal; diff --git a/src/components/UserPortal/UserNavbar/UserNavbar.module.css b/src/components/UserPortal/UserNavbar/UserNavbar.module.css new file mode 100644 index 0000000000..764a24ab93 --- /dev/null +++ b/src/components/UserPortal/UserNavbar/UserNavbar.module.css @@ -0,0 +1,26 @@ +.talawaImage { + width: 40px; + height: auto; + margin-top: -5px; + border: 2px solid white; + margin-right: 10px; + background-color: white; + border-radius: 10px; +} + +.boxShadow { + box-shadow: 4px 4px 8px 4px #c8c8c8; +} + +.colorWhite { + color: white; +} + +.colorPrimary { + background: #31bb6b; +} + +.link { + text-decoration: none !important; + color: inherit; +} diff --git a/src/components/UserPortal/UserNavbar/UserNavbar.test.tsx b/src/components/UserPortal/UserNavbar/UserNavbar.test.tsx new file mode 100644 index 0000000000..8c3447f25a --- /dev/null +++ b/src/components/UserPortal/UserNavbar/UserNavbar.test.tsx @@ -0,0 +1,217 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { render, screen } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import cookies from 'js-cookie'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +import UserNavbar from './UserNavbar'; +import userEvent from '@testing-library/user-event'; +import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations'; + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const MOCKS = [ + { + request: { + query: REVOKE_REFRESH_TOKEN, + }, + result: {}, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +describe('Testing UserNavbar Component [User Portal]', () => { + afterEach(async () => { + await act(async () => { + await i18nForTest.changeLanguage('en'); + }); + }); + + test('Component should be rendered properly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + }); + + test('The language is switched to English', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('languageIcon')); + + userEvent.click(screen.getByTestId('changeLanguageBtn0')); + + await wait(); + + expect(cookies.get('i18next')).toBe('en'); + }); + + test('The language is switched to fr', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('languageIcon')); + + userEvent.click(screen.getByTestId('changeLanguageBtn1')); + + await wait(); + + expect(cookies.get('i18next')).toBe('fr'); + }); + + test('The language is switched to hi', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('languageIcon')); + + userEvent.click(screen.getByTestId('changeLanguageBtn2')); + + await wait(); + + expect(cookies.get('i18next')).toBe('hi'); + }); + + test('The language is switched to sp', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('languageIcon')); + + userEvent.click(screen.getByTestId('changeLanguageBtn3')); + + await wait(); + + expect(cookies.get('i18next')).toBe('sp'); + }); + + test('The language is switched to zh', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('languageIcon')); + + userEvent.click(screen.getByTestId('changeLanguageBtn4')); + + await wait(); + + expect(cookies.get('i18next')).toBe('zh'); + }); + + test('User can see and interact with the dropdown menu', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('logoutDropdown')); + expect(screen.getByText('Settings')).toBeInTheDocument(); + expect(screen.getByTestId('logoutBtn')).toBeInTheDocument(); + }); + + test('User can navigate to the "Settings" page', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('logoutDropdown')); + userEvent.click(screen.getByText('Settings')); + expect(window.location.pathname).toBe('/user/settings'); + }); +}); diff --git a/src/components/UserPortal/UserNavbar/UserNavbar.tsx b/src/components/UserPortal/UserNavbar/UserNavbar.tsx new file mode 100644 index 0000000000..4160b00aad --- /dev/null +++ b/src/components/UserPortal/UserNavbar/UserNavbar.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import styles from './UserNavbar.module.css'; +import TalawaImage from 'assets/images/talawa-logo-600x600.png'; +import { Container, Dropdown, Navbar } from 'react-bootstrap'; +import { languages } from 'utils/languages'; +import i18next from 'i18next'; +import cookies from 'js-cookie'; +import PermIdentityIcon from '@mui/icons-material/PermIdentity'; +import LanguageIcon from '@mui/icons-material/Language'; +import { useTranslation } from 'react-i18next'; +import { useMutation } from '@apollo/client'; +import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations'; +import { useNavigate } from 'react-router-dom'; +import useLocalStorage from 'utils/useLocalstorage'; + +/** + * Navbar component for user-specific actions and settings. + * + * This component provides: + * - A branding image and name. + * - A dropdown for language selection. + * - A dropdown for user actions including profile settings and logout. + * + * @returns JSX.Element - The rendered Navbar component. + */ +function userNavbar(): JSX.Element { + // Hook for local storage operations + const { getItem } = useLocalStorage(); + + // Hook for programmatic navigation + const navigate = useNavigate(); + + // Translation hook for internationalization + const { t } = useTranslation('translation', { + keyPrefix: 'userNavbar', + }); + const { t: tCommon } = useTranslation('common'); + + // Mutation hook for revoking the refresh token + const [revokeRefreshToken] = useMutation(REVOKE_REFRESH_TOKEN); + + // State for managing the current language code + const [currentLanguageCode, setCurrentLanguageCode] = React.useState( + /* istanbul ignore next */ + cookies.get('i18next') || 'en', + ); + + // Retrieve the username from local storage + const userName = getItem('name'); + + /** + * Handles user logout by revoking the refresh token and clearing local storage. + * Redirects to the home page after logout. + */ + /* istanbul ignore next */ + const handleLogout = (): void => { + revokeRefreshToken(); + localStorage.clear(); + navigate('/'); + }; + + return ( + + + {/* Navbar brand with logo and name */} + + Talawa Branding + {t('talawa')} + + + {/* Navbar toggle button for responsive design */} + + + {/* Navbar collapsible content */} + + {/* Dropdown for language selection */} + + + + + + {languages.map((language, index: number) => ( + => { + setCurrentLanguageCode(language.code); + await i18next.changeLanguage(language.code); + }} + disabled={currentLanguageCode === language.code} + data-testid={`changeLanguageBtn${index}`} + > + {' '} + {language.name} + + ))} + + + + {/* Dropdown for user actions */} + + + + + + {/* Display the user's name */} + + {userName} + + {/* Link to user settings */} + navigate('/user/settings')} + className={styles.link} + > + {tCommon('settings')} + + {/* Logout button */} + + {tCommon('logout')} + + + + + + + ); +} + +export default userNavbar; diff --git a/src/components/UserPortal/UserProfile/EventsAttendedByUser.test.tsx b/src/components/UserPortal/UserProfile/EventsAttendedByUser.test.tsx new file mode 100644 index 0000000000..82b173e399 --- /dev/null +++ b/src/components/UserPortal/UserProfile/EventsAttendedByUser.test.tsx @@ -0,0 +1,120 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { EventsAttendedByUser } from './EventsAttendedByUser'; +import { MockedProvider } from '@apollo/client/testing'; +import { EVENT_DETAILS } from 'GraphQl/Queries/Queries'; + +const mockT = (key: string, params?: Record): string => { + if (params) { + return Object.entries(params).reduce( + (acc, [key, value]) => acc.replace(`{{${key}}}`, value), + key, + ); + } + return key; +}; + +const mocks = [ + { + request: { + query: EVENT_DETAILS, + variables: { id: '1' }, + }, + result: { + data: { + event: { + _id: '1', + title: 'Event 1', + startDate: '2023-01-01', + recurring: false, + attendees: [], + organization: { _id: 'org1' }, + }, + }, + }, + }, + { + request: { + query: EVENT_DETAILS, + variables: { id: '2' }, + }, + result: { + data: { + event: { + _id: '2', + title: 'Event 2', + startDate: '2023-01-01', + recurring: false, + attendees: [], + organization: { _id: 'org1' }, + }, + }, + }, + }, +]; + +describe('EventsAttendedByUser Component', () => { + const mockUserWithEvents = { + userDetails: { + firstName: 'John', + lastName: 'Doe', + createdAt: '2023-01-01', + gender: 'Male', + email: 'john@example.com', + phoneNumber: '1234567890', + birthDate: '1990-01-01', + grade: 'A', + empStatus: 'Employed', + maritalStatus: 'Single', + address: '123 Street', + state: 'State', + country: 'Country', + image: 'image.jpg', + eventsAttended: [{ _id: '1' }, { _id: '2' }], + }, + t: mockT, + }; + + const mockUserWithoutEvents = { + userDetails: { + firstName: 'Jane', + lastName: 'Doe', + createdAt: '2023-01-01', + gender: 'Female', + email: 'jane@example.com', + phoneNumber: '0987654321', + birthDate: '1990-01-01', + grade: 'B', + empStatus: 'Unemployed', + maritalStatus: 'Single', + address: '456 Street', + state: 'State', + country: 'Country', + image: 'image.jpg', + eventsAttended: [], + }, + t: mockT, + }; + + test('renders the component with events', () => { + render( + + + , + ); + + expect(screen.getByText('eventAttended')).toBeInTheDocument(); + expect(screen.getAllByTestId('usereventsCard')).toHaveLength(2); + }); + + test('renders no events message when user has no events', () => { + render( + + + , + ); + + expect(screen.getByText('noeventsAttended')).toBeInTheDocument(); + expect(screen.queryByTestId('usereventsCard')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/UserPortal/UserProfile/EventsAttendedByUser.tsx b/src/components/UserPortal/UserProfile/EventsAttendedByUser.tsx new file mode 100644 index 0000000000..13ab9f5f5a --- /dev/null +++ b/src/components/UserPortal/UserProfile/EventsAttendedByUser.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { Card } from 'react-bootstrap'; +import styles from './common.module.css'; +import EventsAttendedByMember from 'components/MemberDetail/EventsAttendedByMember'; +/** + * Component to display events attended by a user in card format + * @param userDetails - User information including attended events + * @param t - Translation function + * @returns Card component containing list of attended events + */ +interface InterfaceUser { + userDetails: { + firstName: string; + lastName: string; + createdAt: string; + gender: string; + email: string; + phoneNumber: string; + birthDate: string; + grade: string; + empStatus: string; + maritalStatus: string; + address: string; + state: string; + country: string; + image: string; + eventsAttended: { _id: string }[]; + }; + t: (key: string) => string; +} +export const EventsAttendedByUser: React.FC = ({ + userDetails, + t, +}) => { + return ( + +
    +
    {t('eventAttended')}
    +
    + + {!userDetails.eventsAttended?.length ? ( +
    +
    {t('noeventsAttended')}
    +
    + ) : ( + userDetails.eventsAttended.map((event: { _id: string }) => ( + + + + )) + )} +
    +
    + ); +}; + +export default EventsAttendedByUser; diff --git a/src/components/UserPortal/UserProfile/UserAddressFields.test.tsx b/src/components/UserPortal/UserProfile/UserAddressFields.test.tsx new file mode 100644 index 0000000000..7aff734bc6 --- /dev/null +++ b/src/components/UserPortal/UserProfile/UserAddressFields.test.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { UserAddressFields } from './UserAddressFields'; +import { countryOptions } from 'utils/formEnumFields'; + +describe('UserAddressFields', () => { + const mockProps = { + tCommon: (key: string) => `translated_${key}`, + t: (key: string) => `translated_${key}`, + handleFieldChange: jest.fn(), + userDetails: { + address: '123 Test Street', + state: 'Test State', + country: 'US', + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('renders all form fields correctly', () => { + render(); + + expect(screen.getByTestId('inputAddress')).toBeInTheDocument(); + expect(screen.getByTestId('inputState')).toBeInTheDocument(); + expect(screen.getByTestId('inputCountry')).toBeInTheDocument(); + }); + + test('displays correct labels with translations', () => { + render(); + + expect(screen.getByText('translated_address')).toBeInTheDocument(); + expect(screen.getByText('translated_state')).toBeInTheDocument(); + expect(screen.getByText('translated_country')).toBeInTheDocument(); + }); + + test('handles address input change', () => { + render(); + + const addressInput = screen.getByTestId('inputAddress'); + fireEvent.change(addressInput, { target: { value: 'New Address' } }); + + expect(mockProps.handleFieldChange).toHaveBeenCalledWith( + 'address', + 'New Address', + ); + }); + + test('handles state input change', () => { + render(); + + const stateInput = screen.getByTestId('inputState'); + fireEvent.change(stateInput, { target: { value: 'New State' } }); + + expect(mockProps.handleFieldChange).toHaveBeenCalledWith( + 'state', + 'New State', + ); + }); + + test('handles country selection change', () => { + render(); + + const countrySelect = screen.getByTestId('inputCountry'); + fireEvent.change(countrySelect, { target: { value: 'CA' } }); + + expect(mockProps.handleFieldChange).toHaveBeenCalledWith('country', 'CA'); + }); + + test('renders all country options', () => { + render(); + + const countrySelect = screen.getByTestId('inputCountry'); + const options = countrySelect.getElementsByTagName('option'); + + expect(options.length).toBe(countryOptions.length + 1); // +1 for disabled option + }); + + test('displays initial values correctly', () => { + render(); + + expect(screen.getByTestId('inputAddress')).toHaveValue('123 Test Street'); + expect(screen.getByTestId('inputState')).toHaveValue('Test State'); + expect(screen.getByTestId('inputCountry')).toHaveValue('US'); + }); +}); diff --git a/src/components/UserPortal/UserProfile/UserAddressFields.tsx b/src/components/UserPortal/UserProfile/UserAddressFields.tsx new file mode 100644 index 0000000000..732209f3b0 --- /dev/null +++ b/src/components/UserPortal/UserProfile/UserAddressFields.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import { countryOptions } from 'utils/formEnumFields'; +import { Col, Form, Row } from 'react-bootstrap'; +import styles from './common.module.css'; + +interface InterfaceUserAddressFieldsProps { + tCommon: (key: string) => string; + t: (key: string) => string; + handleFieldChange: (field: string, value: string) => void; + userDetails: { + address: string; + state: string; + country: string; + }; +} +/** + * Form component containing address-related input fields for user profile + * Includes fields for address, city, state, and country + * @param {Object} props - Component props + * @param {function} props.tCommon - Translation function for common strings + * @param {function} props.t - Translation function for component-specific strings + * @param {function} props.handleFieldChange - Callback for field value changes + * @param {Object} props.userDetails - User's address information + * @returns Form group with address input fields + */ +export const UserAddressFields: React.FC = ({ + tCommon, + t, + handleFieldChange, + userDetails, +}) => { + return ( + + + + {tCommon('address')} + + handleFieldChange('address', e.target.value)} + className={styles.cardControl} + data-testid="inputAddress" + /> + + + + {t('state')} + + handleFieldChange('state', e.target.value)} + className={styles.cardControl} + data-testid="inputState" + /> + + + + {t('country')} + + handleFieldChange('country', e.target.value)} + className={styles.cardControl} + data-testid="inputCountry" + > + + {[...countryOptions] + .sort((a, b) => a.label.localeCompare(b.label)) + .map((country) => ( + + ))} + + + + ); +}; + +export default UserAddressFields; diff --git a/src/components/UserPortal/UserProfile/common.module.css b/src/components/UserPortal/UserProfile/common.module.css new file mode 100644 index 0000000000..a8125dcb3a --- /dev/null +++ b/src/components/UserPortal/UserProfile/common.module.css @@ -0,0 +1,39 @@ +.cardHeader .cardTitle { + font-size: 1.2rem; + font-weight: 600; +} +.scrollableCardBody { + max-height: min(220px, 50vh); + overflow-y: auto; + scroll-behavior: smooth; +} +.cardHeader { + padding: 1.25rem 1rem 1rem 1rem; + border-bottom: 1px solid var(--bs-gray-200); + display: flex; + justify-content: space-between; + align-items: center; +} + +.cardBody { + padding: 1.25rem 1rem 1.5rem 1rem; + display: flex; + flex-direction: column; + overflow-y: scroll; +} + +.cardLabel { + font-weight: bold; + padding-bottom: 1px; + font-size: 14px; + color: #707070; + margin-bottom: 10px; +} + +.cardControl { + margin-bottom: 20px; +} + +.cardButton { + width: fit-content; +} diff --git a/src/components/UserPortal/UserSidebar/UserSidebar.module.css b/src/components/UserPortal/UserSidebar/UserSidebar.module.css new file mode 100644 index 0000000000..aafeaeff97 --- /dev/null +++ b/src/components/UserPortal/UserSidebar/UserSidebar.module.css @@ -0,0 +1,239 @@ +.leftDrawer { + width: calc(300px); + position: fixed; + top: 0; + bottom: 0; + z-index: 100; + display: flex; + flex-direction: column; + padding: 1rem 1rem 0 1rem; + background-color: var(--bs-white); + transition: 0.5s; + font-family: var(--bs-leftDrawer-font-family); +} + +.activeDrawer { + width: calc(300px); + position: fixed; + top: 0; + left: 0; + bottom: 0; + animation: comeToRightBigScreen 0.5s ease-in-out; +} + +.inactiveDrawer { + position: fixed; + top: 0; + left: calc(-300px - 2rem); + bottom: 0; + animation: goToLeftBigScreen 0.5s ease-in-out; +} + +.leftDrawer .talawaLogo { + width: 100%; + height: 65px; +} + +.leftDrawer .talawaText { + font-size: 20px; + text-align: center; + font-weight: 500; +} + +.leftDrawer .titleHeader { + margin: 2rem 0 1rem 0; + font-weight: 600; +} + +.leftDrawer .optionList button { + display: flex; + align-items: center; + width: 100%; + text-align: start; + margin-bottom: 0.8rem; + border-radius: 16px; + outline: none; + border: none; +} + +.leftDrawer .optionList button .iconWrapper { + width: 36px; +} + +.leftDrawer .profileContainer { + border: none; + width: 100%; + padding: 2.1rem 0.5rem; + height: 52px; + display: flex; + align-items: center; + background-color: var(--bs-white); +} + +.leftDrawer .profileContainer:focus { + outline: none; + background-color: var(--bs-gray-100); +} + +.leftDrawer .imageContainer { + width: 68px; +} + +.leftDrawer .profileContainer img { + height: 52px; + width: 52px; + border-radius: 50%; +} + +.leftDrawer .profileContainer .profileText { + flex: 1; + text-align: start; +} + +.leftDrawer .profileContainer .profileText .primaryText { + font-size: 1.1rem; + font-weight: 600; +} + +.leftDrawer .profileContainer .profileText .secondaryText { + font-size: 0.8rem; + font-weight: 400; + color: var(--bs-secondary); + display: block; + text-transform: capitalize; +} + +@media (max-width: 1120px) { + .leftDrawer { + width: calc(250px + 2rem); + padding: 1rem 1rem 0 1rem; + } +} + +/* For tablets */ +@media (max-width: 820px) { + .hideElemByDefault { + display: none; + } + + .leftDrawer { + width: 100%; + left: 0; + right: 0; + } + + .inactiveDrawer { + opacity: 0; + left: 0; + z-index: -1; + animation: closeDrawer 0.4s ease-in-out; + } + + .activeDrawer { + display: flex; + z-index: 100; + animation: openDrawer 0.6s ease-in-out; + } + + .logout { + margin-bottom: 2.5rem !important; + } +} + +@keyframes goToLeftBigScreen { + from { + left: 0; + } + + to { + opacity: 0.1; + left: calc(-300px - 2rem); + } +} + +/* Webkit prefix for older browser compatibility */ +@-webkit-keyframes goToLeftBigScreen { + from { + left: 0; + } + + to { + opacity: 0.1; + left: calc(-300px - 2rem); + } +} + +@keyframes comeToRightBigScreen { + from { + opacity: 0.4; + left: calc(-300px - 2rem); + } + + to { + opacity: 1; + left: 0; + } +} + +/* Webkit prefix for older browser compatibility */ +@-webkit-keyframes comeToRightBigScreen { + from { + opacity: 0.4; + left: calc(-300px - 2rem); + } + + to { + opacity: 1; + left: 0; + } +} + +@keyframes closeDrawer { + from { + left: 0; + opacity: 1; + } + + to { + left: -1000px; + opacity: 0; + } +} + +/* Webkit prefix for older browser compatibility */ +@-webkit-keyframes closeDrawer { + from { + left: 0; + opacity: 1; + } + + to { + left: -1000px; + opacity: 0; + } +} + +@keyframes openDrawer { + from { + opacity: 0; + left: -1000px; + } + + to { + left: 0; + opacity: 1; + } +} + +/* Webkit prefix for older browser compatibility */ +@-webkit-keyframes openDrawer { + from { + opacity: 0; + left: -1000px; + } + + to { + left: 0; + opacity: 1; + } +} diff --git a/src/components/UserPortal/UserSidebar/UserSidebar.test.tsx b/src/components/UserPortal/UserSidebar/UserSidebar.test.tsx new file mode 100644 index 0000000000..79d603614f --- /dev/null +++ b/src/components/UserPortal/UserSidebar/UserSidebar.test.tsx @@ -0,0 +1,551 @@ +import React, { act } from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import styles from './UserSidebar.module.css'; +import { + USER_DETAILS, + USER_JOINED_ORGANIZATIONS, +} from 'GraphQl/Queries/Queries'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import UserSidebar from './UserSidebar'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem } = useLocalStorage(); + +const resizeWindow = (width: number): void => { + act(() => { + window.innerWidth = width; + fireEvent(window, new Event('resize')); + }); +}; + +const props = { + hideDrawer: true, + setHideDrawer: jest.fn(), +}; + +const MOCKS = [ + { + request: { + query: USER_DETAILS, + variables: { + id: 'properId', + }, + }, + result: { + data: { + user: { + user: { + _id: 'properId', + image: null, + firstName: 'Noble', + lastName: 'Mittal', + email: 'noble@mittal.com', + createdAt: '2023-02-18T09:22:27.969Z', + joinedOrganizations: [], + membershipRequests: [], + registeredEvents: [], + gender: '', + birthDate: '2024-03-14', + educationGrade: '', + employmentStatus: '', + maritalStatus: '', + address: { + line1: '', + countryCode: '', + city: '', + state: '', + }, + phone: { + mobile: '', + }, + }, + appUserProfile: { + _id: 'properId', + adminFor: [], + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + isSuperAdmin: true, + pluginCreationAllowed: true, + appLanguageCode: 'en', + }, + }, + }, + }, + }, + { + request: { + query: USER_JOINED_ORGANIZATIONS, + variables: { + id: 'properId', + }, + }, + result: { + data: { + users: [ + { + user: { + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + name: 'Any Organization', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }, + { + request: { + query: USER_DETAILS, + variables: { + id: 'imagePresent', + }, + }, + result: { + data: { + user: { + user: { + _id: '2', + image: 'adssda', + firstName: 'Noble', + lastName: 'Mittal', + email: 'noble@mittal.com', + createdAt: '2023-02-18T09:22:27.969Z', + joinedOrganizations: [], + membershipRequests: [], + registeredEvents: [], + gender: '', + birthDate: '2024-03-14', + educationGrade: '', + employmentStatus: '', + maritalStatus: '', + address: { + line1: '', + countryCode: '', + city: '', + state: '', + }, + phone: { + mobile: '', + }, + }, + appUserProfile: { + _id: '2', + adminFor: [], + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + isSuperAdmin: true, + pluginCreationAllowed: true, + appLanguageCode: 'en', + }, + }, + }, + }, + }, + { + request: { + query: USER_JOINED_ORGANIZATIONS, + variables: { + id: 'imagePresent', + }, + }, + result: { + data: { + users: [ + { + user: { + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + name: 'Any Organization', + image: 'dadsa', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }, + { + request: { + query: USER_DETAILS, + variables: { + id: 'orgEmpty', + }, + }, + result: { + data: { + user: { + user: { + _id: 'orgEmpty', + image: null, + firstName: 'Noble', + lastName: 'Mittal', + email: 'noble@mittal.com', + createdAt: '2023-02-18T09:22:27.969Z', + joinedOrganizations: [], + membershipRequests: [], + registeredEvents: [], + gender: '', + birthDate: '2024-03-14', + educationGrade: '', + employmentStatus: '', + maritalStatus: '', + address: { + line1: '', + countryCode: '', + city: '', + state: '', + }, + phone: { + mobile: '', + }, + }, + appUserProfile: { + _id: 'orgEmpty', + adminFor: [], + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + isSuperAdmin: true, + pluginCreationAllowed: true, + appLanguageCode: 'en', + }, + }, + }, + }, + }, + { + request: { + query: USER_JOINED_ORGANIZATIONS, + variables: { + id: 'orgEmpty', + }, + }, + result: { + data: { + users: [ + { + user: { + joinedOrganizations: [], + }, + }, + ], + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const renderUserSidebar = ( + userId: string, + link: StaticMockLink, +): RenderResult => { + setItem('userId', userId); + return render( + + + + + + + + + , + ); +}; + +describe('UserSidebar Component Tests in User Portal', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('UserSidebar component renders correctly with user data present', async () => { + await act(async () => { + renderUserSidebar('properId', link); + await wait(); + }); + expect(screen.getByText('Talawa User Portal')).toBeInTheDocument(); + }); + + test('Displays the logo and title text of the User Portal', async () => { + await act(async () => { + renderUserSidebar('properId', link); + await wait(); + }); + expect(screen.getByText('Talawa User Portal')).toBeInTheDocument(); + expect(screen.getByTestId('leftDrawerContainer')).toBeVisible(); + }); + + test('UserSidebar renders correctly when joinedOrganizations list is empty', async () => { + await act(async () => { + renderUserSidebar('orgEmpty', link); + await wait(); + }); + expect(screen.getByText('My Organizations')).toBeInTheDocument(); + }); + + test('Renders UserSidebar component with organization image when present', async () => { + await act(async () => { + renderUserSidebar('imagePresent', link); + await wait(); + }); + expect(screen.getByText('Settings')).toBeInTheDocument(); + }); + + test('User profile data renders with all expected navigation links visible', async () => { + await act(async () => { + renderUserSidebar('properId', link); + await wait(); + }); + + const expectedLinks = ['My Organizations', 'Settings', 'Chat']; + expectedLinks.forEach((link) => { + expect(screen.getByText(link)).toBeInTheDocument(); + }); + }); + + test('UserSidebar renders correctly on smaller screens and toggles drawer visibility', async () => { + await act(async () => { + resizeWindow(800); + render( + + + + + + + + + , + ); + }); + const orgsBtn = screen.getByTestId('orgsBtn'); + act(() => orgsBtn.click()); + expect(props.setHideDrawer).toHaveBeenCalledWith(true); + }); + + test('Active route button style changes correctly upon click', async () => { + await act(async () => { + renderUserSidebar('properId', link); + await wait(); + }); + + const orgsBtn = screen.getByTestId('orgsBtn'); + const settingsBtn = screen.getByTestId('settingsBtn'); + + fireEvent.click(orgsBtn); + expect(orgsBtn).toHaveClass('text-white btn btn-success'); + fireEvent.click(settingsBtn); + expect(settingsBtn).toHaveClass('text-white btn btn-success'); + }); + + test('Translation hook displays expected text in UserSidebar', async () => { + await act(async () => { + renderUserSidebar('properId', link); + await wait(); + }); + expect( + screen.getByText(i18nForTest.t('common:settings')), + ).toBeInTheDocument(); + }); + + test('handleLinkClick function closes the sidebar on mobile view when a link is clicked', async () => { + resizeWindow(800); + await act(async () => { + renderUserSidebar('properId', link); + await wait(); + }); + const chatBtn = screen.getByTestId('chatBtn'); + fireEvent.click(chatBtn); + expect(props.setHideDrawer).toHaveBeenCalledWith(true); + }); + + describe('UserSidebar Drawer Visibility Tests on Smaller Screens', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('Clicking a link closes the drawer when window width is 820px or less', () => { + act(() => { + window.innerWidth = 820; + window.dispatchEvent(new Event('resize')); + }); + + render( + + + + + + + + + , + ); + + const linkElement = screen.getByText('My Organizations'); // Adjust text if different + fireEvent.click(linkElement); + + expect(props.setHideDrawer).toHaveBeenCalledWith(true); + }); + + describe('UserSidebar Drawer State Tests', () => { + test('Drawer visibility changes based on hideDrawer prop', () => { + const { rerender } = render( + + + + + + + + + , + ); + + expect(screen.getByTestId('leftDrawerContainer')).toHaveClass( + styles.hideElemByDefault, + ); + + rerender( + + + + + + + + + , + ); + expect(screen.getByTestId('leftDrawerContainer')).toHaveClass( + styles.inactiveDrawer, + ); + + rerender( + + + + + + + + + , + ); + expect(screen.getByTestId('leftDrawerContainer')).toHaveClass( + styles.activeDrawer, + ); + }); + }); + }); +}); diff --git a/src/components/UserPortal/UserSidebar/UserSidebar.tsx b/src/components/UserPortal/UserSidebar/UserSidebar.tsx new file mode 100644 index 0000000000..5e258f8a8e --- /dev/null +++ b/src/components/UserPortal/UserSidebar/UserSidebar.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import Button from 'react-bootstrap/Button'; +import { useTranslation } from 'react-i18next'; +import { NavLink } from 'react-router-dom'; +import OrganizationsIcon from 'assets/svgs/organizations.svg?react'; +import SettingsIcon from 'assets/svgs/settings.svg?react'; +import ChatIcon from 'assets/svgs/chat.svg?react'; +import TalawaLogo from 'assets/svgs/talawa.svg?react'; +import styles from './UserSidebar.module.css'; + +export interface InterfaceUserSidebarProps { + hideDrawer: boolean | null; + setHideDrawer: React.Dispatch>; +} + +/** + * Sidebar component for user navigation, including links to organizations and settings. + * + * Provides: + * - A logo and title for the sidebar. + * - Navigation buttons for "My Organizations" and "Settings". + * - Dynamic styling based on the active route. + * + * @param hideDrawer - Boolean indicating if the sidebar should be hidden or shown. + * @param setHideDrawer - Function to update the `hideDrawer` state. + * + * @returns JSX.Element - The rendered sidebar component. + */ +const userSidebar = ({ + hideDrawer, + setHideDrawer, +}: InterfaceUserSidebarProps): JSX.Element => { + // Translation hook for internationalization + const { t } = useTranslation('translation', { keyPrefix: 'userSidebarOrg' }); + const { t: tCommon } = useTranslation('common'); + + /** + * Handles click events on navigation links. + * Closes the sidebar if the viewport width is 820px or less. + */ + const handleLinkClick = (): void => { + if (window.innerWidth <= 820) { + setHideDrawer(true); + } + }; + + return ( + <> +
    + {/* Logo and title */} + +

    {t('talawaUserPortal')}

    +
    + {tCommon('menu')} +
    +
    + {/* Link to "My Organizations" page */} + + {({ isActive }) => ( + + )} + + {/* Link to "Settings" page */} + + {({ isActive }) => ( + + )} + + + {({ isActive }) => ( + + )} + +
    +
    + + ); +}; + +export default userSidebar; diff --git a/src/components/UserPortal/UserSidebarOrg/UserSidebarOrg.module.css b/src/components/UserPortal/UserSidebarOrg/UserSidebarOrg.module.css new file mode 100644 index 0000000000..b300eb7e89 --- /dev/null +++ b/src/components/UserPortal/UserSidebarOrg/UserSidebarOrg.module.css @@ -0,0 +1,351 @@ +.leftDrawer { + width: calc(300px + 2rem); + min-height: 100%; + position: fixed; + top: 0; + bottom: 0; + z-index: 100; + display: flex; + flex-direction: column; + padding: 0.8rem 1rem 0 1rem; + background-color: var(--bs-white); + transition: 0.5s; + font-family: var(--bs-leftDrawer-font-family); +} + +.activeDrawer { + width: calc(300px + 2rem); + position: fixed; + top: 0; + left: 0; + bottom: 0; + animation: comeToRightBigScreen 0.5s ease-in-out; +} + +.inactiveDrawer { + position: fixed; + top: 0; + left: calc(-300px - 2rem); + bottom: 0; + animation: goToLeftBigScreen 0.5s ease-in-out; +} + +.leftDrawer .brandingContainer { + display: flex; + justify-content: flex-start; + align-items: center; +} + +.leftDrawer .organizationContainer button { + position: relative; + margin: 0.7rem 0; + padding: 2.5rem 0.1rem; + border-radius: 16px; +} + +.leftDrawer .talawaLogo { + width: 50px; + height: 50px; + margin-right: 0.3rem; +} + +.leftDrawer .talawaText { + font-size: 18px; + font-weight: 500; +} + +.leftDrawer .titleHeader { + font-weight: 600; + margin: 0.6rem 0rem; +} + +.leftDrawer .optionList { + height: 100%; + overflow-y: auto; +} + +.leftDrawer .optionList button { + display: flex; + align-items: center; + width: 100%; + text-align: start; + margin-bottom: 0.8rem; + border-radius: 16px; + font-size: 16px; + padding: 0.6rem; + padding-left: 0.8rem; + outline: none; + border: none; +} + +.leftDrawer button .iconWrapper { + width: 36px; +} + +.leftDrawer .optionList .collapseBtn { + height: 48px; + border: none; +} + +.leftDrawer button .iconWrapperSm { + width: 36px; + display: flex; + justify-content: center; + align-items: center; +} + +.leftDrawer .organizationContainer .profileContainer { + background-color: #31bb6b33; + padding-right: 10px; +} + +.leftDrawer .profileContainer { + border: none; + width: 100%; + height: 52px; + border-radius: 16px; + display: flex; + align-items: center; + background-color: var(--bs-white); +} + +.leftDrawer .profileContainer:focus { + outline: none; +} + +.leftDrawer .imageContainer { + width: 68px; + margin-right: 8px; +} + +.leftDrawer .profileContainer img { + height: 52px; + width: 52px; + border-radius: 50%; +} + +.leftDrawer .profileContainer .profileText { + flex: 1; + text-align: start; + overflow: hidden; +} + +.leftDrawer .profileContainer .profileText .primaryText { + font-size: 1.1rem; + font-weight: 600; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; /* number of lines to show */ + -webkit-box-orient: vertical; + word-wrap: break-word; + white-space: normal; +} + +.leftDrawer .profileContainer .profileText .secondaryText { + font-size: 0.8rem; + font-weight: 400; + color: var(--bs-secondary); + display: block; + text-transform: capitalize; +} + +@media (max-width: 1120px) { + .leftDrawer { + width: calc(250px + 2rem); + padding: 1rem 1rem 0 1rem; + } +} + +/* For tablets */ +@media (max-height: 900px) { + .leftDrawer { + width: calc(300px + 1rem); + } + .leftDrawer .talawaLogo { + width: 38px; + height: 38px; + margin-right: 0.4rem; + } + .leftDrawer .talawaText { + font-size: 1rem; + } + .leftDrawer .organizationContainer button { + margin: 0.6rem 0; + padding: 2.2rem 0.1rem; + } + .leftDrawer .optionList button { + margin-bottom: 0.05rem; + font-size: 16px; + padding-left: 0.8rem; + } + .leftDrawer .profileContainer .profileText .primaryText { + font-size: 1rem; + } + .leftDrawer .profileContainer .profileText .secondaryText { + font-size: 0.8rem; + } +} +@media (max-height: 650px) { + .leftDrawer { + padding: 0.5rem 0.8rem 0 0.8rem; + width: calc(250px); + } + .leftDrawer .talawaText { + font-size: 0.8rem; + } + .leftDrawer .organizationContainer button { + margin: 0.2rem 0; + padding: 1.6rem 0rem; + } + .leftDrawer .titleHeader { + font-size: 16px; + } + .leftDrawer .optionList button { + margin-bottom: 0.05rem; + font-size: 14px; + padding: 0.4rem; + padding-left: 0.8rem; + } + .leftDrawer .profileContainer .profileText .primaryText { + font-size: 0.8rem; + } + .leftDrawer .profileContainer .profileText .secondaryText { + font-size: 0.6rem; + } + .leftDrawer .imageContainer { + width: 40px; + margin-left: 5px; + margin-right: 12px; + } + .leftDrawer .imageContainer img { + width: 40px; + height: 100%; + } +} + +@media (max-width: 820px) { + .hideElemByDefault { + display: none; + } + + .leftDrawer { + width: 100%; + left: 0; + right: 0; + } + + .inactiveDrawer { + opacity: 0; + left: 0; + z-index: -1; + animation: closeDrawer 0.2s ease-in-out; + } + + .activeDrawer { + display: flex; + z-index: 100; + animation: openDrawer 0.4s ease-in-out; + } + + .logout { + margin-bottom: 2.5rem; + } +} + +@keyframes goToLeftBigScreen { + from { + left: 0; + } + + to { + opacity: 0.1; + left: calc(-300px - 2rem); + } +} + +/* Webkit prefix for older browser compatibility */ +@-webkit-keyframes goToLeftBigScreen { + from { + left: 0; + } + + to { + opacity: 0.1; + left: calc(-300px - 2rem); + } +} + +@keyframes comeToRightBigScreen { + from { + opacity: 0.4; + left: calc(-300px - 2rem); + } + + to { + opacity: 1; + left: 0; + } +} + +/* Webkit prefix for older browser compatibility */ +@-webkit-keyframes comeToRightBigScreen { + from { + opacity: 0.4; + left: calc(-300px - 2rem); + } + + to { + opacity: 1; + left: 0; + } +} + +@keyframes closeDrawer { + from { + left: 0; + opacity: 1; + } + + to { + left: -1000px; + opacity: 0; + } +} + +/* Webkit prefix for older browser compatibility */ +@-webkit-keyframes closeDrawer { + from { + left: 0; + opacity: 1; + } + + to { + left: -1000px; + opacity: 0; + } +} + +@keyframes openDrawer { + from { + opacity: 0; + left: -1000px; + } + + to { + left: 0; + opacity: 1; + } +} + +/* Webkit prefix for older browser compatibility */ +@-webkit-keyframes openDrawer { + from { + opacity: 0; + left: -1000px; + } + + to { + left: 0; + opacity: 1; + } +} diff --git a/src/components/UserPortal/UserSidebarOrg/UserSidebarOrg.test.tsx b/src/components/UserPortal/UserSidebarOrg/UserSidebarOrg.test.tsx new file mode 100644 index 0000000000..2f28d9afd1 --- /dev/null +++ b/src/components/UserPortal/UserSidebarOrg/UserSidebarOrg.test.tsx @@ -0,0 +1,418 @@ +import React, { act } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import 'jest-localstorage-mock'; +import { I18nextProvider } from 'react-i18next'; +import { BrowserRouter } from 'react-router-dom'; + +import i18nForTest from 'utils/i18nForTest'; +import type { InterfaceUserSidebarOrgProps } from './UserSidebarOrg'; +import UserSidebarOrg from './UserSidebarOrg'; +import { Provider } from 'react-redux'; +import { MockedProvider } from '@apollo/react-testing'; +import { store } from 'state/store'; +import { ORGANIZATIONS_LIST } from 'GraphQl/Queries/Queries'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem } = useLocalStorage(); + +const props: InterfaceUserSidebarOrgProps = { + orgId: '123', + targets: [ + { + name: 'Posts', + url: '/user/organization/123', + }, + { + name: 'People', + url: '/user/people/123', + }, + { + name: 'Events', + url: '/user/events/123', + }, + { + name: 'Donations', + url: '/user/donate/123', + }, + { + name: 'Settings', + url: '/user/settings', + }, + { + name: 'All Organizations', + url: '/user/organizations/', + }, + ], + hideDrawer: false, + setHideDrawer: jest.fn(), +}; + +const MOCKS = [ + { + request: { + query: REVOKE_REFRESH_TOKEN, + }, + result: { + data: { + revokeRefreshTokenForUser: true, + }, + }, + }, + { + request: { + query: ORGANIZATIONS_LIST, + variables: { id: '123' }, + }, + result: { + data: { + organizations: [ + { + _id: '123', + image: null, + creator: { + firstName: 'John', + lastName: 'Doe', + email: 'JohnDoe@example.com', + }, + name: 'Test Organization', + description: 'Testing this organization', + address: { + city: 'Delhi', + countryCode: 'IN', + dependentLocality: 'Some Dependent Locality', + line1: '123 Random Street', + line2: 'Apartment 456', + postalCode: '110001', + sortingCode: 'ABC-123', + state: 'Delhi', + }, + userRegistrationRequired: true, + visibleInSearch: true, + members: [ + { + _id: 'john123', + firstName: 'John', + lastName: 'Doe', + email: 'JohnDoe@example.com', + createdAt: '4567890234', + }, + { + _id: 'jane123', + firstName: 'Jane', + lastName: 'Doe', + email: 'JaneDoe@example.com', + createdAt: '4567890234', + }, + ], + admins: [ + { + _id: 'john123', + firstName: 'John', + lastName: 'Doe', + email: 'JohnDoe@example.com', + createdAt: '4567890234', + }, + ], + membershipRequests: [], + blockedUsers: [], + }, + ], + }, + }, + }, +]; + +const MOCKS_WITH_IMAGE = [ + { + request: { + query: ORGANIZATIONS_LIST, + variables: { id: '123' }, + }, + result: { + data: { + organizations: [ + { + _id: '123', + image: + 'https://api.dicebear.com/5.x/initials/svg?seed=Test%20Organization', + creator: { + firstName: 'John', + lastName: 'Doe', + email: 'JohnDoe@example.com', + }, + name: 'Test Organization', + description: 'Testing this organization', + address: { + city: 'Delhi', + countryCode: 'IN', + dependentLocality: 'Some Dependent Locality', + line1: '123 Random Street', + line2: 'Apartment 456', + postalCode: '110001', + sortingCode: 'ABC-123', + state: 'Delhi', + }, + userRegistrationRequired: true, + visibleInSearch: true, + members: [ + { + _id: 'john123', + firstName: 'John', + lastName: 'Doe', + email: 'JohnDoe@example.com', + createdAt: '4567890234', + }, + { + _id: 'jane123', + firstName: 'Jane', + lastName: 'Doe', + email: 'JaneDoe@example.com', + createdAt: '4567890234', + }, + ], + admins: [ + { + _id: 'john123', + firstName: 'John', + lastName: 'Doe', + email: 'JohnDoe@example.com', + createdAt: '4567890234', + }, + ], + membershipRequests: [], + blockedUsers: [], + }, + ], + }, + }, + }, +]; + +const MOCKS_EMPTY = [ + { + request: { + query: ORGANIZATIONS_LIST, + variables: { id: '123' }, + }, + result: { + data: { + organizations: [], + }, + }, + }, +]; + +const defaultScreens = [ + 'People', + 'Events', + 'Posts', + 'Donations', + 'All Organizations', +]; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const resizeWindow = (width: number): void => { + window.innerWidth = width; + fireEvent(window, new Event('resize')); +}; + +beforeEach(() => { + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + setItem( + 'UserImage', + 'https://api.dicebear.com/5.x/initials/svg?seed=John%20Doe', + ); +}); + +afterEach(() => { + jest.clearAllMocks(); + localStorage.clear(); +}); + +const link = new StaticMockLink(MOCKS, true); +const linkImage = new StaticMockLink(MOCKS_WITH_IMAGE, true); +const linkEmpty = new StaticMockLink(MOCKS_EMPTY, true); + +describe('Testing LeftDrawerOrg component for SUPERADMIN', () => { + test('Component should be rendered properly', async () => { + setItem('UserImage', ''); + setItem('SuperAdmin', true); + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + render( + + + + + + + + + , + ); + await wait(); + defaultScreens.map((screenName) => { + expect(screen.getByText(screenName)).toBeInTheDocument(); + }); + }); + + test('Testing Profile Page & Organization Detail Modal', async () => { + setItem('UserImage', ''); + setItem('SuperAdmin', true); + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + render( + + + + + + + + + , + ); + await wait(); + expect(screen.getByTestId(/orgBtn/i)).toBeInTheDocument(); + }); + + test('Testing Menu Buttons', async () => { + setItem('UserImage', ''); + setItem('SuperAdmin', true); + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + render( + + + + + + + + + , + ); + await wait(); + userEvent.click(screen.getByText('People')); + expect(global.window.location.pathname).toContain('/user/people/123'); + }); + + test('Testing when screen size is less than 820px', async () => { + setItem('SuperAdmin', true); + render( + + + + + + + + + , + ); + await wait(); + resizeWindow(800); + expect(screen.getAllByText(/People/i)[0]).toBeInTheDocument(); + + const peopelBtn = screen.getByTestId(/People/i); + userEvent.click(peopelBtn); + await wait(); + expect(window.location.pathname).toContain('user/people/123'); + }); + + test('Testing when image is present for Organization', async () => { + setItem('UserImage', ''); + setItem('SuperAdmin', true); + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + render( + + + + + + + + + , + ); + await wait(); + }); + + test('Testing when Organization does not exists', async () => { + setItem('UserImage', ''); + setItem('SuperAdmin', true); + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + render( + + + + + + + + + , + ); + await wait(); + expect( + screen.getByText(/Error Occured while loading the Organization/i), + ).toBeInTheDocument(); + }); + + test('Testing Drawer when hideDrawer is null', () => { + setItem('UserImage', ''); + setItem('SuperAdmin', true); + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + render( + + + + + + + + + , + ); + }); + + test('Testing Drawer when hideDrawer is true', () => { + setItem('UserImage', ''); + setItem('SuperAdmin', true); + setItem('FirstName', 'John'); + setItem('LastName', 'Doe'); + render( + + + + + + + + + , + ); + }); +}); diff --git a/src/components/UserPortal/UserSidebarOrg/UserSidebarOrg.tsx b/src/components/UserPortal/UserSidebarOrg/UserSidebarOrg.tsx new file mode 100644 index 0000000000..19036b2307 --- /dev/null +++ b/src/components/UserPortal/UserSidebarOrg/UserSidebarOrg.tsx @@ -0,0 +1,197 @@ +import { useQuery } from '@apollo/client'; +import { WarningAmberOutlined } from '@mui/icons-material'; +import { ORGANIZATIONS_LIST } from 'GraphQl/Queries/Queries'; +import CollapsibleDropdown from 'components/CollapsibleDropdown/CollapsibleDropdown'; +import IconComponent from 'components/IconComponent/IconComponent'; +import React, { useEffect, useState } from 'react'; +import Button from 'react-bootstrap/Button'; +import { useTranslation } from 'react-i18next'; +import { NavLink } from 'react-router-dom'; +import type { TargetsType } from 'state/reducers/routesReducer'; +import type { InterfaceQueryOrganizationsListObject } from 'utils/interfaces'; +import AngleRightIcon from 'assets/svgs/angleRight.svg?react'; +import TalawaLogo from 'assets/svgs/talawa.svg?react'; +import styles from './UserSidebarOrg.module.css'; +import Avatar from 'components/Avatar/Avatar'; + +export interface InterfaceUserSidebarOrgProps { + orgId: string; + targets: TargetsType[]; + hideDrawer: boolean | null; + setHideDrawer: React.Dispatch>; +} + +/** + * Sidebar component for user navigation within an organization. + * + * Provides: + * - Branding with the Talawa logo. + * - Displays the current organization's details. + * - Navigation options with links and collapsible dropdowns. + * + * @param orgId - ID of the current organization. + * @param targets - List of navigation targets. + * @param hideDrawer - Boolean indicating if the sidebar should be hidden or shown. + * @param setHideDrawer - Function to update the `hideDrawer` state. + * + * @returns JSX.Element - The rendered sidebar component. + */ +const UserSidebarOrg = ({ + targets, + orgId, + hideDrawer, + setHideDrawer, +}: InterfaceUserSidebarOrgProps): JSX.Element => { + // Translation hook for internationalization + const { t } = useTranslation('translation', { keyPrefix: 'userSidebarOrg' }); + const { t: tCommon } = useTranslation('common'); + + // State for managing dropdown visibility + const [showDropdown, setShowDropdown] = React.useState(false); + + // State for organization data + const [organization, setOrganization] = + useState(); + + // Query to fetch organization data + const { + data, + loading, + }: { + data: + | { organizations: InterfaceQueryOrganizationsListObject[] } + | undefined; + loading: boolean; + } = useQuery(ORGANIZATIONS_LIST, { + variables: { id: orgId }, + }); + + // Set organization data once the query is complete + useEffect(() => { + let isMounted = true; + if (data && isMounted) { + setOrganization(data?.organizations[0]); + } + return () => { + isMounted = false; + }; + }, [data]); + + /** + * Handles click events on navigation links. + * Closes the sidebar if the viewport width is 820px or less. + */ + const handleLinkClick = (): void => { + if (window.innerWidth <= 820) { + setHideDrawer(true); + } + }; + + return ( + <> +
    + {/* Branding Section */} +
    + + {t('talawaUserPortal')} +
    + + {/* Organization Section */} +
    + {loading ? ( + <> + + + ) : ( + + )} +
    + + {/* Options List */} +
    +
    + {tCommon('menu')} +
    + {targets.map(({ name, url }, index) => { + return url ? ( + + {({ isActive }) => ( + + )} + + ) : ( + + ); + })} +
    +
    + + ); +}; + +export default UserSidebarOrg; diff --git a/src/components/UserProfileSettings/DeleteUser.test.tsx b/src/components/UserProfileSettings/DeleteUser.test.tsx new file mode 100644 index 0000000000..34ab44fbe5 --- /dev/null +++ b/src/components/UserProfileSettings/DeleteUser.test.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { BrowserRouter } from 'react-router-dom'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; +import DeleteUser from './DeleteUser'; + +describe('Delete User component', () => { + test('renders delete user correctly', () => { + const { getByText, getAllByText } = render( + + + + + + + , + ); + + expect( + getByText( + 'By clicking on Delete User button your user will be permanently deleted along with its events, tags and all related data.', + ), + ).toBeInTheDocument(); + expect(getAllByText('Delete User')[0]).toBeInTheDocument(); + }); +}); diff --git a/src/components/UserProfileSettings/DeleteUser.tsx b/src/components/UserProfileSettings/DeleteUser.tsx new file mode 100644 index 0000000000..90b5f25433 --- /dev/null +++ b/src/components/UserProfileSettings/DeleteUser.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Button, Card } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import styles from './UserProfileSettings.module.css'; + +/** + * DeleteUser component displays a card with a button to delete a user. + * It includes a message and a button to trigger the delete action. + * + * @returns The JSX element for the delete user card. + */ +const DeleteUser: React.FC = () => { + const { t } = useTranslation('translation', { + keyPrefix: 'settings', + }); + return ( + <> + +
    +
    {t('deleteUser')}
    +
    + +

    {t('deleteUserMessage')}

    + +
    +
    + + ); +}; + +export default DeleteUser; diff --git a/src/components/UserProfileSettings/OtherSettings.test.tsx b/src/components/UserProfileSettings/OtherSettings.test.tsx new file mode 100644 index 0000000000..990a430931 --- /dev/null +++ b/src/components/UserProfileSettings/OtherSettings.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { BrowserRouter } from 'react-router-dom'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; +import OtherSettings from './OtherSettings'; + +describe('Delete User component', () => { + test('renders delete user correctly', () => { + const { getByText } = render( + + + + + + + , + ); + + expect(getByText('Other Settings')).toBeInTheDocument(); + expect(getByText('Change Language')).toBeInTheDocument(); + }); +}); diff --git a/src/components/UserProfileSettings/OtherSettings.tsx b/src/components/UserProfileSettings/OtherSettings.tsx new file mode 100644 index 0000000000..e83632c8b1 --- /dev/null +++ b/src/components/UserProfileSettings/OtherSettings.tsx @@ -0,0 +1,32 @@ +import ChangeLanguageDropDown from 'components/ChangeLanguageDropdown/ChangeLanguageDropDown'; +import React from 'react'; +import { Card, Form } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import styles from './UserProfileSettings.module.css'; + +/** + * OtherSettings component displays a card with settings options such as changing the language. + * It includes a label and a dropdown for selecting a different language. + * + * @returns The JSX element for the other settings card. + */ +const OtherSettings: React.FC = () => { + const { t } = useTranslation('translation', { + keyPrefix: 'settings', + }); + return ( + +
    +
    {t('otherSettings')}
    +
    + + + {t('changeLanguage')} + + + +
    + ); +}; + +export default OtherSettings; diff --git a/src/components/UserProfileSettings/UserProfile.test.tsx b/src/components/UserProfileSettings/UserProfile.test.tsx new file mode 100644 index 0000000000..82caad5d81 --- /dev/null +++ b/src/components/UserProfileSettings/UserProfile.test.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import UserProfile from './UserProfile'; +import { MockedProvider } from '@apollo/react-testing'; +import { BrowserRouter } from 'react-router-dom'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; + +describe('UserProfile component', () => { + test('renders user profile details correctly', () => { + const userDetails = { + firstName: 'Christopher', + lastName: 'Doe', + createdAt: '2023-04-13T04:53:17.742+00:00', + email: 'john.doe@example.com', + image: 'profile-image-url', + }; + const { getByText, getByAltText } = render( + + + + + + + , + ); + + expect(getByText('Chris..')).toBeInTheDocument(); + expect(getByText('john..@example.com')).toBeInTheDocument(); + + const profileImage = getByAltText('profile picture'); + expect(profileImage).toBeInTheDocument(); + expect(profileImage).toHaveAttribute('src', 'profile-image-url'); + + expect(getByText('Joined 13 April 2023')).toBeInTheDocument(); + + expect(getByText('Copy Profile Link')).toBeInTheDocument(); + }); +}); diff --git a/src/components/UserProfileSettings/UserProfile.tsx b/src/components/UserProfileSettings/UserProfile.tsx new file mode 100644 index 0000000000..9d24c15480 --- /dev/null +++ b/src/components/UserProfileSettings/UserProfile.tsx @@ -0,0 +1,106 @@ +import Avatar from 'components/Avatar/Avatar'; +import React from 'react'; +import { Button, Card } from 'react-bootstrap'; +import CalendarMonthOutlinedIcon from '@mui/icons-material/CalendarMonthOutlined'; +import { useTranslation } from 'react-i18next'; +import styles from './UserProfileSettings.module.css'; +import { Tooltip as ReactTooltip } from 'react-tooltip'; + +interface InterfaceUserProfile { + firstName: string; + lastName: string; + createdAt: string; + email: string; + image: string; +} +const joinedDate = (param: string): string => { + const date = new Date(param); + if (date?.toDateString() === 'Invalid Date') { + return 'Unavailable'; + } + const day = date.getDate(); + const month = date.toLocaleString('default', { month: 'long' }); + const year = date.getFullYear(); + return `${day} ${month} ${year}`; +}; + +/** + * UserProfile component displays user profile details including an avatar or profile image, name, email, and join date. + * It also provides a button to copy the profile link. + * + * @param props - The properties to be passed into the component. + * @param firstName - The first name of the user. + * @param lastName - The last name of the user. + * @param email - The email address of the user. + * @param image - The URL of the user's profile image. + * @returns The JSX element for the user profile card. + */ +const UserProfile: React.FC = ({ + firstName, + lastName, + createdAt, + email, + image, +}): JSX.Element => { + const { t } = useTranslation('translation', { + keyPrefix: 'settings', + }); + const { t: tCommon } = useTranslation('common'); + + return ( + <> + +
    +
    {t('profileDetails')}
    +
    + +
    +
    + {image && image !== 'null' ? ( + {`profile + ) : ( + + )} +
    +
    + + {firstName.length > 10 + ? firstName.slice(0, 5) + '..' + : firstName} + + + + {email.length > 10 + ? email.slice(0, 4) + '..' + email.slice(email.indexOf('@')) + : email} + + + + + + {tCommon('joined')} {joinedDate(createdAt)} + + +
    +
    +
    + +
    +
    +
    + + ); +}; + +export default UserProfile; diff --git a/src/components/UserProfileSettings/UserProfileSettings.module.css b/src/components/UserProfileSettings/UserProfileSettings.module.css new file mode 100644 index 0000000000..2c7cc76f57 --- /dev/null +++ b/src/components/UserProfileSettings/UserProfileSettings.module.css @@ -0,0 +1,77 @@ +.cardHeader { + padding: 1.25rem 1rem 1rem 1rem; + border-bottom: 1px solid var(--bs-gray-200); + display: flex; + justify-content: space-between; + align-items: center; +} + +.cardHeader .cardTitle { + font-size: 1.2rem; + font-weight: 600; +} + +.cardBody { + padding: 1.25rem 1rem 1.5rem 1rem; + display: flex; + flex-direction: column; +} + +.cardLabel { + font-weight: bold; + padding-bottom: 1px; + font-size: 14px; + color: #707070; + margin-bottom: 10px; +} + +.cardControl { + margin-bottom: 20px; +} + +.cardButton { + width: fit-content; +} + +.imgContianer { + margin: 0 2rem 0 0; +} + +.imgContianer img { + height: 120px; + width: 120px; + border-radius: 50%; +} + +.profileDetails { + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-evenly; + margin-left: 10%; +} + +@media screen and (max-width: 1280px) and (min-width: 992px) { + .imgContianer { + margin: 1rem auto; + } + .profileContainer { + flex-direction: column; + } +} + +@media screen and (max-width: 992px) { + .profileContainer { + align-items: center; + justify-content: center; + } +} + +@media screen and (max-width: 420px) { + .imgContianer { + margin: 1rem auto; + } + .profileContainer { + flex-direction: column; + } +} diff --git a/src/components/UsersTableItem/UserTableItem.test.tsx b/src/components/UsersTableItem/UserTableItem.test.tsx new file mode 100644 index 0000000000..687165b78d --- /dev/null +++ b/src/components/UsersTableItem/UserTableItem.test.tsx @@ -0,0 +1,1341 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18nForTest from 'utils/i18nForTest'; +import type { InterfaceQueryUserListItem } from 'utils/interfaces'; +import { MOCKS, MOCKS2, MOCKS_UPDATE } from './UserTableItemMocks'; +import UsersTableItem from './UsersTableItem'; +import { BrowserRouter } from 'react-router-dom'; +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(MOCKS2, true); +const link3 = new StaticMockLink(MOCKS_UPDATE, true); +import useLocalStorage from 'utils/useLocalstorage'; +import { + REMOVE_ADMIN_MUTATION, + REMOVE_MEMBER_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import userEvent from '@testing-library/user-event'; + +const { setItem } = useLocalStorage(); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} +const resetAndRefetchMock = jest.fn(); + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + warning: jest.fn(), + }, +})); + +Object.defineProperty(window, 'location', { + value: { + replace: jest.fn(), + }, + writable: true, +}); + +const mockNavgatePush = jest.fn(); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavgatePush, +})); + +beforeEach(() => { + setItem('SuperAdmin', true); + setItem('id', '123'); +}); + +afterEach(() => { + localStorage.clear(); + jest.clearAllMocks(); +}); + +describe('Testing User Table Item', () => { + console.error = jest.fn((message) => { + if (message.includes('validateDOMNesting')) { + return; + } + // Log other console errors + console.warn(message); + }); + test('Should render props and text elements test for the page component', async () => { + const props: { + user: InterfaceQueryUserListItem; + index: number; + loggedInUserId: string; + resetAndRefetch: () => void; + } = { + user: { + user: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '2023-09-29T15:39:36.355Z', + organizationsBlockedBy: [ + { + _id: 'xyz', + name: 'XYZ', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-01-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + { + _id: 'mno', + name: 'MNO', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-01-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + ], + joinedOrganizations: [ + { + _id: 'abc', + name: 'Joined Organization 1', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-06-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + { + _id: 'def', + name: 'Joined Organization 2', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-07-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + ], + registeredEvents: [], + membershipRequests: [], + }, + appUserProfile: { + _id: '123', + isSuperAdmin: true, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + adminFor: [ + { + _id: 'abc', + }, + ], + }, + }, + index: 0, + loggedInUserId: '123', + resetAndRefetch: resetAndRefetchMock, + }; + + render( + + + + + + + , + ); + + await wait(); + expect(screen.getByText(/1/i)).toBeInTheDocument(); + expect(screen.getByText(/John Doe/i)).toBeInTheDocument(); + expect(screen.getByText(/john@example.com/i)).toBeInTheDocument(); + expect(screen.getByTestId(`showJoinedOrgsBtn${123}`)).toBeInTheDocument(); + expect( + screen.getByTestId(`showBlockedByOrgsBtn${123}`), + ).toBeInTheDocument(); + }); + + test('Should render elements correctly when JoinedOrgs and BlockedByOrgs are empty', async () => { + const props: { + user: InterfaceQueryUserListItem; + index: number; + loggedInUserId: string; + resetAndRefetch: () => void; + } = { + user: { + user: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '2023-09-29T15:39:36.355Z', + organizationsBlockedBy: [], + joinedOrganizations: [], + registeredEvents: [], + membershipRequests: [], + }, + appUserProfile: { + _id: '123', + isSuperAdmin: true, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + adminFor: [ + { + _id: 'abc', + }, + ], + }, + }, + index: 0, + loggedInUserId: '123', + resetAndRefetch: resetAndRefetchMock, + }; + render( + + + + + , + ); + + await wait(); + const showJoinedOrgsBtn = screen.getByTestId(`showJoinedOrgsBtn${123}`); // 123 is userId + const showBlockedByOrgsBtn = screen.getByTestId( + `showBlockedByOrgsBtn${123}`, + ); // 123 is userId + + // Open JoinedOrgs Modal -> Expect modal to contain text and no search box -> Close Modal + fireEvent.click(showJoinedOrgsBtn); + expect( + screen.queryByTestId(`searchByNameJoinedOrgs`), + ).not.toBeInTheDocument(); + expect( + screen.getByText(/John Doe has not joined any organization/i), + ).toBeInTheDocument(); + fireEvent.click(screen.getByTestId(`closeJoinedOrgsBtn${123}`)); + + // Open BlockedByOrgs Modal -> Expect modal to contain text and no search box -> Close Modal + fireEvent.click(showBlockedByOrgsBtn); + expect( + screen.queryByTestId(`searchByNameOrgsBlockedBy`), + ).not.toBeInTheDocument(); + expect( + screen.getByText(/John Doe is not blocked by any organization/i), + ).toBeInTheDocument(); + fireEvent.click(screen.getByTestId(`closeBlockedByOrgsBtn${123}`)); + }); + + test('Should render props and text elements test for the Joined Organizations Modal properly', async () => { + const props: { + user: InterfaceQueryUserListItem; + index: number; + loggedInUserId: string; + resetAndRefetch: () => void; + } = { + user: { + user: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '2023-09-29T15:39:36.355Z', + organizationsBlockedBy: [ + { + _id: 'xyz', + name: 'XYZ', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-01-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + { + _id: 'mno', + name: 'MNO', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-01-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + ], + joinedOrganizations: [ + { + _id: 'abc', + name: 'Joined Organization 1', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-06-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + { + _id: 'def', + name: 'Joined Organization 2', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-07-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + ], + registeredEvents: [], + membershipRequests: [], + }, + appUserProfile: { + _id: '123', + isSuperAdmin: true, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + adminFor: [ + { + _id: 'abc', + }, + ], + }, + }, + index: 0, + loggedInUserId: '123', + resetAndRefetch: resetAndRefetchMock, + }; + + render( + + + + + , + ); + + await wait(); + const showJoinedOrgsBtn = screen.getByTestId(`showJoinedOrgsBtn${123}`); + expect(showJoinedOrgsBtn).toBeInTheDocument(); + fireEvent.click(showJoinedOrgsBtn); + expect(screen.getByTestId('modal-joined-org-123')).toBeInTheDocument(); + + // Close using escape key and reopen + fireEvent.keyDown(screen.getByTestId('modal-joined-org-123'), { + key: 'Escape', + code: 'Escape', + keyCode: 27, + charCode: 27, + }); + expect( + screen.queryByRole('dialog')?.className.includes('show'), + ).toBeFalsy(); + fireEvent.click(showJoinedOrgsBtn); + // Close using close button and reopen + fireEvent.click(screen.getByTestId(`closeJoinedOrgsBtn${123}`)); + expect( + screen.queryByRole('dialog')?.className.includes('show'), + ).toBeFalsy(); + + fireEvent.click(showJoinedOrgsBtn); + + // Expect the following to exist in modal + const inputBox = screen.getByTestId(`searchByNameJoinedOrgs`); + expect(inputBox).toBeInTheDocument(); + expect(screen.getByText(/Joined Organization 1/i)).toBeInTheDocument(); + expect(screen.getByText(/Joined Organization 2/i)).toBeInTheDocument(); + const elementsWithKingston = screen.getAllByText(/Kingston/i); + elementsWithKingston.forEach((element) => { + expect(element).toBeInTheDocument(); + }); + expect(screen.getByText(/29-06-2023/i)).toBeInTheDocument(); + expect(screen.getByText(/29-07-2023/i)).toBeInTheDocument(); + expect(screen.getByTestId('removeUserFromOrgBtnabc')).toBeInTheDocument(); + expect(screen.getByTestId('removeUserFromOrgBtndef')).toBeInTheDocument(); + + // Search for Joined Organization 1 + const searchBtn = screen.getByTestId(`searchBtnJoinedOrgs`); + fireEvent.keyUp(inputBox, { + target: { value: 'Joined Organization 1' }, + }); + fireEvent.click(searchBtn); + expect(screen.getByText(/Joined Organization 1/i)).toBeInTheDocument(); + expect( + screen.queryByText(/Joined Organization 2/i), + ).not.toBeInTheDocument(); + + // Search for an Organization which does not exist + fireEvent.keyUp(inputBox, { + key: 'Enter', + target: { value: 'Joined Organization 3' }, + }); + expect( + screen.getByText(`No results found for "Joined Organization 3"`), + ).toBeInTheDocument(); + + // Now clear the search box + fireEvent.keyUp(inputBox, { key: 'Enter', target: { value: '' } }); + fireEvent.keyUp(inputBox, { target: { value: '' } }); + fireEvent.click(searchBtn); + // Click on Creator Link + fireEvent.click(screen.getByTestId(`creatorabc`)); + expect(toast.success).toHaveBeenCalledWith('Profile Page Coming Soon !'); + + // Click on Organization Link + fireEvent.click(screen.getByText(/Joined Organization 1/i)); + expect(window.location.replace).toHaveBeenCalledWith('/orgdash/abc'); + expect(mockNavgatePush).toHaveBeenCalledWith('/orgdash/abc'); + fireEvent.click(screen.getByTestId(`closeJoinedOrgsBtn${123}`)); + }); + + test('Should render props and text elements test for the Blocked By Organizations Modal properly', async () => { + const props: { + user: InterfaceQueryUserListItem; + index: number; + loggedInUserId: string; + resetAndRefetch: () => void; + } = { + user: { + user: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '2023-09-29T15:39:36.355Z', + organizationsBlockedBy: [ + { + _id: 'xyz', + name: 'XYZ', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-01-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + { + _id: 'mno', + name: 'MNO', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-03-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + ], + joinedOrganizations: [ + { + _id: 'abc', + name: 'Joined Organization 1', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-06-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + { + _id: 'def', + name: 'Joined Organization 2', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-07-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + ], + registeredEvents: [], + membershipRequests: [], + }, + appUserProfile: { + _id: '123', + isSuperAdmin: true, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + adminFor: [ + { + _id: 'xyz', + }, + ], + }, + }, + index: 0, + loggedInUserId: '123', + resetAndRefetch: resetAndRefetchMock, + }; + + render( + + + + + + + , + ); + + await wait(); + const showBlockedByOrgsBtn = screen.getByTestId( + `showBlockedByOrgsBtn${123}`, + ); + expect(showBlockedByOrgsBtn).toBeInTheDocument(); + fireEvent.click(showBlockedByOrgsBtn); + expect(screen.getByTestId('modal-blocked-org-123')).toBeInTheDocument(); + + // Close using escape key and reopen + fireEvent.keyDown(screen.getByTestId('modal-blocked-org-123'), { + key: 'Escape', + code: 'Escape', + keyCode: 27, + charCode: 27, + }); + expect( + screen.queryByRole('dialog')?.className.includes('show'), + ).toBeFalsy(); + fireEvent.click(showBlockedByOrgsBtn); + // Close using close button and reopen + fireEvent.click(screen.getByTestId(`closeBlockedByOrgsBtn${123}`)); + expect( + screen.queryByRole('dialog')?.className.includes('show'), + ).toBeFalsy(); + + fireEvent.click(showBlockedByOrgsBtn); + + // Expect the following to exist in modal + + const inputBox = screen.getByTestId(`searchByNameOrgsBlockedBy`); + expect(inputBox).toBeInTheDocument(); + expect(screen.getByText(/XYZ/i)).toBeInTheDocument(); + expect(screen.getByText(/MNO/i)).toBeInTheDocument(); + const elementsWithKingston = screen.getAllByText(/Kingston/i); + elementsWithKingston.forEach((element) => { + expect(element).toBeInTheDocument(); + }); + expect(screen.getByText(/29-01-2023/i)).toBeInTheDocument(); + expect(screen.getByText(/29-03-2023/i)).toBeInTheDocument(); + expect(screen.getByTestId('removeUserFromOrgBtnxyz')).toBeInTheDocument(); + expect(screen.getByTestId('removeUserFromOrgBtnmno')).toBeInTheDocument(); + // Click on Creator Link + fireEvent.click(screen.getByTestId(`creatorxyz`)); + expect(toast.success).toHaveBeenCalledWith('Profile Page Coming Soon !'); + + // Search for Blocked Organization 1 + const searchBtn = screen.getByTestId(`searchBtnOrgsBlockedBy`); + fireEvent.keyUp(inputBox, { + target: { value: 'XYZ' }, + }); + fireEvent.click(searchBtn); + expect(screen.getByText(/XYZ/i)).toBeInTheDocument(); + expect(screen.queryByText(/MNO/i)).not.toBeInTheDocument(); + + // Search for an Organization which does not exist + fireEvent.keyUp(inputBox, { + key: 'Enter', + target: { value: 'Blocked Organization 3' }, + }); + expect( + screen.getByText(`No results found for "Blocked Organization 3"`), + ).toBeInTheDocument(); + + // Now clear the search box + fireEvent.keyUp(inputBox, { key: 'Enter', target: { value: '' } }); + fireEvent.keyUp(inputBox, { target: { value: '' } }); + fireEvent.click(searchBtn); + + // Click on Organization Link + fireEvent.click(screen.getByText(/XYZ/i)); + expect(window.location.replace).toHaveBeenCalledWith('/orgdash/xyz'); + expect(mockNavgatePush).toHaveBeenCalledWith('/orgdash/xyz'); + fireEvent.click(screen.getByTestId(`closeBlockedByOrgsBtn${123}`)); + }); + + test('Remove user from Organization should function properly in Organizations Joined Modal', async () => { + const props: { + user: InterfaceQueryUserListItem; + index: number; + loggedInUserId: string; + resetAndRefetch: () => void; + } = { + user: { + user: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '2023-09-29T15:39:36.355Z', + organizationsBlockedBy: [ + { + _id: 'xyz', + name: 'XYZ', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-01-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + { + _id: 'mno', + name: 'MNO', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-01-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + ], + joinedOrganizations: [ + { + _id: 'abc', + name: 'Joined Organization 1', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-06-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + { + _id: 'def', + name: 'Joined Organization 2', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-07-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + ], + registeredEvents: [], + membershipRequests: [], + }, + appUserProfile: { + _id: '123', + isSuperAdmin: true, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + adminFor: [ + { + _id: 'abc', + }, + ], + }, + }, + index: 0, + loggedInUserId: '123', + resetAndRefetch: resetAndRefetchMock, + }; + + render( + + + + + + + , + ); + + await wait(); + const showJoinedOrgsBtn = screen.getByTestId(`showJoinedOrgsBtn${123}`); + expect(showJoinedOrgsBtn).toBeInTheDocument(); + fireEvent.click(showJoinedOrgsBtn); + expect(screen.getByTestId('modal-joined-org-123')).toBeInTheDocument(); + fireEvent.click(showJoinedOrgsBtn); + fireEvent.click(screen.getByTestId(`removeUserFromOrgBtn${'abc'}`)); + expect(screen.getByTestId('modal-remove-user-123')).toBeInTheDocument(); + + // Close using escape key and reopen + fireEvent.keyDown(screen.getByTestId('modal-joined-org-123'), { + key: 'Escape', + code: 'Escape', + keyCode: 27, + charCode: 27, + }); + expect( + screen + .queryAllByRole('dialog') + .some((el) => el.className.includes('show')), + ).toBeTruthy(); + fireEvent.click(showJoinedOrgsBtn); + // Close using close button and reopen + fireEvent.click(screen.getByTestId('closeRemoveUserModal123')); + expect( + screen + .queryAllByRole('dialog') + .some((el) => el.className.includes('show')), + ).toBeTruthy(); + + fireEvent.click(showJoinedOrgsBtn); + fireEvent.click(screen.getByTestId(`removeUserFromOrgBtn${'abc'}`)); + const confirmRemoveBtn = screen.getByTestId(`confirmRemoveUser123`); + expect(confirmRemoveBtn).toBeInTheDocument(); + + fireEvent.click(confirmRemoveBtn); + }); + + test('Remove user from Organization should function properly in Organizations Blocked by Modal', async () => { + const props: { + user: InterfaceQueryUserListItem; + index: number; + loggedInUserId: string; + resetAndRefetch: () => void; + } = { + user: { + user: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: 'https://api.dicebear.com/5.x/initials/svg?seed=John%20Doe', + email: 'john@example.com', + createdAt: '2022-09-29T15:39:36.355Z', + organizationsBlockedBy: [ + { + _id: 'xyz', + name: 'Blocked Organization 1', + image: + 'https://api.dicebear.com/5.x/initials/svg?seed=Blocked%20Organization%201', + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-08-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: + 'https://api.dicebear.com/5.x/initials/svg?seed=John%20Doe', + email: 'john@example.com', + }, + }, + { + _id: 'mno', + name: 'Blocked Organization 2', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-09-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + ], + joinedOrganizations: [ + { + _id: 'abc', + name: 'Joined Organization 1', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-08-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + { + _id: 'def', + name: 'Joined Organization 2', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-09-19T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + }, + }, + ], + registeredEvents: [], + membershipRequests: [], + }, + appUserProfile: { + _id: '123', + isSuperAdmin: true, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + adminFor: [ + { + _id: 'abc', + }, + { + _id: 'xyz', + }, + ], + }, + }, + index: 0, + loggedInUserId: '123', + resetAndRefetch: resetAndRefetchMock, + }; + + render( + + + + + + + , + ); + + await wait(); + const showBlockedByOrgsBtn = screen.getByTestId( + `showBlockedByOrgsBtn${123}`, + ); + expect(showBlockedByOrgsBtn).toBeInTheDocument(); + fireEvent.click(showBlockedByOrgsBtn); + expect(screen.getByTestId('modal-blocked-org-123')).toBeInTheDocument(); + fireEvent.click(showBlockedByOrgsBtn); + fireEvent.click(screen.getByTestId(`removeUserFromOrgBtn${'xyz'}`)); + expect(screen.getByTestId('modal-remove-user-123')).toBeInTheDocument(); + + // Close using escape key and reopen + fireEvent.keyDown(screen.getByTestId('modal-blocked-org-123'), { + key: 'Escape', + code: 'Escape', + keyCode: 27, + charCode: 27, + }); + expect( + screen + .queryAllByRole('dialog') + .some((el) => el.className.includes('show')), + ).toBeTruthy(); + fireEvent.click(showBlockedByOrgsBtn); + // Close using close button and reopen + fireEvent.click(screen.getByTestId('closeRemoveUserModal123')); + expect( + screen + .queryAllByRole('dialog') + .some((el) => el.className.includes('show')), + ).toBeTruthy(); + + fireEvent.click(showBlockedByOrgsBtn); + fireEvent.click(screen.getByTestId(`removeUserFromOrgBtn${'xyz'}`)); + const confirmRemoveBtn = screen.getByTestId(`confirmRemoveUser123`); + expect(confirmRemoveBtn).toBeInTheDocument(); + + fireEvent.click(confirmRemoveBtn); + }); + + test('handles errors in removeUser mutation', async () => { + const props: { + user: InterfaceQueryUserListItem; + index: number; + loggedInUserId: string; + resetAndRefetch: () => void; + } = { + user: { + user: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: 'https://api.dicebear.com/5.x/initials/svg?seed=John%20Doe', + email: '', + createdAt: '2022-09-29T15:39:36.355Z', + organizationsBlockedBy: [ + { + _id: 'xyz', + name: 'Blocked Organization 1', + image: + 'https://api.dicebear.com/5.x/initials/svg?seed=Blocked%20Organization%201', + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-08-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: + 'https://api.dicebear.com/5.x/initials/svg?seed=John%20Doe', + email: '', + }, + }, + ], + joinedOrganizations: [ + { + _id: 'abc', + name: 'Joined Organization 1', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-08-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: '', + }, + }, + ], + registeredEvents: [], + membershipRequests: [], + }, + appUserProfile: { + _id: '123', + isSuperAdmin: true, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + adminFor: [ + { + _id: 'abc', + }, + { + _id: 'xyz', + }, + ], + }, + }, + index: 0, + loggedInUserId: '123', + resetAndRefetch: resetAndRefetchMock, + }; + + const mocks = [ + { + request: { + query: REMOVE_MEMBER_MUTATION, + variables: { + userId: '123', + orgId: 'xyz', + }, + }, + result: { + errors: [ + { + message: 'User does not exist', + }, + ], + }, + }, + ]; + + render( + + + + + + + , + ); + + await wait(); + const showBlockedByOrgsBtn = screen.getByTestId( + `showBlockedByOrgsBtn${123}`, + ); + expect(showBlockedByOrgsBtn).toBeInTheDocument(); + fireEvent.click(showBlockedByOrgsBtn); + expect(screen.getByTestId('modal-blocked-org-123')).toBeInTheDocument(); + }); + + test('change role button should function properly', async () => { + const props: { + user: InterfaceQueryUserListItem; + index: number; + loggedInUserId: string; + resetAndRefetch: () => void; + } = { + user: { + user: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: 'https://api.dicebear.com/5.x/initials/svg?seed=John%20Doe', + email: '', + createdAt: '2022-09-29T15:39:36.355Z', + organizationsBlockedBy: [ + { + _id: 'xyz', + name: 'Blocked Organization 1', + image: + 'https://api.dicebear.com/5.x/initials/svg?seed=Blocked%20Organization%201', + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-08-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: + 'https://api.dicebear.com/5.x/initials/svg?seed=John%20Doe', + email: '', + }, + }, + ], + joinedOrganizations: [ + { + _id: 'abc', + name: 'Joined Organization 1', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '2023-08-29T15:39:36.355Z', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: '', + }, + }, + ], + registeredEvents: [], + + membershipRequests: [], + }, + appUserProfile: { + _id: '123', + isSuperAdmin: false, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + adminFor: [ + { + _id: 'abc', + }, + { + _id: 'xyz', + }, + ], + }, + }, + index: 0, + loggedInUserId: '123', + resetAndRefetch: resetAndRefetchMock, + }; + + render( + + + + + + + , + ); + + await wait(); + const showJoinedOrgs = screen.getByTestId(`showJoinedOrgsBtn${123}`); + expect(showJoinedOrgs).toBeInTheDocument(); + fireEvent.click(showJoinedOrgs); + const changeRoleBtn = screen.getByTestId( + `changeRoleInOrg${'abc'}`, + ) as HTMLSelectElement; + expect(changeRoleBtn).toBeInTheDocument(); + userEvent.selectOptions(changeRoleBtn, 'ADMIN'); + await wait(); + userEvent.selectOptions(changeRoleBtn, 'USER'); + await wait(); + expect(changeRoleBtn.value).toBe(`USER?abc`); + await wait(); + }); +}); diff --git a/src/components/UsersTableItem/UserTableItemMocks.ts b/src/components/UsersTableItem/UserTableItemMocks.ts new file mode 100644 index 0000000000..3162487f33 --- /dev/null +++ b/src/components/UsersTableItem/UserTableItemMocks.ts @@ -0,0 +1,86 @@ +import { + REMOVE_MEMBER_MUTATION, + UPDATE_USER_ROLE_IN_ORG_MUTATION, +} from 'GraphQl/Mutations/mutations'; + +const MOCKS = [ + { + request: { + query: REMOVE_MEMBER_MUTATION, + variables: { + userid: '123', + orgid: 'abc', + }, + }, + result: { + data: { + removeMember: { + _id: '123', + }, + }, + }, + }, + { + request: { + query: UPDATE_USER_ROLE_IN_ORG_MUTATION, + variables: { + userId: '123', + organizationId: 'abc', + role: 'USER', + }, + }, + result: { + data: { + updateUserRoleInOrganization: { + _id: '123', + }, + }, + }, + }, +]; + +const MOCKS2 = [ + { + request: { + query: REMOVE_MEMBER_MUTATION, + variables: { + userid: '123', + orgid: 'abc', + }, + }, + error: new Error('Failed to remove member'), + }, +]; + +const MOCKS_UPDATE = [ + { + request: { + query: UPDATE_USER_ROLE_IN_ORG_MUTATION, + variables: { + userId: '123', + organizationId: 'abc', + role: 'ADMIN', + }, + }, + error: new Error('Failed to update user role in organization'), + }, + { + request: { + query: UPDATE_USER_ROLE_IN_ORG_MUTATION, + variables: { + userId: '123', + organizationId: 'abc', + role: 'USER', + }, + }, + result: { + data: { + updateUserRoleInOrganization: { + _id: '123', + }, + }, + }, + }, +]; + +export { MOCKS, MOCKS2, MOCKS_UPDATE }; diff --git a/src/components/UsersTableItem/UsersTableItem.module.css b/src/components/UsersTableItem/UsersTableItem.module.css new file mode 100644 index 0000000000..d5fad679a7 --- /dev/null +++ b/src/components/UsersTableItem/UsersTableItem.module.css @@ -0,0 +1,26 @@ +.input { + position: relative; +} + +.notJoined { + height: 300px; + display: flex; + justify-content: center; + align-items: center; +} + +.modalTable img[alt='creator'] { + height: 24px; + width: 24px; + object-fit: contain; + border-radius: 12px; + margin-right: 0.4rem; +} + +.modalTable img[alt='orgImage'] { + height: 28px; + width: 28px; + object-fit: contain; + border-radius: 4px; + margin-right: 0.4rem; +} diff --git a/src/components/UsersTableItem/UsersTableItem.tsx b/src/components/UsersTableItem/UsersTableItem.tsx new file mode 100644 index 0000000000..9e94b8a9f5 --- /dev/null +++ b/src/components/UsersTableItem/UsersTableItem.tsx @@ -0,0 +1,593 @@ +import { useMutation } from '@apollo/client'; +import { Search } from '@mui/icons-material'; +import { + REMOVE_MEMBER_MUTATION, + UPDATE_USER_ROLE_IN_ORG_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import Avatar from 'components/Avatar/Avatar'; +import dayjs from 'dayjs'; +import React, { useState } from 'react'; +import { Button, Form, Modal, Row, Table } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { errorHandler } from 'utils/errorHandler'; +import type { InterfaceQueryUserListItem } from 'utils/interfaces'; +import styles from './UsersTableItem.module.css'; +type Props = { + user: InterfaceQueryUserListItem; + index: number; + loggedInUserId: string; + resetAndRefetch: () => void; +}; +const UsersTableItem = (props: Props): JSX.Element => { + const { t } = useTranslation('translation', { keyPrefix: 'users' }); + const { t: tCommon } = useTranslation('common'); + const { user, index, resetAndRefetch } = props; + const [showJoinedOrganizations, setShowJoinedOrganizations] = useState(false); + const [showBlockedOrganizations, setShowBlockedOrganizations] = + useState(false); + const [showRemoveUserModal, setShowRemoveUserModal] = useState(false); + const [removeUserProps, setremoveUserProps] = useState<{ + orgName: string; + orgId: string; + setShowOnCancel: 'JOINED' | 'BLOCKED' | ''; + }>({ + orgName: '', + orgId: '', + setShowOnCancel: '', + }); + const [joinedOrgs, setJoinedOrgs] = useState(user.user.joinedOrganizations); + const [orgsBlockedBy, setOrgsBlockedBy] = useState( + user.user.organizationsBlockedBy, + ); + const [searchByNameJoinedOrgs, setSearchByNameJoinedOrgs] = useState(''); + const [searchByNameOrgsBlockedBy, setSearchByNameOrgsBlockedBy] = + useState(''); + const [removeUser] = useMutation(REMOVE_MEMBER_MUTATION); + const [updateUserInOrgType] = useMutation(UPDATE_USER_ROLE_IN_ORG_MUTATION); + const navigate = useNavigate(); + const confirmRemoveUser = async (): Promise => { + try { + const { data } = await removeUser({ + variables: { + userid: user.user._id, + orgid: removeUserProps.orgId, + }, + }); + if (data) { + toast.success( + tCommon('removedSuccessfully', { item: 'User' }) as string, + ); + resetAndRefetch(); + } + } catch (error: unknown) { + errorHandler(t, error); + } + }; + const changeRoleInOrg = async ( + e: React.ChangeEvent, + ): Promise => { + const { value } = e.target; + const inputData = value.split('?'); + try { + const { data } = await updateUserInOrgType({ + variables: { + userId: user.user._id, + role: inputData[0], + organizationId: inputData[1], + }, + }); + if (data) { + toast.success(t('roleUpdated') as string); + resetAndRefetch(); + } + } catch (error: unknown) { + errorHandler(t, error); + } + }; + function goToOrg(_id: string): void { + const url = '/orgdash/' + _id; + window.location.replace(url); + navigate(url); + } + function handleCreator(): void { + toast.success('Profile Page Coming Soon !'); + } + const searchJoinedOrgs = (value: string): void => { + setSearchByNameJoinedOrgs(value); + if (value == '') { + setJoinedOrgs(user.user.joinedOrganizations); + } else { + const filteredOrgs = user.user.joinedOrganizations.filter((org) => + org.name.toLowerCase().includes(value.toLowerCase()), + ); + setJoinedOrgs(filteredOrgs); + } + }; + const searchOrgsBlockedBy = (value: string): void => { + setSearchByNameOrgsBlockedBy(value); + if (value == '') { + setOrgsBlockedBy(user.user.organizationsBlockedBy); + } else { + const filteredOrgs = user.user.organizationsBlockedBy.filter((org) => + org.name.toLowerCase().includes(value.toLowerCase()), + ); + setOrgsBlockedBy(filteredOrgs); + } + }; + const handleSearchJoinedOrgs = ( + e: React.KeyboardEvent, + ): void => { + if (e.key === 'Enter') { + const { value } = e.currentTarget; + searchJoinedOrgs(value); + } + }; + const handleSearchByOrgsBlockedBy = ( + e: React.KeyboardEvent, + ): void => { + if (e.key === 'Enter') { + const { value } = e.currentTarget; + searchOrgsBlockedBy(value); + } + }; + const handleSearchButtonClickJoinedOrgs = (): void => { + const inputValue = + (document.getElementById('orgname-joined-orgs') as HTMLInputElement) + ?.value || ''; + searchJoinedOrgs(inputValue); + }; + const handleSearchButtonClickOrgsBlockedBy = (): void => { + const inputValue = + (document.getElementById('orgname-blocked-by') as HTMLInputElement) + ?.value || ''; + searchOrgsBlockedBy(inputValue); + }; + function onHideRemoveUserModal(): void { + setShowRemoveUserModal(false); + if (removeUserProps.setShowOnCancel == 'JOINED') { + setShowJoinedOrganizations(true); + } else if (removeUserProps.setShowOnCancel == 'BLOCKED') { + setShowBlockedOrganizations(true); + } + } + const isSuperAdmin = user.appUserProfile.isSuperAdmin; + return ( + <> + + {index + 1} + {`${user.user.firstName} ${user.user.lastName}`} + {user.user.email} + + + + + + + + setShowJoinedOrganizations(false)} + > + + + {t('orgJoinedBy')} {`${user.user.firstName}`}{' '} + {`${user.user.lastName}`} ({user.user.joinedOrganizations.length}) + + + + {user.user.joinedOrganizations.length !== 0 && ( +
    + + +
    + )} + + {user.user.joinedOrganizations.length == 0 ? ( +
    +

    + {user.user.firstName} {user.user.lastName}{' '} + {t('hasNotJoinedAnyOrg')} +

    +
    + ) : joinedOrgs.length == 0 ? ( +
    +

    + {tCommon('noResultsFoundFor')} " + {searchByNameJoinedOrgs} + " +

    +
    + ) : ( + + + + + + + + + + + + + + {joinedOrgs.map((org) => { + let isAdmin = false; + user.appUserProfile.adminFor.map((item) => { + if (item._id == org._id) { + isAdmin = true; + } + }); + return ( + + + {org.address && } + + + + + + + ); + })} + +
    {tCommon('name')}{tCommon('address')}{tCommon('createdOn')}{tCommon('createdBy')}{tCommon('usersRole')}{tCommon('changeRole')}{tCommon('action')}
    + + {org.address.city}{dayjs(org.createdAt).format('DD-MM-YYYY')} + + + {isSuperAdmin + ? 'SUPERADMIN' + : isAdmin + ? 'ADMIN' + : 'USER'} + + + {isSuperAdmin ? ( + <> + + + + + ) : isAdmin ? ( + <> + + + + ) : ( + <> + + + + )} + + + +
    + )} +
    +
    + + + +
    + setShowBlockedOrganizations(false)} + data-testid={`modal-blocked-org-${user.user._id}`} + > + + + {t('orgThatBlocked')} {`${user.user.firstName}`}{' '} + {`${user.user.lastName}`} ({user.user.organizationsBlockedBy.length} + ) + + + + {user.user.organizationsBlockedBy.length !== 0 && ( +
    + + +
    + )} + + {user.user.organizationsBlockedBy.length == 0 ? ( +
    +

    + {user.user.firstName} {user.user.lastName}{' '} + {t('isNotBlockedByAnyOrg')} +

    +
    + ) : orgsBlockedBy.length == 0 ? ( +
    +

    {`${tCommon('noResultsFoundFor')} "${searchByNameOrgsBlockedBy}"`}

    +
    + ) : ( + + + + + + + + + + + + + + {orgsBlockedBy.map((org) => { + let isAdmin = false; + user.appUserProfile.adminFor.map((item) => { + if (item._id == org._id) { + isAdmin = true; + } + }); + return ( + + + {org.address && } + + + + + + + ); + })} + +
    {tCommon('name')}{tCommon('address')}{tCommon('createdOn')}{tCommon('createdBy')}{tCommon('usersRole')}{tCommon('changeRole')}{tCommon('action')}
    + + {org.address.city}{dayjs(org.createdAt).format('DD-MM-YYYY')} + + {isAdmin ? 'ADMIN' : 'USER'} + + {isSuperAdmin ? ( + <> + + + + + ) : isAdmin ? ( + <> + + + + ) : ( + <> + + + + )} + + + +
    + )} +
    +
    + + + +
    + onHideRemoveUserModal()} + > + + + {t('removeUserFrom', { org: removeUserProps.orgName })} + + + +

    + {t('removeConfirmation', { + name: `${user.user.firstName} ${user.user.lastName}`, + org: removeUserProps.orgName, + })} +

    +
    + + + + +
    + + ); +}; +export default UsersTableItem; diff --git a/src/components/Venues/VenueCard.tsx b/src/components/Venues/VenueCard.tsx new file mode 100644 index 0000000000..752ac95139 --- /dev/null +++ b/src/components/Venues/VenueCard.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { Card, Button } from 'react-bootstrap'; +import defaultImg from 'assets/images/defaultImg.png'; +import PeopleIcon from 'assets/svgs/people.svg?react'; +import styles from 'screens/OrganizationVenues/OrganizationVenues.module.css'; +import { useTranslation } from 'react-i18next'; +import type { InterfaceQueryVenueListItem } from 'utils/interfaces'; + +interface InterfaceVenueCardProps { + venueItem: InterfaceQueryVenueListItem; + index: number; + showEditVenueModal: (venueItem: InterfaceQueryVenueListItem) => void; + handleDelete: (venueId: string) => void; +} + +/** + * Represents a card component displaying venue information. + * + * This component renders a card with the venue's image, name, capacity, and description. + * It also provides buttons to edit or delete the venue. + * + * @param venueItem - The venue item to be displayed in the card. + * @param index - The index of the venue item in the list, used for test IDs. + * @param showEditVenueModal - Function to show the edit venue modal, passing the current venue item. + * @param handleDelete - Function to handle the deletion of the venue, passing the venue ID. + * + * @returns JSX.Element - The `VenueCard` component. + * + * @example + * ```tsx + * + * ``` + */ +const VenueCard = ({ + venueItem, + index, + showEditVenueModal, + handleDelete, +}: InterfaceVenueCardProps): JSX.Element => { + // Translation hook for internationalization + const { t: tCommon } = useTranslation('common'); + + return ( +
    +
    + + {/* Venue image or default image if none provided */} + + + + {/* Venue name with truncation if too long */} +
    + {venueItem.name.length > 25 + ? venueItem.name.slice(0, 25) + '...' + : venueItem.name} +
    + + {/* Venue capacity with icon */} +
    + Capacity: {venueItem.capacity} + +
    +
    + + {/* Venue description with truncation if too long */} + {venueItem.description && venueItem.description.length > 75 + ? venueItem.description.slice(0, 75) + '...' + : venueItem.description} + +
    +
    + {/* Edit button */} + + {/* Delete button */} + +
    +
    +
    +
    + ); +}; + +export default VenueCard; diff --git a/src/components/Venues/VenueModal.module.css b/src/components/Venues/VenueModal.module.css new file mode 100644 index 0000000000..e88b022187 --- /dev/null +++ b/src/components/Venues/VenueModal.module.css @@ -0,0 +1,53 @@ +.titlemodal { + color: #707070; + font-weight: 600; + font-size: 32px; + width: 65%; + margin-bottom: 0px; +} + +.greenregbtn { + margin: 1rem 0 0; + margin-top: 10px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 10px; + border-radius: 5px; + background-color: #31bb6b; + width: 100%; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; +} + +.preview { + display: flex; + position: relative; + width: 100%; + margin-top: 10px; + justify-content: center; +} +.preview img { + width: 400px; + height: auto; +} + +.closeButtonP { + position: absolute; + top: 0px; + right: 0px; + background: transparent; + transform: scale(1.2); + cursor: pointer; + border: none; + color: #707070; + font-weight: 600; + font-size: 16px; + cursor: pointer; +} diff --git a/src/components/Venues/VenueModal.test.tsx b/src/components/Venues/VenueModal.test.tsx new file mode 100644 index 0000000000..b299c8ff20 --- /dev/null +++ b/src/components/Venues/VenueModal.test.tsx @@ -0,0 +1,287 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; + +import type { InterfaceVenueModalProps } from './VenueModal'; +import VenueModal from './VenueModal'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import userEvent from '@testing-library/user-event'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import { + CREATE_VENUE_MUTATION, + UPDATE_VENUE_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import type { ApolloLink } from '@apollo/client'; + +const MOCKS = [ + { + request: { + query: CREATE_VENUE_MUTATION, + variables: { + name: 'Test Venue', + description: 'Test Venue Desc', + capacity: 100, + organizationId: 'orgId', + file: '', + }, + }, + result: { + data: { + createVenue: { + _id: 'orgId', + }, + }, + }, + }, + { + request: { + query: UPDATE_VENUE_MUTATION, + variables: { + capacity: 200, + description: 'Updated description', + file: 'image1', + id: 'venue1', + name: 'Updated Venue', + organizationId: 'orgId', + }, + }, + result: { + data: { + editVenue: { + _id: 'venue1', + }, + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +const mockId = 'orgId'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: mockId }), +})); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }, +})); + +const props: InterfaceVenueModalProps[] = [ + { + show: true, + onHide: jest.fn(), + edit: false, + venueData: null, + refetchVenues: jest.fn(), + orgId: 'orgId', + }, + { + show: true, + onHide: jest.fn(), + edit: true, + venueData: { + _id: 'venue1', + name: 'Venue 1', + description: 'Updated description for venue 1', + image: 'image1', + capacity: '100', + }, + refetchVenues: jest.fn(), + orgId: 'orgId', + }, +]; + +const renderVenueModal = ( + props: InterfaceVenueModalProps, + link: ApolloLink, +): RenderResult => { + return render( + + + + + + + + + , + ); +}; + +describe('VenueModal', () => { + global.alert = jest.fn(); + + test('renders correctly when show is true', async () => { + renderVenueModal(props[0], link); + expect(screen.getByText('Venue Details')).toBeInTheDocument(); + }); + + test('does not render when show is false', () => { + const { container } = renderVenueModal({ ...props[0], show: false }, link); + expect(container.firstChild).toBeNull(); + }); + + test('populates form fields correctly in edit mode', () => { + renderVenueModal(props[1], link); + expect(screen.getByDisplayValue('Venue 1')).toBeInTheDocument(); + expect( + screen.getByDisplayValue('Updated description for venue 1'), + ).toBeInTheDocument(); + expect(screen.getByDisplayValue('100')).toBeInTheDocument(); + }); + + test('form fields are empty in create mode', () => { + renderVenueModal(props[0], link); + expect(screen.getByPlaceholderText('Enter Venue Name')).toHaveValue(''); + expect(screen.getByPlaceholderText('Enter Venue Description')).toHaveValue( + '', + ); + expect(screen.getByPlaceholderText('Enter Venue Capacity')).toHaveValue(''); + }); + + test('calls onHide when close button is clicked', () => { + renderVenueModal(props[0], link); + fireEvent.click(screen.getByTestId('createVenueModalCloseBtn')); + expect(props[0].onHide).toHaveBeenCalled(); + }); + + test('displays image preview and clear button when an image is selected', async () => { + renderVenueModal(props[0], link); + + const file = new File(['chad'], 'chad.png', { type: 'image/png' }); + const fileInput = screen.getByTestId('venueImgUrl'); + userEvent.upload(fileInput, file); + + await wait(); + + expect(screen.getByAltText('Venue Image Preview')).toBeInTheDocument(); + expect(screen.getByTestId('closeimage')).toBeInTheDocument(); + }); + + test('removes image preview when clear button is clicked', async () => { + renderVenueModal(props[0], link); + + const file = new File(['chad'], 'chad.png', { type: 'image/png' }); + const fileInput = screen.getByTestId('venueImgUrl'); + userEvent.upload(fileInput, file); + + await wait(); + + const form = screen.getByTestId('venueForm'); + form.addEventListener('submit', (e) => e.preventDefault()); + fireEvent.click(screen.getByTestId('closeimage')); + + expect( + screen.queryByAltText('Venue Image Preview'), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('closeimage')).not.toBeInTheDocument(); + }); + + test('shows error when venue name is empty', async () => { + renderVenueModal(props[0], link); + + const form = screen.getByTestId('venueForm'); + form.addEventListener('submit', (e) => e.preventDefault()); + + const submitButton = screen.getByTestId('createVenueBtn'); + fireEvent.click(submitButton); + + await wait(); + + expect(toast.error).toHaveBeenCalledWith('Venue title cannot be empty!'); + }); + + test('shows error when venue capacity is not a positive number', async () => { + renderVenueModal(props[0], link); + + const nameInput = screen.getByPlaceholderText('Enter Venue Name'); + fireEvent.change(nameInput, { target: { value: 'Test venue' } }); + + const capacityInput = screen.getByPlaceholderText('Enter Venue Capacity'); + fireEvent.change(capacityInput, { target: { value: '-1' } }); + + const form = screen.getByTestId('venueForm'); + form.addEventListener('submit', (e) => e.preventDefault()); + + const submitButton = screen.getByTestId('createVenueBtn'); + fireEvent.click(submitButton); + + await wait(); + + expect(toast.error).toHaveBeenCalledWith( + 'Capacity must be a positive number!', + ); + }); + + test('shows success toast when a new venue is created', async () => { + renderVenueModal(props[0], link); + + const nameInput = screen.getByPlaceholderText('Enter Venue Name'); + fireEvent.change(nameInput, { target: { value: 'Test Venue' } }); + const descriptionInput = screen.getByPlaceholderText( + 'Enter Venue Description', + ); + fireEvent.change(descriptionInput, { + target: { value: 'Test Venue Desc' }, + }); + + const capacityInput = screen.getByPlaceholderText('Enter Venue Capacity'); + fireEvent.change(capacityInput, { target: { value: 100 } }); + const form = screen.getByTestId('venueForm'); + form.addEventListener('submit', (e) => e.preventDefault()); + + const submitButton = screen.getByTestId('createVenueBtn'); + fireEvent.click(submitButton); + + await wait(); + + expect(toast.success).toHaveBeenCalledWith('Venue added Successfully'); + }); + + test('shows success toast when an existing venue is updated', async () => { + renderVenueModal(props[1], link); + + const nameInput = screen.getByDisplayValue('Venue 1'); + fireEvent.change(nameInput, { target: { value: 'Updated Venue' } }); + const descriptionInput = screen.getByDisplayValue( + 'Updated description for venue 1', + ); + fireEvent.change(descriptionInput, { + target: { value: 'Updated description' }, + }); + + const capacityInput = screen.getByDisplayValue('100'); + fireEvent.change(capacityInput, { target: { value: 200 } }); + const form = screen.getByTestId('venueForm'); + form.addEventListener('submit', (e) => e.preventDefault()); + + const submitButton = screen.getByTestId('updateVenueBtn'); + fireEvent.click(submitButton); + + await wait(); + + expect(toast.success).toHaveBeenCalledWith( + 'Venue details updated successfully', + ); + }); +}); diff --git a/src/components/Venues/VenueModal.tsx b/src/components/Venues/VenueModal.tsx new file mode 100644 index 0000000000..476964b910 --- /dev/null +++ b/src/components/Venues/VenueModal.tsx @@ -0,0 +1,278 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import styles from './VenueModal.module.css'; +import { toast } from 'react-toastify'; +import { useTranslation } from 'react-i18next'; +import { useMutation } from '@apollo/client'; +import { + CREATE_VENUE_MUTATION, + UPDATE_VENUE_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import { errorHandler } from 'utils/errorHandler'; +import convertToBase64 from 'utils/convertToBase64'; +import type { InterfaceQueryVenueListItem } from 'utils/interfaces'; + +export interface InterfaceVenueModalProps { + show: boolean; + onHide: () => void; + refetchVenues: () => void; + orgId: string; + venueData?: InterfaceQueryVenueListItem | null; + edit: boolean; +} + +/** + * A modal component for creating or updating venue information. + * + * This component displays a modal window where users can enter details for a venue, such as name, description, capacity, and an image. + * It also handles submitting the form data to create or update a venue based on whether the `edit` prop is true or false. + * + * @param show - A flag indicating if the modal should be visible. + * @param onHide - A function to call when the modal should be closed. + * @param refetchVenues - A function to refetch the list of venues after a successful operation. + * @param orgId - The ID of the organization to which the venue belongs. + * @param venueData - Optional venue data to prefill the form for editing. If null, the form will be empty. + * @param edit - A flag indicating if the modal is in edit mode. If true, the component will update an existing venue; if false, it will create a new one. + * + * @returns The rendered modal component. + */ +const VenueModal = ({ + show, + onHide, + refetchVenues, + orgId, + edit, + venueData, +}: InterfaceVenueModalProps): JSX.Element => { + // Translation hooks for different languages + const { t } = useTranslation('translation', { + keyPrefix: 'organizationVenues', + }); + const { t: tCommon } = useTranslation('common'); + + // State to manage image preview and form data + const [venueImage, setVenueImage] = useState(false); + const [formState, setFormState] = useState({ + name: venueData?.name || '', + description: venueData?.description || '', + capacity: venueData?.capacity || '', + imageURL: venueData?.image || '', + }); + + // Reference for the file input element + const fileInputRef = useRef(null); + + // Mutation function for creating or updating a venue + const [mutate, { loading }] = useMutation( + edit ? UPDATE_VENUE_MUTATION : CREATE_VENUE_MUTATION, + ); + + /** + * Handles form submission to create or update a venue. + * + * Validates form inputs and sends a request to the server to create or update the venue. + * If the operation is successful, it shows a success message, refetches venues, and resets the form. + * + * @returns A promise that resolves when the submission is complete. + */ + const handleSubmit = useCallback(async () => { + if (formState.name.trim().length === 0) { + toast.error(t('venueTitleError') as string); + return; + } + + const capacityNum = parseInt(formState.capacity); + if (isNaN(capacityNum) || capacityNum <= 0) { + toast.error(t('venueCapacityError') as string); + return; + } + + try { + const { data } = await mutate({ + variables: { + capacity: capacityNum, + file: formState.imageURL, + description: formState.description, + name: formState.name, + organizationId: orgId, + ...(edit && { id: venueData?._id }), + }, + }); + /* istanbul ignore next */ + if (data) { + toast.success( + edit ? (t('venueUpdated') as string) : (t('venueAdded') as string), + ); + refetchVenues(); + onHide(); + setFormState({ + name: '', + description: '', + capacity: '', + imageURL: '', + }); + setVenueImage(false); + } + } catch (error) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }, [ + edit, + formState, + mutate, + onHide, + orgId, + refetchVenues, + t, + venueData?._id, + ]); + + /** + * Clears the selected image and resets the image preview. + * + * This function also clears the file input field. + */ + const clearImageInput = useCallback(() => { + setFormState((prevState) => ({ ...prevState, imageURL: '' })); + setVenueImage(false); + /* istanbul ignore next */ + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, []); + + // Update form state when venueData changes + useEffect(() => { + setFormState({ + name: venueData?.name || '', + description: venueData?.description || '', + capacity: venueData?.capacity || '', + imageURL: venueData?.image || '', + }); + setVenueImage(venueData?.image ? true : false); + }, [venueData]); + + const { name, description, capacity, imageURL } = formState; + + return ( + + +

    {t('venueDetails')}

    + +
    + +
    + + { + setFormState({ + ...formState, + name: e.target.value, + }); + }} + /> + + { + setFormState({ + ...formState, + description: e.target.value, + }); + }} + /> + + { + setFormState({ + ...formState, + capacity: e.target.value, + }); + }} + /> + {t('image')} + , + ): Promise => { + setFormState((prevPostFormState) => ({ + ...prevPostFormState, + imageURL: '', + })); + setVenueImage(true); + const file = e.target.files?.[0]; + /* istanbul ignore next */ + if (file) { + setFormState({ + ...formState, + imageURL: await convertToBase64(file), + }); + } + }} + /> + {venueImage && ( +
    + Venue Image Preview + +
    + )} + + + +
    +
    + ); +}; + +export default VenueModal; diff --git a/src/components/plugins/DummyPlugin/DummyPlugin.module.css b/src/components/plugins/DummyPlugin/DummyPlugin.module.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/components/plugins/DummyPlugin/DummyPlugin.test.jsx b/src/components/plugins/DummyPlugin/DummyPlugin.test.jsx new file mode 100644 index 0000000000..e1abb52a1e --- /dev/null +++ b/src/components/plugins/DummyPlugin/DummyPlugin.test.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { I18nextProvider } from 'react-i18next'; +import { store } from 'state/store'; +import DummyPlugin from './DummyPlugin'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +const link = new StaticMockLink([], true); + +describe('Testing dummy plugin', () => { + test('should render props and text elements test for the page component', () => { + const { getByText } = render( + + + + + + + + + , + ); + + expect(getByText(/Welcome to the Dummy Plugin!/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/plugins/DummyPlugin/DummyPlugin.tsx b/src/components/plugins/DummyPlugin/DummyPlugin.tsx new file mode 100644 index 0000000000..5b837ea076 --- /dev/null +++ b/src/components/plugins/DummyPlugin/DummyPlugin.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import AddOn from 'components/AddOn/AddOn'; + +/** + * A dummy plugin component that renders a welcome message inside an `AddOn` component. + * + * This component is used for demonstration or testing purposes and does not have any + * additional functionality or properties. + * + * @returns JSX.Element - Renders the `AddOn` component containing a welcome message. + */ +function DummyPlugin(): JSX.Element { + return ( + +
    Welcome to the Dummy Plugin!
    +
    + ); +} + +export default DummyPlugin; diff --git a/src/components/plugins/DummyPlugin2/DummyPlugin2.module.css b/src/components/plugins/DummyPlugin2/DummyPlugin2.module.css new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/components/plugins/DummyPlugin2/DummyPlugin2.test.jsx b/src/components/plugins/DummyPlugin2/DummyPlugin2.test.jsx new file mode 100644 index 0000000000..c2cfe03a1e --- /dev/null +++ b/src/components/plugins/DummyPlugin2/DummyPlugin2.test.jsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import DummyPlugin2 from './DummyPlugin2'; + +describe('Testing DummyPlugin2', () => { + test('should render DummyPlugin2 component', () => { + render( + + + + + , + ); + }); +}); diff --git a/src/components/plugins/DummyPlugin2/DummyPlugin2.tsx b/src/components/plugins/DummyPlugin2/DummyPlugin2.tsx new file mode 100644 index 0000000000..dca6d63ee3 --- /dev/null +++ b/src/components/plugins/DummyPlugin2/DummyPlugin2.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +/** + * A placeholder component for demonstration or testing purposes. + * It renders an empty `div` element. + * + * This component currently does not have any additional functionality + * or properties. + */ +function DummyPlugin2(): JSX.Element { + return
    ; +} + +export default DummyPlugin2; diff --git a/src/components/plugins/index.ts b/src/components/plugins/index.ts new file mode 100644 index 0000000000..db688e0da2 --- /dev/null +++ b/src/components/plugins/index.ts @@ -0,0 +1,4 @@ +import DummyPlugin from './DummyPlugin/DummyPlugin'; +import DummyPlugin2 from './DummyPlugin2/DummyPlugin2'; + +export { DummyPlugin, DummyPlugin2 }; diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000000..d405ec5d1f --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,53 @@ +import { + FacebookLogo, + LinkedInLogo, + GithubLogo, + InstagramLogo, + SlackLogo, + XLogo, + YoutubeLogo, + RedditLogo, +} from 'assets/svgs/social-icons'; + +export const socialMediaLinks = [ + { + tag: 'facebook', + href: 'https://www.facebook.com/palisadoesproject', + logo: FacebookLogo, + }, + { + tag: 'X', + href: 'https://X.com/palisadoesorg?lang=en', + logo: XLogo, + }, + { + tag: 'linkedIn', + href: 'https://www.linkedin.com/company/palisadoes/', + logo: LinkedInLogo, + }, + { + tag: 'gitHub', + href: 'https://github.com/PalisadoesFoundation', + logo: GithubLogo, + }, + { + tag: 'youTube', + href: 'https://www.youtube.com/@PalisadoesOrganization', + logo: YoutubeLogo, + }, + { + tag: 'slack', + href: 'https://www.palisadoes.org/slack', + logo: SlackLogo, + }, + { + tag: 'instagram', + href: 'https://www.instagram.com/palisadoes/', + logo: InstagramLogo, + }, + { + tag: 'reddit', + href: '', + logo: RedditLogo, + }, +]; diff --git a/src/index.css b/src/index.css deleted file mode 100644 index ec2585e8c0..0000000000 --- a/src/index.css +++ /dev/null @@ -1,13 +0,0 @@ -body { - margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; -} diff --git a/src/index.tsx b/src/index.tsx index 15d9bd34d8..ec1d45ae69 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,14 +1,159 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import './index.css'; +import React, { Suspense } from 'react'; +import { createRoot } from 'react-dom/client'; +import { BrowserRouter } from 'react-router-dom'; +import type { NormalizedCacheObject } from '@apollo/client'; +import { + ApolloClient, + ApolloProvider, + InMemoryCache, + HttpLink, + split, +} from '@apollo/client'; +import { getMainDefinition } from '@apollo/client/utilities'; +import { GraphQLWsLink } from '@apollo/client/link/subscriptions'; +import { createClient } from 'graphql-ws'; +import { onError } from '@apollo/link-error'; +import './assets/css/app.css'; +import 'bootstrap/dist/js/bootstrap.min.js'; +import 'react-datepicker/dist/react-datepicker.css'; +import 'flag-icons/css/flag-icons.min.css'; +import { Provider } from 'react-redux'; +import { ToastContainer, toast } from 'react-toastify'; +import 'react-toastify/dist/ReactToastify.css'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + import App from './App'; -import reportWebVitals from './reportWebVitals'; +import { store } from './state/store'; +import { + BACKEND_URL, + REACT_APP_BACKEND_WEBSOCKET_URL, +} from 'Constant/constant'; +import { refreshToken } from 'utils/getRefreshToken'; +import { ThemeProvider, createTheme } from '@mui/material'; +import { ApolloLink } from '@apollo/client/core'; +import { setContext } from '@apollo/client/link/context'; +import '../src/assets/css/scrollStyles.css'; + +const theme = createTheme({ + palette: { + primary: { + main: '#31bb6b', + }, + }, +}); +import useLocalStorage from 'utils/useLocalstorage'; +import i18n from './utils/i18n'; +import { requestMiddleware, responseMiddleware } from 'utils/timezoneUtils'; + +const { getItem } = useLocalStorage(); +const authLink = setContext((_, { headers }) => { + const lng = i18n.language; + return { + headers: { + ...headers, + authorization: 'Bearer ' + getItem('token') || '', + 'Accept-Language': lng, + }, + }; +}); + +const errorLink = onError( + ({ graphQLErrors, networkError, operation, forward }) => { + if (graphQLErrors) { + graphQLErrors.map(({ message }) => { + if (message === 'User is not authenticated') { + refreshToken().then((success) => { + if (success) { + const oldHeaders = operation.getContext().headers; + operation.setContext({ + headers: { + ...oldHeaders, + authorization: 'Bearer ' + getItem('token'), + }, + }); + return forward(operation); + } else { + localStorage.clear(); + } + }); + } + }); + } else if (networkError) { + console.log(`[Network error]: ${networkError}`); + toast.error( + 'API server unavailable. Check your connection or try again later', + { + toastId: 'apiServer', + }, + ); + } + }, +); + +const httpLink = new HttpLink({ + uri: BACKEND_URL, +}); -ReactDOM.render( - - - , - document.getElementById('root') +// if didnt work use /subscriptions +const wsLink = new GraphQLWsLink( + createClient({ + url: REACT_APP_BACKEND_WEBSOCKET_URL, + }), ); -reportWebVitals(); +// const wsLink = new GraphQLWsLink( +// createClient({ +// url: 'ws://localhost:4000/subscriptions', +// }), +// ); +// The split function takes three parameters: +// +// * A function that's called for each operation to execute +// * The Link to use for an operation if the function returns a "truthy" value +// * The Link to use for an operation if the function returns a "falsy" value +const splitLink = split( + ({ query }) => { + const definition = getMainDefinition(query); + return ( + definition.kind === 'OperationDefinition' && + definition.operation === 'subscription' + ); + }, + wsLink, + httpLink, +); + +const combinedLink = ApolloLink.from([ + errorLink, + authLink, + requestMiddleware, + responseMiddleware, + splitLink, +]); + +const client: ApolloClient = new ApolloClient({ + cache: new InMemoryCache(), + link: combinedLink, +}); +const fallbackLoader =
    ; + +const container = document.getElementById('root'); +const root = createRoot(container!); // Note the use of '!' is to assert the container is not null + +root.render( + + + + + + + + + + + + + + , +); diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts deleted file mode 100644 index 6431bc5fc6..0000000000 --- a/src/react-app-env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/src/reportWebVitals.ts b/src/reportWebVitals.ts index eb4be08f20..f49db29f28 100644 --- a/src/reportWebVitals.ts +++ b/src/reportWebVitals.ts @@ -1,13 +1,13 @@ -import { ReportHandler } from 'web-vitals'; +import { promises } from 'dns'; +import type { MetricType } from 'web-vitals'; -const reportWebVitals = (onPerfEntry?: ReportHandler): void => { +const reportWebVitals = (onPerfEntry?: (metric: MetricType) => void): void => { if (onPerfEntry && onPerfEntry instanceof Function) { - import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { - getCLS(onPerfEntry); - getFID(onPerfEntry); - getFCP(onPerfEntry); - getLCP(onPerfEntry); - getTTFB(onPerfEntry); + import('web-vitals').then(({ onCLS, onFCP, onLCP, onTTFB }) => { + onCLS(onPerfEntry); + onFCP(onPerfEntry); + onLCP(onPerfEntry); + onTTFB(onPerfEntry); }); } }; diff --git a/src/screens/BlockUser/BlockUser.module.css b/src/screens/BlockUser/BlockUser.module.css new file mode 100644 index 0000000000..ed93446206 --- /dev/null +++ b/src/screens/BlockUser/BlockUser.module.css @@ -0,0 +1,102 @@ +.btnsContainer { + display: flex; + margin: 2.5rem 0 2.5rem 0; +} + +.btnsContainer .btnsBlock { + display: flex; +} + +.btnsContainer .btnsBlock button { + margin-left: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.btnsContainer .inputContainer { + flex: 1; + position: relative; +} + +.btnsContainer .input { + width: 70%; + position: relative; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); +} + +.btnsContainer .inputContainer button { + width: 52px; +} + +.largeBtnsWrapper { + display: flex; +} + +.listBox { + width: 100%; + flex: 1; +} + +.notFound { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +@media (max-width: 1020px) { + .btnsContainer { + flex-direction: column; + margin: 1.5rem 0; + } + + .btnsContainer .input { + width: 100%; + } + + .btnsContainer .btnsBlock { + margin: 1.5rem 0 0 0; + justify-content: space-between; + } + + .btnsContainer .btnsBlock button { + margin: 0; + } + + .btnsContainer .btnsBlock div button { + margin-right: 1.5rem; + } +} + +/* For mobile devices */ + +@media (max-width: 520px) { + .btnsContainer { + margin-bottom: 0; + } + + .btnsContainer .btnsBlock { + display: block; + margin-top: 1rem; + margin-right: 0; + } + + .largeBtnsWrapper { + flex-direction: column; + } + + .btnsContainer .btnsBlock div { + flex: 1; + } + + .btnsContainer .btnsBlock button { + margin-bottom: 1rem; + margin-right: 0; + width: 100%; + } +} diff --git a/src/screens/BlockUser/BlockUser.test.tsx b/src/screens/BlockUser/BlockUser.test.tsx new file mode 100644 index 0000000000..c851470d9b --- /dev/null +++ b/src/screens/BlockUser/BlockUser.test.tsx @@ -0,0 +1,668 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { + BLOCK_USER_MUTATION, + UNBLOCK_USER_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import { + BLOCK_PAGE_MEMBER_LIST, + ORGANIZATIONS_LIST, +} from 'GraphQl/Queries/Queries'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { ToastContainer } from 'react-toastify'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18nForTest from 'utils/i18nForTest'; + +import BlockUser from './BlockUser'; + +let userQueryCalled = false; + +const USER_BLOCKED = { + _id: '123', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@gmail.com', + organizationsBlockedBy: [ + { + _id: 'orgid', + }, + ], +}; + +const USER_UNBLOCKED = { + _id: '456', + firstName: 'Sam', + lastName: 'Smith', + email: 'samsmith@gmail.com', + organizationsBlockedBy: [], +}; + +const DATA_INITIAL = { + data: { + organizationsMemberConnection: { + edges: [USER_BLOCKED, USER_UNBLOCKED], + }, + }, +}; + +const DATA_AFTER_MUTATION = { + data: { + organizationsMemberConnection: { + edges: [ + { + _id: '123', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@gmail.com', + organizationsBlockedBy: [], + }, + { + _id: '456', + firstName: 'Sam', + lastName: 'Smith', + email: 'samsmith@gmail.com', + organizationsBlockedBy: [ + { + _id: 'orgid', + }, + ], + }, + ], + }, + }, +}; + +const MOCKS = [ + { + request: { + query: ORGANIZATIONS_LIST, + variables: { + id: 'orgid', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgid', + image: '', + creator: { + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + name: 'name', + description: 'description', + location: 'location', + members: { + _id: 'id', + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + admins: { + _id: 'id', + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + membershipRequests: { + _id: 'id', + user: { + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + }, + blockedUsers: { + _id: 'id', + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + }, + ], + }, + }, + }, + + { + request: { + query: BLOCK_USER_MUTATION, + variables: { + userId: '456', + orgId: 'orgid', + }, + }, + result: { + data: { + blockUser: { + _id: '456', + }, + }, + }, + }, + + { + request: { + query: UNBLOCK_USER_MUTATION, + variables: { + userId: '123', + orgId: 'orgid', + }, + }, + result: { + data: { + unblockUser: { + _id: '123', + }, + }, + }, + }, + + { + request: { + query: BLOCK_PAGE_MEMBER_LIST, + variables: { + firstName_contains: '', + lastName_contains: '', + orgId: 'orgid', + }, + }, + result: DATA_INITIAL, + newData: (): typeof DATA_AFTER_MUTATION | typeof DATA_INITIAL => { + if (userQueryCalled) { + return DATA_AFTER_MUTATION; + } else { + userQueryCalled = true; + + return DATA_INITIAL; + } + }, + }, + + { + request: { + query: BLOCK_PAGE_MEMBER_LIST, + variables: { + firstName_contains: 'john', + lastName_contains: '', + orgId: 'orgid', + }, + }, + result: { + data: { + organizationsMemberConnection: { + edges: [USER_BLOCKED], + }, + }, + }, + }, + + { + request: { + query: BLOCK_PAGE_MEMBER_LIST, + variables: { + firstName_contains: '', + lastName_contains: 'doe', + orgId: 'orgid', + }, + }, + result: { + data: { + organizationsMemberConnection: { + edges: [USER_BLOCKED], + }, + }, + }, + }, + + { + request: { + query: BLOCK_PAGE_MEMBER_LIST, + variables: { + firstName_contains: 'Peter', + lastName_contains: '', + orgId: 'orgid', + }, + }, + result: { + data: { + organizationsMemberConnection: { + edges: [], + }, + }, + }, + }, +]; +const MOCKS_EMPTY = [ + { + request: { + query: ORGANIZATIONS_LIST, + variables: { + id: 'orgid', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgid', + image: '', + creator: { + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + name: 'name', + description: 'description', + location: 'location', + members: { + _id: 'id', + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + admins: { + _id: 'id', + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + membershipRequests: { + _id: 'id', + user: { + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + }, + blockedUsers: { + _id: 'id', + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + }, + ], + }, + }, + }, + + { + request: { + query: BLOCK_PAGE_MEMBER_LIST, + variables: { + firstName_contains: 'Peter', + lastName_contains: '', + orgId: 'orgid', + }, + }, + result: { + data: { + organizationsMemberConnection: { + edges: [], + }, + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(MOCKS_EMPTY, true); + +async function wait(ms = 500): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgid' }), +})); + +describe('Testing Block/Unblock user screen', () => { + beforeEach(() => { + userQueryCalled = false; + }); + + test('Components should be rendered properly', async () => { + window.location.assign('/blockuser/orgid'); + + render( + + + + + + + + + , + ); + + await wait(); + + expect(screen.getByText('Search By First Name')).toBeInTheDocument(); + + expect(window.location).toBeAt('/blockuser/orgid'); + }); + + test('Testing block user functionality', async () => { + window.location.assign('/blockuser/orgid'); + + render( + + + + + + + + + , + ); + + await act(async () => { + userEvent.click(screen.getByTestId('userFilter')); + }); + userEvent.click(screen.getByTestId('showMembers')); + await wait(); + + expect(screen.getByTestId('unBlockUser123')).toBeInTheDocument(); + expect(screen.getByTestId('blockUser456')).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('unBlockUser123')); + await wait(); + + expect(screen.getByTestId('blockUser123')).toBeInTheDocument(); + expect(screen.getByTestId('unBlockUser456')).toBeInTheDocument(); + + expect(window.location).toBeAt('/blockuser/orgid'); + }); + + test('Testing unblock user functionality', async () => { + window.location.assign('/blockuser/orgid'); + + render( + + + + + + + + + , + ); + await act(async () => { + userEvent.click(screen.getByTestId('userFilter')); + }); + userEvent.click(screen.getByTestId('showMembers')); + + await wait(); + + expect(screen.getByTestId('unBlockUser123')).toBeInTheDocument(); + expect(screen.getByTestId('blockUser456')).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('blockUser456')); + await wait(); + + expect(screen.getByTestId('blockUser123')).toBeInTheDocument(); + expect(screen.getByTestId('unBlockUser456')).toBeInTheDocument(); + + expect(window.location).toBeAt('/blockuser/orgid'); + }); + + test('Testing First Name Filter', async () => { + window.location.assign('/blockuser/orgid'); + + render( + + + + + + + + + , + ); + await act(async () => { + userEvent.click(screen.getByTestId('userFilter')); + }); + userEvent.click(screen.getByTestId('showMembers')); + + await wait(); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('Sam Smith')).toBeInTheDocument(); + + // Open Dropdown + await act(async () => { + userEvent.click(screen.getByTestId('nameFilter')); + }); + // Select option and enter first name + userEvent.click(screen.getByTestId('searchByFirstName')); + const firstNameInput = screen.getByPlaceholderText(/Search by First Name/i); + userEvent.type(firstNameInput, 'john{enter}'); + + await wait(700); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText('Sam Smith')).not.toBeInTheDocument(); + + expect(window.location).toBeAt('/blockuser/orgid'); + }); + + test('Testing Last Name Filter', async () => { + window.location.assign('/blockuser/orgid'); + + render( + + + + + + + + + , + ); + + await act(async () => { + userEvent.click(screen.getByTestId('userFilter')); + }); + userEvent.click(screen.getByTestId('showMembers')); + + await wait(); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('Sam Smith')).toBeInTheDocument(); + + // Open Dropdown + await act(async () => { + userEvent.click(screen.getByTestId('nameFilter')); + }); + // Select option and enter last name + userEvent.click(screen.getByTestId('searchByLastName')); + const lastNameInput = screen.getByPlaceholderText(/Search by Last Name/i); + userEvent.type(lastNameInput, 'doe{enter}'); + + await wait(700); + + expect(lastNameInput).toHaveValue('doe'); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText('Sam Smith')).not.toBeInTheDocument(); + expect(window.location).toBeAt('/blockuser/orgid'); + }); + + test('Testing No Spammers Present', async () => { + window.location.assign('/blockuser/orgid'); + render( + + + + + + + + + , + ); + + await wait(); + expect(screen.getByText(/No spammer found/i)).toBeInTheDocument(); + expect(window.location).toBeAt('/blockuser/orgid'); + }); + + test('Testing All Members', async () => { + window.location.assign('/blockuser/orgid'); + + render( + + + + + + + + + + , + ); + await wait(); + await act(async () => { + userEvent.click(screen.getByTestId('userFilter')); + }); + userEvent.click(screen.getByTestId('showMembers')); + + await wait(700); + + expect(screen.getByTestId(/userFilter/i)).toHaveTextContent('All Members'); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('Sam Smith')).toBeInTheDocument(); + + expect(window.location).toBeAt('/blockuser/orgid'); + }); + + test('Testing Blocked Users', async () => { + window.location.assign('/blockuser/orgid'); + + render( + + + + + + + + + + , + ); + + await act(async () => { + userEvent.click(screen.getByTestId('userFilter')); + }); + + userEvent.click(screen.getByTestId('showBlockedMembers')); + await wait(); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText('Sam Smith')).not.toBeInTheDocument(); + + expect(window.location).toBeAt('/blockuser/orgid'); + }); + + test('Testing table data getting rendered', async () => { + window.location.assign('/orglist/orgid'); + const link = new StaticMockLink(MOCKS, true); + render( + + + + + + + + + , + ); + await act(async () => { + userEvent.click(screen.getByTestId('userFilter')); + }); + userEvent.click(screen.getByTestId('showMembers')); + + await wait(); + + expect(screen.getByTestId(/userList/)).toBeInTheDocument(); + expect(screen.getAllByText('Block/Unblock')).toHaveLength(1); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('Sam Smith')).toBeInTheDocument(); + }); + + test('Testing No Results Found', async () => { + window.location.assign('/blockuser/orgid'); + render( + + + + + + + + + , + ); + + const input = screen.getByPlaceholderText('Search By First Name'); + await act(async () => { + userEvent.type(input, 'Peter{enter}'); + }); + await wait(700); + expect( + screen.getByText(`No results found for "Peter"`), + ).toBeInTheDocument(); + expect(window.location).toBeAt('/blockuser/orgid'); + }); + + test('Testing Search functionality', async () => { + window.location.assign('/blockuser/orgid'); + + render( + + + + + + + + + + , + ); + await wait(); + const searchBar = screen.getByTestId(/searchByName/i); + const searchBtn = screen.getByTestId(/searchBtn/i); + expect(searchBar).toBeInTheDocument(); + userEvent.type(searchBar, 'Dummy{enter}'); + await wait(); + userEvent.clear(searchBar); + userEvent.type(searchBar, 'Dummy'); + userEvent.click(searchBtn); + await wait(); + userEvent.clear(searchBar); + userEvent.type(searchBar, ''); + userEvent.click(searchBtn); + }); +}); diff --git a/src/screens/BlockUser/BlockUser.tsx b/src/screens/BlockUser/BlockUser.tsx new file mode 100644 index 0000000000..1f36257bb1 --- /dev/null +++ b/src/screens/BlockUser/BlockUser.tsx @@ -0,0 +1,351 @@ +import { useMutation, useQuery } from '@apollo/client'; +import React, { useEffect, useState, useCallback } from 'react'; +import { Dropdown, Form, Table } from 'react-bootstrap'; +import Button from 'react-bootstrap/Button'; +import { toast } from 'react-toastify'; + +import { Search } from '@mui/icons-material'; +import SortIcon from '@mui/icons-material/Sort'; +import { + BLOCK_USER_MUTATION, + UNBLOCK_USER_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import { BLOCK_PAGE_MEMBER_LIST } from 'GraphQl/Queries/Queries'; +import TableLoader from 'components/TableLoader/TableLoader'; +import { useTranslation } from 'react-i18next'; +import { errorHandler } from 'utils/errorHandler'; +import styles from './BlockUser.module.css'; +import { useParams } from 'react-router-dom'; + +interface InterfaceMember { + _id: string; + email: string; + firstName: string; + lastName: string; + organizationsBlockedBy: { + _id: string; + __typename: 'Organization'; + }[]; + __typename: 'User'; +} + +/** + * Requests component displays and manages a list of users that can be blocked or unblocked. + * + * This component allows users to search for members by their first name or last name, + * toggle between viewing blocked and all members, and perform block/unblock operations. + * + * @returns JSX.Element - The `Requests` component. + * + * @example + * ```tsx + * + * ``` + */ +const Requests = (): JSX.Element => { + // Translation hooks for internationalization + const { t } = useTranslation('translation', { + keyPrefix: 'blockUnblockUser', + }); + const { t: tCommon } = useTranslation('common'); + + document.title = t('title'); // Set document title + const { orgId: currentUrl } = useParams(); // Get current organization ID from URL + + // State hooks + const [membersData, setMembersData] = useState([]); + const [searchByFirstName, setSearchByFirstName] = useState(true); + const [searchByName, setSearchByName] = useState(''); + const [showBlockedMembers, setShowBlockedMembers] = useState(true); + + // Query to fetch members list + const { + data: memberData, + loading: loadingMembers, + error: memberError, + refetch: memberRefetch, + } = useQuery(BLOCK_PAGE_MEMBER_LIST, { + variables: { + orgId: currentUrl, + firstName_contains: '', + lastName_contains: '', + }, + }); + + // Mutations for blocking and unblocking users + const [blockUser] = useMutation(BLOCK_USER_MUTATION); + const [unBlockUser] = useMutation(UNBLOCK_USER_MUTATION); + + // Effect to update member data based on filters and data changes + useEffect(() => { + if (!memberData) { + setMembersData([]); + return; + } + + if (!showBlockedMembers) { + setMembersData(memberData?.organizationsMemberConnection.edges || []); + } else { + const blockUsers = memberData?.organizationsMemberConnection.edges.filter( + (user: InterfaceMember) => + user.organizationsBlockedBy.some((org) => org._id === currentUrl), + ); + setMembersData(blockUsers || []); + } + }, [memberData, showBlockedMembers, currentUrl]); + + // Handler for blocking a user + const handleBlockUser = useCallback( + async (userId: string): Promise => { + try { + const { data } = await blockUser({ + variables: { + userId, + orgId: currentUrl, + }, + }); + /* istanbul ignore next */ + if (data) { + toast.success(t('blockedSuccessfully') as string); + memberRefetch(); + } + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }, + [blockUser, currentUrl, memberRefetch, t], + ); + + // Handler for unblocking a user + const handleUnBlockUser = useCallback( + async (userId: string): Promise => { + try { + const { data } = await unBlockUser({ + variables: { + userId, + orgId: currentUrl, + }, + }); + /* istanbul ignore next */ + if (data) { + toast.success(t('Un-BlockedSuccessfully') as string); + memberRefetch(); + } + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }, + [unBlockUser, currentUrl, memberRefetch, t], + ); + + // Display error if member query fails + useEffect(() => { + if (memberError) { + toast.error(memberError.message); + } + }, [memberError]); + + // Search handler + const handleSearch = useCallback( + (value: string): void => { + setSearchByName(value); + memberRefetch({ + orgId: currentUrl, + firstName_contains: searchByFirstName ? value : '', + lastName_contains: searchByFirstName ? '' : value, + }); + }, + [searchByFirstName, memberRefetch, currentUrl], + ); + + // Search by Enter key + const handleSearchByEnter = useCallback( + (e: React.KeyboardEvent): void => { + if (e.key === 'Enter') { + const { value } = e.currentTarget; + handleSearch(value); + } + }, + [handleSearch], + ); + + // Search button click handler + const handleSearchByBtnClick = useCallback((): void => { + const inputValue = searchByName; + handleSearch(inputValue); + }, [handleSearch, searchByName]); + + // Header titles for the table + const headerTitles: string[] = [ + '#', + tCommon('name'), + tCommon('email'), + t('block_unblock'), + ]; + + return ( + <> +
    + {/* Buttons Container */} +
    +
    +
    + setSearchByName(e.target.value)} + onKeyUp={handleSearchByEnter} + /> + +
    +
    +
    +
    + {/* Dropdown for filtering members */} + + {/* Dropdown for sorting by name */} + +
    +
    +
    + {/* Table */} + {loadingMembers === false && + membersData.length === 0 && + searchByName.length > 0 ? ( +
    +

    + {tCommon('noResultsFoundFor')} "{searchByName}" +

    +
    + ) : loadingMembers === false && membersData.length === 0 ? ( +
    +

    {t('noSpammerFound')}

    +
    + ) : ( +
    + {loadingMembers ? ( + + ) : ( + + + + {headerTitles.map((title: string, index: number) => { + return ( + + ); + })} + + + + {membersData.map((user, index: number) => { + return ( + + + + + + + ); + })} + +
    + {title} +
    {index + 1}{`${user.firstName} ${user.lastName}`}{user.email} + {user.organizationsBlockedBy.some( + (spam) => spam._id === currentUrl, + ) ? ( + + ) : ( + + )} +
    + )} +
    + )} +
    + + ); +}; + +export default Requests; diff --git a/src/screens/CommunityProfile/CommunityProfile.module.css b/src/screens/CommunityProfile/CommunityProfile.module.css new file mode 100644 index 0000000000..1e6eac2bae --- /dev/null +++ b/src/screens/CommunityProfile/CommunityProfile.module.css @@ -0,0 +1,41 @@ +.card { + width: fit-content; +} + +.cardHeader { + padding: 1.25rem 1rem 1rem 1rem; + border-bottom: 1px solid var(--bs-gray-200); + display: flex; + justify-content: space-between; + align-items: center; +} + +.cardHeader .cardTitle { + font-size: 1.5rem; +} + +.formLabel { + font-weight: normal; + padding-bottom: 0; + font-size: 1rem; + color: black; +} +.cardBody { + min-height: 180px; +} + +.cardBody .textBox { + margin: 0 0 3rem 0; + color: var(--bs-secondary); +} + +.socialInput { + height: 2.5rem; +} + +@media (max-width: 520px) { + .btn { + flex-direction: column; + justify-content: center; + } +} diff --git a/src/screens/CommunityProfile/CommunityProfile.test.tsx b/src/screens/CommunityProfile/CommunityProfile.test.tsx new file mode 100644 index 0000000000..d7e056caa4 --- /dev/null +++ b/src/screens/CommunityProfile/CommunityProfile.test.tsx @@ -0,0 +1,334 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import 'jest-localstorage-mock'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; + +import { StaticMockLink } from 'utils/StaticMockLink'; +import CommunityProfile from './CommunityProfile'; +import i18n from 'utils/i18nForTest'; +import { GET_COMMUNITY_DATA } from 'GraphQl/Queries/Queries'; +import { BrowserRouter } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { RESET_COMMUNITY, UPDATE_COMMUNITY } from 'GraphQl/Mutations/mutations'; + +const MOCKS1 = [ + { + request: { + query: GET_COMMUNITY_DATA, + }, + result: { + data: { + getCommunityData: null, + }, + }, + }, + { + request: { + query: UPDATE_COMMUNITY, + variables: { + data: { + name: 'Name', + websiteLink: 'https://website.com', + logo: 'data:image/png;base64,bG9nbw==', + socialMediaUrls: { + facebook: 'https://socialurl.com', + instagram: 'https://socialurl.com', + X: 'https://socialurl.com', + linkedIn: 'https://socialurl.com', + gitHub: 'https://socialurl.com', + youTube: 'https://socialurl.com', + reddit: 'https://socialurl.com', + slack: 'https://socialurl.com', + }, + }, + }, + }, + result: { + data: { + updateCommunity: true, + }, + }, + }, +]; + +const MOCKS2 = [ + { + request: { + query: GET_COMMUNITY_DATA, + }, + result: { + data: { + getCommunityData: { + _id: null, + name: null, + logoUrl: null, + websiteLink: null, + socialMediaUrls: { + facebook: null, + gitHub: null, + youTube: null, + instagram: null, + linkedIn: null, + reddit: null, + slack: null, + X: null, + }, + }, + }, + }, + }, + { + request: { + query: RESET_COMMUNITY, + variables: { + resetPreLoginImageryId: 'communityId', + }, + }, + result: { + data: { + resetCommunity: true, + }, + }, + }, +]; + +const MOCKS3 = [ + { + request: { + query: GET_COMMUNITY_DATA, + }, + result: { + data: { + getCommunityData: { + _id: 'communityId', + name: 'testName', + logoUrl: 'image.png', + websiteLink: 'http://websitelink.com', + socialMediaUrls: { + facebook: 'http://sociallink.com', + gitHub: 'http://sociallink.com', + youTube: 'http://sociallink.com', + instagram: 'http://sociallink.com', + linkedIn: 'http://sociallink.com', + reddit: 'http://sociallink.com', + slack: 'http://sociallink.com', + X: 'http://sociallink.com', + }, + }, + }, + }, + }, + { + request: { + query: RESET_COMMUNITY, + variables: { + resetPreLoginImageryId: 'communityId', + }, + }, + result: { + data: { + resetCommunity: true, + }, + }, + }, +]; + +const link1 = new StaticMockLink(MOCKS1, true); +const link2 = new StaticMockLink(MOCKS2, true); +const link3 = new StaticMockLink(MOCKS3, true); + +const profileVariables = { + name: 'Name', + websiteLink: 'https://website.com', + socialUrl: 'https://socialurl.com', + logo: new File(['logo'], 'test.png', { + type: 'image/png', + }), +}; + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +describe('Testing Community Profile Screen', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('Components should render properly', async () => { + window.location.assign('/communityProfile'); + + render( + + + + + + + , + ); + await wait(); + + expect(screen.getByPlaceholderText(/Community Name/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/Website Link/i)).toBeInTheDocument(); + expect(screen.getByTestId(/facebook/i)).toBeInTheDocument(); + expect(screen.getByTestId(/instagram/i)).toBeInTheDocument(); + expect(screen.getByTestId(/X/i)).toBeInTheDocument(); + expect(screen.getByTestId(/linkedIn/i)).toBeInTheDocument(); + expect(screen.getByTestId(/github/i)).toBeInTheDocument(); + expect(screen.getByTestId(/youtube/i)).toBeInTheDocument(); + expect(screen.getByTestId(/reddit/i)).toBeInTheDocument(); + expect(screen.getByTestId(/slack/i)).toBeInTheDocument(); + expect(screen.getByTestId('resetChangesBtn')).toBeInTheDocument(); + expect(screen.getByTestId('resetChangesBtn')).toBeDisabled(); + expect(screen.getByTestId('saveChangesBtn')).toBeInTheDocument(); + expect(screen.getByTestId('saveChangesBtn')).toBeDisabled(); + }); + + test('Testing all the input fields and update community data feature', async () => { + window.location.assign('/communityProfile'); + + await act(async () => { + render( + + + + + + + , + ); + }); + await wait(); + + const communityName = screen.getByPlaceholderText(/Community Name/i); + const websiteLink = screen.getByPlaceholderText(/Website Link/i); + const logo = screen.getByTestId(/fileInput/i); + const facebook = screen.getByTestId(/facebook/i); + const instagram = screen.getByTestId(/instagram/i); + const X = screen.getByTestId(/X/i); + const linkedIn = screen.getByTestId(/linkedIn/i); + const github = screen.getByTestId(/github/i); + const youtube = screen.getByTestId(/youtube/i); + const reddit = screen.getByTestId(/reddit/i); + const slack = screen.getByTestId(/slack/i); + const saveChangesBtn = screen.getByTestId(/saveChangesBtn/i); + const resetChangeBtn = screen.getByTestId(/resetChangesBtn/i); + + userEvent.type(communityName, profileVariables.name); + userEvent.type(websiteLink, profileVariables.websiteLink); + userEvent.type(facebook, profileVariables.socialUrl); + userEvent.type(instagram, profileVariables.socialUrl); + userEvent.type(X, profileVariables.socialUrl); + userEvent.type(linkedIn, profileVariables.socialUrl); + userEvent.type(github, profileVariables.socialUrl); + userEvent.type(youtube, profileVariables.socialUrl); + userEvent.type(reddit, profileVariables.socialUrl); + userEvent.type(slack, profileVariables.socialUrl); + userEvent.upload(logo, profileVariables.logo); + await wait(); + + expect(communityName).toHaveValue(profileVariables.name); + expect(websiteLink).toHaveValue(profileVariables.websiteLink); + // expect(logo).toBeTruthy(); + expect(facebook).toHaveValue(profileVariables.socialUrl); + expect(instagram).toHaveValue(profileVariables.socialUrl); + expect(X).toHaveValue(profileVariables.socialUrl); + expect(linkedIn).toHaveValue(profileVariables.socialUrl); + expect(github).toHaveValue(profileVariables.socialUrl); + expect(youtube).toHaveValue(profileVariables.socialUrl); + expect(reddit).toHaveValue(profileVariables.socialUrl); + expect(slack).toHaveValue(profileVariables.socialUrl); + expect(saveChangesBtn).not.toBeDisabled(); + expect(resetChangeBtn).not.toBeDisabled(); + await wait(); + + userEvent.click(saveChangesBtn); + await wait(); + }); + + test('If the queried data has some fields null then the input field should be empty', async () => { + render( + + + + + , + ); + await wait(); + + expect(screen.getByPlaceholderText(/Community Name/i)).toHaveValue(''); + expect(screen.getByPlaceholderText(/Website Link/i)).toHaveValue(''); + expect(screen.getByTestId(/facebook/i)).toHaveValue(''); + expect(screen.getByTestId(/instagram/i)).toHaveValue(''); + expect(screen.getByTestId(/X/i)).toHaveValue(''); + expect(screen.getByTestId(/linkedIn/i)).toHaveValue(''); + expect(screen.getByTestId(/github/i)).toHaveValue(''); + expect(screen.getByTestId(/youtube/i)).toHaveValue(''); + expect(screen.getByTestId(/reddit/i)).toHaveValue(''); + expect(screen.getByTestId(/slack/i)).toHaveValue(''); + }); + + test('Should clear out all the input field when click on Reset Changes button', async () => { + render( + + + + + , + ); + await wait(); + + const resetChangesBtn = screen.getByTestId('resetChangesBtn'); + userEvent.click(resetChangesBtn); + await wait(); + + expect(screen.getByPlaceholderText(/Community Name/i)).toHaveValue(''); + expect(screen.getByPlaceholderText(/Website Link/i)).toHaveValue(''); + expect(screen.getByTestId(/facebook/i)).toHaveValue(''); + expect(screen.getByTestId(/instagram/i)).toHaveValue(''); + expect(screen.getByTestId(/X/i)).toHaveValue(''); + expect(screen.getByTestId(/linkedIn/i)).toHaveValue(''); + expect(screen.getByTestId(/github/i)).toHaveValue(''); + expect(screen.getByTestId(/youtube/i)).toHaveValue(''); + expect(screen.getByTestId(/reddit/i)).toHaveValue(''); + expect(screen.getByTestId(/slack/i)).toHaveValue(''); + expect(toast.success).toHaveBeenCalled(); + }); + + test('Should have empty input fields when queried result is null', async () => { + render( + + + + + , + ); + + expect(screen.getByPlaceholderText(/Community Name/i)).toHaveValue(''); + expect(screen.getByPlaceholderText(/Website Link/i)).toHaveValue(''); + expect(screen.getByTestId(/facebook/i)).toHaveValue(''); + expect(screen.getByTestId(/instagram/i)).toHaveValue(''); + expect(screen.getByTestId(/X/i)).toHaveValue(''); + expect(screen.getByTestId(/linkedIn/i)).toHaveValue(''); + expect(screen.getByTestId(/github/i)).toHaveValue(''); + expect(screen.getByTestId(/youtube/i)).toHaveValue(''); + expect(screen.getByTestId(/reddit/i)).toHaveValue(''); + expect(screen.getByTestId(/slack/i)).toHaveValue(''); + }); +}); diff --git a/src/screens/CommunityProfile/CommunityProfile.tsx b/src/screens/CommunityProfile/CommunityProfile.tsx new file mode 100644 index 0000000000..d96c923eb3 --- /dev/null +++ b/src/screens/CommunityProfile/CommunityProfile.tsx @@ -0,0 +1,427 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Card, Form } from 'react-bootstrap'; +import { useMutation, useQuery } from '@apollo/client'; +import { toast } from 'react-toastify'; + +import Loader from 'components/Loader/Loader'; +import { GET_COMMUNITY_DATA } from 'GraphQl/Queries/Queries'; +import { UPDATE_COMMUNITY, RESET_COMMUNITY } from 'GraphQl/Mutations/mutations'; +import { + FacebookLogo, + InstagramLogo, + XLogo, + LinkedInLogo, + GithubLogo, + YoutubeLogo, + RedditLogo, + SlackLogo, +} from 'assets/svgs/social-icons'; +import convertToBase64 from 'utils/convertToBase64'; +import styles from './CommunityProfile.module.css'; +import { errorHandler } from 'utils/errorHandler'; +import UpdateSession from '../../components/UpdateSession/UpdateSession'; + +/** + * `CommunityProfile` component allows users to view and update their community profile details. + * + * It includes functionalities to: + * - Display current community profile information + * - Update profile details including social media links and logo + * - Reset profile changes to the initial state + * + * @returns JSX.Element - The `CommunityProfile` component. + * + * @example + * ```tsx + * + * ``` + */ +const CommunityProfile = (): JSX.Element => { + // Translation hooks for internationalization + const { t } = useTranslation('translation', { + keyPrefix: 'communityProfile', + }); + const { t: tCommon } = useTranslation('common'); + + document.title = t('title'); // Set document title + + // Define the type for pre-login imagery data + type PreLoginImageryDataType = { + _id: string; + name: string | undefined; + websiteLink: string | undefined; + logoUrl: string | undefined; + socialMediaUrls: { + facebook: string | undefined; + instagram: string | undefined; + X: string | undefined; + linkedIn: string | undefined; + gitHub: string | undefined; + youTube: string | undefined; + reddit: string | undefined; + slack: string | undefined; + }; + }; + + // State hook for managing profile variables + const [profileVariable, setProfileVariable] = React.useState({ + name: '', + websiteLink: '', + logoUrl: '', + facebook: '', + instagram: '', + X: '', + linkedIn: '', + github: '', + youtube: '', + reddit: '', + slack: '', + }); + + // Query to fetch community data + const { data, loading } = useQuery(GET_COMMUNITY_DATA); + + // Mutations for updating and resetting community data + const [uploadPreLoginImagery] = useMutation(UPDATE_COMMUNITY); + const [resetPreLoginImagery] = useMutation(RESET_COMMUNITY); + + // Effect to set profile data from fetched data + React.useEffect(() => { + const preLoginData: PreLoginImageryDataType | undefined = + data?.getCommunityData; + preLoginData && + setProfileVariable({ + name: preLoginData.name ?? '', + websiteLink: preLoginData.websiteLink ?? '', + logoUrl: preLoginData.logoUrl ?? '', + facebook: preLoginData.socialMediaUrls.facebook ?? '', + instagram: preLoginData.socialMediaUrls.instagram ?? '', + X: preLoginData.socialMediaUrls.X ?? '', + linkedIn: preLoginData.socialMediaUrls.linkedIn ?? '', + github: preLoginData.socialMediaUrls.gitHub ?? '', + youtube: preLoginData.socialMediaUrls.youTube ?? '', + reddit: preLoginData.socialMediaUrls.reddit ?? '', + slack: preLoginData.socialMediaUrls.slack ?? '', + }); + }, [data]); + + /** + * Handles change events for form inputs. + * + * @param e - Change event for input elements + */ + const handleOnChange = (e: React.ChangeEvent): void => { + setProfileVariable({ + ...profileVariable, + [e.target.name]: e.target.value, + }); + }; + + /** + * Handles form submission to update community profile. + * + * @param e - Form submit event + */ + const handleOnSubmit = async ( + e: React.FormEvent, + ): Promise => { + e.preventDefault(); + try { + await uploadPreLoginImagery({ + variables: { + data: { + name: profileVariable.name, + websiteLink: profileVariable.websiteLink, + logo: profileVariable.logoUrl, + socialMediaUrls: { + facebook: profileVariable.facebook, + instagram: profileVariable.instagram, + X: profileVariable.X, + linkedIn: profileVariable.linkedIn, + gitHub: profileVariable.github, + youTube: profileVariable.youtube, + reddit: profileVariable.reddit, + slack: profileVariable.slack, + }, + }, + }, + }); + toast.success(t('profileChangedMsg') as string); + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error as Error); + } + }; + + /** + * Resets profile data to initial values and performs a reset operation. + */ + const resetData = async (): Promise => { + const preLoginData: PreLoginImageryDataType | undefined = + data?.getCommunityData; + try { + setProfileVariable({ + name: '', + websiteLink: '', + logoUrl: '', + facebook: '', + instagram: '', + X: '', + linkedIn: '', + github: '', + youtube: '', + reddit: '', + slack: '', + }); + + await resetPreLoginImagery({ + variables: { + resetPreLoginImageryId: preLoginData?._id, + }, + }); + toast.success(t(`resetData`) as string); + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error as Error); + } + }; + + /** + * Determines whether the save and reset buttons should be disabled. + * + * @returns boolean - True if buttons should be disabled, otherwise false + */ + const isDisabled = (): boolean => { + if ( + profileVariable.name == '' && + profileVariable.websiteLink == '' && + profileVariable.logoUrl == '' + ) { + return true; + } else { + return false; + } + }; + + if (loading) { + ; + } + + return ( + <> + +
    +
    {t('editProfile')}
    +
    + +
    {t('communityProfileInfo')}
    +
    + + + {t('communityName')} + + + + + + {t('wesiteLink')} + + + + + {t('logo')} + + + + {t('social')} + + {/* Social media inputs */} +
    + Facebook Logo + +
    +
    + Instagram Logo + +
    +
    + X Logo + +
    +
    + LinkedIn Logo + +
    +
    + Github Logo + +
    +
    + Youtube Logo + +
    +
    + Reddit Logo + +
    +
    + Slack Logo + +
    +
    +
    + + +
    +
    +
    +
    + + + + ); +}; + +export default CommunityProfile; diff --git a/src/screens/EventManagement/EventManagement.test.tsx b/src/screens/EventManagement/EventManagement.test.tsx new file mode 100644 index 0000000000..a119caad42 --- /dev/null +++ b/src/screens/EventManagement/EventManagement.test.tsx @@ -0,0 +1,143 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import EventManagement from './EventManagement'; +import userEvent from '@testing-library/user-event'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { MOCKS_WITH_TIME } from 'components/EventManagement/Dashboard/EventDashboard.mocks'; +import useLocalStorage from 'utils/useLocalstorage'; +const { setItem } = useLocalStorage(); + +const mockWithTime = new StaticMockLink(MOCKS_WITH_TIME, true); + +const renderEventManagement = (): RenderResult => { + return render( + + + + + + } + /> + paramsError} + /> + eventsScreen} + /> + userEventsScreen + } + /> + + + + + , + ); +}; + +describe('Event Management', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId', eventId: 'eventId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('Testing back button navigation when userType is SuperAdmin', async () => { + setItem('SuperAdmin', true); + renderEventManagement(); + + const backButton = screen.getByTestId('backBtn'); + userEvent.click(backButton); + await waitFor(() => { + const eventsScreen = screen.getByTestId('eventsScreen'); + expect(eventsScreen).toBeInTheDocument(); + }); + }); + + test('Testing event management tab switching', async () => { + renderEventManagement(); + + const registrantsButton = screen.getByTestId('registrantsBtn'); + userEvent.click(registrantsButton); + + const registrantsTab = screen.getByTestId('eventRegistrantsTab'); + expect(registrantsTab).toBeInTheDocument(); + const eventAttendanceButton = screen.getByTestId('attendanceBtn'); + userEvent.click(eventAttendanceButton); + const eventAttendanceTab = screen.getByTestId('eventAttendanceTab'); + expect(eventAttendanceTab).toBeInTheDocument(); + const eventActionsButton = screen.getByTestId('actionsBtn'); + userEvent.click(eventActionsButton); + + const eventActionsTab = screen.getByTestId('eventActionsTab'); + expect(eventActionsTab).toBeInTheDocument(); + + const eventAgendasButton = screen.getByTestId('agendasBtn'); + userEvent.click(eventAgendasButton); + + const eventAgendasTab = screen.getByTestId('eventAgendasTab'); + expect(eventAgendasTab).toBeInTheDocument(); + + const eventStatsButton = screen.getByTestId('statisticsBtn'); + userEvent.click(eventStatsButton); + + const eventStatsTab = screen.getByTestId('eventStatsTab'); + expect(eventStatsTab).toBeInTheDocument(); + + const volunteerButton = screen.getByTestId('volunteersBtn'); + userEvent.click(volunteerButton); + + const eventVolunteersTab = screen.getByTestId('eventVolunteersTab'); + expect(eventVolunteersTab).toBeInTheDocument(); + }); + test('renders nothing when invalid tab is selected', () => { + render( + + + + + + } + /> + + + + + , + ); + + // Force an invalid tab state + const setTab = jest.fn(); + React.useState = jest.fn().mockReturnValue(['invalidTab', setTab]); + + // Verify nothing is rendered + expect(screen.queryByTestId('eventDashboardTab')).toBeInTheDocument(); + expect(screen.queryByTestId('eventRegistrantsTab')).not.toBeInTheDocument(); + expect(screen.queryByTestId('eventAttendanceTab')).not.toBeInTheDocument(); + expect(screen.queryByTestId('eventActionsTab')).not.toBeInTheDocument(); + expect(screen.queryByTestId('eventVolunteersTab')).not.toBeInTheDocument(); + expect(screen.queryByTestId('eventAgendasTab')).not.toBeInTheDocument(); + expect(screen.queryByTestId('eventStatsTab')).not.toBeInTheDocument(); + }); +}); diff --git a/src/screens/EventManagement/EventManagement.tsx b/src/screens/EventManagement/EventManagement.tsx new file mode 100644 index 0000000000..2e5cdbd419 --- /dev/null +++ b/src/screens/EventManagement/EventManagement.tsx @@ -0,0 +1,281 @@ +import React, { useState } from 'react'; +import Row from 'react-bootstrap/Row'; +import Col from 'react-bootstrap/Col'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import { FaChevronLeft, FaTasks } from 'react-icons/fa'; +import { MdOutlineDashboard } from 'react-icons/md'; +import EventRegistrantsIcon from 'assets/svgs/people.svg?react'; +import { BsPersonCheck } from 'react-icons/bs'; +import { IoMdStats, IoIosHand } from 'react-icons/io'; +import EventAgendaItemsIcon from 'assets/svgs/agenda-items.svg?react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown } from 'react-bootstrap'; + +import EventDashboard from 'components/EventManagement/Dashboard/EventDashboard'; +import OrganizationActionItems from 'screens/OrganizationActionItems/OrganizationActionItems'; +import VolunteerContainer from 'screens/EventVolunteers/VolunteerContainer'; +import EventAgendaItems from 'components/EventManagement/EventAgendaItems/EventAgendaItems'; +import useLocalStorage from 'utils/useLocalstorage'; +import EventAttendance from 'components/EventManagement/EventAttendance/EventAttendance'; +/** + * List of tabs for the event dashboard. + * + * Each tab is associated with an icon and value. + */ +const eventDashboardTabs: { + value: TabOptions; + icon: JSX.Element; +}[] = [ + { + value: 'dashboard', + icon: , + }, + { + value: 'registrants', + icon: , + }, + { + value: 'attendance', + icon: , + }, + { + value: 'agendas', + icon: , + }, + { + value: 'actions', + icon: , + }, + { + value: 'volunteers', + icon: , + }, + { + value: 'statistics', + icon: , + }, +]; + +/** + * Tab options for the event management component. + */ +type TabOptions = + | 'dashboard' + | 'registrants' + | 'attendance' + | 'agendas' + | 'actions' + | 'volunteers' + | 'statistics'; + +/** + * `EventManagement` component handles the display and navigation of different event management sections. + * + * It provides a tabbed interface for: + * - Viewing event dashboard + * - Managing event registrants + * - Handling event actions + * - Reviewing event agendas + * - Viewing event statistics + * - Managing event volunteers + * - Managing event attendance + * + * @returns JSX.Element - The `EventManagement` component. + * + * @example + * ```tsx + * + * ``` + */ +const EventManagement = (): JSX.Element => { + // Translation hook for internationalization + const { t } = useTranslation('translation', { + keyPrefix: 'eventManagement', + }); + + // Custom hook for accessing local storage + const { getItem } = useLocalStorage(); + + // Determine user role based on local storage + const superAdmin = getItem('SuperAdmin'); + const adminFor = getItem('AdminFor'); + /*istanbul ignore next*/ + const userRole = superAdmin + ? 'SUPERADMIN' + : adminFor?.length > 0 + ? 'ADMIN' + : 'USER'; + + // Extract event and organization IDs from URL parameters + const { eventId, orgId } = useParams(); + /*istanbul ignore next*/ + if (!eventId || !orgId) { + // Redirect if event ID or organization ID is missing + return ; + } + + // Hook for navigation + const navigate = useNavigate(); + + // State hook for managing the currently selected tab + const [tab, setTab] = useState('dashboard'); + + /** + * Renders a button for each tab with the appropriate icon and label. + * + * @param value - The tab value + * @param icon - The icon to display for the tab + * @returns JSX.Element - The rendered button component + */ + const renderButton = ({ + value, + icon, + }: { + value: TabOptions; + icon: React.ReactNode; + }): JSX.Element => { + const selected = tab === value; + const variant = selected ? 'success' : 'light'; + const translatedText = t(value); + + const className = selected + ? 'px-4 d-flex align-items-center rounded-3 shadow-sm' + : 'text-secondary bg-white px-4 d-flex align-items-center rounded-3 shadow-sm'; + const props = { + variant, + className, + style: { height: '2.5rem' }, + onClick: () => setTab(value), + 'data-testid': `${value}Btn`, + }; + + return ( + + ); + }; + + const handleBack = (): void => { + /*istanbul ignore next*/ + if (userRole === 'USER') { + navigate(`/user/events/${orgId}`); + } else { + navigate(`/orgevents/${orgId}`); + } + }; + + return ( +
    + + +
    + + {eventDashboardTabs.map(renderButton)} +
    + + + + {t(tab)} + + + {/* Render dropdown items for each settings category */} + {eventDashboardTabs.map(({ value, icon }, index) => ( + setTab(value) + } + className={`d-flex gap-2 ${tab === value ? 'text-secondary' : ''}`} + > + {icon} {t(value)} + + ))} + + + + + +
    +
    +
    + + {/* Render content based on the selected settings category */} + {(() => { + switch (tab) { + case 'dashboard': + return ( +
    + +
    + ); + case 'registrants': + return ( +
    Event Registrants
    + ); + case 'attendance': + return ( +
    + +
    + ); + case 'actions': + return ( +
    + +
    + ); + case 'volunteers': + return ( +
    + +
    + ); + case 'agendas': + return ( +
    + +
    + ); + case 'statistics': + return ( +
    +

    Statistics

    +
    + ); + /*istanbul ignore next*/ + default: + /*istanbul ignore next*/ + return null; + } + })()} +
    + ); +}; +export default EventManagement; diff --git a/src/screens/EventVolunteers/EventVolunteers.module.css b/src/screens/EventVolunteers/EventVolunteers.module.css new file mode 100644 index 0000000000..84b19f0a9f --- /dev/null +++ b/src/screens/EventVolunteers/EventVolunteers.module.css @@ -0,0 +1,266 @@ +/* Toggle Btn */ +.toggleGroup { + width: 50%; + min-width: 20rem; + margin: 0.5rem 0rem; +} + +.toggleBtn { + padding: 0rem; + height: 2rem; + display: flex; + justify-content: center; + align-items: center; +} + +.toggleBtn:hover { + color: #31bb6b !important; +} + +input[type='radio']:checked + label { + background-color: #31bb6a50 !important; +} + +input[type='radio']:checked + label:hover { + color: black !important; +} + +.actionItemsContainer { + height: 90vh; +} + +.actionItemModal { + max-width: 80vw; + margin-top: 2vh; + margin-left: 13vw; +} + +.datediv { + display: flex; + flex-direction: row; +} + +.datebox { + width: 90%; + border-radius: 7px; + outline: none; + box-shadow: none; + padding-top: 2px; + padding-bottom: 2px; + padding-right: 5px; + padding-left: 5px; + margin-right: 5px; + margin-left: 5px; +} + +.dropdownToggle { + margin-bottom: 0; + display: flex; +} + +.dropdownModalToggle { + width: 50%; +} + +.errorIcon { + transform: scale(1.5); + color: var(--bs-danger); + margin-bottom: 1rem; +} + +.greenregbtn { + margin: 1rem 0 0; + margin-top: 15px; + border: 1px solid var(--bs-gray-300); + box-shadow: 0 2px 2px var(--bs-gray-300); + padding: 10px 10px; + border-radius: 5px; + background-color: var(--bs-primary); + width: 100%; + font-size: 16px; + color: var(--bs-white); + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; +} + +hr { + border: none; + height: 1px; + background-color: var(--bs-gray-500); + margin: 1rem; +} + +.iconContainer { + display: flex; + justify-content: flex-end; +} +.icon { + margin: 1px; +} + +.message { + margin-top: 25%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.preview { + display: flex; + flex-direction: row; + font-weight: 900; + font-size: 16px; + color: rgb(80, 80, 80); +} + +.removeFilterIcon { + cursor: pointer; +} + +.searchForm { + display: inline; +} + +.view { + margin-left: 2%; + font-weight: 600; + font-size: 16px; + color: var(--bs-gray-600); +} + +/* header (search, filter, dropdown) */ +.btnsContainer { + display: flex; + margin: 0.5rem 0 1.5rem 0; +} + +.btnsContainer .input { + flex: 1; + min-width: 18rem; + position: relative; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); +} + +.btnsContainer .input button { + width: 52px; +} + +.noOutline input { + outline: none; +} + +.noOutline input:disabled { + -webkit-text-fill-color: black !important; +} + +.noOutline textarea:disabled { + -webkit-text-fill-color: black !important; +} + +.inputField { + margin-top: 10px; + margin-bottom: 10px; + background-color: white; + box-shadow: 0 1px 1px #31bb6b; +} + +.inputField > button { + padding-top: 10px; + padding-bottom: 10px; +} + +.dropdown { + background-color: white; + border: 1px solid #31bb6b; + position: relative; + display: inline-block; + color: #31bb6b; +} + +/* Action Items Data Grid */ +.rowBackground { + background-color: var(--bs-white); + max-height: 120px; +} + +.tableHeader { + background-color: var(--bs-primary); + color: var(--bs-white); + font-size: 1rem; +} + +.chipIcon { + height: 0.9rem !important; +} + +.chip { + height: 1.5rem !important; +} + +.active { + background-color: #31bb6a50 !important; +} + +.pending { + background-color: #ffd76950 !important; + color: #bb952bd0 !important; + border-color: #bb952bd0 !important; +} + +/* Modals */ +.itemModal { + max-width: 80vw; + margin-top: 2vh; + margin-left: 13vw; +} + +.titlemodal { + color: #707070; + font-weight: 600; + font-size: 32px; + width: 65%; + margin-bottom: 0px; +} + +.modalCloseBtn { + width: 40px; + height: 40px; + padding: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.imageContainer { + display: flex; + align-items: center; + justify-content: center; + margin-right: 0.5rem; +} + +.TableImage { + object-fit: cover; + width: 25px !important; + height: 25px !important; + border-radius: 100% !important; +} + +.avatarContainer { + width: 28px; + height: 26px; +} + +/* Modal Table (Groups & Assignments) */ +.modalTable { + max-height: 220px; + overflow-y: auto; +} diff --git a/src/screens/EventVolunteers/Requests/Requests.mocks.ts b/src/screens/EventVolunteers/Requests/Requests.mocks.ts new file mode 100644 index 0000000000..4d2ae14ed0 --- /dev/null +++ b/src/screens/EventVolunteers/Requests/Requests.mocks.ts @@ -0,0 +1,221 @@ +import { UPDATE_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Mutations/EventVolunteerMutation'; +import { USER_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Queries/EventVolunteerQueries'; + +const membership1 = { + _id: 'membershipId1', + status: 'requested', + createdAt: '2024-10-29T10:18:05.851Z', + event: { + _id: 'eventId', + title: 'Event 1', + startDate: '2044-10-31', + }, + volunteer: { + _id: 'volunteerId1', + user: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: 'img-url', + }, + }, + group: null, +}; + +const membership2 = { + _id: 'membershipId2', + status: 'requested', + createdAt: '2024-10-30T10:18:05.851Z', + event: { + _id: 'eventId', + title: 'Event 2', + startDate: '2044-11-31', + }, + volunteer: { + _id: 'volunteerId1', + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + group: null, +}; + +export const MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + eventId: 'eventId', + status: 'requested', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1, membership2], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + eventId: 'eventId', + status: 'requested', + }, + orderBy: 'createdAt_DESC', + }, + }, + result: { + data: { + getVolunteerMembership: [membership2, membership1], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + eventId: 'eventId', + status: 'requested', + }, + orderBy: 'createdAt_ASC', + }, + }, + result: { + data: { + getVolunteerMembership: [membership1, membership2], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + eventId: 'eventId', + status: 'requested', + userName: 'T', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership2], + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'accepted', + }, + }, + result: { + data: { + updateVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'rejected', + }, + }, + result: { + data: { + updateVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + eventId: 'eventId', + status: 'requested', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [], + }, + }, + }, +]; + +export const ERROR_MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + eventId: 'eventId', + status: 'requested', + userName: undefined, + }, + orderBy: undefined, + }, + }, + error: new Error('Mock Graphql USER_VOLUNTEER_MEMBERSHIP Error'), + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'accepted', + }, + }, + error: new Error('Mock Graphql UPDATE_VOLUNTEER_MEMBERSHIP Error'), + }, +]; + +export const UPDATE_ERROR_MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + eventId: 'eventId', + status: 'requested', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1, membership2], + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'accepted', + }, + }, + error: new Error('Mock Graphql UPDATE_VOLUNTEER_MEMBERSHIP Error'), + }, +]; diff --git a/src/screens/EventVolunteers/Requests/Requests.test.tsx b/src/screens/EventVolunteers/Requests/Requests.test.tsx new file mode 100644 index 0000000000..3b55ea872c --- /dev/null +++ b/src/screens/EventVolunteers/Requests/Requests.test.tsx @@ -0,0 +1,232 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import Requests from './Requests'; +import type { ApolloLink } from '@apollo/client'; +import { + MOCKS, + EMPTY_MOCKS, + ERROR_MOCKS, + UPDATE_ERROR_MOCKS, +} from './Requests.mocks'; +import { toast } from 'react-toastify'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(ERROR_MOCKS); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const link4 = new StaticMockLink(UPDATE_ERROR_MOCKS); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const renderRequests = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } /> + } + /> + + + + + + , + ); +}; + +describe('Testing Requests Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId', eventId: 'eventId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> + } + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Requests screen', async () => { + renderRequests(link1); + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + }); + + it('Check Sorting Functionality', async () => { + renderRequests(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + let sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by createdAt_DESC + fireEvent.click(sortBtn); + const createdAtDESC = await screen.findByTestId('createdAt_DESC'); + expect(createdAtDESC).toBeInTheDocument(); + fireEvent.click(createdAtDESC); + + let volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName[0]).toHaveTextContent('Teresa Bradley'); + + // Sort by createdAt_ASC + sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const createdAtASC = await screen.findByTestId('createdAt_ASC'); + expect(createdAtASC).toBeInTheDocument(); + fireEvent.click(createdAtASC); + + volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName[0]).toHaveTextContent('John Doe'); + }); + + it('Search Requests by volunteer name', async () => { + renderRequests(link1); + + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + // Search by name on press of ENTER + userEvent.type(searchInput, 'T'); + await debounceWait(); + + await waitFor(() => { + const volunteerName = screen.getAllByTestId('volunteerName'); + expect(volunteerName[0]).toHaveTextContent('Teresa Bradley'); + }); + }); + + it('should render screen with No Requests', async () => { + renderRequests(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noRequests)).toBeInTheDocument(); + }); + }); + + it('Error while fetching requests data', async () => { + renderRequests(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Accept Request', async () => { + renderRequests(link1); + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + const acceptBtn = await screen.findAllByTestId('acceptBtn'); + expect(acceptBtn).toHaveLength(2); + + // Accept Request + userEvent.click(acceptBtn[0]); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.requestAccepted); + }); + }); + + it('Reject Request', async () => { + renderRequests(link1); + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + const rejectBtn = await screen.findAllByTestId('rejectBtn'); + expect(rejectBtn).toHaveLength(2); + + // Reject Request + userEvent.click(rejectBtn[0]); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.requestRejected); + }); + }); + + it('Error in Update Request Mutation', async () => { + renderRequests(link4); + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + const acceptBtn = await screen.findAllByTestId('acceptBtn'); + expect(acceptBtn).toHaveLength(2); + + // Accept Request + userEvent.click(acceptBtn[0]); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/EventVolunteers/Requests/Requests.tsx b/src/screens/EventVolunteers/Requests/Requests.tsx new file mode 100644 index 0000000000..41abcad763 --- /dev/null +++ b/src/screens/EventVolunteers/Requests/Requests.tsx @@ -0,0 +1,338 @@ +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Navigate, useParams } from 'react-router-dom'; +import { FaXmark } from 'react-icons/fa6'; +import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; + +import { useMutation, useQuery } from '@apollo/client'; +import Loader from 'components/Loader/Loader'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import Avatar from 'components/Avatar/Avatar'; +import styles from '../EventVolunteers.module.css'; +import { USER_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Queries/EventVolunteerQueries'; +import type { InterfaceVolunteerMembership } from 'utils/interfaces'; +import dayjs from 'dayjs'; +import { UPDATE_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Mutations/EventVolunteerMutation'; +import { toast } from 'react-toastify'; +import { debounce } from '@mui/material'; + +const dataGridStyle = { + '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { + outline: 'none !important', + }, + '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-root': { + borderRadius: '0.5rem', + }, + '& .MuiDataGrid-main': { + borderRadius: '0.5rem', + }, +}; + +/** + * Component for managing and displaying Volunteer Membership requests for an event. + * + * This component allows users to view, filter, sort, and create action items. It also allows users to accept or reject volunteer membership requests. + * + * @returns The rendered component. + */ +function requests(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Get the organization ID from URL parameters + const { orgId, eventId } = useParams(); + + if (!orgId || !eventId) { + return ; + } + + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState< + 'createdAt_ASC' | 'createdAt_DESC' | null + >(null); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const [updateMembership] = useMutation(UPDATE_VOLUNTEER_MEMBERSHIP); + + const updateMembershipStatus = async ( + id: string, + status: 'accepted' | 'rejected', + ): Promise => { + try { + await updateMembership({ + variables: { + id: id, + status: status, + }, + }); + toast.success( + t( + status === 'accepted' ? 'requestAccepted' : 'requestRejected', + ) as string, + ); + refetchRequests(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + + /** + * Query to fetch volunteer Membership requests for the event. + */ + const { + data: requestsData, + loading: requestsLoading, + error: requestsError, + refetch: refetchRequests, + }: { + data?: { + getVolunteerMembership: InterfaceVolunteerMembership[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(USER_VOLUNTEER_MEMBERSHIP, { + variables: { + where: { + eventId, + status: 'requested', + userName: searchTerm ? searchTerm : undefined, + }, + orderBy: sortBy ? sortBy : undefined, + }, + }); + + const requests = useMemo(() => { + if (!requestsData) return []; + return requestsData.getVolunteerMembership; + }, [requestsData]); + + // loads the requests when the component mounts + if (requestsLoading) return ; + if (requestsError) { + // Displays an error message if there is an issue loading the requests + return ( +
    +
    + +
    + {tErrors('errorLoading', { entity: 'Volunteership Requests' })} +
    +
    +
    + ); + } + + const columns: GridColDef[] = [ + { + field: 'srNo', + headerName: 'Sr. No.', + flex: 1, + minWidth: 100, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return params.row.id; + }, + }, + { + field: 'volunteer', + headerName: 'Volunteer', + flex: 3, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + const { firstName, lastName, image } = params.row.volunteer.user; + return ( +
    + {image ? ( + volunteer + ) : ( +
    + +
    + )} + {firstName + ' ' + lastName} +
    + ); + }, + }, + { + field: 'requestDate', + headerName: 'Request Date', + flex: 2, + minWidth: 150, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return dayjs(params.row.createdAt).format('DD/MM/YYYY'); + }, + }, + { + field: 'options', + headerName: 'Options', + align: 'center', + flex: 2, + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + <> + + + + ); + }, + }, + ]; + + return ( +
    + {/* Header with search, filter and Create Button */} +
    +
    + { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
    +
    +
    + + + + {tCommon('sort')} + + + setSortBy('createdAt_DESC')} + data-testid="createdAt_DESC" + > + {t('latest')} + + setSortBy('createdAt_ASC')} + data-testid="createdAt_ASC" + > + {t('earliest')} + + + +
    +
    +
    + + {/* Table with Volunteer Membership Requests */} + + {requests.length > 0 ? ( + row._id} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={requests.map((request, index) => ({ + id: index + 1, + ...request, + }))} + columns={columns} + isRowSelectable={() => false} + /> + ) : ( +
    +
    {t('noRequests')}
    +
    + )} +
    + ); +} + +export default requests; diff --git a/src/screens/EventVolunteers/VolunteerContainer.test.tsx b/src/screens/EventVolunteers/VolunteerContainer.test.tsx new file mode 100644 index 0000000000..928d04195c --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerContainer.test.tsx @@ -0,0 +1,94 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import i18n from 'utils/i18n'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import VolunteerContainer from './VolunteerContainer'; +import userEvent from '@testing-library/user-event'; +import { MOCKS } from './Volunteers/Volunteers.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { LocalizationProvider } from '@mui/x-date-pickers'; + +const link1 = new StaticMockLink(MOCKS); + +const renderVolunteerContainer = (): RenderResult => { + return render( + + + + + + + } + /> + paramsError} + /> + + + + + + , + ); +}; + +describe('Testing Volunteer Container', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId', eventId: 'eventId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> + } + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + test('Testing Volunteer Container Screen -> Toggle screens', async () => { + renderVolunteerContainer(); + + const groupRadio = await screen.findByTestId('groupsRadio'); + expect(groupRadio).toBeInTheDocument(); + userEvent.click(groupRadio); + + const requestsRadio = await screen.findByTestId('requestsRadio'); + expect(requestsRadio).toBeInTheDocument(); + userEvent.click(requestsRadio); + + const individualRadio = await screen.findByTestId('individualRadio'); + expect(individualRadio).toBeInTheDocument(); + userEvent.click(individualRadio); + }); +}); diff --git a/src/screens/EventVolunteers/VolunteerContainer.tsx b/src/screens/EventVolunteers/VolunteerContainer.tsx new file mode 100644 index 0000000000..1a425a706e --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerContainer.tsx @@ -0,0 +1,113 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Navigate, useParams } from 'react-router-dom'; +import styles from './EventVolunteers.module.css'; +import { HiUserGroup, HiUser } from 'react-icons/hi2'; +import Volunteers from './Volunteers/Volunteers'; +import VolunteerGroups from './VolunteerGroups/VolunteerGroups'; +import { FaRegFile } from 'react-icons/fa6'; +import Requests from './Requests/Requests'; + +/** + * Container Component for Volunteer or VolunteerGroups as per selection. + * + * This component allows users switch between Volunteers and VolunteerGroups. + * + * @returns The rendered component. + */ +function volunteerContainer(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + + // Get the organization ID and event ID from URL parameters + const { orgId, eventId } = useParams(); + + if (!orgId || !eventId) { + return ; + } + + const [dataType, setDataType] = useState<'individual' | 'group' | 'requests'>( + 'individual', + ); + + return ( +
    +
    + + {t( + `${dataType === 'group' ? 'volunteerGroups' : dataType === 'individual' ? 'volunteers' : 'requests'}`, + )} + +
    +
    + setDataType('individual')} + /> + + + setDataType('group')} + checked={dataType === 'group'} + /> + + + setDataType('requests')} + checked={dataType === 'requests'} + /> + +
    +
    +
    + + {dataType === 'individual' ? ( + + ) : dataType === 'group' ? ( + + ) : ( + + )} +
    + ); +} + +export default volunteerContainer; diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupDeleteModal.test.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupDeleteModal.test.tsx new file mode 100644 index 0000000000..05c2dab5ff --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupDeleteModal.test.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18n from 'utils/i18nForTest'; +import { MOCKS, MOCKS_ERROR } from './VolunteerGroups.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { InterfaceDeleteVolunteerGroupModal } from './VolunteerGroupDeleteModal'; +import VolunteerGroupDeleteModal from './VolunteerGroupDeleteModal'; +import userEvent from '@testing-library/user-event'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceDeleteVolunteerGroupModal[] = [ + { + isOpen: true, + hide: jest.fn(), + refetchGroups: jest.fn(), + group: { + _id: 'groupId', + name: 'Group 1', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, +]; + +const renderGroupDeleteModal = ( + link: ApolloLink, + props: InterfaceDeleteVolunteerGroupModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing Group Delete Modal', () => { + it('Delete Group', async () => { + renderGroupDeleteModal(link1, itemProps[0]); + expect(screen.getByText(t.deleteGroup)).toBeInTheDocument(); + + const yesBtn = screen.getByTestId('deleteyesbtn'); + expect(yesBtn).toBeInTheDocument(); + userEvent.click(yesBtn); + + await waitFor(() => { + expect(itemProps[0].refetchGroups).toHaveBeenCalled(); + expect(itemProps[0].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.volunteerGroupDeleted); + }); + }); + + it('Close Delete Modal', async () => { + renderGroupDeleteModal(link1, itemProps[0]); + expect(screen.getByText(t.deleteGroup)).toBeInTheDocument(); + + const noBtn = screen.getByTestId('deletenobtn'); + expect(noBtn).toBeInTheDocument(); + userEvent.click(noBtn); + + await waitFor(() => { + expect(itemProps[0].hide).toHaveBeenCalled(); + }); + }); + + it('Delete Group -> Error', async () => { + renderGroupDeleteModal(link2, itemProps[0]); + expect(screen.getByText(t.deleteGroup)).toBeInTheDocument(); + + const yesBtn = screen.getByTestId('deleteyesbtn'); + expect(yesBtn).toBeInTheDocument(); + userEvent.click(yesBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupDeleteModal.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupDeleteModal.tsx new file mode 100644 index 0000000000..33132bfd33 --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupDeleteModal.tsx @@ -0,0 +1,100 @@ +import { Button, Modal } from 'react-bootstrap'; +import styles from '../EventVolunteers.module.css'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation } from '@apollo/client'; +import type { InterfaceVolunteerGroupInfo } from 'utils/interfaces'; +import { toast } from 'react-toastify'; +import { DELETE_VOLUNTEER_GROUP } from 'GraphQl/Mutations/EventVolunteerMutation'; + +export interface InterfaceDeleteVolunteerGroupModal { + isOpen: boolean; + hide: () => void; + group: InterfaceVolunteerGroupInfo | null; + refetchGroups: () => void; +} + +/** + * A modal dialog for confirming the deletion of a volunteer group. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param group - The volunteer group to be deleted. + * @param refetchGroups - Function to refetch the volunteer groups after deletion. + * + * @returns The rendered modal component. + * + * + * The `VolunteerGroupDeleteModal` component displays a confirmation dialog when a user attempts to delete a volunteer group. + * It allows the user to either confirm or cancel the deletion. + * On confirmation, the `deleteVolunteerGroup` mutation is called to remove the volunteer group from the database, + * and the `refetchGroups` function is invoked to update the list of volunteer groups. + * A success or error toast notification is shown based on the result of the deletion operation. + * + * The modal includes: + * - A header with a title and a close button. + * - A body with a message asking for confirmation. + * - A footer with "Yes" and "No" buttons to confirm or cancel the deletion. + * + * The `deleteVolunteerGroup` mutation is used to perform the deletion operation. + */ + +const VolunteerGroupDeleteModal: React.FC< + InterfaceDeleteVolunteerGroupModal +> = ({ isOpen, hide, group, refetchGroups }) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + + const [deleteVolunteerGroup] = useMutation(DELETE_VOLUNTEER_GROUP); + + const deleteHandler = async (): Promise => { + try { + await deleteVolunteerGroup({ + variables: { + id: group?._id, + }, + }); + refetchGroups(); + hide(); + toast.success(t('volunteerGroupDeleted')); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + return ( + <> + + +

    {t('deleteGroup')}

    + +
    + +

    {t('deleteVolunteerGroupMsg')}

    +
    + + + + +
    + + ); +}; +export default VolunteerGroupDeleteModal; diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupModal.test.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupModal.test.tsx new file mode 100644 index 0000000000..2fc0b2e348 --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupModal.test.tsx @@ -0,0 +1,349 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18n from 'utils/i18nForTest'; +import { MOCKS, MOCKS_ERROR } from './VolunteerGroups.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { InterfaceVolunteerGroupModal } from './VolunteerGroupModal'; +import GroupModal from './VolunteerGroupModal'; +import userEvent from '@testing-library/user-event'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceVolunteerGroupModal[] = [ + { + isOpen: true, + hide: jest.fn(), + eventId: 'eventId', + orgId: 'orgId', + refetchGroups: jest.fn(), + mode: 'create', + group: null, + }, + { + isOpen: true, + hide: jest.fn(), + eventId: 'eventId', + orgId: 'orgId', + refetchGroups: jest.fn(), + mode: 'edit', + group: { + _id: 'groupId', + name: 'Group 1', + description: 'desc', + volunteersRequired: 2, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + eventId: 'eventId', + orgId: 'orgId', + refetchGroups: jest.fn(), + mode: 'edit', + group: { + _id: 'groupId', + name: 'Group 1', + description: null, + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, +]; + +const renderGroupModal = ( + link: ApolloLink, + props: InterfaceVolunteerGroupModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing VolunteerGroupModal', () => { + it('GroupModal -> Create', async () => { + renderGroupModal(link1, itemProps[0]); + expect(screen.getAllByText(t.createGroup)).toHaveLength(2); + + const nameInput = screen.getByLabelText(`${t.name} *`); + expect(nameInput).toBeInTheDocument(); + fireEvent.change(nameInput, { target: { value: 'Group 1' } }); + expect(nameInput).toHaveValue('Group 1'); + + const descInput = screen.getByLabelText(t.description); + expect(descInput).toBeInTheDocument(); + fireEvent.change(descInput, { target: { value: 'desc' } }); + expect(descInput).toHaveValue('desc'); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '10' } }); + expect(vrInput).toHaveValue('10'); + + // Select Leader + const memberSelect = await screen.findByTestId('leaderSelect'); + expect(memberSelect).toBeInTheDocument(); + const memberInputField = within(memberSelect).getByRole('combobox'); + fireEvent.mouseDown(memberInputField); + + const memberOption = await screen.findByText('Harve Lance'); + expect(memberOption).toBeInTheDocument(); + fireEvent.click(memberOption); + + // Select Volunteers + const volunteerSelect = await screen.findByTestId('volunteerSelect'); + expect(volunteerSelect).toBeInTheDocument(); + const volunteerInputField = within(volunteerSelect).getByRole('combobox'); + fireEvent.mouseDown(volunteerInputField); + + const volunteerOption = await screen.findByText('John Doe'); + expect(volunteerOption).toBeInTheDocument(); + fireEvent.click(volunteerOption); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.volunteerGroupCreated); + expect(itemProps[0].refetchGroups).toHaveBeenCalled(); + expect(itemProps[0].hide).toHaveBeenCalled(); + }); + }); + + it('GroupModal -> Create -> Error', async () => { + renderGroupModal(link2, itemProps[0]); + expect(screen.getAllByText(t.createGroup)).toHaveLength(2); + + const nameInput = screen.getByLabelText(`${t.name} *`); + expect(nameInput).toBeInTheDocument(); + fireEvent.change(nameInput, { target: { value: 'Group 1' } }); + expect(nameInput).toHaveValue('Group 1'); + + const descInput = screen.getByLabelText(t.description); + expect(descInput).toBeInTheDocument(); + fireEvent.change(descInput, { target: { value: 'desc' } }); + expect(descInput).toHaveValue('desc'); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '10' } }); + expect(vrInput).toHaveValue('10'); + + // Select Leader + const memberSelect = await screen.findByTestId('leaderSelect'); + expect(memberSelect).toBeInTheDocument(); + const memberInputField = within(memberSelect).getByRole('combobox'); + fireEvent.mouseDown(memberInputField); + + const memberOption = await screen.findByText('Harve Lance'); + expect(memberOption).toBeInTheDocument(); + fireEvent.click(memberOption); + + // Select Volunteers + const volunteerSelect = await screen.findByTestId('volunteerSelect'); + expect(volunteerSelect).toBeInTheDocument(); + const volunteerInputField = within(volunteerSelect).getByRole('combobox'); + fireEvent.mouseDown(volunteerInputField); + + const volunteerOption = await screen.findByText('John Doe'); + expect(volunteerOption).toBeInTheDocument(); + fireEvent.click(volunteerOption); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + it('GroupModal -> Update', async () => { + renderGroupModal(link1, itemProps[1]); + expect(screen.getAllByText(t.updateGroup)).toHaveLength(2); + + const nameInput = screen.getByLabelText(`${t.name} *`); + expect(nameInput).toBeInTheDocument(); + fireEvent.change(nameInput, { target: { value: 'Group 2' } }); + expect(nameInput).toHaveValue('Group 2'); + + const descInput = screen.getByLabelText(t.description); + expect(descInput).toBeInTheDocument(); + fireEvent.change(descInput, { target: { value: 'desc new' } }); + expect(descInput).toHaveValue('desc new'); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '10' } }); + expect(vrInput).toHaveValue('10'); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.volunteerGroupUpdated); + expect(itemProps[1].refetchGroups).toHaveBeenCalled(); + expect(itemProps[1].hide).toHaveBeenCalled(); + }); + }); + + it('GroupModal -> Details -> Update -> Error', async () => { + renderGroupModal(link2, itemProps[1]); + expect(screen.getAllByText(t.updateGroup)).toHaveLength(2); + + const nameInput = screen.getByLabelText(`${t.name} *`); + expect(nameInput).toBeInTheDocument(); + fireEvent.change(nameInput, { target: { value: 'Group 2' } }); + expect(nameInput).toHaveValue('Group 2'); + + const descInput = screen.getByLabelText(t.description); + expect(descInput).toBeInTheDocument(); + fireEvent.change(descInput, { target: { value: 'desc new' } }); + expect(descInput).toHaveValue('desc new'); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '10' } }); + expect(vrInput).toHaveValue('10'); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + it('Try adding different values for volunteersRequired', async () => { + renderGroupModal(link1, itemProps[2]); + expect(screen.getAllByText(t.updateGroup)).toHaveLength(2); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '-1' } }); + + await waitFor(() => { + expect(vrInput).toHaveValue(''); + }); + + userEvent.clear(vrInput); + userEvent.type(vrInput, '1{backspace}'); + + await waitFor(() => { + expect(vrInput).toHaveValue(''); + }); + + fireEvent.change(vrInput, { target: { value: '0' } }); + await waitFor(() => { + expect(vrInput).toHaveValue(''); + }); + + fireEvent.change(vrInput, { target: { value: '19' } }); + await waitFor(() => { + expect(vrInput).toHaveValue('19'); + }); + }); + + it('GroupModal -> Update -> No values updated', async () => { + renderGroupModal(link1, itemProps[1]); + expect(screen.getAllByText(t.updateGroup)).toHaveLength(2); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupModal.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupModal.tsx new file mode 100644 index 0000000000..5bfb1eff2b --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupModal.tsx @@ -0,0 +1,341 @@ +import type { ChangeEvent } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import type { + InterfaceCreateVolunteerGroup, + InterfaceUserInfo, + InterfaceVolunteerGroupInfo, +} from 'utils/interfaces'; +import styles from '../EventVolunteers.module.css'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery } from '@apollo/client'; +import { toast } from 'react-toastify'; +import { Autocomplete, FormControl, TextField } from '@mui/material'; + +import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; +import { + CREATE_VOLUNTEER_GROUP, + UPDATE_VOLUNTEER_GROUP, +} from 'GraphQl/Mutations/EventVolunteerMutation'; + +export interface InterfaceVolunteerGroupModal { + isOpen: boolean; + hide: () => void; + eventId: string; + orgId: string; + group: InterfaceVolunteerGroupInfo | null; + refetchGroups: () => void; + mode: 'create' | 'edit'; +} + +/** + * A modal dialog for creating or editing a volunteer group. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param eventId - The ID of the event associated with volunteer group. + * @param orgId - The ID of the organization associated with volunteer group. + * @param group - The volunteer group object to be edited. + * @param refetchGroups - Function to refetch the volunteer groups after creation or update. + * @param mode - The mode of the modal (create or edit). + * @returns The rendered modal component. + * + * The `VolunteerGroupModal` component displays a form within a modal dialog for creating or editing a Volunteer Group. + * It includes fields for entering the group name, description, volunteersRequired, and selecting volunteers/leaders. + * + * The modal includes: + * - A header with a title indicating the current mode (create or edit) and a close button. + * - A form with: + * - An input field for entering the group name. + * - A textarea for entering the group description. + * - A multi-select dropdown for selecting leader. + * - A multi-select dropdown for selecting volunteers. + * - An input field for entering the number of volunteers required. + * - A submit button to create or update the pledge. + * + * On form submission, the component either: + * - Calls `updatePledge` mutation to update an existing pledge, or + * - Calls `createPledge` mutation to create a new pledge. + * + * Success or error messages are displayed using toast notifications based on the result of the mutation. + */ + +const VolunteerGroupModal: React.FC = ({ + isOpen, + hide, + eventId, + orgId, + group, + refetchGroups, + mode, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + + const [formState, setFormState] = useState({ + name: group?.name ?? '', + description: group?.description ?? '', + leader: group?.leader ?? null, + volunteerUsers: group?.volunteers.map((volunteer) => volunteer.user) ?? [], + volunteersRequired: group?.volunteersRequired ?? null, + }); + const [members, setMembers] = useState([]); + + const [updateVolunteerGroup] = useMutation(UPDATE_VOLUNTEER_GROUP); + const [createVolunteerGroup] = useMutation(CREATE_VOLUNTEER_GROUP); + + const { data: memberData } = useQuery(MEMBERS_LIST, { + variables: { id: orgId }, + }); + + useEffect(() => { + setFormState({ + name: group?.name ?? '', + description: group?.description ?? '', + leader: group?.leader ?? null, + volunteerUsers: + group?.volunteers.map((volunteer) => volunteer.user) ?? [], + volunteersRequired: group?.volunteersRequired ?? null, + }); + }, [group]); + + useEffect(() => { + if (memberData) { + setMembers(memberData.organizations[0].members); + } + }, [memberData]); + + const { name, description, leader, volunteerUsers, volunteersRequired } = + formState; + + const updateGroupHandler = useCallback( + async (e: ChangeEvent): Promise => { + e.preventDefault(); + + const updatedFields: { + [key: string]: number | string | undefined | null; + } = {}; + + if (name !== group?.name) { + updatedFields.name = name; + } + if (description !== group?.description) { + updatedFields.description = description; + } + if (volunteersRequired !== group?.volunteersRequired) { + updatedFields.volunteersRequired = volunteersRequired; + } + try { + await updateVolunteerGroup({ + variables: { + id: group?._id, + data: { ...updatedFields, eventId }, + }, + }); + toast.success(t('volunteerGroupUpdated')); + refetchGroups(); + hide(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }, + [formState, group], + ); + + // Function to create a new volunteer group + const createGroupHandler = useCallback( + async (e: ChangeEvent): Promise => { + try { + e.preventDefault(); + await createVolunteerGroup({ + variables: { + data: { + eventId, + leaderId: leader?._id, + name, + description, + volunteersRequired, + volunteerUserIds: volunteerUsers.map((user) => user._id), + }, + }, + }); + + toast.success(t('volunteerGroupCreated')); + refetchGroups(); + setFormState({ + name: '', + description: '', + leader: null, + volunteerUsers: [], + volunteersRequired: null, + }); + hide(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }, + [formState, eventId], + ); + + return ( + + +

    + {t(mode === 'edit' ? 'updateGroup' : 'createGroup')} +

    + +
    + +
    + {/* Input field to enter the group name */} + + + + setFormState({ ...formState, name: e.target.value }) + } + /> + + + {/* Input field to enter the group description */} + + + + setFormState({ ...formState, description: e.target.value }) + } + /> + + + {/* A dropdown to select leader for volunteer group */} + + option._id === value._id} + filterSelectedOptions={true} + getOptionLabel={(member: InterfaceUserInfo): string => + `${member.firstName} ${member.lastName}` + } + onChange={ + /*istanbul ignore next*/ + (_, newLeader): void => { + if (newLeader) { + setFormState({ + ...formState, + leader: newLeader, + volunteerUsers: [...volunteerUsers, newLeader], + }); + } else { + setFormState({ + ...formState, + leader: null, + volunteerUsers: volunteerUsers.filter( + (user) => user._id !== leader?._id, + ), + }); + } + } + } + renderInput={(params) => ( + + )} + /> + + + {/* A Multi-select dropdown to select more than one volunteer */} + + option._id === value._id} + filterSelectedOptions={true} + getOptionLabel={(member: InterfaceUserInfo): string => + `${member.firstName} ${member.lastName}` + } + disabled={mode === 'edit'} + onChange={ + /*istanbul ignore next*/ + (_, newUsers): void => { + setFormState({ + ...formState, + volunteerUsers: newUsers, + }); + } + } + renderInput={(params) => ( + + )} + /> + + + + { + if (parseInt(e.target.value) > 0) { + setFormState({ + ...formState, + volunteersRequired: parseInt(e.target.value), + }); + } else if (e.target.value === '') { + setFormState({ + ...formState, + volunteersRequired: null, + }); + } + }} + /> + + + + {/* Button to submit the volunteer group form */} + +
    +
    +
    + ); +}; +export default VolunteerGroupModal; diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal.test.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal.test.tsx new file mode 100644 index 0000000000..94c34923a2 --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal.test.tsx @@ -0,0 +1,126 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18n from 'utils/i18nForTest'; +import type { InterfaceVolunteerGroupViewModal } from './VolunteerGroupViewModal'; +import VolunteerGroupViewModal from './VolunteerGroupViewModal'; + +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceVolunteerGroupViewModal[] = [ + { + isOpen: true, + hide: jest.fn(), + group: { + _id: 'groupId', + name: 'Group 1', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + group: { + _id: 'groupId', + name: 'Group 1', + description: null, + volunteersRequired: 10, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: 'img--url', + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, +]; + +const renderGroupViewModal = ( + props: InterfaceVolunteerGroupViewModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing VolunteerGroupViewModal', () => { + it('Render VolunteerGroupViewModal (variation 1)', async () => { + renderGroupViewModal(itemProps[0]); + expect(screen.getByText(t.groupDetails)).toBeInTheDocument(); + expect(screen.getByTestId('leader_avatar')).toBeInTheDocument(); + expect(screen.getByTestId('creator_avatar')).toBeInTheDocument(); + }); + + it('Render VolunteerGroupViewModal (variation 2)', async () => { + renderGroupViewModal(itemProps[1]); + expect(screen.getByText(t.groupDetails)).toBeInTheDocument(); + expect(screen.getByTestId('leader_image')).toBeInTheDocument(); + expect(screen.getByTestId('creator_image')).toBeInTheDocument(); + }); +}); diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal.tsx new file mode 100644 index 0000000000..70994bd4e5 --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal.tsx @@ -0,0 +1,235 @@ +import { Button, Form, Modal } from 'react-bootstrap'; +import type { InterfaceVolunteerGroupInfo } from 'utils/interfaces'; +import styles from '../EventVolunteers.module.css'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + FormControl, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, +} from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; + +export interface InterfaceVolunteerGroupViewModal { + isOpen: boolean; + hide: () => void; + group: InterfaceVolunteerGroupInfo; +} + +/** + * A modal dialog for viewing volunteer group information for an event. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param group - The volunteer group to display in the modal. + * + * @returns The rendered modal component. + * + * The `VolunteerGroupViewModal` component displays all the fields of a volunteer group in a modal dialog. + * + * The modal includes: + * - A header with a title and a close button. + * - fields for volunteer name, status, hours volunteered, groups, and assignments. + */ + +const VolunteerGroupViewModal: React.FC = ({ + isOpen, + hide, + group, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + + const { leader, creator, name, volunteersRequired, description, volunteers } = + group; + + return ( + + +

    {t('groupDetails')}

    + +
    + +
    + {/* Group name & Volunteers Required */} + + + + + {description && ( + + + + )} + + {/* Input field to enter the group description */} + {description && ( + + + + + + )} + + + + {leader.image ? ( + Volunteer + ) : ( +
    + +
    + )} + + ), + }} + /> +
    + + + + {creator.image ? ( + Volunteer + ) : ( +
    + +
    + )} + + ), + }} + /> +
    +
    + {/* Table for Associated Volunteers */} + {volunteers && volunteers.length > 0 && ( + + + Volunteers + + + + + + + Sr. No. + Name + + + + {volunteers.map((volunteer, index) => { + const { firstName, lastName } = volunteer.user; + return ( + + + {index + 1} + + + {firstName + ' ' + lastName} + + + ); + })} + +
    +
    +
    + )} +
    +
    +
    + ); +}; +export default VolunteerGroupViewModal; diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.mocks.ts b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.mocks.ts new file mode 100644 index 0000000000..b8cc52e875 --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.mocks.ts @@ -0,0 +1,452 @@ +import { + CREATE_VOLUNTEER_GROUP, + DELETE_VOLUNTEER_GROUP, + UPDATE_VOLUNTEER_GROUP, +} from 'GraphQl/Mutations/EventVolunteerMutation'; +import { EVENT_VOLUNTEER_GROUP_LIST } from 'GraphQl/Queries/EventVolunteerQueries'; +import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; + +const group1 = { + _id: 'groupId1', + name: 'Group 1', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, +}; + +const group2 = { + _id: 'groupId2', + name: 'Group 2', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-27T15:25:13.044Z', + creator: { + _id: 'creatorId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId2', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, +}; + +const group3 = { + _id: 'groupId3', + name: 'Group 3', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-27T15:34:15.889Z', + creator: { + _id: 'creatorId3', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId1', + firstName: 'Bruce', + lastName: 'Garza', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId3', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, +}; + +export const MOCKS = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: null, + name_contains: '', + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1, group2, group3], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: null, + name_contains: '', + }, + orderBy: 'volunteers_DESC', + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1, group2], + }, + }, + }, + + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: null, + name_contains: '', + }, + orderBy: 'volunteers_ASC', + }, + }, + result: { + data: { + getEventVolunteerGroups: [group2, group1], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: null, + name_contains: '1', + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: '', + name_contains: null, + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1, group2, group3], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: 'Bruce', + name_contains: null, + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group3], + }, + }, + }, + { + request: { + query: MEMBERS_LIST, + variables: { + id: 'orgId', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + members: [ + { + _id: 'userId', + firstName: 'Harve', + lastName: 'Lance', + email: 'harve@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + { + _id: 'userId2', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: CREATE_VOLUNTEER_GROUP, + variables: { + data: { + eventId: 'eventId', + leaderId: 'userId', + name: 'Group 1', + description: 'desc', + volunteerUserIds: ['userId', 'userId2'], + volunteersRequired: 10, + }, + }, + }, + result: { + data: { + createEventVolunteerGroup: { + _id: 'groupId', + }, + }, + }, + }, + { + request: { + query: DELETE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + }, + }, + result: { + data: { + removeEventVolunteerGroup: { + _id: 'groupId', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + data: { + eventId: 'eventId', + name: 'Group 2', + description: 'desc new', + volunteersRequired: 10, + }, + }, + }, + result: { + data: { + updateEventVolunteerGroup: { + _id: 'groupId', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + data: { + eventId: 'eventId', + }, + }, + }, + result: { + data: { + updateEventVolunteerGroup: { + _id: 'groupId', + }, + }, + }, + }, +]; + +export const MOCKS_EMPTY = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: null, + name_contains: '', + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [], + }, + }, + }, +]; + +export const MOCKS_ERROR = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + eventId: 'eventId', + leaderName: null, + name_contains: '', + }, + orderBy: null, + }, + }, + error: new Error('An error occurred'), + }, + { + request: { + query: DELETE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + }, + }, + error: new Error('An error occurred'), + }, + { + request: { + query: MEMBERS_LIST, + variables: { + id: 'orgId', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + members: [ + { + _id: 'userId', + firstName: 'Harve', + lastName: 'Lance', + email: 'harve@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + { + _id: 'userId2', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: CREATE_VOLUNTEER_GROUP, + variables: { + data: { + eventId: 'eventId', + leaderId: 'userId', + name: 'Group 1', + description: 'desc', + volunteerUserIds: ['userId', 'userId2'], + volunteersRequired: 10, + }, + }, + }, + error: new Error('An error occurred'), + }, + { + request: { + query: UPDATE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + data: { + eventId: 'eventId', + name: 'Group 2', + description: 'desc new', + volunteersRequired: 10, + }, + }, + }, + error: new Error('An error occurred'), + }, +]; diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.test.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.test.tsx new file mode 100644 index 0000000000..0dcba34a3a --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.test.tsx @@ -0,0 +1,232 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import VolunteerGroups from './VolunteerGroups'; +import type { ApolloLink } from '@apollo/client'; +import { MOCKS, MOCKS_EMPTY, MOCKS_ERROR } from './VolunteerGroups.mocks'; + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const link3 = new StaticMockLink(MOCKS_EMPTY); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderVolunteerGroups = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } + /> + } + /> + + + + + + , + ); +}; + +describe('Testing VolunteerGroups Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId', eventId: 'eventId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> + } + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Groups screen', async () => { + renderVolunteerGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + }); + + it('Check Sorting Functionality', async () => { + renderVolunteerGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + let sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by members_DESC + fireEvent.click(sortBtn); + const volunteersDESC = await screen.findByTestId('volunteers_DESC'); + expect(volunteersDESC).toBeInTheDocument(); + fireEvent.click(volunteersDESC); + + let groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 1'); + + // Sort by members_ASC + sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const volunteersASC = await screen.findByTestId('volunteers_ASC'); + expect(volunteersASC).toBeInTheDocument(); + fireEvent.click(volunteersASC); + + groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 2'); + }); + + it('Search by Groups', async () => { + renderVolunteerGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByGroup = await screen.findByTestId('group'); + expect(searchByGroup).toBeInTheDocument(); + userEvent.click(searchByGroup); + + userEvent.type(searchInput, '1'); + await debounceWait(); + + const groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 1'); + }); + + it('Search by Leader', async () => { + renderVolunteerGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByLeader = await screen.findByTestId('leader'); + expect(searchByLeader).toBeInTheDocument(); + userEvent.click(searchByLeader); + + // Search by name on press of ENTER + userEvent.type(searchInput, 'Bruce'); + await debounceWait(); + + const groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 1'); + }); + + it('should render screen with No Groups', async () => { + renderVolunteerGroups(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noVolunteerGroups)).toBeInTheDocument(); + }); + }); + + it('Error while fetching groups data', async () => { + renderVolunteerGroups(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Open and close ViewModal', async () => { + renderVolunteerGroups(link1); + + const viewGroupBtn = await screen.findAllByTestId('viewGroupBtn'); + userEvent.click(viewGroupBtn[0]); + + expect(await screen.findByText(t.groupDetails)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('volunteerViewModalCloseBtn')); + }); + + it('Open and Close Delete Modal', async () => { + renderVolunteerGroups(link1); + + const deleteGroupBtn = await screen.findAllByTestId('deleteGroupBtn'); + userEvent.click(deleteGroupBtn[0]); + + expect(await screen.findByText(t.deleteGroup)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); + + it('Open and close GroupModal (Edit)', async () => { + renderVolunteerGroups(link1); + + const editGroupBtn = await screen.findAllByTestId('editGroupBtn'); + userEvent.click(editGroupBtn[0]); + + expect(await screen.findAllByText(t.updateGroup)).toHaveLength(2); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); + + it('Open and close GroupModal (Create)', async () => { + renderVolunteerGroups(link1); + + const createGroupBtn = await screen.findByTestId('createGroupBtn'); + userEvent.click(createGroupBtn); + + expect(await screen.findAllByText(t.createGroup)).toHaveLength(2); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); +}); diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.tsx new file mode 100644 index 0000000000..fa98abc9f2 --- /dev/null +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.tsx @@ -0,0 +1,444 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Navigate, useParams } from 'react-router-dom'; + +import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; + +import { useQuery } from '@apollo/client'; + +import type { InterfaceVolunteerGroupInfo } from 'utils/interfaces'; +import Loader from 'components/Loader/Loader'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { debounce, Stack } from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; +import styles from '../EventVolunteers.module.css'; +import { EVENT_VOLUNTEER_GROUP_LIST } from 'GraphQl/Queries/EventVolunteerQueries'; +import VolunteerGroupModal from './VolunteerGroupModal'; +import VolunteerGroupDeleteModal from './VolunteerGroupDeleteModal'; +import VolunteerGroupViewModal from './VolunteerGroupViewModal'; + +enum ModalState { + SAME = 'same', + DELETE = 'delete', + VIEW = 'view', +} + +const dataGridStyle = { + '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { + outline: 'none !important', + }, + '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-root': { + borderRadius: '0.5rem', + }, + '& .MuiDataGrid-main': { + borderRadius: '0.5rem', + }, +}; + +/** + * Component for managing volunteer groups for an event. + * This component allows users to view, filter, sort, and create action items. It also provides a modal for creating and editing action items. + * @returns The rendered component. + */ +function volunteerGroups(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Get the organization ID from URL parameters + const { orgId, eventId } = useParams(); + + if (!orgId || !eventId) { + return ; + } + + const [group, setGroup] = useState(null); + const [modalMode, setModalMode] = useState<'create' | 'edit'>('create'); + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState< + 'volunteers_ASC' | 'volunteers_DESC' | null + >(null); + const [searchBy, setSearchBy] = useState<'leader' | 'group'>('group'); + const [modalState, setModalState] = useState<{ + [key in ModalState]: boolean; + }>({ + [ModalState.SAME]: false, + [ModalState.DELETE]: false, + [ModalState.VIEW]: false, + }); + + /** + * Query to fetch the list of volunteer groups for the event. + */ + const { + data: groupsData, + loading: groupsLoading, + error: groupsError, + refetch: refetchGroups, + }: { + data?: { + getEventVolunteerGroups: InterfaceVolunteerGroupInfo[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(EVENT_VOLUNTEER_GROUP_LIST, { + variables: { + where: { + eventId: eventId, + leaderName: searchBy === 'leader' ? searchTerm : null, + name_contains: searchBy === 'group' ? searchTerm : null, + }, + orderBy: sortBy, + }, + }); + + const openModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: true })); + + const closeModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: false })); + + const handleModalClick = useCallback( + (group: InterfaceVolunteerGroupInfo | null, modal: ModalState): void => { + if (modal === ModalState.SAME) { + setModalMode(group ? 'edit' : 'create'); + } + setGroup(group); + openModal(modal); + }, + [openModal], + ); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const groups = useMemo( + () => groupsData?.getEventVolunteerGroups || [], + [groupsData], + ); + + if (groupsLoading) { + return ; + } + + if (groupsError) { + return ( +
    + +
    + {tErrors('errorLoading', { entity: 'Volunteer Groups' })} +
    +
    + ); + } + + const columns: GridColDef[] = [ + { + field: 'group', + headerName: 'Group', + flex: 1, + align: 'left', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
    + {params.row.name} +
    + ); + }, + }, + { + field: 'leader', + headerName: 'Leader', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + const { _id, firstName, lastName, image } = params.row.leader; + return ( +
    + {image ? ( + Assignee + ) : ( +
    + +
    + )} + {firstName + ' ' + lastName} +
    + ); + }, + }, + { + field: 'actions', + headerName: 'Actions Completed', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
    + {params.row.assignments.length} +
    + ); + }, + }, + { + field: 'volunteers', + headerName: 'No. of Volunteers', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
    + {params.row.volunteers.length} +
    + ); + }, + }, + { + field: 'options', + headerName: 'Options', + align: 'center', + flex: 1, + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + <> + + + + + ); + }, + }, + ]; + + return ( +
    + {/* Header with search, filter and Create Button */} +
    +
    + { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
    +
    +
    + + + + {tCommon('searchBy', { item: '' })} + + + setSearchBy('leader')} + data-testid="leader" + > + {t('leader')} + + setSearchBy('group')} + data-testid="group" + > + {t('group')} + + + + + + + {tCommon('sort')} + + + setSortBy('volunteers_DESC')} + data-testid="volunteers_DESC" + > + {t('mostVolunteers')} + + setSortBy('volunteers_ASC')} + data-testid="volunteers_ASC" + > + {t('leastVolunteers')} + + + +
    +
    + +
    +
    +
    + + {/* Table with Volunteer Groups */} + row._id} + slots={{ + noRowsOverlay: () => ( + + {t('noVolunteerGroups')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={groups.map((group, index) => ({ + id: index + 1, + ...group, + }))} + columns={columns} + isRowSelectable={() => false} + /> + + closeModal(ModalState.SAME)} + refetchGroups={refetchGroups} + eventId={eventId} + orgId={orgId} + group={group} + mode={modalMode} + /> + + {group && ( + <> + closeModal(ModalState.VIEW)} + group={group} + /> + + closeModal(ModalState.DELETE)} + refetchGroups={refetchGroups} + group={group} + /> + + )} +
    + ); +} + +export default volunteerGroups; diff --git a/src/screens/EventVolunteers/Volunteers/VolunteerCreateModal.test.tsx b/src/screens/EventVolunteers/Volunteers/VolunteerCreateModal.test.tsx new file mode 100644 index 0000000000..cac8fe94f0 --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/VolunteerCreateModal.test.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18n from 'utils/i18nForTest'; +import { MOCKS, MOCKS_ERROR } from './Volunteers.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { InterfaceVolunteerCreateModal } from './VolunteerCreateModal'; +import VolunteerCreateModal from './VolunteerCreateModal'; +import userEvent from '@testing-library/user-event'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceVolunteerCreateModal[] = [ + { + isOpen: true, + hide: jest.fn(), + eventId: 'eventId', + orgId: 'orgId', + refetchVolunteers: jest.fn(), + }, +]; + +const renderCreateModal = ( + link: ApolloLink, + props: InterfaceVolunteerCreateModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing VolunteerCreateModal', () => { + it('VolunteerCreateModal -> Create', async () => { + renderCreateModal(link1, itemProps[0]); + expect(screen.getAllByText(t.addVolunteer)).toHaveLength(2); + + // Select Volunteers + const membersSelect = await screen.findByTestId('membersSelect'); + expect(membersSelect).toBeInTheDocument(); + const volunteerInputField = within(membersSelect).getByRole('combobox'); + fireEvent.mouseDown(volunteerInputField); + + const volunteerOption = await screen.findByText('John Doe'); + expect(volunteerOption).toBeInTheDocument(); + fireEvent.click(volunteerOption); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.volunteerAdded); + expect(itemProps[0].refetchVolunteers).toHaveBeenCalled(); + expect(itemProps[0].hide).toHaveBeenCalled(); + }); + }); + + it('VolunteerCreateModal -> Create -> Error', async () => { + renderCreateModal(link2, itemProps[0]); + expect(screen.getAllByText(t.addVolunteer)).toHaveLength(2); + + // Select Volunteers + const membersSelect = await screen.findByTestId('membersSelect'); + expect(membersSelect).toBeInTheDocument(); + const volunteerInputField = within(membersSelect).getByRole('combobox'); + fireEvent.mouseDown(volunteerInputField); + + const volunteerOption = await screen.findByText('John Doe'); + expect(volunteerOption).toBeInTheDocument(); + fireEvent.click(volunteerOption); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/EventVolunteers/Volunteers/VolunteerCreateModal.tsx b/src/screens/EventVolunteers/Volunteers/VolunteerCreateModal.tsx new file mode 100644 index 0000000000..6b4a1e3f0c --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/VolunteerCreateModal.tsx @@ -0,0 +1,152 @@ +import type { ChangeEvent } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import type { InterfaceUserInfo } from 'utils/interfaces'; +import styles from '../EventVolunteers.module.css'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery } from '@apollo/client'; +import { toast } from 'react-toastify'; +import { Autocomplete, TextField } from '@mui/material'; + +import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; +import { ADD_VOLUNTEER } from 'GraphQl/Mutations/EventVolunteerMutation'; + +export interface InterfaceVolunteerCreateModal { + isOpen: boolean; + hide: () => void; + eventId: string; + orgId: string; + refetchVolunteers: () => void; +} + +/** + * A modal dialog for add a volunteer for an event. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param eventId - The ID of the event associated with volunteer. + * @param orgId - The ID of the organization associated with volunteer. + * @param refetchVolunteers - Function to refetch the volunteers after adding a volunteer. + * + * @returns The rendered modal component. + * + * The `VolunteerCreateModal` component displays a form within a modal dialog for adding a volunteer. + * It includes fields for selecting user. + * + * The modal includes: + * - A header with a title and a close button. + * - A form with: + * - A multi-select dropdown for selecting user be added as volunteer. + * - A submit button to create or update the pledge. + * + * On form submission, the component: + * - Calls `addVolunteer` mutation to add a new Volunteer. + * + * Success or error messages are displayed using toast notifications based on the result of the mutation. + */ + +const VolunteerCreateModal: React.FC = ({ + isOpen, + hide, + eventId, + orgId, + refetchVolunteers, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + + const [userId, setUserId] = useState(''); + const [members, setMembers] = useState([]); + const [addVolunteer] = useMutation(ADD_VOLUNTEER); + + const { data: memberData } = useQuery(MEMBERS_LIST, { + variables: { id: orgId }, + }); + + useEffect(() => { + if (memberData) { + setMembers(memberData.organizations[0].members); + } + }, [memberData]); + + // Function to add a volunteer for an event + const addVolunteerHandler = useCallback( + async (e: ChangeEvent): Promise => { + try { + e.preventDefault(); + await addVolunteer({ + variables: { + data: { + eventId, + userId, + }, + }, + }); + + toast.success(t('volunteerAdded')); + refetchVolunteers(); + setUserId(''); + hide(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }, + [userId, eventId], + ); + + return ( + + +

    {t('addVolunteer')}

    + +
    + +
    + {/* A Multi-select dropdown enables admin to invite a member as volunteer */} + + option._id === value._id} + filterSelectedOptions={true} + getOptionLabel={(member: InterfaceUserInfo): string => + `${member.firstName} ${member.lastName}` + } + onChange={ + /*istanbul ignore next*/ + (_, newVolunteer): void => { + setUserId(newVolunteer?._id ?? ''); + } + } + renderInput={(params) => } + /> + + + {/* Button to submit the volunteer form */} + +
    +
    +
    + ); +}; +export default VolunteerCreateModal; diff --git a/src/screens/EventVolunteers/Volunteers/VolunteerDeleteModal.test.tsx b/src/screens/EventVolunteers/Volunteers/VolunteerDeleteModal.test.tsx new file mode 100644 index 0000000000..dd9d6d5985 --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/VolunteerDeleteModal.test.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18n from 'utils/i18nForTest'; +import { MOCKS, MOCKS_ERROR } from './Volunteers.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { InterfaceDeleteVolunteerModal } from './VolunteerDeleteModal'; +import VolunteerDeleteModal from './VolunteerDeleteModal'; +import userEvent from '@testing-library/user-event'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceDeleteVolunteerModal[] = [ + { + isOpen: true, + hide: jest.fn(), + refetchVolunteers: jest.fn(), + volunteer: { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 10, + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + assignments: [], + groups: [ + { + _id: 'groupId1', + name: 'group1', + volunteers: [ + { + _id: 'volunteerId1', + }, + ], + }, + ], + }, + }, +]; + +const renderVolunteerDeleteModal = ( + link: ApolloLink, + props: InterfaceDeleteVolunteerModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing Volunteer Delete Modal', () => { + it('Delete Volunteer', async () => { + renderVolunteerDeleteModal(link1, itemProps[0]); + expect(screen.getByText(t.removeVolunteer)).toBeInTheDocument(); + + const yesBtn = screen.getByTestId('deleteyesbtn'); + expect(yesBtn).toBeInTheDocument(); + userEvent.click(yesBtn); + + await waitFor(() => { + expect(itemProps[0].refetchVolunteers).toHaveBeenCalled(); + expect(itemProps[0].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.volunteerRemoved); + }); + }); + + it('Close Delete Modal', async () => { + renderVolunteerDeleteModal(link1, itemProps[0]); + expect(screen.getByText(t.removeVolunteer)).toBeInTheDocument(); + + const noBtn = screen.getByTestId('deletenobtn'); + expect(noBtn).toBeInTheDocument(); + userEvent.click(noBtn); + + await waitFor(() => { + expect(itemProps[0].hide).toHaveBeenCalled(); + }); + }); + + it('Delete Volunteer -> Error', async () => { + renderVolunteerDeleteModal(link2, itemProps[0]); + expect(screen.getByText(t.removeVolunteer)).toBeInTheDocument(); + + const yesBtn = screen.getByTestId('deleteyesbtn'); + expect(yesBtn).toBeInTheDocument(); + userEvent.click(yesBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/EventVolunteers/Volunteers/VolunteerDeleteModal.tsx b/src/screens/EventVolunteers/Volunteers/VolunteerDeleteModal.tsx new file mode 100644 index 0000000000..8f253fdf50 --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/VolunteerDeleteModal.tsx @@ -0,0 +1,103 @@ +import { Button, Modal } from 'react-bootstrap'; +import styles from '../EventVolunteers.module.css'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation } from '@apollo/client'; +import type { InterfaceEventVolunteerInfo } from 'utils/interfaces'; +import { toast } from 'react-toastify'; +import { DELETE_VOLUNTEER } from 'GraphQl/Mutations/EventVolunteerMutation'; + +export interface InterfaceDeleteVolunteerModal { + isOpen: boolean; + hide: () => void; + volunteer: InterfaceEventVolunteerInfo; + refetchVolunteers: () => void; +} + +/** + * A modal dialog for confirming the deletion of a volunteer. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param volunteer - The volunteer object to be deleted. + * @param refetchVolunteers - Function to refetch the volunteers after deletion. + * + * @returns The rendered modal component. + * + * + * The `VolunteerDeleteModal` component displays a confirmation dialog when a user attempts to delete a volunteer. + * It allows the user to either confirm or cancel the deletion. + * On confirmation, the `deleteVolunteer` mutation is called to remove the pledge from the database, + * and the `refetchVolunteers` function is invoked to update the list of volunteers. + * A success or error toast notification is shown based on the result of the deletion operation. + * + * The modal includes: + * - A header with a title and a close button. + * - A body with a message asking for confirmation. + * - A footer with "Yes" and "No" buttons to confirm or cancel the deletion. + * + * The `deleteVolunteer` mutation is used to perform the deletion operation. + */ + +const VolunteerDeleteModal: React.FC = ({ + isOpen, + hide, + volunteer, + refetchVolunteers, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + + const [deleteVolunteer] = useMutation(DELETE_VOLUNTEER); + + const deleteHandler = async (): Promise => { + try { + await deleteVolunteer({ + variables: { + id: volunteer._id, + }, + }); + refetchVolunteers(); + hide(); + toast.success(t('volunteerRemoved')); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + return ( + <> + + +

    {t('removeVolunteer')}

    + +
    + +

    {t('removeVolunteerMsg')}

    +
    + + + + +
    + + ); +}; +export default VolunteerDeleteModal; diff --git a/src/screens/EventVolunteers/Volunteers/VolunteerViewModal.test.tsx b/src/screens/EventVolunteers/Volunteers/VolunteerViewModal.test.tsx new file mode 100644 index 0000000000..155dba8464 --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/VolunteerViewModal.test.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import i18n from 'utils/i18nForTest'; +import type { InterfaceVolunteerViewModal } from './VolunteerViewModal'; +import VolunteerViewModal from './VolunteerViewModal'; + +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceVolunteerViewModal[] = [ + { + isOpen: true, + hide: jest.fn(), + volunteer: { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 10, + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + assignments: [], + groups: [ + { + _id: 'groupId1', + name: 'group1', + volunteers: [ + { + _id: 'volunteerId1', + }, + ], + }, + ], + }, + }, + { + isOpen: true, + hide: jest.fn(), + volunteer: { + _id: 'volunteerId2', + hasAccepted: false, + hoursVolunteered: null, + user: { + _id: 'userId3', + firstName: 'Bruce', + lastName: 'Graza', + image: 'img-url', + }, + assignments: [], + groups: [], + }, + }, +]; + +const renderVolunteerViewModal = ( + props: InterfaceVolunteerViewModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing VolunteerViewModal', () => { + it('Render VolunteerViewModal (variation 1)', async () => { + renderVolunteerViewModal(itemProps[0]); + expect(screen.getByText(t.volunteerDetails)).toBeInTheDocument(); + expect(screen.getByTestId('volunteer_avatar')).toBeInTheDocument(); + }); + + it('Render VolunteerViewModal (variation 2)', async () => { + renderVolunteerViewModal(itemProps[1]); + expect(screen.getByText(t.volunteerDetails)).toBeInTheDocument(); + expect(screen.getByTestId('volunteer_image')).toBeInTheDocument(); + }); +}); diff --git a/src/screens/EventVolunteers/Volunteers/VolunteerViewModal.tsx b/src/screens/EventVolunteers/Volunteers/VolunteerViewModal.tsx new file mode 100644 index 0000000000..0904d34b9c --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/VolunteerViewModal.tsx @@ -0,0 +1,202 @@ +import { Button, Form, Modal } from 'react-bootstrap'; +import type { InterfaceEventVolunteerInfo } from 'utils/interfaces'; +import styles from '../EventVolunteers.module.css'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + FormControl, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, +} from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; +import { HistoryToggleOff, TaskAlt } from '@mui/icons-material'; + +export interface InterfaceVolunteerViewModal { + isOpen: boolean; + hide: () => void; + volunteer: InterfaceEventVolunteerInfo; +} + +/** + * A modal dialog for viewing volunteer information for an event. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param volunteer - The volunteer object to be displayed. + * + * @returns The rendered modal component. + * + * The `VolunteerViewModal` component displays all the fields of a volunteer in a modal dialog. + * + * The modal includes: + * - A header with a title and a close button. + * - fields for volunteer name, status, hours volunteered, groups, and assignments. + */ + +const VolunteerViewModal: React.FC = ({ + isOpen, + hide, + volunteer, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + + const { user, hasAccepted, hoursVolunteered, groups } = volunteer; + + return ( + + +

    {t('volunteerDetails')}

    + +
    + +
    + {/* Volunteer Name & Avatar */} + + + + {user.image ? ( + Volunteer + ) : ( +
    + +
    + )} + + ), + }} + /> +
    +
    + {/* Status and hours volunteered */} + + + {hasAccepted ? ( + + ) : ( + + )} + + ), + style: { + color: hasAccepted ? 'green' : '#ed6c02', + }, + }} + inputProps={{ + style: { + WebkitTextFillColor: hasAccepted ? 'green' : '#ed6c02', + }, + }} + disabled + /> + + + + {/* Table for Associated Volunteer Groups */} + {groups && groups.length > 0 && ( + + + Volunteer Groups Joined + + + + + + + Sr. No. + Group Name + + No. of Members + + + + + {groups.map((group, index) => { + const { _id, name, volunteers } = group; + return ( + + + {index + 1} + + + {name} + + + {volunteers.length} + + + ); + })} + +
    +
    +
    + )} +
    +
    +
    + ); +}; +export default VolunteerViewModal; diff --git a/src/screens/EventVolunteers/Volunteers/Volunteers.mocks.ts b/src/screens/EventVolunteers/Volunteers/Volunteers.mocks.ts new file mode 100644 index 0000000000..72c927b8ff --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/Volunteers.mocks.ts @@ -0,0 +1,303 @@ +import { + ADD_VOLUNTEER, + DELETE_VOLUNTEER, +} from 'GraphQl/Mutations/EventVolunteerMutation'; +import { EVENT_VOLUNTEER_LIST } from 'GraphQl/Queries/EventVolunteerQueries'; +import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; + +const volunteer1 = { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 10, + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + assignments: [], + groups: [ + { + _id: 'groupId1', + name: 'group1', + volunteers: [ + { + _id: 'volunteerId1', + }, + ], + }, + ], +}; + +const volunteer2 = { + _id: 'volunteerId2', + hasAccepted: false, + hoursVolunteered: null, + user: { + _id: 'userId3', + firstName: 'Bruce', + lastName: 'Graza', + image: 'img-url', + }, + assignments: [], + groups: [], +}; + +export const MOCKS = [ + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '' }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteers: [volunteer1, volunteer2], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '' }, + orderBy: 'hoursVolunteered_ASC', + }, + }, + result: { + data: { + getEventVolunteers: [volunteer2, volunteer1], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '' }, + orderBy: 'hoursVolunteered_DESC', + }, + }, + result: { + data: { + getEventVolunteers: [volunteer1, volunteer2], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: 'T' }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteers: [volunteer1], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '', hasAccepted: false }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteers: [volunteer2], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '', hasAccepted: false }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteers: [volunteer2], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '', hasAccepted: true }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteers: [volunteer1], + }, + }, + }, + { + request: { + query: DELETE_VOLUNTEER, + variables: { + id: 'volunteerId1', + }, + }, + result: { + data: { + removeEventVolunteer: { + _id: 'volunteerId1', + }, + }, + }, + }, + { + request: { + query: MEMBERS_LIST, + variables: { + id: 'orgId', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + members: [ + { + _id: 'userId2', + firstName: 'Harve', + lastName: 'Lance', + email: 'harve@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + { + _id: 'userId3', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: ADD_VOLUNTEER, + variables: { + data: { + eventId: 'eventId', + userId: 'userId3', + }, + }, + }, + result: { + data: { + createEventVolunteer: { + _id: 'volunteerId1', + }, + }, + }, + }, +]; + +export const MOCKS_ERROR = [ + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '' }, + orderBy: null, + }, + }, + error: new Error('An error occurred'), + }, + { + request: { + query: DELETE_VOLUNTEER, + variables: { + id: 'volunteerId1', + }, + }, + error: new Error('An error occurred'), + }, + { + request: { + query: MEMBERS_LIST, + variables: { + id: 'orgId', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + members: [ + { + _id: 'userId2', + firstName: 'Harve', + lastName: 'Lance', + email: 'harve@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + { + _id: 'userId3', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: ADD_VOLUNTEER, + variables: { + data: { + eventId: 'eventId', + userId: 'userId3', + }, + }, + }, + error: new Error('An error occurred'), + }, +]; + +export const MOCKS_EMPTY = [ + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { + where: { eventId: 'eventId', name_contains: '' }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteers: [], + }, + }, + }, +]; diff --git a/src/screens/EventVolunteers/Volunteers/Volunteers.test.tsx b/src/screens/EventVolunteers/Volunteers/Volunteers.test.tsx new file mode 100644 index 0000000000..2af25b0b84 --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/Volunteers.test.tsx @@ -0,0 +1,245 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import Volunteers from './Volunteers'; +import type { ApolloLink } from '@apollo/client'; +import { MOCKS, MOCKS_EMPTY, MOCKS_ERROR } from './Volunteers.mocks'; + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const link3 = new StaticMockLink(MOCKS_EMPTY); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderVolunteers = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } /> + } + /> + + + + + + , + ); +}; + +describe('Testing Volunteers Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId', eventId: 'eventId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> + } + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Volunteers screen', async () => { + renderVolunteers(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + }); + + it('Check Sorting Functionality', async () => { + renderVolunteers(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + let sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by hoursVolunteered_DESC + fireEvent.click(sortBtn); + const hoursVolunteeredDESC = await screen.findByTestId( + 'hoursVolunteered_DESC', + ); + expect(hoursVolunteeredDESC).toBeInTheDocument(); + fireEvent.click(hoursVolunteeredDESC); + + let volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName[0]).toHaveTextContent('Teresa Bradley'); + + // Sort by hoursVolunteered_ASC + sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const hoursVolunteeredASC = await screen.findByTestId( + 'hoursVolunteered_ASC', + ); + expect(hoursVolunteeredASC).toBeInTheDocument(); + fireEvent.click(hoursVolunteeredASC); + + volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName[0]).toHaveTextContent('Bruce Graza'); + }); + + it('Filter Volunteers by status (All)', async () => { + renderVolunteers(link1); + + const filterBtn = await screen.findByTestId('filter'); + expect(filterBtn).toBeInTheDocument(); + + // Filter by All + fireEvent.click(filterBtn); + await waitFor(() => { + expect(screen.getByTestId('statusAll')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('statusAll')); + + const volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName).toHaveLength(2); + }); + + it('Filter Volunteers by status (Pending)', async () => { + renderVolunteers(link1); + + const filterBtn = await screen.findByTestId('filter'); + expect(filterBtn).toBeInTheDocument(); + + // Filter by Pending + fireEvent.click(filterBtn); + await waitFor(() => { + expect(screen.getByTestId('statusPending')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('statusPending')); + + const volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName[0]).toHaveTextContent('Bruce Graza'); + }); + + it('Filter Volunteers by status (Accepted)', async () => { + renderVolunteers(link1); + + const filterBtn = await screen.findByTestId('filter'); + expect(filterBtn).toBeInTheDocument(); + + // Filter by Accepted + fireEvent.click(filterBtn); + await waitFor(() => { + expect(screen.getByTestId('statusAccepted')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('statusAccepted')); + + const volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName[0]).toHaveTextContent('Teresa Bradley'); + }); + + it('Search', async () => { + renderVolunteers(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + userEvent.type(searchInput, 'T'); + await debounceWait(); + + const volunteerName = await screen.findAllByTestId('volunteerName'); + expect(volunteerName[0]).toHaveTextContent('Teresa Bradley'); + }); + + it('should render screen with No Volunteers', async () => { + renderVolunteers(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noVolunteers)).toBeInTheDocument(); + }); + }); + + it('Error while fetching volunteers data', async () => { + renderVolunteers(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Open and close Volunteer Modal (View)', async () => { + renderVolunteers(link1); + + const viewItemBtn = await screen.findAllByTestId('viewItemBtn'); + userEvent.click(viewItemBtn[0]); + + expect(await screen.findByText(t.volunteerDetails)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); + + it('Open and Close Volunteer Modal (Delete)', async () => { + renderVolunteers(link1); + + const deleteItemBtn = await screen.findAllByTestId('deleteItemBtn'); + userEvent.click(deleteItemBtn[0]); + + expect(await screen.findByText(t.removeVolunteer)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); + + it('Open and close Volunteer Modal (Create)', async () => { + renderVolunteers(link1); + + const addVolunteerBtn = await screen.findByTestId('addVolunteerBtn'); + userEvent.click(addVolunteerBtn); + + expect(await screen.findAllByText(t.addVolunteer)).toHaveLength(2); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); +}); diff --git a/src/screens/EventVolunteers/Volunteers/Volunteers.tsx b/src/screens/EventVolunteers/Volunteers/Volunteers.tsx new file mode 100644 index 0000000000..770bd35ef4 --- /dev/null +++ b/src/screens/EventVolunteers/Volunteers/Volunteers.tsx @@ -0,0 +1,462 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Navigate, useParams } from 'react-router-dom'; + +import { + Circle, + FilterAltOutlined, + Search, + Sort, + WarningAmberRounded, +} from '@mui/icons-material'; + +import { useQuery } from '@apollo/client'; +import Loader from 'components/Loader/Loader'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { Chip, debounce, Stack } from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; +import styles from '../EventVolunteers.module.css'; +import { EVENT_VOLUNTEER_LIST } from 'GraphQl/Queries/EventVolunteerQueries'; +import type { InterfaceEventVolunteerInfo } from 'utils/interfaces'; +import VolunteerCreateModal from './VolunteerCreateModal'; +import VolunteerDeleteModal from './VolunteerDeleteModal'; +import VolunteerViewModal from './VolunteerViewModal'; + +enum VolunteerStatus { + All = 'all', + Pending = 'pending', + Accepted = 'accepted', +} + +enum ModalState { + ADD = 'add', + DELETE = 'delete', + VIEW = 'view', +} + +const dataGridStyle = { + '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { + outline: 'none !important', + }, + '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-root': { + borderRadius: '0.5rem', + }, + '& .MuiDataGrid-main': { + borderRadius: '0.5rem', + }, +}; + +/** + * Component for managing and displaying event volunteers realted to an event. + * + * This component allows users to view, filter, sort, and create volunteers. It also handles fetching and displaying related data such as volunteer acceptance status, etc. + * + * @returns The rendered component. + */ +function volunteers(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Get the organization ID from URL parameters + const { orgId, eventId } = useParams(); + + if (!orgId || !eventId) { + return ; + } + + const [volunteer, setVolunteer] = + useState(null); + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState< + 'hoursVolunteered_ASC' | 'hoursVolunteered_DESC' | null + >(null); + const [status, setStatus] = useState(VolunteerStatus.All); + const [modalState, setModalState] = useState<{ + [key in ModalState]: boolean; + }>({ + [ModalState.ADD]: false, + [ModalState.DELETE]: false, + [ModalState.VIEW]: false, + }); + + const openModal = (modal: ModalState): void => { + setModalState((prevState) => ({ ...prevState, [modal]: true })); + }; + + const closeModal = (modal: ModalState): void => { + setModalState((prevState) => ({ ...prevState, [modal]: false })); + }; + + const handleOpenModal = useCallback( + ( + volunteer: InterfaceEventVolunteerInfo | null, + modalType: ModalState, + ): void => { + setVolunteer(volunteer); + openModal(modalType); + }, + [openModal], + ); + + /** + * Query to fetch event volunteers for the event. + */ + const { + data: volunteersData, + loading: volunteersLoading, + error: volunteersError, + refetch: refetchVolunteers, + }: { + data?: { + getEventVolunteers: InterfaceEventVolunteerInfo[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(EVENT_VOLUNTEER_LIST, { + variables: { + where: { + eventId: eventId, + hasAccepted: + status === VolunteerStatus.All + ? undefined + : status === VolunteerStatus.Accepted, + name_contains: searchTerm, + }, + orderBy: sortBy, + }, + }); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const volunteers = useMemo( + () => volunteersData?.getEventVolunteers || [], + [volunteersData], + ); + + if (volunteersLoading) { + return ; + } + + if (volunteersError) { + return ( +
    + +
    + {tErrors('errorLoading', { entity: 'Volunteers' })} +
    +
    + ); + } + + const columns: GridColDef[] = [ + { + field: 'volunteer', + headerName: 'Volunteer', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + const { _id, firstName, lastName, image } = params.row.user; + return ( +
    + {image ? ( + volunteer + ) : ( +
    + +
    + )} + {firstName + ' ' + lastName} +
    + ); + }, + }, + { + field: 'status', + headerName: 'Status', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + } + label={params.row.hasAccepted ? 'Accepted' : 'Pending'} + variant="outlined" + color="primary" + className={`${styles.chip} ${params.row.hasAccepted ? styles.active : styles.pending}`} + /> + ); + }, + }, + { + field: 'hours', + headerName: 'Hours Volunteered', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
    + {params.row.hoursVolunteered ?? '-'} +
    + ); + }, + }, + { + field: 'actionItem', + headerName: 'Actions Completed', + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + flex: 1, + renderCell: (params: GridCellParams) => { + return ( +
    + {params.row.assignments.length} +
    + ); + }, + }, + { + field: 'options', + headerName: 'Options', + align: 'center', + flex: 1, + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + <> + + + + ); + }, + }, + ]; + + return ( +
    + {/* Header with search, filter and Create Button */} +
    +
    + { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
    +
    +
    + + + + {tCommon('sort')} + + + setSortBy('hoursVolunteered_DESC')} + data-testid="hoursVolunteered_DESC" + > + {t('mostHoursVolunteered')} + + setSortBy('hoursVolunteered_ASC')} + data-testid="hoursVolunteered_ASC" + > + {t('leastHoursVolunteered')} + + + + + + + {t('status')} + + + setStatus(VolunteerStatus.All)} + data-testid="statusAll" + > + {tCommon('all')} + + setStatus(VolunteerStatus.Pending)} + data-testid="statusPending" + > + {tCommon('pending')} + + setStatus(VolunteerStatus.Accepted)} + data-testid="statusAccepted" + > + {t('accepted')} + + + +
    +
    + +
    +
    +
    + + {/* Table with Volunteers */} + row._id} + slots={{ + noRowsOverlay: () => ( + + {t('noVolunteers')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={volunteers.map((volunteer, index) => ({ + id: index + 1, + ...volunteer, + }))} + columns={columns} + isRowSelectable={() => false} + /> + + closeModal(ModalState.ADD)} + eventId={eventId} + orgId={orgId} + refetchVolunteers={refetchVolunteers} + /> + + {volunteer && ( + <> + closeModal(ModalState.VIEW)} + volunteer={volunteer} + /> + closeModal(ModalState.DELETE)} + volunteer={volunteer} + refetchVolunteers={refetchVolunteers} + /> + + )} +
    + ); +} + +export default volunteers; diff --git a/src/screens/ForgotPassword/ForgotPassword.module.css b/src/screens/ForgotPassword/ForgotPassword.module.css new file mode 100644 index 0000000000..74e09aecc6 --- /dev/null +++ b/src/screens/ForgotPassword/ForgotPassword.module.css @@ -0,0 +1,71 @@ +.pageWrapper { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; +} + +.cardBody { + padding: 2rem; + background-color: #fff; + border-radius: 0.8rem; + border: 1px solid var(--bs-gray-200); +} + +.keyWrapper { + height: 72px; + width: 72px; + transform: rotate(180deg); + position: relative; + overflow: hidden; + display: block; + display: flex; + justify-content: center; + align-items: center; + border-radius: 50%; + margin: 1rem auto; +} + +.keyWrapper .themeOverlay { + position: absolute; + background-color: var(--bs-primary); + height: 100%; + width: 100%; + opacity: 0.15; +} + +.keyWrapper .keyLogo { + height: 42px; + width: 42px; + -webkit-animation: zoomIn 0.3s ease-in-out; + animation: zoomIn 0.3s ease-in-out; +} + +@-webkit-keyframes zoomIn { + 0% { + opacity: 0; + -webkit-transform: scale(0.5); + transform: scale(0.5); + } + + 100% { + opacity: 1; + -webkit-transform: scale(1); + transform: scale(1); + } +} + +@keyframes zoomIn { + 0% { + opacity: 0; + -webkit-transform: scale(0.5); + transform: scale(0.5); + } + + 100% { + opacity: 1; + -webkit-transform: scale(1); + transform: scale(1); + } +} diff --git a/src/screens/ForgotPassword/ForgotPassword.test.tsx b/src/screens/ForgotPassword/ForgotPassword.test.tsx new file mode 100644 index 0000000000..be1b1706f8 --- /dev/null +++ b/src/screens/ForgotPassword/ForgotPassword.test.tsx @@ -0,0 +1,415 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import 'jest-localstorage-mock'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { toast, ToastContainer } from 'react-toastify'; + +import { GENERATE_OTP_MUTATION } from 'GraphQl/Mutations/mutations'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +// import i18nForTest from 'utils/i18nForTest'; +import ForgotPassword from './ForgotPassword'; +import i18n from 'utils/i18nForTest'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem, removeItem } = useLocalStorage(); + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }, +})); + +const MOCKS = [ + { + request: { + query: GENERATE_OTP_MUTATION, + variables: { + email: 'johndoe@gmail.com', + }, + }, + result: { + data: { + otp: { + otpToken: 'otpToken', + }, + }, + }, + }, + + { + request: { + query: GENERATE_OTP_MUTATION, + variables: { + email: 'notexists@gmail.com', + }, + }, + error: new Error('User not found'), + }, +]; + +const MOCKS_INTERNET_UNAVAILABLE = [ + { + request: { + query: GENERATE_OTP_MUTATION, + variables: { + email: 'johndoe@gmail.com', + }, + }, + error: new Error('Failed to fetch'), + }, +]; + +const link = new StaticMockLink(MOCKS, true); +const notWorkingLink = new StaticMockLink([], true); +const talawaApiUnavailableLink = new StaticMockLink( + MOCKS_INTERNET_UNAVAILABLE, + true, +); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const translations = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.forgotPassword ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +beforeEach(() => { + setItem('IsLoggedIn', 'FALSE'); +}); +afterEach(() => { + localStorage.clear(); +}); + +describe('Testing Forgot Password screen', () => { + test('Component should be rendered properly', async () => { + window.location.assign('/orglist'); + + render( + + + + + + + + + , + ); + + await wait(); + + expect(screen.getByText(/Forgot Password/i)).toBeInTheDocument(); + expect(screen.getByText(/Registered Email/i)).toBeInTheDocument(); + expect(screen.getByText(/Get Otp/i)).toBeInTheDocument(); + expect(screen.getByText(/Back to Login/i)).toBeInTheDocument(); + expect(window.location).toBeAt('/orglist'); + }); + + test('Testing, If user is already loggedIn', async () => { + setItem('IsLoggedIn', 'TRUE'); + + render( + + + + + + + + + , + ); + + await wait(); + }); + + test('Testing get OTP functionality', async () => { + const formData = { + email: 'johndoe@gmail.com', + }; + + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.type( + screen.getByPlaceholderText(/Registered email/i), + formData.email, + ); + + userEvent.click(screen.getByText('Get OTP')); + await waitFor(() => { + expect(toast.success).toHaveBeenCalled(); + }); + }); + + test('Testing forgot password functionality', async () => { + const formData = { + userOtp: '12345', + newPassword: 'johnDoe', + confirmNewPassword: 'johnDoe', + email: 'johndoe@gmail.com', + }; + + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.type( + screen.getByPlaceholderText(/Registered email/i), + formData.email, + ); + + userEvent.click(screen.getByText('Get OTP')); + await wait(); + + userEvent.type(screen.getByPlaceholderText('e.g. 12345'), formData.userOtp); + userEvent.type(screen.getByTestId('newPassword'), formData.newPassword); + userEvent.type( + screen.getByTestId('confirmNewPassword'), + formData.confirmNewPassword, + ); + setItem('otpToken', 'lorem ipsum'); + userEvent.click(screen.getByText('Change Password')); + await wait(); + }); + + test('Testing forgot password functionality, if the otp got deleted from the local storage', async () => { + const formData = { + userOtp: '12345', + newPassword: 'johnDoe', + confirmNewPassword: 'johnDoe', + email: 'johndoe@gmail.com', + }; + + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.type( + screen.getByPlaceholderText(/Registered email/i), + formData.email, + ); + + userEvent.click(screen.getByText('Get OTP')); + await wait(); + + userEvent.type(screen.getByPlaceholderText('e.g. 12345'), formData.userOtp); + userEvent.type(screen.getByTestId('newPassword'), formData.newPassword); + userEvent.type( + screen.getByTestId('confirmNewPassword'), + formData.confirmNewPassword, + ); + removeItem('otpToken'); + userEvent.click(screen.getByText('Change Password')); + await wait(); + }); + + test('Testing forgot password functionality, when new password and confirm password is not same', async () => { + const formData = { + email: 'johndoe@gmail.com', + userOtp: '12345', + newPassword: 'johnDoe', + confirmNewPassword: 'doeJohn', + }; + + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.type( + screen.getByPlaceholderText(/Registered email/i), + formData.email, + ); + + userEvent.click(screen.getByText('Get OTP')); + await wait(); + + userEvent.type(screen.getByPlaceholderText('e.g. 12345'), formData.userOtp); + userEvent.type(screen.getByTestId('newPassword'), formData.newPassword); + userEvent.type( + screen.getByTestId('confirmNewPassword'), + formData.confirmNewPassword, + ); + + userEvent.click(screen.getByText('Change Password')); + }); + + test('Testing forgot password functionality, when the user is not found', async () => { + const formData = { + email: 'notexists@gmail.com', + }; + // setItem('otpToken', ''); + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.type( + screen.getByPlaceholderText(/Registered email/i), + formData.email, + ); + + userEvent.click(screen.getByText('Get OTP')); + await waitFor(() => { + expect(toast.warn).toHaveBeenCalledWith(translations.emailNotRegistered); + }); + }); + + test('Testing forgot password functionality, when there is an error except unregistered email and api failure', async () => { + render( + + + + + + + + + , + ); + userEvent.click(screen.getByText('Get OTP')); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith(translations.errorSendingMail); + }); + }); + + test('Testing forgot password functionality, when talawa api failed', async () => { + const formData = { + email: 'johndoe@gmail.com', + }; + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.type( + screen.getByPlaceholderText(/Registered email/i), + formData.email, + ); + userEvent.click(screen.getByText('Get OTP')); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith( + translations.talawaApiUnavailable, + ); + }); + }); + + test('Testing forgot password functionality, when otp token is not present', async () => { + const formData = { + userOtp: '12345', + newPassword: 'johnDoe', + confirmNewPassword: 'johnDoe', + email: 'johndoe@gmail.com', + }; + + setItem('otpToken', ''); + + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.type( + screen.getByPlaceholderText(/Registered email/i), + formData.email, + ); + + userEvent.click(screen.getByText('Get OTP')); + await wait(); + + userEvent.type(screen.getByPlaceholderText('e.g. 12345'), formData.userOtp); + userEvent.type(screen.getByTestId('newPassword'), formData.newPassword); + userEvent.type( + screen.getByTestId('confirmNewPassword'), + formData.confirmNewPassword, + ); + userEvent.click(screen.getByText('Change Password')); + }); +}); diff --git a/src/screens/ForgotPassword/ForgotPassword.tsx b/src/screens/ForgotPassword/ForgotPassword.tsx new file mode 100644 index 0000000000..663960572b --- /dev/null +++ b/src/screens/ForgotPassword/ForgotPassword.tsx @@ -0,0 +1,285 @@ +import { useMutation } from '@apollo/client'; +import type { ChangeEvent } from 'react'; +import React, { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { toast } from 'react-toastify'; + +import { + FORGOT_PASSWORD_MUTATION, + GENERATE_OTP_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import KeyLogo from 'assets/svgs/key.svg?react'; + +import ArrowRightAlt from '@mui/icons-material/ArrowRightAlt'; +import Loader from 'components/Loader/Loader'; +import { Form } from 'react-bootstrap'; +import Button from 'react-bootstrap/Button'; +import { useTranslation } from 'react-i18next'; +import { errorHandler } from 'utils/errorHandler'; +import styles from './ForgotPassword.module.css'; +import useLocalStorage from 'utils/useLocalstorage'; + +/** + * `ForgotPassword` component allows users to reset their password. + * + * It provides two stages: + * 1. Entering the registered email to receive an OTP. + * 2. Entering the OTP and new password to reset the password. + * + * @returns JSX.Element - The `ForgotPassword` component. + * + * @example + * ```tsx + * + * ``` + */ +const ForgotPassword = (): JSX.Element => { + // Translation hook for internationalization + const { t } = useTranslation('translation', { + keyPrefix: 'forgotPassword', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Set the page title + document.title = t('title'); + + // Custom hook for accessing local storage + const { getItem, removeItem, setItem } = useLocalStorage(); + + // State hooks for form data and UI + const [showEnterEmail, setShowEnterEmail] = useState(true); + + const [registeredEmail, setregisteredEmail] = useState(''); + + const [forgotPassFormData, setForgotPassFormData] = useState({ + userOtp: '', + newPassword: '', + confirmNewPassword: '', + }); + + // GraphQL mutations + const [otp, { loading: otpLoading }] = useMutation(GENERATE_OTP_MUTATION); + const [forgotPassword, { loading: forgotPasswordLoading }] = useMutation( + FORGOT_PASSWORD_MUTATION, + ); + + // Check if the user is already logged in + const isLoggedIn = getItem('IsLoggedIn'); + useEffect(() => { + if (isLoggedIn == 'TRUE') { + window.location.replace('/orglist'); + } + return () => { + removeItem('otpToken'); + }; + }, []); + + /** + * Handles the form submission for generating OTP. + * + * @param e - The form submit event + */ + const getOTP = async (e: ChangeEvent): Promise => { + e.preventDefault(); + + try { + const { data } = await otp({ + variables: { + email: registeredEmail, + }, + }); + + setItem('otpToken', data.otp.otpToken); + toast.success(t('OTPsent')); + setShowEnterEmail(false); + } catch (error: unknown) { + if ((error as Error).message === 'User not found') { + toast.warn(tErrors('emailNotRegistered')); + } else if ((error as Error).message === 'Failed to fetch') { + toast.error(tErrors('talawaApiUnavailable')); + } else { + toast.error(tErrors('errorSendingMail')); + } + } + }; + + /** + * Handles the form submission for resetting the password. + * + * @param e - The form submit event + */ + const submitForgotPassword = async ( + e: ChangeEvent, + ): Promise => { + e.preventDefault(); + const { userOtp, newPassword, confirmNewPassword } = forgotPassFormData; + + if (newPassword !== confirmNewPassword) { + toast.error(t('passwordMismatches') as string); + return; + } + + const otpToken = getItem('otpToken'); + + if (!otpToken) { + return; + } + + try { + const { data } = await forgotPassword({ + variables: { + userOtp, + newPassword, + otpToken, + }, + }); + + /* istanbul ignore next */ + if (data) { + toast.success(t('passwordChanges') as string); + setShowEnterEmail(true); + setForgotPassFormData({ + userOtp: '', + newPassword: '', + confirmNewPassword: '', + }); + } + } catch (error: unknown) { + setShowEnterEmail(true); + /* istanbul ignore next */ + errorHandler(t, error); + } + }; + + // Show loader while performing OTP or password reset operations + if (otpLoading || forgotPasswordLoading) { + return ; + } + + return ( + <> +
    +
    +
    +
    +
    +
    + +
    +

    + {tCommon('forgotPassword')} +

    + {showEnterEmail ? ( +
    +
    + + {t('registeredEmail')}: + +
    + + setregisteredEmail(e.target.value) + } + /> +
    + +
    +
    + ) : ( +
    +
    + {t('enterOtp')}: + + setForgotPassFormData({ + ...forgotPassFormData, + userOtp: e.target.value, + }) + } + /> + + {t('enterNewPassword')}: + + + setForgotPassFormData({ + ...forgotPassFormData, + newPassword: e.target.value, + }) + } + /> + + {t('cofirmNewPassword')}: + + + setForgotPassFormData({ + ...forgotPassFormData, + confirmNewPassword: e.target.value, + }) + } + /> + + +
    + )} +
    + + + {t('backToLogin')} + +
    +
    +
    +
    +
    + + ); +}; + +export default ForgotPassword; diff --git a/src/screens/FundCampaignPledge/FundCampaignPledge.module.css b/src/screens/FundCampaignPledge/FundCampaignPledge.module.css new file mode 100644 index 0000000000..cdf4476267 --- /dev/null +++ b/src/screens/FundCampaignPledge/FundCampaignPledge.module.css @@ -0,0 +1,273 @@ +.pledgeContainer { + margin: 0.6rem 0; +} + +.container { + min-height: 100vh; +} + +.pledgeModal { + max-width: 80vw; + margin-top: 2vh; + margin-left: 13vw; +} + +.titlemodal { + color: #707070; + font-weight: 600; + font-size: 32px; + width: 65%; + margin-bottom: 0px; +} + +.modalCloseBtn { + width: 40px; + height: 40px; + padding: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.greenregbtn { + margin: 1rem 0 0; + margin-top: 15px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 10px; + border-radius: 5px; + background-color: #31bb6b; + width: 100%; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; +} +.message { + margin-top: 25%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.errorIcon { + transform: scale(1.5); + color: var(--bs-danger); + margin-bottom: 1rem; +} + +.btnsContainer { + display: flex; + gap: 0.8rem; + margin: 2.2rem 0 0.8rem 0; +} + +.btnsContainer .input { + flex: 1; + min-width: 18rem; + position: relative; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); +} + +.btnsContainer .input button { + width: 52px; +} + +.inputField { + background-color: white; + box-shadow: 0 1px 1px #31bb6b; +} + +.dropdown { + background-color: white; + border: 1px solid #31bb6b; + position: relative; + display: inline-block; + color: #31bb6b; +} + +.tableHeader { + background-color: var(--bs-primary); + color: var(--bs-white); + font-size: 1rem; +} + +.rowBackground { + background-color: var(--bs-white); + max-height: 120px; +} + +.TableImage { + object-fit: cover; + width: 25px !important; + height: 25px !important; + border-radius: 100% !important; +} + +.avatarContainer { + width: 28px; + height: 26px; +} + +.imageContainer { + display: flex; + align-items: center; + justify-content: center; +} + +.pledgerContainer { + display: flex; + align-items: center; + justify-content: center; + margin: 0.1rem 0.25rem; + gap: 0.25rem; + padding: 0.25rem 0.45rem; + border-radius: 0.35rem; + background-color: #31bb6b33; + height: 2.2rem; + margin-top: 0.75rem; +} + +.noOutline input { + outline: none; +} + +.overviewContainer { + display: flex; + gap: 7rem; + width: 100%; + justify-content: space-between; + margin: 1.5rem 0 0 0; + padding: 1.25rem 2rem; + background-color: rgba(255, 255, 255, 0.591); + + box-shadow: rgba(0, 0, 0, 0.16) 0px 1px 4px; + border-radius: 0.5rem; +} + +.titleContainer { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.titleContainer h3 { + font-size: 1.75rem; + font-weight: 750; + color: #5e5e5e; + margin-top: 0.2rem; +} + +.titleContainer span { + font-size: 0.9rem; + margin-left: 0.5rem; + font-weight: lighter; + color: #707070; +} + +.raisedAmount { + display: flex; + justify-content: center; + align-items: center; + font-size: 1.25rem; + font-weight: 750; + color: #5e5e5e; +} + +.progressContainer { + display: flex; + flex-direction: column; + gap: 0.5rem; + flex-grow: 1; +} + +.progress { + margin-top: 0.2rem; + display: flex; + flex-direction: column; + gap: 0.3rem; +} + +.endpoints { + display: flex; + position: relative; + font-size: 0.85rem; +} + +.start { + position: absolute; + top: 0px; +} + +.end { + position: absolute; + top: 0px; + right: 0px; +} + +.moreContainer { + display: flex; + align-items: center; +} + +.moreContainer:hover { + text-decoration: underline; + cursor: pointer; +} + +.popup { + z-index: 50; + border-radius: 0.5rem; + font-family: sans-serif; + font-weight: 500; + font-size: 0.875rem; + margin-top: 0.5rem; + padding: 0.75rem; + border: 1px solid #e2e8f0; + background-color: white; + color: #1e293b; + box-shadow: 0 0.5rem 1rem rgb(0 0 0 / 0.15); + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.popupExtra { + max-height: 15rem; + overflow-y: auto; +} + +.toggleGroup { + width: 50%; + min-width: 27.75rem; + margin: 0.5rem 0rem; +} + +.toggleBtn { + padding: 0rem; + height: 30px; + display: flex; + justify-content: center; + align-items: center; +} + +.toggleBtn:hover { + color: #31bb6b !important; +} + +input[type='radio']:checked + label { + background-color: #31bb6a50 !important; +} + +input[type='radio']:checked + label:hover { + color: black !important; +} diff --git a/src/screens/FundCampaignPledge/FundCampaignPledge.test.tsx b/src/screens/FundCampaignPledge/FundCampaignPledge.test.tsx new file mode 100644 index 0000000000..3fb5993775 --- /dev/null +++ b/src/screens/FundCampaignPledge/FundCampaignPledge.test.tsx @@ -0,0 +1,364 @@ +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18nForTest from '../../utils/i18nForTest'; +import FundCampaignPledge from './FundCampaignPledge'; +import { + EMPTY_MOCKS, + MOCKS, + MOCKS_FUND_CAMPAIGN_PLEDGE_ERROR, +} from './PledgesMocks'; +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); +jest.mock('@mui/x-date-pickers/DateTimePicker', () => { + return { + DateTimePicker: jest.requireActual( + '@mui/x-date-pickers/DesktopDateTimePicker', + ).DesktopDateTimePicker, + }; +}); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_FUND_CAMPAIGN_PLEDGE_ERROR); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const translations = JSON.parse( + JSON.stringify(i18nForTest.getDataByLanguage('en')?.translation.pledges), +); + +const renderFundCampaignPledge = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } + /> +
    } + /> + + + + + + , + ); +}; + +describe('Testing Campaign Pledge Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId', fundCampaignId: 'fundCampaignId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } + /> + } + /> + + + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render the Campaign Pledge screen', async () => { + renderFundCampaignPledge(link1); + await waitFor(() => { + expect(screen.getByTestId('searchPledger')).toBeInTheDocument(); + }); + }); + + it('open and closes Create Pledge modal', async () => { + renderFundCampaignPledge(link1); + + const addPledgeBtn = await screen.findByTestId('addPledgeBtn'); + expect(addPledgeBtn).toBeInTheDocument(); + userEvent.click(addPledgeBtn); + + await waitFor(() => + expect(screen.getAllByText(translations.createPledge)).toHaveLength(2), + ); + userEvent.click(screen.getByTestId('pledgeModalCloseBtn')); + await waitFor(() => + expect(screen.queryByTestId('pledgeModalCloseBtn')).toBeNull(), + ); + }); + + it('open and closes update pledge modal', async () => { + renderFundCampaignPledge(link1); + + const editPledgeBtn = await screen.findAllByTestId('editPledgeBtn'); + await waitFor(() => expect(editPledgeBtn[0]).toBeInTheDocument()); + userEvent.click(editPledgeBtn[0]); + + await waitFor(() => + expect(screen.getByText(translations.editPledge)).toBeInTheDocument(), + ); + userEvent.click(screen.getByTestId('pledgeModalCloseBtn')); + await waitFor(() => + expect(screen.queryByTestId('pledgeModalCloseBtn')).toBeNull(), + ); + }); + + it('open and closes delete pledge modal', async () => { + renderFundCampaignPledge(link1); + + const deletePledgeBtn = await screen.findAllByTestId('deletePledgeBtn'); + await waitFor(() => expect(deletePledgeBtn[0]).toBeInTheDocument()); + userEvent.click(deletePledgeBtn[0]); + + await waitFor(() => + expect(screen.getByText(translations.deletePledge)).toBeInTheDocument(), + ); + userEvent.click(screen.getByTestId('deletePledgeCloseBtn')); + await waitFor(() => + expect(screen.queryByTestId('deletePledgeCloseBtn')).toBeNull(), + ); + }); + + it('Search the Pledges list by Users', async () => { + renderFundCampaignPledge(link1); + const searchPledger = await screen.findByTestId('searchPledger'); + fireEvent.change(searchPledger, { + target: { value: 'John' }, + }); + + await waitFor(() => { + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText('Jane Doe')).toBeNull(); + }); + }); + + it('should render the Campaign Pledge screen with error', async () => { + renderFundCampaignPledge(link2); + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('renders the empty pledge component', async () => { + renderFundCampaignPledge(link3); + await waitFor(() => + expect(screen.getByText(translations.noPledges)).toBeInTheDocument(), + ); + }); + + it('check if user image renders', async () => { + renderFundCampaignPledge(link1); + await waitFor(() => { + expect(screen.getByTestId('searchPledger')).toBeInTheDocument(); + }); + + const image = await screen.findByTestId('image1'); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', 'img-url'); + }); + + it('should render extraUserDetails in Popup', async () => { + renderFundCampaignPledge(link1); + await waitFor(() => { + expect(screen.getByTestId('searchPledger')).toBeInTheDocument(); + }); + const searchPledger = await screen.findByTestId('searchPledger'); + expect(searchPledger).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('filter')); + await waitFor(() => { + expect(screen.getByTestId('amount_DESC')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('amount_DESC')); + + await waitFor(() => { + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText('Jane Doe')).toBeInTheDocument(); + }); + + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('John Doe2')).toBeInTheDocument(); + expect(screen.queryByText('John Doe3')).toBeNull(); + expect(screen.queryByText('John Doe4')).toBeNull(); + + const moreContainer = await screen.findAllByTestId('moreContainer'); + userEvent.click(moreContainer[0]); + + await waitFor(() => { + expect(screen.getByText('John Doe3')).toBeInTheDocument(); + expect(screen.getByText('John Doe4')).toBeInTheDocument(); + expect(screen.getByTestId('extra1')).toBeInTheDocument(); + expect(screen.getByTestId('extra2')).toBeInTheDocument(); + expect(screen.getByTestId('extraAvatar8')).toBeInTheDocument(); + const image = screen.getByTestId('extraImage1'); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', 'img-url3'); + }); + + userEvent.click(moreContainer[0]); + await waitFor(() => { + expect(screen.queryByText('John Doe3')).toBeNull(); + expect(screen.queryByText('John Doe4')).toBeNull(); + }); + }); + + it('should render Progress Bar with Raised amount (CONSTANT) & Pledged Amount', async () => { + renderFundCampaignPledge(link1); + await waitFor(() => { + expect(screen.getByTestId('searchPledger')).toBeInTheDocument(); + }); + const raised = screen.getByText('Raised amount'); + const pledged = screen.getByText('Pledged amount'); + expect(pledged).toBeInTheDocument(); + expect(raised).toBeInTheDocument(); + + userEvent.click(raised); + + await waitFor(() => { + expect(screen.getByTestId('progressBar')).toBeInTheDocument(); + expect(screen.getByTestId('progressBar')).toHaveTextContent('$0'); + }); + + userEvent.click(pledged); + + await waitFor(() => { + expect(screen.getByTestId('progressBar')).toBeInTheDocument(); + expect(screen.getByTestId('progressBar')).toHaveTextContent('$300'); + }); + }); + + it('Sort the Pledges list by Lowest Amount', async () => { + renderFundCampaignPledge(link1); + + const searchPledger = await screen.findByTestId('searchPledger'); + expect(searchPledger).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('filter')); + await waitFor(() => { + expect(screen.getByTestId('amount_ASC')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('amount_ASC')); + + await waitFor(() => { + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText('Jane Doe')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('amountCell')[0]).toHaveTextContent('100'); + }); + }); + + it('Sort the Pledges list by Highest Amount', async () => { + renderFundCampaignPledge(link1); + + const searchPledger = await screen.findByTestId('searchPledger'); + expect(searchPledger).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('filter')); + await waitFor(() => { + expect(screen.getByTestId('amount_DESC')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('amount_DESC')); + + await waitFor(() => { + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText('Jane Doe')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('amountCell')[0]).toHaveTextContent('200'); + }); + }); + + it('Sort the Pledges list by latest endDate', async () => { + renderFundCampaignPledge(link1); + + const searchPledger = await screen.findByTestId('searchPledger'); + expect(searchPledger).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('filter')); + await waitFor(() => { + expect(screen.getByTestId('endDate_DESC')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('endDate_DESC')); + + await waitFor(() => { + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText('Jane Doe')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('amountCell')[0]).toHaveTextContent('100'); + }); + }); + + it('Sort the Pledges list by earliest endDate', async () => { + renderFundCampaignPledge(link1); + + const searchPledger = await screen.findByTestId('searchPledger'); + expect(searchPledger).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('filter')); + await waitFor(() => { + expect(screen.getByTestId('endDate_ASC')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('endDate_ASC')); + + await waitFor(() => { + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText('Jane Doe')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('amountCell')[0]).toHaveTextContent('200'); + }); + }); +}); diff --git a/src/screens/FundCampaignPledge/FundCampaignPledge.tsx b/src/screens/FundCampaignPledge/FundCampaignPledge.tsx new file mode 100644 index 0000000000..d14ee9de06 --- /dev/null +++ b/src/screens/FundCampaignPledge/FundCampaignPledge.tsx @@ -0,0 +1,633 @@ +import { useQuery, type ApolloQueryResult } from '@apollo/client'; +import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import { FUND_CAMPAIGN_PLEDGE } from 'GraphQl/Queries/fundQueries'; +import Loader from 'components/Loader/Loader'; +import { Unstable_Popup as BasePopup } from '@mui/base/Unstable_Popup'; +import dayjs from 'dayjs'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import { Navigate, useParams } from 'react-router-dom'; +import { currencySymbols } from 'utils/currency'; +import styles from './FundCampaignPledge.module.css'; +import PledgeDeleteModal from './PledgeDeleteModal'; +import PledgeModal from './PledgeModal'; +import { Breadcrumbs, Link, Stack, Typography } from '@mui/material'; +import { DataGrid } from '@mui/x-data-grid'; +import Avatar from 'components/Avatar/Avatar'; +import type { GridCellParams, GridColDef } from '@mui/x-data-grid'; +import type { + InterfacePledgeInfo, + InterfaceUserInfo, + InterfaceQueryFundCampaignsPledges, +} from 'utils/interfaces'; +import ProgressBar from 'react-bootstrap/ProgressBar'; + +interface InterfaceCampaignInfo { + name: string; + goal: number; + startDate: Date; + endDate: Date; + currency: string; +} + +enum ModalState { + SAME = 'same', + DELETE = 'delete', +} + +const dataGridStyle = { + '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { + outline: 'none !important', + }, + '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-root': { + borderRadius: '0.5rem', + }, + '& .MuiDataGrid-main': { + borderRadius: '0.5rem', + }, +}; +const fundCampaignPledge = (): JSX.Element => { + const { t } = useTranslation('translation', { + keyPrefix: 'pledges', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + const { fundCampaignId, orgId } = useParams(); + if (!fundCampaignId || !orgId) { + return ; + } + + const [campaignInfo, setCampaignInfo] = useState({ + name: '', + goal: 0, + startDate: new Date(), + endDate: new Date(), + currency: '', + }); + + const [modalState, setModalState] = useState<{ + [key in ModalState]: boolean; + }>({ + [ModalState.SAME]: false, + [ModalState.DELETE]: false, + }); + + const [anchor, setAnchor] = useState(null); + const [extraUsers, setExtraUsers] = useState([]); + const [progressIndicator, setProgressIndicator] = useState< + 'raised' | 'pledged' + >('pledged'); + const open = Boolean(anchor); + const id = open ? 'simple-popup' : undefined; + const [pledgeModalMode, setPledgeModalMode] = useState<'edit' | 'create'>( + 'create', + ); + const [pledge, setPledge] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + + const [sortBy, setSortBy] = useState< + 'amount_ASC' | 'amount_DESC' | 'endDate_ASC' | 'endDate_DESC' + >('endDate_DESC'); + + const { + data: pledgeData, + loading: pledgeLoading, + error: pledgeError, + refetch: refetchPledge, + }: { + data?: { + getFundraisingCampaigns: InterfaceQueryFundCampaignsPledges[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => Promise< + ApolloQueryResult<{ + getFundraisingCampaigns: InterfaceQueryFundCampaignsPledges[]; + }> + >; + } = useQuery(FUND_CAMPAIGN_PLEDGE, { + variables: { + where: { + id: fundCampaignId, + }, + pledgeOrderBy: sortBy, + }, + }); + + const endDate = dayjs( + pledgeData?.getFundraisingCampaigns[0]?.endDate, + 'YYYY-MM-DD', + ).toDate(); + + const { pledges, totalPledged, fundName } = useMemo(() => { + let totalPledged = 0; + const pledges = + pledgeData?.getFundraisingCampaigns[0].pledges.filter((pledge) => { + totalPledged += pledge.amount; + const search = searchTerm.toLowerCase(); + return pledge.users.some((user) => { + const fullName = `${user.firstName} ${user.lastName}`; + return fullName.toLowerCase().includes(search); + }); + }) ?? []; + const fundName = + pledgeData?.getFundraisingCampaigns[0].fundId.name ?? tCommon('Funds'); + return { pledges, totalPledged, fundName }; + }, [pledgeData, searchTerm]); + + useEffect(() => { + if (pledgeData) { + setCampaignInfo({ + name: pledgeData.getFundraisingCampaigns[0].name, + goal: pledgeData.getFundraisingCampaigns[0].fundingGoal, + startDate: pledgeData.getFundraisingCampaigns[0].startDate, + endDate: pledgeData.getFundraisingCampaigns[0].endDate, + currency: pledgeData.getFundraisingCampaigns[0].currency, + }); + } + }, [pledgeData]); + + useEffect(() => { + refetchPledge(); + }, [sortBy, refetchPledge]); + + const openModal = (modal: ModalState): void => { + setModalState((prevState) => ({ ...prevState, [modal]: true })); + }; + + const closeModal = (modal: ModalState): void => { + setModalState((prevState) => ({ ...prevState, [modal]: false })); + }; + + const handleOpenModal = useCallback( + (pledge: InterfacePledgeInfo | null, mode: 'edit' | 'create'): void => { + setPledge(pledge); + setPledgeModalMode(mode); + openModal(ModalState.SAME); + }, + [openModal], + ); + + const handleDeleteClick = useCallback( + (pledge: InterfacePledgeInfo): void => { + setPledge(pledge); + openModal(ModalState.DELETE); + }, + [openModal], + ); + + const handleClick = ( + event: React.MouseEvent, + users: InterfaceUserInfo[], + ): void => { + setExtraUsers(users); + setAnchor(anchor ? null : event.currentTarget); + }; + + if (pledgeLoading) return ; + if (pledgeError) { + return ( +
    +
    + +
    + {tErrors('errorLoading', { entity: 'Pledges' })} +
    + {pledgeError.message} +
    +
    +
    + ); + } + + const columns: GridColDef[] = [ + { + field: 'pledgers', + headerName: 'Pledgers', + flex: 3, + minWidth: 50, + align: 'left', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return ( +
    + {params.row.users + .slice(0, 2) + .map((user: InterfaceUserInfo, index: number) => ( +
    + {user.image ? ( + pledge + ) : ( +
    + +
    + )} + + {user.firstName + ' ' + user.lastName} + +
    + ))} + {params.row.users.length > 2 && ( +
    handleClick(e, params.row.users.slice(2))} + > + +{params.row.users.length - 2} more... +
    + )} +
    + ); + }, + }, + { + field: 'startDate', + headerName: 'Start Date', + flex: 1, + minWidth: 150, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return dayjs(params.row.startDate).format('DD/MM/YYYY'); + }, + }, + { + field: 'endDate', + headerName: 'End Date', + minWidth: 150, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + flex: 1, + sortable: false, + renderCell: (params: GridCellParams) => { + return dayjs(params.row.endDate).format('DD/MM/YYYY'); + }, + }, + { + field: 'amount', + headerName: 'Pledged', + flex: 1, + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return ( +
    + { + currencySymbols[ + params.row.currency as keyof typeof currencySymbols + ] + } + {params.row.amount} +
    + ); + }, + }, + { + field: 'donated', + headerName: 'Donated', + flex: 1, + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return ( +
    + { + currencySymbols[ + params.row.currency as keyof typeof currencySymbols + ] + } + 0 +
    + ); + }, + }, + { + field: 'action', + headerName: 'Action', + flex: 1, + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return ( + <> + + + + ); + }, + }, + ]; + + return ( +
    + + history.go(-2) + } + > + {fundName} + + history.back() + } + > + {campaignInfo?.name} + + {t('pledges')} + +
    +
    +

    {campaignInfo?.name}

    + + {t('endsOn')} {campaignInfo?.endDate.toString()} + +
    +
    +
    +
    + setProgressIndicator('pledged')} + /> + + + setProgressIndicator('raised')} + checked={progressIndicator === 'raised'} + /> + +
    +
    + +
    + +
    +
    $0
    +
    ${campaignInfo?.goal}
    +
    +
    +
    +
    +
    +
    + setSearchTerm(e.target.value)} + data-testid="searchPledger" + /> + +
    +
    +
    + + + + {tCommon('sort')} + + + setSortBy('amount_ASC')} + data-testid="amount_ASC" + > + {t('lowestAmount')} + + setSortBy('amount_DESC')} + data-testid="amount_DESC" + > + {t('highestAmount')} + + setSortBy('endDate_DESC')} + data-testid="endDate_DESC" + > + {t('latestEndDate')} + + setSortBy('endDate_ASC')} + data-testid="endDate_ASC" + > + {t('earliestEndDate')} + + + +
    +
    + +
    +
    +
    + row._id} + slots={{ + noRowsOverlay: () => ( + + {t('noPledges')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={pledges.map((pledge) => ({ + _id: pledge._id, + users: pledge.users, + startDate: pledge.startDate, + endDate: pledge.endDate, + amount: pledge.amount, + currency: pledge.currency, + }))} + columns={columns} + isRowSelectable={() => false} + /> + {/* Update Pledge ModalState */} + closeModal(ModalState.SAME)} + campaignId={fundCampaignId} + orgId={orgId} + pledge={pledge} + refetchPledge={refetchPledge} + endDate={pledgeData?.getFundraisingCampaigns[0].endDate as Date} + mode={pledgeModalMode} + /> + {/* Delete Pledge ModalState */} + closeModal(ModalState.DELETE)} + pledge={pledge} + refetchPledge={refetchPledge} + /> + 4 ? styles.popupExtra : ''}`} + > + {extraUsers.map((user: InterfaceUserInfo, index: number) => ( +
    + {user.image ? ( + pledger + ) : ( +
    + +
    + )} + + {user.firstName + ' ' + user.lastName} + +
    + ))} +
    +
    + ); +}; +export default fundCampaignPledge; diff --git a/src/screens/FundCampaignPledge/PledgeDeleteModal.test.tsx b/src/screens/FundCampaignPledge/PledgeDeleteModal.test.tsx new file mode 100644 index 0000000000..dbaae76504 --- /dev/null +++ b/src/screens/FundCampaignPledge/PledgeDeleteModal.test.tsx @@ -0,0 +1,101 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import type { InterfaceDeletePledgeModal } from './PledgeDeleteModal'; +import PledgeDeleteModal from './PledgeDeleteModal'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18nForTest from '../../utils/i18nForTest'; +import { MOCKS_DELETE_PLEDGE_ERROR, MOCKS } from './PledgesMocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_DELETE_PLEDGE_ERROR); +const translations = JSON.parse( + JSON.stringify(i18nForTest.getDataByLanguage('en')?.translation.pledges), +); + +const pledgeProps: InterfaceDeletePledgeModal = { + isOpen: true, + hide: jest.fn(), + pledge: { + _id: '1', + amount: 100, + currency: 'USD', + startDate: '2024-01-01', + endDate: '2024-01-10', + users: [ + { + _id: '1', + firstName: 'John', + lastName: 'Doe', + image: 'img-url', + }, + ], + }, + refetchPledge: jest.fn(), +}; + +const renderPledgeDeleteModal = ( + link: ApolloLink, + props: InterfaceDeletePledgeModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('PledgeDeleteModal', () => { + it('should render PledgeDeleteModal', () => { + renderPledgeDeleteModal(link, pledgeProps); + expect(screen.getByTestId('deletePledgeCloseBtn')).toBeInTheDocument(); + }); + + it('should successfully Delete pledge', async () => { + renderPledgeDeleteModal(link, pledgeProps); + expect(screen.getByTestId('deletePledgeCloseBtn')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('deleteyesbtn')); + + await waitFor(() => { + expect(pledgeProps.refetchPledge).toHaveBeenCalled(); + expect(pledgeProps.hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(translations.pledgeDeleted); + }); + }); + + it('should fail to Delete pledge', async () => { + renderPledgeDeleteModal(link2, pledgeProps); + expect(screen.getByTestId('deletePledgeCloseBtn')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('deleteyesbtn')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Error deleting pledge'); + }); + }); +}); diff --git a/src/screens/FundCampaignPledge/PledgeDeleteModal.tsx b/src/screens/FundCampaignPledge/PledgeDeleteModal.tsx new file mode 100644 index 0000000000..288baa16ef --- /dev/null +++ b/src/screens/FundCampaignPledge/PledgeDeleteModal.tsx @@ -0,0 +1,103 @@ +import { Button, Modal } from 'react-bootstrap'; +import styles from './FundCampaignPledge.module.css'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation } from '@apollo/client'; +import { DELETE_PLEDGE } from 'GraphQl/Mutations/PledgeMutation'; +import type { InterfacePledgeInfo } from 'utils/interfaces'; +import { toast } from 'react-toastify'; + +export interface InterfaceDeletePledgeModal { + isOpen: boolean; + hide: () => void; + pledge: InterfacePledgeInfo | null; + refetchPledge: () => void; +} + +/** + * A modal dialog for confirming the deletion of a pledge. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param pledge - The pledge object to be deleted. + * @param refetchPledge - Function to refetch the pledges after deletion. + * + * @returns The rendered modal component. + * + * + * The `PledgeDeleteModal` component displays a confirmation dialog when a user attempts to delete a pledge. + * It allows the user to either confirm or cancel the deletion. + * On confirmation, the `deletePledge` mutation is called to remove the pledge from the database, + * and the `refetchPledge` function is invoked to update the list of pledges. + * A success or error toast notification is shown based on the result of the deletion operation. + * + * The modal includes: + * - A header with a title and a close button. + * - A body with a message asking for confirmation. + * - A footer with "Yes" and "No" buttons to confirm or cancel the deletion. + * + * The `deletePledge` mutation is used to perform the deletion operation. + */ + +const PledgeDeleteModal: React.FC = ({ + isOpen, + hide, + pledge, + refetchPledge, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'pledges', + }); + const { t: tCommon } = useTranslation('common'); + + const [deletePledge] = useMutation(DELETE_PLEDGE); + + const deleteHandler = async (): Promise => { + try { + await deletePledge({ + variables: { + id: pledge?._id, + }, + }); + refetchPledge(); + hide(); + toast.success(t('pledgeDeleted') as string); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + return ( + <> + + +

    {t('deletePledge')}

    + +
    + +

    {t('deletePledgeMsg')}

    +
    + + + + +
    + + ); +}; +export default PledgeDeleteModal; diff --git a/src/screens/FundCampaignPledge/PledgeModal.test.tsx b/src/screens/FundCampaignPledge/PledgeModal.test.tsx new file mode 100644 index 0000000000..50d6c94624 --- /dev/null +++ b/src/screens/FundCampaignPledge/PledgeModal.test.tsx @@ -0,0 +1,213 @@ +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18nForTest from '../../utils/i18nForTest'; +import { PLEDGE_MODAL_MOCKS } from './PledgesMocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { InterfacePledgeModal } from './PledgeModal'; +import PledgeModal from './PledgeModal'; +import React from 'react'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('@mui/x-date-pickers/DateTimePicker', () => { + return { + DateTimePicker: jest.requireActual( + '@mui/x-date-pickers/DesktopDateTimePicker', + ).DesktopDateTimePicker, + }; +}); + +const link1 = new StaticMockLink(PLEDGE_MODAL_MOCKS); +const translations = JSON.parse( + JSON.stringify(i18nForTest.getDataByLanguage('en')?.translation.pledges), +); + +const pledgeProps: InterfacePledgeModal[] = [ + { + isOpen: true, + hide: jest.fn(), + pledge: { + _id: '1', + amount: 100, + currency: 'USD', + startDate: '2024-01-01', + endDate: '2024-01-10', + users: [ + { + _id: '1', + firstName: 'John', + lastName: 'Doe', + image: undefined, + }, + ], + }, + refetchPledge: jest.fn(), + campaignId: 'campaignId', + orgId: 'orgId', + endDate: new Date(), + mode: 'create', + }, + { + isOpen: true, + hide: jest.fn(), + pledge: { + _id: '1', + amount: 100, + currency: 'USD', + startDate: '2024-01-01', + endDate: '2024-01-10', + users: [ + { + _id: '1', + firstName: 'John', + lastName: 'Doe', + image: undefined, + }, + ], + }, + refetchPledge: jest.fn(), + campaignId: 'campaignId', + orgId: 'orgId', + endDate: new Date(), + mode: 'edit', + }, +]; +const renderPledgeModal = ( + link: ApolloLink, + props: InterfacePledgeModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('PledgeModal', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId', fundCampaignId: 'fundCampaignId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + it('should populate form fields with correct values in edit mode', async () => { + renderPledgeModal(link1, pledgeProps[1]); + await waitFor(() => + expect(screen.getByText(translations.editPledge)).toBeInTheDocument(), + ); + expect(screen.getByTestId('pledgerSelect')).toHaveTextContent('John Doe'); + expect(screen.getByLabelText('Start Date')).toHaveValue('01/01/2024'); + expect(screen.getByLabelText('End Date')).toHaveValue('10/01/2024'); + expect(screen.getByLabelText('Currency')).toHaveTextContent('USD ($)'); + expect(screen.getByLabelText('Amount')).toHaveValue('100'); + }); + + it('should update pledgeAmount when input value changes', async () => { + renderPledgeModal(link1, pledgeProps[1]); + const amountInput = screen.getByLabelText('Amount'); + expect(amountInput).toHaveValue('100'); + fireEvent.change(amountInput, { target: { value: '200' } }); + expect(amountInput).toHaveValue('200'); + }); + + it('should not update pledgeAmount when input value is less than or equal to 0', async () => { + renderPledgeModal(link1, pledgeProps[1]); + const amountInput = screen.getByLabelText('Amount'); + expect(amountInput).toHaveValue('100'); + fireEvent.change(amountInput, { target: { value: '-10' } }); + expect(amountInput).toHaveValue('100'); + }); + + it('should update pledgeStartDate when a new date is selected', async () => { + renderPledgeModal(link1, pledgeProps[1]); + const startDateInput = screen.getByLabelText('Start Date'); + fireEvent.change(startDateInput, { target: { value: '02/01/2024' } }); + expect(startDateInput).toHaveValue('02/01/2024'); + expect(pledgeProps[1].pledge?.startDate).toEqual('2024-01-01'); + }); + + it('pledgeStartDate onChange when its null', async () => { + renderPledgeModal(link1, pledgeProps[1]); + const startDateInput = screen.getByLabelText('Start Date'); + fireEvent.change(startDateInput, { target: { value: null } }); + expect(pledgeProps[1].pledge?.startDate).toEqual('2024-01-01'); + }); + + it('should update pledgeEndDate when a new date is selected', async () => { + renderPledgeModal(link1, pledgeProps[1]); + const startDateInput = screen.getByLabelText('End Date'); + fireEvent.change(startDateInput, { target: { value: '02/01/2024' } }); + expect(startDateInput).toHaveValue('02/01/2024'); + expect(pledgeProps[1].pledge?.endDate).toEqual('2024-01-10'); + }); + + it('pledgeEndDate onChange when its null', async () => { + renderPledgeModal(link1, pledgeProps[1]); + const endDateInput = screen.getByLabelText('End Date'); + fireEvent.change(endDateInput, { target: { value: null } }); + expect(pledgeProps[1].pledge?.endDate).toEqual('2024-01-10'); + }); + + it('should create pledge', async () => { + renderPledgeModal(link1, pledgeProps[0]); + + fireEvent.change(screen.getByLabelText('Amount'), { + target: { value: '200' }, + }); + fireEvent.change(screen.getByLabelText('Start Date'), { + target: { value: '02/01/2024' }, + }); + fireEvent.change(screen.getByLabelText('End Date'), { + target: { value: '02/01/2024' }, + }); + + expect(screen.getByLabelText('Amount')).toHaveValue('200'); + expect(screen.getByLabelText('Start Date')).toHaveValue('02/01/2024'); + expect(screen.getByLabelText('End Date')).toHaveValue('02/01/2024'); + expect(screen.getByTestId('submitPledgeBtn')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('submitPledgeBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalled(); + expect(pledgeProps[0].refetchPledge).toHaveBeenCalled(); + expect(pledgeProps[0].hide).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/FundCampaignPledge/PledgeModal.tsx b/src/screens/FundCampaignPledge/PledgeModal.tsx new file mode 100644 index 0000000000..911d90e77e --- /dev/null +++ b/src/screens/FundCampaignPledge/PledgeModal.tsx @@ -0,0 +1,356 @@ +import { DatePicker } from '@mui/x-date-pickers'; +import dayjs, { type Dayjs } from 'dayjs'; +import type { ChangeEvent } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import { currencyOptions, currencySymbols } from 'utils/currency'; +import type { + InterfaceCreatePledge, + InterfacePledgeInfo, + InterfaceUserInfo, +} from 'utils/interfaces'; +import styles from './FundCampaignPledge.module.css'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery } from '@apollo/client'; +import { CREATE_PlEDGE, UPDATE_PLEDGE } from 'GraphQl/Mutations/PledgeMutation'; +import { toast } from 'react-toastify'; +import { + Autocomplete, + FormControl, + InputLabel, + MenuItem, + Select, + TextField, +} from '@mui/material'; + +import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; + +export interface InterfacePledgeModal { + isOpen: boolean; + hide: () => void; + campaignId: string; + orgId: string; + pledge: InterfacePledgeInfo | null; + refetchPledge: () => void; + endDate: Date; + mode: 'create' | 'edit'; +} + +/** + * A modal dialog for creating or editing a pledge. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param campaignId - The ID of the campaign associated with the pledge. + * @param orgId - The ID of the organization associated with the pledge. + * @param pledge - The pledge object to be edited, or `null` if creating a new pledge. + * @param refetchPledge - Function to refetch the list of pledges after creation or update. + * @param endDate - The end date of the campaign to ensure pledge dates are within this range. + * @param mode - The mode indicating whether the modal is for creating a new pledge or editing an existing one. + * + * @returns The rendered modal component. + * + * The `PledgeModal` component displays a form within a modal dialog for creating or editing a pledge. + * It includes fields for selecting users, entering an amount, choosing a currency, and setting start and end dates for the pledge. + * + * The modal includes: + * - A header with a title indicating the current mode (create or edit) and a close button. + * - A form with: + * - A multi-select dropdown for selecting users to participate in the pledge. + * - Date pickers for selecting the start and end dates of the pledge. + * - A dropdown for selecting the currency of the pledge amount. + * - An input field for entering the pledge amount. + * - A submit button to create or update the pledge. + * + * On form submission, the component either: + * - Calls `updatePledge` mutation to update an existing pledge, or + * - Calls `createPledge` mutation to create a new pledge. + * + * Success or error messages are displayed using toast notifications based on the result of the mutation. + */ + +const PledgeModal: React.FC = ({ + isOpen, + hide, + campaignId, + orgId, + pledge, + refetchPledge, + endDate, + mode, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'pledges', + }); + const { t: tCommon } = useTranslation('common'); + + const [formState, setFormState] = useState({ + pledgeUsers: [], + pledgeAmount: pledge?.amount ?? 0, + pledgeCurrency: pledge?.currency ?? 'USD', + pledgeEndDate: new Date(pledge?.endDate ?? new Date()), + pledgeStartDate: new Date(pledge?.startDate ?? new Date()), + }); + const [pledgers, setPledgers] = useState([]); + const [updatePledge] = useMutation(UPDATE_PLEDGE); + const [createPledge] = useMutation(CREATE_PlEDGE); + + const { data: memberData } = useQuery(MEMBERS_LIST, { + variables: { id: orgId }, + }); + + useEffect(() => { + setFormState({ + pledgeUsers: pledge?.users ?? [], + pledgeAmount: pledge?.amount ?? 0, + pledgeCurrency: pledge?.currency ?? 'USD', + pledgeEndDate: new Date(pledge?.endDate ?? new Date()), + pledgeStartDate: new Date(pledge?.startDate ?? new Date()), + }); + }, [pledge]); + + useEffect(() => { + if (memberData) { + /*istanbul ignore next*/ + setPledgers(memberData.organizations[0].members); + } + }, [memberData]); + + const { + pledgeUsers, + pledgeAmount, + pledgeCurrency, + pledgeStartDate, + pledgeEndDate, + } = formState; + + /*istanbul ignore next*/ + const updatePledgeHandler = useCallback( + async (e: ChangeEvent): Promise => { + e.preventDefault(); + const startDate = dayjs(pledgeStartDate).format('YYYY-MM-DD'); + const endDate = dayjs(pledgeEndDate).format('YYYY-MM-DD'); + + const updatedFields: { + [key: string]: number | string | string[] | undefined; + } = {}; + // checks if there are changes to the pledge and adds them to the updatedFields object + if (pledgeAmount !== pledge?.amount) { + updatedFields.amount = pledgeAmount; + } + if (pledgeCurrency !== pledge?.currency) { + updatedFields.currency = pledgeCurrency; + } + if (startDate !== dayjs(pledge?.startDate).format('YYYY-MM-DD')) { + updatedFields.startDate = startDate; + } + if (endDate !== dayjs(pledge?.endDate).format('YYYY-MM-DD')) { + updatedFields.endDate = endDate; + } + if (pledgeUsers !== pledge?.users) { + updatedFields.users = pledgeUsers.map((user) => user._id); + } + try { + await updatePledge({ + variables: { + id: pledge?._id, + ...updatedFields, + }, + }); + toast.success(t('pledgeUpdated') as string); + refetchPledge(); + hide(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }, + [formState, pledge], + ); + + // Function to create a new pledge + const createPledgeHandler = useCallback( + async (e: ChangeEvent): Promise => { + try { + e.preventDefault(); + await createPledge({ + variables: { + campaignId, + amount: pledgeAmount, + currency: pledgeCurrency, + startDate: dayjs(pledgeStartDate).format('YYYY-MM-DD'), + endDate: dayjs(pledgeEndDate).format('YYYY-MM-DD'), + userIds: pledgeUsers.map((user) => user._id), + }, + }); + + toast.success(t('pledgeCreated') as string); + refetchPledge(); + setFormState({ + pledgeUsers: [], + pledgeAmount: 0, + pledgeCurrency: 'USD', + pledgeEndDate: new Date(), + pledgeStartDate: new Date(), + }); + hide(); + } catch (error: unknown) { + /*istanbul ignore next*/ + toast.error((error as Error).message); + } + }, + [formState, campaignId], + ); + + return ( + + +

    + {t(mode === 'edit' ? 'editPledge' : 'createPledge')} +

    + +
    + +
    + {/* A Multi-select dropdown enables admin to select more than one pledger for participating in a pledge */} + + option._id === value._id} + filterSelectedOptions={true} + getOptionLabel={(member: InterfaceUserInfo): string => + `${member.firstName} ${member.lastName}` + } + onChange={ + /*istanbul ignore next*/ + (_, newPledgers): void => { + setFormState({ + ...formState, + pledgeUsers: newPledgers, + }); + } + } + renderInput={(params) => ( + + )} + /> + + + {/* Date Calendar Component to select start date of an event */} + { + if (date) { + setFormState({ + ...formState, + pledgeStartDate: date.toDate(), + pledgeEndDate: + pledgeEndDate && + /*istanbul ignore next*/ + (pledgeEndDate < date?.toDate() + ? date.toDate() + : pledgeEndDate), + }); + } + }} + minDate={dayjs(pledgeStartDate)} + maxDate={dayjs(endDate)} + /> + {/* Date Calendar Component to select end Date of an event */} + { + if (date) { + setFormState({ + ...formState, + pledgeEndDate: date.toDate(), + }); + } + }} + minDate={dayjs(pledgeStartDate)} + maxDate={dayjs(endDate)} + /> + + + {/* Dropdown to select the currency in which amount is to be pledged */} + + + {t('currency')} + + + + {/* Input field to enter amount to be pledged */} + + { + if (parseInt(e.target.value) > 0) { + setFormState({ + ...formState, + pledgeAmount: parseInt(e.target.value), + }); + } + }} + /> + + + {/* Button to submit the pledge form */} + +
    +
    +
    + ); +}; +export default PledgeModal; diff --git a/src/screens/FundCampaignPledge/PledgesMocks.ts b/src/screens/FundCampaignPledge/PledgesMocks.ts new file mode 100644 index 0000000000..2a583fa887 --- /dev/null +++ b/src/screens/FundCampaignPledge/PledgesMocks.ts @@ -0,0 +1,457 @@ +import { + CREATE_PlEDGE, + DELETE_PLEDGE, + UPDATE_PLEDGE, +} from 'GraphQl/Mutations/PledgeMutation'; +import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; +import { FUND_CAMPAIGN_PLEDGE } from 'GraphQl/Queries/fundQueries'; + +const memberList = { + request: { + query: MEMBERS_LIST, + variables: { + id: 'orgId', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + members: [ + { + createdAt: '2023-04-13T04:53:17.742Z', + email: 'testuser4@example.com', + firstName: 'John', + image: 'img-url', + lastName: 'Doe', + organizationsBlockedBy: [], + __typename: 'User', + _id: '1', + }, + { + createdAt: '2024-04-13T04:53:17.742Z', + email: 'testuser2@example.com', + firstName: 'Anna', + image: null, + lastName: 'Bradley', + organizationsBlockedBy: [], + __typename: 'User', + _id: '2', + }, + ], + }, + ], + }, + }, +}; + +export const MOCKS = [ + memberList, + { + request: { + query: FUND_CAMPAIGN_PLEDGE, + variables: { + where: { + id: 'fundCampaignId', + }, + pledgeOrderBy: 'endDate_DESC', + }, + }, + result: { + data: { + getFundraisingCampaigns: [ + { + fundId: { + name: 'Fund 1', + }, + name: 'Campaign Name', + fundingGoal: 1000, + currency: 'USD', + startDate: '2024-01-01', + endDate: '2034-08-08', + pledges: [ + { + _id: '1', + amount: 100, + currency: 'USD', + startDate: '2024-01-01', + endDate: '2024-01-10', + users: [ + { + _id: '1', + firstName: 'John', + lastName: 'Doe', + image: 'img-url', + }, + ], + }, + { + _id: '2', + amount: 200, + currency: 'USD', + startDate: '2024-01-01', + endDate: '2024-01-09', + users: [ + { + _id: '2', + firstName: 'Jane', + lastName: 'Doe', + image: null, + }, + ], + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: FUND_CAMPAIGN_PLEDGE, + variables: { + where: { + id: 'fundCampaignId', + }, + pledgeOrderBy: 'endDate_ASC', + }, + }, + result: { + data: { + getFundraisingCampaigns: [ + { + fundId: { + name: 'Fund 1', + }, + name: 'Campaign Name', + fundingGoal: 1000, + currency: 'USD', + startDate: '2024-01-01', + endDate: '2024-08-08', + pledges: [ + { + _id: '2', + amount: 200, + currency: 'USD', + startDate: '2024-01-01', + endDate: '2024-01-09', + users: [ + { + _id: '2', + firstName: 'Jane', + lastName: 'Doe', + image: null, + }, + ], + }, + { + _id: '1', + amount: 100, + currency: 'USD', + startDate: '2024-01-01', + endDate: '2024-01-10', + users: [ + { + _id: '1', + firstName: 'John', + lastName: 'Doe', + image: null, + }, + ], + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: FUND_CAMPAIGN_PLEDGE, + variables: { + where: { + id: 'fundCampaignId', + }, + pledgeOrderBy: 'amount_DESC', + }, + }, + result: { + data: { + getFundraisingCampaigns: [ + { + fundId: { + name: 'Fund 1', + }, + name: 'Campaign Name', + fundingGoal: 1000, + currency: 'USD', + startDate: '2024-01-01', + endDate: '2024-08-08', + pledges: [ + { + _id: '2', + amount: 200, + currency: 'USD', + startDate: '2024-01-01', + endDate: '2024-01-09', + users: [ + { + _id: '2', + firstName: 'Jane', + lastName: 'Doe', + image: null, + }, + { + _id: '2', + firstName: 'John', + lastName: 'Doe2', + image: 'img-url2', + }, + { + _id: '3', + firstName: 'John', + lastName: 'Doe3', + image: 'img-url3', + }, + { + _id: '4', + firstName: 'John', + lastName: 'Doe4', + image: 'img-url4', + }, + { + _id: '5', + firstName: 'John', + lastName: 'Doe5', + image: 'img-url5', + }, + { + _id: '6', + firstName: 'John', + lastName: 'Doe6', + image: 'img-url6', + }, + { + _id: '7', + firstName: 'John', + lastName: 'Doe7', + image: 'img-url7', + }, + { + _id: '8', + firstName: 'John', + lastName: 'Doe8', + image: 'img-url8', + }, + { + _id: '9', + firstName: 'John', + lastName: 'Doe9', + image: 'img-url9', + }, + { + _id: '10', + firstName: 'John', + lastName: 'Doe10', + image: null, + }, + ], + }, + { + _id: '1', + amount: 100, + currency: 'USD', + startDate: '2024-01-01', + endDate: '2024-01-10', + users: [ + { + _id: '1', + firstName: 'John', + lastName: 'Doe', + image: null, + }, + ], + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: FUND_CAMPAIGN_PLEDGE, + variables: { + where: { + id: 'fundCampaignId', + }, + pledgeOrderBy: 'amount_ASC', + }, + }, + result: { + data: { + getFundraisingCampaigns: [ + { + fundId: { + name: 'Fund 1', + }, + name: 'Campaign Name', + fundingGoal: 1000, + currency: 'USD', + startDate: '2024-01-01', + endDate: '2024-08-08', + pledges: [ + { + _id: '1', + amount: 100, + currency: 'USD', + startDate: '2024-01-01', + endDate: '2024-01-10', + users: [ + { + _id: '1', + firstName: 'John', + lastName: 'Doe', + image: null, + }, + ], + }, + { + _id: '2', + amount: 200, + currency: 'USD', + startDate: '2024-01-01', + endDate: '2024-01-09', + users: [ + { + _id: '2', + firstName: 'Jane', + lastName: 'Doe', + image: null, + }, + ], + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: DELETE_PLEDGE, + variables: { + id: '1', + }, + }, + result: { + data: { + removeFundraisingCampaignPledge: { + _id: '1', + }, + }, + }, + }, +]; + +export const MOCKS_FUND_CAMPAIGN_PLEDGE_ERROR = [ + memberList, + { + request: { + query: FUND_CAMPAIGN_PLEDGE, + variables: { + where: { + id: 'fundCampaignId', + }, + pledgeOrderBy: 'endDate_DESC', + }, + }, + error: new Error('Error fetching pledges'), + }, +]; + +export const MOCKS_DELETE_PLEDGE_ERROR = [ + memberList, + { + request: { + query: DELETE_PLEDGE, + variables: { + id: '1', + }, + }, + error: new Error('Error deleting pledge'), + }, +]; + +export const EMPTY_MOCKS = [ + memberList, + { + request: { + query: FUND_CAMPAIGN_PLEDGE, + variables: { + where: { + id: 'fundCampaignId', + }, + pledgeOrderBy: 'endDate_DESC', + }, + }, + result: { + data: { + getFundraisingCampaigns: [ + { + fundId: { + name: 'Fund 1', + }, + name: 'Campaign Name', + fundingGoal: 1000, + currency: 'USD', + startDate: '2024-01-01', + endDate: '2024-01-01', + pledges: [], + }, + ], + }, + }, + }, +]; + +export const PLEDGE_MODAL_MOCKS = [ + memberList, + { + request: { + query: UPDATE_PLEDGE, + variables: { + id: '1', + amount: 200, + }, + }, + result: { + data: { + updateFundraisingCampaignPledge: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: CREATE_PlEDGE, + variables: { + campaignId: 'campaignId', + amount: 200, + currency: 'USD', + startDate: '2024-01-02', + endDate: '2024-01-02', + userIds: ['1'], + }, + }, + result: { + data: { + createFundraisingCampaignPledge: { + _id: '3', + }, + }, + }, + }, +]; diff --git a/src/screens/Leaderboard/Leaderboard.mocks.ts b/src/screens/Leaderboard/Leaderboard.mocks.ts new file mode 100644 index 0000000000..b6b22c832a --- /dev/null +++ b/src/screens/Leaderboard/Leaderboard.mocks.ts @@ -0,0 +1,198 @@ +import { VOLUNTEER_RANKING } from 'GraphQl/Queries/EventVolunteerQueries'; + +const rank1 = { + rank: 1, + hoursVolunteered: 5, + user: { + _id: 'userId1', + lastName: 'Bradley', + firstName: 'Teresa', + image: 'image-url', + email: 'testuser4@example.com', + }, +}; + +const rank2 = { + rank: 2, + hoursVolunteered: 4, + user: { + _id: 'userId2', + lastName: 'Garza', + firstName: 'Bruce', + image: null, + email: 'testuser5@example.com', + }, +}; + +const rank3 = { + rank: 3, + hoursVolunteered: 3, + user: { + _id: 'userId3', + lastName: 'Doe', + firstName: 'John', + image: null, + email: 'testuser6@example.com', + }, +}; + +const rank4 = { + rank: 4, + hoursVolunteered: 2, + user: { + _id: 'userId4', + lastName: 'Doe', + firstName: 'Jane', + image: null, + email: 'testuser7@example.com', + }, +}; + +export const MOCKS = [ + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + nameContains: '', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [rank1, rank2, rank3, rank4], + }, + }, + }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_ASC', + timeFrame: 'allTime', + nameContains: '', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [rank4, rank3, rank2, rank1], + }, + }, + }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'weekly', + nameContains: '', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [rank1], + }, + }, + }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'monthly', + nameContains: '', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [rank1, rank2], + }, + }, + }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'yearly', + nameContains: '', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [rank1, rank2, rank3], + }, + }, + }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + nameContains: 'T', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [rank1], + }, + }, + }, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + nameContains: '', + }, + }, + }, + result: { + data: { + getVolunteerRanks: [], + }, + }, + }, +]; + +export const ERROR_MOCKS = [ + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + nameContains: '', + }, + }, + }, + error: new Error('Mock Graphql VOLUNTEER_RANKING Error'), + }, +]; diff --git a/src/screens/Leaderboard/Leaderboard.test.tsx b/src/screens/Leaderboard/Leaderboard.test.tsx new file mode 100644 index 0000000000..d2f12a9052 --- /dev/null +++ b/src/screens/Leaderboard/Leaderboard.test.tsx @@ -0,0 +1,264 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import Leaderboard from './Leaderboard'; +import type { ApolloLink } from '@apollo/client'; +import { MOCKS, EMPTY_MOCKS, ERROR_MOCKS } from './Leaderboard.mocks'; + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(ERROR_MOCKS); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const t = { + ...JSON.parse( + JSON.stringify(i18n.getDataByLanguage('en')?.translation.leaderboard ?? {}), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderLeaderboard = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } /> + } + /> + } + /> + + + + + + , + ); +}; + +describe('Testing Leaderboard Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> + } + /> + + + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Leaderboard screen', async () => { + renderLeaderboard(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + }); + + it('Check Sorting Functionality', async () => { + renderLeaderboard(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + const sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by hours_DESC + fireEvent.click(sortBtn); + const hoursDesc = await screen.findByTestId('hours_DESC'); + expect(hoursDesc).toBeInTheDocument(); + fireEvent.click(hoursDesc); + + let userName = await screen.findAllByTestId('userName'); + expect(userName[0]).toHaveTextContent('Teresa Bradley'); + + // Sort by hours_ASC + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const hoursAsc = await screen.findByTestId('hours_ASC'); + expect(hoursAsc).toBeInTheDocument(); + fireEvent.click(hoursAsc); + + userName = await screen.findAllByTestId('userName'); + expect(userName[0]).toHaveTextContent('Jane Doe'); + }); + + it('Check Timeframe filter Functionality (All Time)', async () => { + renderLeaderboard(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + // Filter by allTime + const filter = await screen.findByTestId('timeFrame'); + expect(filter).toBeInTheDocument(); + + fireEvent.click(filter); + const timeFrameAll = await screen.findByTestId('timeFrameAll'); + expect(timeFrameAll).toBeInTheDocument(); + + fireEvent.click(timeFrameAll); + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(4); + }); + + it('Check Timeframe filter Functionality (Weekly)', async () => { + renderLeaderboard(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + const filter = await screen.findByTestId('timeFrame'); + expect(filter).toBeInTheDocument(); + + // Filter by weekly + expect(filter).toBeInTheDocument(); + fireEvent.click(filter); + + const timeFrameWeekly = await screen.findByTestId('timeFrameWeekly'); + expect(timeFrameWeekly).toBeInTheDocument(); + fireEvent.click(timeFrameWeekly); + + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(1); + }); + + it('Check Timeframe filter Functionality (Monthly)', async () => { + renderLeaderboard(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + // Filter by monthly + const filter = await screen.findByTestId('timeFrame'); + expect(filter).toBeInTheDocument(); + fireEvent.click(filter); + + const timeFrameMonthly = await screen.findByTestId('timeFrameMonthly'); + expect(timeFrameMonthly).toBeInTheDocument(); + fireEvent.click(timeFrameMonthly); + + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(2); + }); + + it('Check Timeframe filter Functionality (Yearly)', async () => { + renderLeaderboard(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + }); + + // Filter by yearly + const filter = await screen.findByTestId('timeFrame'); + expect(filter).toBeInTheDocument(); + fireEvent.click(filter); + + const timeFrameYearly = await screen.findByTestId('timeFrameYearly'); + expect(timeFrameYearly).toBeInTheDocument(); + fireEvent.click(timeFrameYearly); + + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(3); + }); + + it('Search Volunteers', async () => { + renderLeaderboard(link1); + + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + // Search by name on press of ENTER + userEvent.type(searchInput, 'T'); + await debounceWait(); + + await waitFor(() => { + const userName = screen.getAllByTestId('userName'); + expect(userName).toHaveLength(1); + }); + }); + + it('OnClick of Member navigate to Member Screen', async () => { + renderLeaderboard(link1); + + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const userName = screen.getAllByTestId('userName'); + userEvent.click(userName[0]); + + await waitFor(() => { + expect(screen.getByTestId('memberScreen')).toBeInTheDocument(); + }); + }); + + it('should render Leaderboard screen with No Volunteers', async () => { + renderLeaderboard(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noVolunteers)).toBeInTheDocument(); + }); + }); + + it('Error while fetching volunteer data', async () => { + renderLeaderboard(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/screens/Leaderboard/Leaderboard.tsx b/src/screens/Leaderboard/Leaderboard.tsx new file mode 100644 index 0000000000..c5ad7a2efe --- /dev/null +++ b/src/screens/Leaderboard/Leaderboard.tsx @@ -0,0 +1,372 @@ +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; + +import { + FilterAltOutlined, + Search, + Sort, + WarningAmberRounded, +} from '@mui/icons-material'; +import gold from 'assets/images/gold.png'; +import silver from 'assets/images/silver.png'; +import bronze from 'assets/images/bronze.png'; + +import type { InterfaceVolunteerRank } from 'utils/interfaces'; +import styles from '../OrganizationActionItems/OrganizationActionItems.module.css'; +import Loader from 'components/Loader/Loader'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { debounce, Stack } from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; +import { VOLUNTEER_RANKING } from 'GraphQl/Queries/EventVolunteerQueries'; +import { useQuery } from '@apollo/client'; + +enum TimeFrame { + All = 'allTime', + Weekly = 'weekly', + Monthly = 'monthly', + Yearly = 'yearly', +} + +const dataGridStyle = { + '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { + outline: 'none !important', + }, + '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-root': { + borderRadius: '0.5rem', + }, + '& .MuiDataGrid-main': { + borderRadius: '0.5rem', + }, +}; + +/** + * Component to display the leaderboard of volunteers. + * + * This component shows a leaderboard of volunteers ranked by hours contributed, + * with features for filtering by time frame and sorting by hours. It displays + * volunteer details including rank, name, email, and hours volunteered. + * + * @returns The rendered component. + */ +function leaderboard(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'leaderboard', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Get the organization ID from URL parameters + const { orgId } = useParams(); + + if (!orgId) { + return ; + } + + const navigate = useNavigate(); + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState<'hours_ASC' | 'hours_DESC'>( + 'hours_DESC', + ); + const [timeFrame, setTimeFrame] = useState(TimeFrame.All); + + /** + * Query to fetch volunteer rankings. + */ + const { + data: rankingsData, + loading: rankingsLoading, + error: rankingsError, + }: { + data?: { + getVolunteerRanks: InterfaceVolunteerRank[]; + }; + loading: boolean; + error?: Error | undefined; + } = useQuery(VOLUNTEER_RANKING, { + variables: { + orgId, + where: { + orderBy: sortBy, + timeFrame: timeFrame, + nameContains: searchTerm, + }, + }, + }); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const rankings = useMemo( + () => rankingsData?.getVolunteerRanks || [], + [rankingsData], + ); + + if (rankingsLoading) { + return ; + } + + if (rankingsError) { + return ( +
    + +
    + {tErrors('errorLoading', { entity: 'Volunteer Rankings' })} +
    +
    + ); + } + + const columns: GridColDef[] = [ + { + field: 'rank', + headerName: 'Rank', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + if (params.row.rank === 1) { + return ( + <> + gold + + ); + } else if (params.row.rank === 2) { + return ( + <> + silver + + ); + } else if (params.row.rank === 3) { + return ( + <> + bronze + + ); + } else return <>{params.row.rank}; + }, + }, + { + field: 'volunteer', + headerName: 'Volunteer', + flex: 2, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + const { _id, firstName, lastName, image } = params.row.user; + + return ( + <> +
    + navigate(`/member/${orgId}`, { state: { id: _id } }) + } + data-testid="userName" + > + {image ? ( + User + ) : ( +
    + +
    + )} + {firstName + ' ' + lastName} +
    + + ); + }, + }, + { + field: 'email', + headerName: 'Email', + flex: 2, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
    + {params.row.user.email} +
    + ); + }, + }, + { + field: 'hoursVolunteered', + headerName: 'Hours Volunteered', + flex: 2, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return
    {params.row.hoursVolunteered}
    ; + }, + }, + ]; + + return ( +
    + {/* Header with search, filter and Create Button */} +
    +
    + { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
    +
    +
    + + + + {tCommon('sort')} + + + setSortBy('hours_DESC')} + data-testid="hours_DESC" + > + {t('mostHours')} + + setSortBy('hours_ASC')} + data-testid="hours_ASC" + > + {t('leastHours')} + + + + + + + {t('timeFrame')} + + + setTimeFrame(TimeFrame.All)} + data-testid="timeFrameAll" + > + {t('allTime')} + + setTimeFrame(TimeFrame.Weekly)} + data-testid="timeFrameWeekly" + > + {t('weekly')} + + setTimeFrame(TimeFrame.Monthly)} + data-testid="timeFrameMonthly" + > + {t('monthly')} + + setTimeFrame(TimeFrame.Yearly)} + data-testid="timeFrameYearly" + > + {t('yearly')} + + + +
    +
    +
    + + {/* Table with Action Items */} + row.user._id} + slots={{ + noRowsOverlay: () => ( + + {t('noVolunteers')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={rankings.map((ranking, index) => ({ + id: index + 1, + ...ranking, + }))} + columns={columns} + isRowSelectable={() => false} + /> +
    + ); +} + +export default leaderboard; diff --git a/src/screens/LoginPage/LoginPage.module.css b/src/screens/LoginPage/LoginPage.module.css new file mode 100644 index 0000000000..e7ce0eca7e --- /dev/null +++ b/src/screens/LoginPage/LoginPage.module.css @@ -0,0 +1,235 @@ +.login_background { + min-height: 100vh; +} + +.communityLogo { + object-fit: contain; +} + +.row .left_portion { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + height: 100vh; +} + +.selectOrgText input { + outline: none !important; +} + +.row .left_portion .inner .palisadoes_logo { + width: 600px; + height: auto; +} + +.row .right_portion { + min-height: 100vh; + position: relative; + overflow-y: scroll; + display: flex; + flex-direction: column; + justify-content: center; + padding: 1rem 2.5rem; + background: var(--bs-white); +} + +.row .right_portion::-webkit-scrollbar { + display: none; +} + +.row .right_portion .langChangeBtn { + margin: 0; + position: absolute; + top: 1rem; + left: 1rem; +} + +.langChangeBtnStyle { + width: 7.5rem; + height: 2.2rem; + padding: 0; +} + +.row .right_portion .talawa_logo { + height: 5rem; + width: 5rem; + display: block; + margin: 1.5rem auto 1rem; + -webkit-animation: zoomIn 0.3s ease-in-out; + animation: zoomIn 0.3s ease-in-out; +} + +.row .orText { + display: block; + position: absolute; + top: 0; + left: calc(50% - 2.6rem); + margin: 0 auto; + padding: 0.35rem 2rem; + z-index: 100; + background: var(--bs-white); + color: var(--bs-secondary); +} + +@media (max-width: 992px) { + .row .left_portion { + padding: 0 2rem; + } + + .row .left_portion .inner .palisadoes_logo { + width: 100%; + } +} + +@media (max-width: 769px) { + .row { + flex-direction: column-reverse; + } + + .row .right_portion, + .row .left_portion { + height: unset; + } + + .row .right_portion { + min-height: 100vh; + overflow-y: unset; + } + + .row .left_portion .inner { + display: flex; + justify-content: center; + } + + .row .left_portion .inner .palisadoes_logo { + height: 70px; + width: unset; + position: absolute; + margin: 0.5rem; + top: 0; + right: 0; + z-index: 100; + } + + .row .left_portion .inner p { + margin-bottom: 0; + padding: 1rem; + } + + .socialIcons { + margin-bottom: 1rem; + } +} + +@media (max-width: 577px) { + .row .right_portion { + padding: 1rem 1rem 0 1rem; + } + + .row .right_portion .langChangeBtn { + position: absolute; + margin: 1rem; + left: 0; + top: 0; + } + + .marginTopForReg { + margin-top: 4rem !important; + } + + .row .right_portion .talawa_logo { + height: 120px; + margin: 0 auto 2rem auto; + } + + .socialIcons { + margin-bottom: 1rem; + } +} + +.active_tab { + -webkit-animation: fadeIn 0.3s ease-in-out; + animation: fadeIn 0.3s ease-in-out; +} + +@-webkit-keyframes zoomIn { + 0% { + opacity: 0; + -webkit-transform: scale(0.5); + transform: scale(0.5); + } + + 100% { + opacity: 1; + -webkit-transform: scale(1); + transform: scale(1); + } +} + +@keyframes zoomIn { + 0% { + opacity: 0; + -webkit-transform: scale(0.5); + transform: scale(0.5); + } + + 100% { + opacity: 1; + -webkit-transform: scale(1); + transform: scale(1); + } +} + +@-webkit-keyframes fadeIn { + 0% { + opacity: 0; + -webkit-transform: translateY(2rem); + transform: translateY(2rem); + } + + 100% { + opacity: 1; + -webkit-transform: translateY(0); + transform: translateY(0); + } +} + +@keyframes fadeIn { + 0% { + opacity: 0; + -webkit-transform: translateY(2rem); + transform: translateY(2rem); + } + + 100% { + opacity: 1; + -webkit-transform: translateY(0); + transform: translateY(0); + } +} + +.socialIcons { + display: flex; + gap: 16px; + justify-content: center; +} + +.password_checks { + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-direction: column; +} + +.password_check_element { + margin-top: -10px; +} + +.password_check_element_top { + margin-top: 18px; +} + +.password_check_element_bottom { + margin-bottom: -20px; +} diff --git a/src/screens/LoginPage/LoginPage.test.tsx b/src/screens/LoginPage/LoginPage.test.tsx new file mode 100644 index 0000000000..698c83d42e --- /dev/null +++ b/src/screens/LoginPage/LoginPage.test.tsx @@ -0,0 +1,1003 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import 'jest-localstorage-mock'; +import 'jest-location-mock'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import LoginPage from './LoginPage'; +import { + LOGIN_MUTATION, + RECAPTCHA_MUTATION, + SIGNUP_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { BACKEND_URL } from 'Constant/constant'; +import useLocalStorage from 'utils/useLocalstorage'; +import { GET_COMMUNITY_DATA, ORGANIZATION_LIST } from 'GraphQl/Queries/Queries'; +import { debug } from 'jest-preview'; + +const MOCKS = [ + { + request: { + query: LOGIN_MUTATION, + variables: { + email: 'johndoe@gmail.com', + password: 'johndoe', + }, + }, + result: { + data: { + login: { + user: { + _id: '1', + }, + appUserProfile: { + isSuperAdmin: false, + adminFor: ['123', '456'], + }, + accessToken: 'accessToken', + refreshToken: 'refreshToken', + }, + }, + }, + }, + { + request: { + query: SIGNUP_MUTATION, + variables: { + firstName: 'John Patrick ', + lastName: 'Doe ', + email: 'johndoe@gmail.com', + password: 'johnDoe', + }, + }, + result: { + data: { + register: { + user: { + _id: '1', + }, + accessToken: 'accessToken', + refreshToken: 'refreshToken', + }, + }, + }, + }, + { + request: { + query: RECAPTCHA_MUTATION, + variables: { + recaptchaToken: null, + }, + }, + result: { + data: { + recaptcha: true, + }, + }, + }, + { + request: { + query: GET_COMMUNITY_DATA, + }, + result: { + data: { + getCommunityData: null, + }, + }, + }, +]; + +const MOCKS2 = [ + { + request: { + query: GET_COMMUNITY_DATA, + }, + result: { + data: { + getCommunityData: { + _id: 'communitId', + websiteLink: 'http://link.com', + name: 'testName', + logoUrl: 'image.png', + __typename: 'Community', + socialMediaUrls: { + facebook: 'http://url.com', + gitHub: 'http://url.com', + youTube: 'http://url.com', + instagram: 'http://url.com', + linkedIn: 'http://url.com', + reddit: 'http://url.com', + slack: 'http://url.com', + X: null, + __typename: 'SocialMediaUrls', + }, + }, + }, + }, + }, +]; +const MOCKS3 = [ + { + request: { + query: ORGANIZATION_LIST, + }, + result: { + data: { + organizations: [ + { + _id: '6437904485008f171cf29924', + image: null, + creator: { + firstName: 'Wilt', + lastName: 'Shepherd', + }, + name: 'Unity Foundation', + members: [ + { + _id: '64378abd85008f171cf2990d', + }, + ], + admins: [ + { + _id: '64378abd85008f171cf2990d', + }, + ], + createdAt: '2023-04-13T05:16:52.827Z', + address: { + city: 'Bronx', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 Random Street', + line2: 'Apartment 456', + postalCode: '10451', + sortingCode: 'ABC-123', + state: 'NYC', + }, + }, + { + _id: 'db1d5caad2ade57ab811e681', + image: null, + creator: { + firstName: 'Sonya', + lastName: 'Jones', + }, + name: 'Mills Group', + members: [ + { + _id: '661b8410bd25a325da05e67c', + }, + ], + admins: [ + { + _id: '661b8410bd25a325da05e67c', + }, + ], + createdAt: '2024-04-14T07:21:52.940Z', + address: { + city: 'Lake Martineside', + countryCode: 'SL', + dependentLocality: 'Apt. 544', + line1: '5112 Dare Centers', + line2: 'Suite 163', + postalCode: '10452', + sortingCode: '46565-3458', + state: 'New Hampshire', + }, + }, + ], + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(MOCKS2, true); +const link3 = new StaticMockLink(MOCKS3, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('Constant/constant.ts', () => ({ + ...jest.requireActual('Constant/constant.ts'), + REACT_APP_USE_RECAPTCHA: 'yes', + RECAPTCHA_SITE_KEY: 'xxx', +})); + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +jest.mock('react-google-recaptcha', () => { + const react = jest.requireActual('react'); + const recaptcha = react.forwardRef( + ( + props: { + onChange: (value: string) => void; + } & React.InputHTMLAttributes, + ref: React.LegacyRef | undefined, + ): JSX.Element => { + const { onChange, ...otherProps } = props; + + const handleChange = ( + event: React.ChangeEvent, + ): void => { + if (onChange) { + onChange(event.target.value); + } + }; + + return ( + <> + + + ); + }, + ); + return recaptcha; +}); + +describe('Testing Login Page Screen', () => { + test('Component Should be rendered properly', async () => { + window.location.assign('/orglist'); + + render( + + + + + + + + + , + ); + + await wait(); + const adminLink = screen.getByText(/Admin/i); + userEvent.click(adminLink); + await wait(); + expect(screen.getByText(/Admin/i)).toBeInTheDocument(); + expect(window.location).toBeAt('/orglist'); + }); + + test('There should be default values of pre-login data when queried result is null', async () => { + render( + + + + + + + + + , + ); + await wait(); + + expect(screen.getByTestId('PalisadoesLogo')).toBeInTheDocument(); + expect( + screen.getAllByTestId('PalisadoesSocialMedia')[0], + ).toBeInTheDocument(); + + await wait(); + expect(screen.queryByTestId('preLoginLogo')).not.toBeInTheDocument(); + expect(screen.queryAllByTestId('preLoginSocialMedia')[0]).toBeUndefined(); + }); + + test('There should be a different values of pre-login data if the queried result is not null', async () => { + render( + + + + + + + + + , + ); + await wait(); + expect(screen.getByTestId('preLoginLogo')).toBeInTheDocument(); + expect(screen.getAllByTestId('preLoginSocialMedia')[0]).toBeInTheDocument(); + + await wait(); + expect(screen.queryByTestId('PalisadoesLogo')).not.toBeInTheDocument(); + expect(screen.queryAllByTestId('PalisadoesSocialMedia')[0]).toBeUndefined(); + }); + + test('Testing registration functionality', async () => { + const formData = { + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@gmail.com', + password: 'John@123', + confirmPassword: 'John@123', + }; + + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId(/goToRegisterPortion/i)); + + await wait(); + + userEvent.type( + screen.getByPlaceholderText(/First Name/i), + formData.firstName, + ); + userEvent.type( + screen.getByPlaceholderText(/Last name/i), + formData.lastName, + ); + userEvent.type(screen.getByTestId(/signInEmail/i), formData.email); + userEvent.type(screen.getByPlaceholderText('Password'), formData.password); + userEvent.type( + screen.getByPlaceholderText('Confirm Password'), + formData.confirmPassword, + ); + + userEvent.click(screen.getByTestId('registrationBtn')); + }); + + test('Testing registration functionality when all inputs are invalid', async () => { + const formData = { + firstName: '1234', + lastName: '8890', + email: 'j@l.co', + password: 'john@123', + confirmPassword: 'john@123', + }; + + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId(/goToRegisterPortion/i)); + + await wait(); + + userEvent.type( + screen.getByPlaceholderText(/First Name/i), + formData.firstName, + ); + userEvent.type( + screen.getByPlaceholderText(/Last name/i), + formData.lastName, + ); + userEvent.type(screen.getByTestId(/signInEmail/i), formData.email); + userEvent.type(screen.getByPlaceholderText('Password'), formData.password); + userEvent.type( + screen.getByPlaceholderText('Confirm Password'), + formData.confirmPassword, + ); + userEvent.click(screen.getByTestId('registrationBtn')); + }); + + test('Testing registration functionality, when password and confirm password is not same', async () => { + const formData = { + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@gmail.com', + password: 'johnDoe@1', + confirmPassword: 'doeJohn@2', + }; + + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId(/goToRegisterPortion/i)); + + userEvent.type( + screen.getByPlaceholderText(/First Name/i), + formData.firstName, + ); + userEvent.type( + screen.getByPlaceholderText(/Last Name/i), + formData.lastName, + ); + userEvent.type(screen.getByTestId(/signInEmail/i), formData.email); + userEvent.type(screen.getByPlaceholderText('Password'), formData.password); + userEvent.type( + screen.getByPlaceholderText('Confirm Password'), + formData.confirmPassword, + ); + + userEvent.click(screen.getByTestId('registrationBtn')); + }); + + test('Testing registration functionality, when input is not filled correctly', async () => { + const formData = { + firstName: 'J', + lastName: 'D', + email: 'johndoe@gmail.com', + password: 'joe', + confirmPassword: 'joe', + }; + + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId(/goToRegisterPortion/i)); + + userEvent.type( + screen.getByPlaceholderText(/First Name/i), + formData.firstName, + ); + userEvent.type( + screen.getByPlaceholderText(/Last Name/i), + formData.lastName, + ); + userEvent.type(screen.getByTestId(/signInEmail/i), formData.email); + userEvent.type(screen.getByPlaceholderText('Password'), formData.password); + userEvent.type( + screen.getByPlaceholderText('Confirm Password'), + formData.confirmPassword, + ); + + userEvent.click(screen.getByTestId('registrationBtn')); + }); + + test('switches to login tab on successful registration', async () => { + const formData = { + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@gmail.com', + password: 'johndoe', + confirmPassword: 'johndoe', + }; + + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId(/goToRegisterPortion/i)); + userEvent.type( + screen.getByPlaceholderText(/First Name/i), + formData.firstName, + ); + userEvent.type( + screen.getByPlaceholderText(/Last name/i), + formData.lastName, + ); + userEvent.type(screen.getByTestId(/signInEmail/i), formData.email); + userEvent.type(screen.getByPlaceholderText('Password'), formData.password); + userEvent.type( + screen.getByPlaceholderText('Confirm Password'), + formData.confirmPassword, + ); + + userEvent.click(screen.getByTestId('registrationBtn')); + + await wait(); + + // Check if the login tab is now active by checking for elements that only appear in the login tab + expect(screen.getByTestId('loginBtn')).toBeInTheDocument(); + expect(screen.getByTestId('goToRegisterPortion')).toBeInTheDocument(); + }); + + test('Testing toggle login register portion', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('goToRegisterPortion')); + + userEvent.click(screen.getByTestId('goToLoginPortion')); + + await wait(); + }); + + test('Testing login functionality', async () => { + const formData = { + email: 'johndoe@gmail.com', + password: 'johndoe', + }; + + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.type(screen.getByTestId(/loginEmail/i), formData.email); + userEvent.type( + screen.getByPlaceholderText(/Enter Password/i), + formData.password, + ); + + userEvent.click(screen.getByTestId('loginBtn')); + + await wait(); + }); + + test('Testing password preview feature for login', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + const input = screen.getByTestId('password') as HTMLInputElement; + const toggleText = screen.getByTestId('showLoginPassword'); + // password should be hidden + expect(input.type).toBe('password'); + // click the toggle button to show password + userEvent.click(toggleText); + expect(input.type).toBe('text'); + // click the toggle button to hide password + userEvent.click(toggleText); + expect(input.type).toBe('password'); + + await wait(); + }); + + test('Testing password preview feature for register', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('goToRegisterPortion')); + + const input = screen.getByTestId('passwordField') as HTMLInputElement; + const toggleText = screen.getByTestId('showPassword'); + // password should be hidden + expect(input.type).toBe('password'); + // click the toggle button to show password + userEvent.click(toggleText); + expect(input.type).toBe('text'); + // click the toggle button to hide password + userEvent.click(toggleText); + expect(input.type).toBe('password'); + + await wait(); + }); + + test('Testing confirm password preview feature', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('goToRegisterPortion')); + + const input = screen.getByTestId('cpassword') as HTMLInputElement; + const toggleText = screen.getByTestId('showPasswordCon'); + // password should be hidden + expect(input.type).toBe('password'); + // click the toggle button to show password + userEvent.click(toggleText); + expect(input.type).toBe('text'); + // click the toggle button to hide password + userEvent.click(toggleText); + expect(input.type).toBe('password'); + + await wait(); + }); + + test('Testing for the password error warning when user firsts lands on a page', async () => { + render( + + + + + + + + + , + ); + await wait(); + + expect(screen.queryByTestId('passwordCheck')).toBeNull(); + }); + + test('Testing for the password error warning when user clicks on password field and password is less than 8 character', async () => { + const password = { + password: '7', + }; + + render( + + + + + + + + + , + ); + await wait(); + + userEvent.click(screen.getByTestId('goToRegisterPortion')); + + userEvent.type(screen.getByPlaceholderText('Password'), password.password); + + expect(screen.getByTestId('passwordField')).toHaveFocus(); + + expect(password.password.length).toBeLessThan(8); + + expect(screen.queryByTestId('passwordCheck')).toBeInTheDocument(); + }); + + test('Testing for the password error warning when user clicks on password field and password is greater than or equal to 8 character', async () => { + const password = { + password: '12345678', + }; + + render( + + + + + + + + + , + ); + await wait(); + + userEvent.click(screen.getByTestId('goToRegisterPortion')); + + userEvent.type(screen.getByPlaceholderText('Password'), password.password); + + expect(screen.getByTestId('passwordField')).toHaveFocus(); + + expect(password.password.length).toBeGreaterThanOrEqual(8); + + expect(screen.queryByTestId('passwordCheck')).toBeNull(); + }); + + test('Testing for the password error warning when user clicks on fields except password field and password is less than 8 character', async () => { + const password = { + password: '7', + }; + + render( + + + + + + + + + , + ); + await wait(); + + userEvent.click(screen.getByTestId('goToRegisterPortion')); + + expect(screen.getByPlaceholderText('Password')).not.toHaveFocus(); + + userEvent.type(screen.getByPlaceholderText('Password'), password.password); + + expect(password.password.length).toBeLessThan(8); + + expect(screen.queryByTestId('passwordCheck')).toBeInTheDocument(); + }); + + test('Testing for the password error warning when user clicks on fields except password field and password is greater than or equal to 8 character', async () => { + const password = { + password: '12345678', + }; + + render( + + + + + + + + + , + ); + await wait(); + + userEvent.click(screen.getByTestId('goToRegisterPortion')); + + await wait(); + + expect(screen.getByPlaceholderText('Password')).not.toHaveFocus(); + + userEvent.type(screen.getByPlaceholderText('Password'), password.password); + + expect(password.password.length).toBeGreaterThanOrEqual(8); + + expect(screen.queryByTestId('passwordCheck')).toBeNull(); + }); + + test('Component Should be rendered properly for user login', async () => { + window.location.assign('/user/organizations'); + + render( + + + + + + + + + , + ); + + await wait(); + const userLink = screen.getByText(/User/i); + userEvent.click(userLink); + await wait(); + expect(screen.getByText(/User Login/i)).toBeInTheDocument(); + expect(window.location).toBeAt('/user/organizations'); + }); + + test('on value change of ReCAPTCHA onChange event should be triggered in both the captcha', async () => { + render( + + + + + + + + + , + ); + await wait(); + + const recaptchaElements = screen.getAllByTestId('mock-recaptcha'); + + for (const recaptchaElement of recaptchaElements) { + const inputElement = recaptchaElement as HTMLInputElement; + + fireEvent.input(inputElement, { + target: { value: 'test-token' }, + }); + + fireEvent.change(inputElement, { + target: { value: 'test-token2' }, + }); + + expect(recaptchaElement).toHaveValue('test-token2'); + } + }); +}); + +describe('Testing redirect if already logged in', () => { + test('Logged in as USER', async () => { + const { setItem } = useLocalStorage(); + setItem('IsLoggedIn', 'TRUE'); + setItem('userId', 'id'); + render( + + + + + + + + + , + ); + await wait(); + expect(mockNavigate).toHaveBeenCalledWith('/user/organizations'); + }); + test('Logged in as Admin or SuperAdmin', async () => { + const { setItem } = useLocalStorage(); + setItem('IsLoggedIn', 'TRUE'); + setItem('userId', null); + render( + + + + + + + + + , + ); + await wait(); + expect(mockNavigate).toHaveBeenCalledWith('/orglist'); + }); +}); +test('Render the Select Organization list and change the option', async () => { + render( + + + + + + + + + , + ); + + await wait(); + userEvent.click(screen.getByTestId(/goToRegisterPortion/i)); + await wait(); + const autocomplete = screen.getByTestId('selectOrg'); + const input = within(autocomplete).getByRole('combobox'); + autocomplete.focus(); + // the value here can be any string you want, so you may also consider to + // wrapper it as a function and pass in inputValue as parameter + fireEvent.change(input, { target: { value: 'a' } }); + fireEvent.keyDown(autocomplete, { key: 'ArrowDown' }); + fireEvent.keyDown(autocomplete, { key: 'Enter' }); + + debug(); +}); + +describe('Talawa-API server fetch check', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('Checks if Talawa-API resource is loaded successfully', async () => { + global.fetch = jest.fn(() => Promise.resolve({} as unknown as Response)); + + await act(async () => { + render( + + + + + + + + + , + ); + }); + + expect(fetch).toHaveBeenCalledWith(BACKEND_URL); + }); + + test('displays warning message when resource loading fails', async () => { + const mockError = new Error('Network error'); + global.fetch = jest.fn(() => Promise.reject(mockError)); + + await act(async () => { + render( + + + + + + + + + , + ); + }); + + expect(fetch).toHaveBeenCalledWith(BACKEND_URL); + }); +}); diff --git a/src/screens/LoginPage/LoginPage.tsx b/src/screens/LoginPage/LoginPage.tsx new file mode 100644 index 0000000000..7703266afa --- /dev/null +++ b/src/screens/LoginPage/LoginPage.tsx @@ -0,0 +1,888 @@ +import { useQuery, useMutation } from '@apollo/client'; +import { Check, Clear } from '@mui/icons-material'; +import type { ChangeEvent } from 'react'; +import React, { useEffect, useState } from 'react'; +import { Form } from 'react-bootstrap'; +import Button from 'react-bootstrap/Button'; +import Col from 'react-bootstrap/Col'; +import Row from 'react-bootstrap/Row'; +import ReCAPTCHA from 'react-google-recaptcha'; +import { useTranslation } from 'react-i18next'; +import { Link, useNavigate } from 'react-router-dom'; +import { toast } from 'react-toastify'; + +import EmailOutlinedIcon from '@mui/icons-material/EmailOutlined'; +import { + BACKEND_URL, + REACT_APP_USE_RECAPTCHA, + RECAPTCHA_SITE_KEY, +} from 'Constant/constant'; +import { + LOGIN_MUTATION, + RECAPTCHA_MUTATION, + SIGNUP_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import { GET_COMMUNITY_DATA, ORGANIZATION_LIST } from 'GraphQl/Queries/Queries'; +import PalisadoesLogo from 'assets/svgs/palisadoes.svg?react'; +import TalawaLogo from 'assets/svgs/talawa.svg?react'; +import ChangeLanguageDropDown from 'components/ChangeLanguageDropdown/ChangeLanguageDropDown'; +import LoginPortalToggle from 'components/LoginPortalToggle/LoginPortalToggle'; +import { errorHandler } from 'utils/errorHandler'; +import useLocalStorage from 'utils/useLocalstorage'; +import { socialMediaLinks } from '../../constants'; +import styles from './LoginPage.module.css'; +import type { InterfaceQueryOrganizationListObject } from 'utils/interfaces'; +import { Autocomplete, TextField } from '@mui/material'; +import useSession from 'utils/useSession'; +import i18n from 'utils/i18n'; + +/** + * LoginPage component is used to render the login page of the application where user can login or register + * to the application using email and password. The component also provides the functionality to switch between login and + * register form. + * + */ + +const loginPage = (): JSX.Element => { + const { t } = useTranslation('translation', { keyPrefix: 'loginPage' }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + const navigate = useNavigate(); + + const { getItem, setItem } = useLocalStorage(); + + document.title = t('title'); + + type PasswordValidation = { + lowercaseChar: boolean; + uppercaseChar: boolean; + numericValue: boolean; + specialChar: boolean; + }; + + const [recaptchaToken, setRecaptchaToken] = useState(null); + const [showTab, setShowTab] = useState<'LOGIN' | 'REGISTER'>('LOGIN'); + const [role, setRole] = useState<'admin' | 'user'>('admin'); + const [isInputFocused, setIsInputFocused] = useState(false); + const [signformState, setSignFormState] = useState({ + signfirstName: '', + signlastName: '', + signEmail: '', + signPassword: '', + cPassword: '', + signOrg: '', + }); + const [formState, setFormState] = useState({ + email: '', + password: '', + }); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = + useState(false); + const [showAlert, setShowAlert] = useState({ + lowercaseChar: true, + uppercaseChar: true, + numericValue: true, + specialChar: true, + }); + const [organizations, setOrganizations] = useState([]); + + const passwordValidationRegExp = { + lowercaseCharRegExp: new RegExp('[a-z]'), + uppercaseCharRegExp: new RegExp('[A-Z]'), + numericalValueRegExp: new RegExp('\\d'), + specialCharRegExp: new RegExp('[!@#$%^&*()_+{}\\[\\]:;<>,.?~\\\\/-]'), + }; + + const handlePasswordCheck = (pass: string): void => { + setShowAlert({ + lowercaseChar: !passwordValidationRegExp.lowercaseCharRegExp.test(pass), + uppercaseChar: !passwordValidationRegExp.uppercaseCharRegExp.test(pass), + numericValue: !passwordValidationRegExp.numericalValueRegExp.test(pass), + specialChar: !passwordValidationRegExp.specialCharRegExp.test(pass), + }); + }; + + const handleRoleToggle = (role: 'admin' | 'user'): void => { + setRole(role); + }; + + useEffect(() => { + const isLoggedIn = getItem('IsLoggedIn'); + if (isLoggedIn == 'TRUE') { + navigate(getItem('userId') !== null ? '/user/organizations' : '/orglist'); + extendSession(); + } + }, []); + + const togglePassword = (): void => setShowPassword(!showPassword); + const toggleConfirmPassword = (): void => + setShowConfirmPassword(!showConfirmPassword); + + const { data, refetch } = useQuery(GET_COMMUNITY_DATA); + useEffect(() => { + // refetching the data if the pre-login data updates + refetch(); + }, [data]); + const [login, { loading: loginLoading }] = useMutation(LOGIN_MUTATION); + const [signup, { loading: signinLoading }] = useMutation(SIGNUP_MUTATION); + const [recaptcha] = useMutation(RECAPTCHA_MUTATION); + const { data: orgData } = useQuery(ORGANIZATION_LIST); + const { startSession, extendSession } = useSession(); + useEffect(() => { + if (orgData) { + const options = orgData.organizations.map( + (org: InterfaceQueryOrganizationListObject) => { + const tempObj: { label: string; id: string } | null = {} as { + label: string; + id: string; + }; + tempObj['label'] = + `${org.name}(${org.address?.city},${org.address?.state},${org.address?.countryCode})`; + tempObj['id'] = org._id; + return tempObj; + }, + ); + setOrganizations(options); + } + }, [orgData]); + + useEffect(() => { + async function loadResource(): Promise { + try { + await fetch(BACKEND_URL as string); + } catch (error) { + /* istanbul ignore next */ + errorHandler(t, error); + } + } + + loadResource(); + }, []); + + const verifyRecaptcha = async ( + recaptchaToken: string | null, + ): Promise => { + try { + /* istanbul ignore next */ + if (REACT_APP_USE_RECAPTCHA !== 'yes') { + return true; + } + const { data } = await recaptcha({ + variables: { + recaptchaToken, + }, + }); + + return data.recaptcha; + } catch { + /* istanbul ignore next */ + toast.error(t('captchaError') as string); + } + }; + + const handleCaptcha = (token: string | null): void => { + setRecaptchaToken(token); + }; + + const signupLink = async (e: ChangeEvent): Promise => { + e.preventDefault(); + + const { + signfirstName, + signlastName, + signEmail, + signPassword, + cPassword, + signOrg, + } = signformState; + + const isVerified = await verifyRecaptcha(recaptchaToken); + /* istanbul ignore next */ + if (!isVerified) { + toast.error(t('Please_check_the_captcha') as string); + return; + } + + const isValidName = (value: string): boolean => { + // Allow letters, spaces, and hyphens, but not consecutive spaces or hyphens + return /^[a-zA-Z]+(?:[-\s][a-zA-Z]+)*$/.test(value.trim()); + }; + + const validatePassword = (password: string): boolean => { + const lengthCheck = new RegExp('^.{6,}$'); + return ( + lengthCheck.test(password) && + passwordValidationRegExp.lowercaseCharRegExp.test(password) && + passwordValidationRegExp.uppercaseCharRegExp.test(password) && + passwordValidationRegExp.numericalValueRegExp.test(password) && + passwordValidationRegExp.specialCharRegExp.test(password) + ); + }; + + if ( + isValidName(signfirstName) && + isValidName(signlastName) && + signfirstName.trim().length > 1 && + signlastName.trim().length > 1 && + signEmail.length >= 8 && + signPassword.length > 1 && + validatePassword(signPassword) + ) { + if (cPassword == signPassword) { + try { + const { data: signUpData } = await signup({ + variables: { + firstName: signfirstName, + lastName: signlastName, + email: signEmail, + password: signPassword, + orgId: signOrg, + }, + }); + + /* istanbul ignore next */ + if (signUpData) { + toast.success( + t( + role === 'admin' ? 'successfullyRegistered' : 'afterRegister', + ) as string, + ); + setShowTab('LOGIN'); + setSignFormState({ + signfirstName: '', + signlastName: '', + signEmail: '', + signPassword: '', + cPassword: '', + signOrg: '', + }); + } + } catch (error) { + /* istanbul ignore next */ + errorHandler(t, error); + } + } else { + toast.warn(t('passwordMismatches') as string); + } + } else { + if (!isValidName(signfirstName)) { + toast.warn(t('firstName_invalid') as string); + } + if (!isValidName(signlastName)) { + toast.warn(t('lastName_invalid') as string); + } + if (!validatePassword(signPassword)) { + toast.warn(t('password_invalid') as string); + } + if (signEmail.length < 8) { + toast.warn(t('email_invalid') as string); + } + } + }; + + const loginLink = async (e: ChangeEvent): Promise => { + e.preventDefault(); + const isVerified = await verifyRecaptcha(recaptchaToken); + /* istanbul ignore next */ + if (!isVerified) { + toast.error(t('Please_check_the_captcha') as string); + return; + } + + try { + const { data: loginData } = await login({ + variables: { + email: formState.email, + password: formState.password, + }, + }); + + /* istanbul ignore next */ + if (loginData) { + i18n.changeLanguage(loginData.login.appUserProfile.appLanguageCode); + const { login } = loginData; + const { user, appUserProfile } = login; + const isAdmin: boolean = + appUserProfile.isSuperAdmin || appUserProfile.adminFor.length !== 0; + + if (role === 'admin' && !isAdmin) { + toast.warn(tErrors('notAuthorised') as string); + return; + } + const loggedInUserId = user._id; + + setItem('token', login.accessToken); + setItem('refreshToken', login.refreshToken); + setItem('IsLoggedIn', 'TRUE'); + setItem('name', `${user.firstName} ${user.lastName}`); + setItem('email', user.email); + setItem('FirstName', user.firstName); + setItem('LastName', user.lastName); + setItem('UserImage', user.image); + + if (role === 'admin') { + setItem('id', loggedInUserId); + setItem('SuperAdmin', appUserProfile.isSuperAdmin); + setItem('AdminFor', appUserProfile.adminFor); + } else { + setItem('userId', loggedInUserId); + } + + navigate(role === 'admin' ? '/orglist' : '/user/organizations'); + startSession(); + } else { + toast.warn(tErrors('notFound') as string); + } + } catch (error) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }; + + const socialIconsList = socialMediaLinks.map(({ href, logo, tag }, index) => + data?.getCommunityData ? ( + data.getCommunityData?.socialMediaUrls?.[tag] && ( + + + + ) + ) : ( + + + + ), + ); + + return ( + <> +
    + + +
    + {data?.getCommunityData ? ( + + Community Logo +

    {data.getCommunityData.name}

    +
    + ) : ( + + +

    {t('fromPalisadoes')}

    +
    + )} +
    +
    {socialIconsList}
    + + +
    + + + + + + {/* LOGIN FORM */} +
    +
    +

    + {role === 'admin' ? tCommon('login') : t('userLogin')} +

    + {tCommon('email')} +
    + { + setFormState({ + ...formState, + email: e.target.value, + }); + }} + autoComplete="username" + data-testid="loginEmail" + /> + +
    + + {tCommon('password')} + +
    + { + setFormState({ + ...formState, + password: e.target.value, + }); + }} + disabled={loginLoading} + autoComplete="current-password" + /> + +
    +
    + + {tCommon('forgotPassword')} + +
    + {REACT_APP_USE_RECAPTCHA === 'yes' ? ( +
    + +
    + ) : ( + /* istanbul ignore next */ + <> + )} + +
    +
    + {tCommon('OR')} +
    + +
    +
    + {/* REGISTER FORM */} +
    +
    +

    + {tCommon('register')} +

    + + +
    + {tCommon('firstName')} + { + setSignFormState({ + ...signformState, + signfirstName: e.target.value, + }); + }} + /> +
    + + +
    + {tCommon('lastName')} + { + setSignFormState({ + ...signformState, + signlastName: e.target.value, + }); + }} + /> +
    + +
    +
    + {tCommon('email')} +
    + { + setSignFormState({ + ...signformState, + signEmail: e.target.value.toLowerCase(), + }); + }} + /> + +
    +
    + +
    + {tCommon('password')} +
    + setIsInputFocused(true)} + onBlur={(): void => setIsInputFocused(false)} + required + value={signformState.signPassword} + onChange={(e): void => { + setSignFormState({ + ...signformState, + signPassword: e.target.value, + }); + handlePasswordCheck(e.target.value); + }} + /> + +
    +
    + {isInputFocused ? ( + signformState.signPassword.length < 6 ? ( +
    +

    + + + + {t('atleast_6_char_long')} +

    +
    + ) : ( +

    + + + + {t('atleast_6_char_long')} +

    + ) + ) : null} + + {!isInputFocused && + signformState.signPassword.length > 0 && + signformState.signPassword.length < 6 && ( +
    + + + + {t('atleast_6_char_long')} +
    + )} + {isInputFocused && ( +

    + {showAlert.lowercaseChar ? ( + + + + ) : ( + + + + )} + {t('lowercase_check')} +

    + )} + {isInputFocused && ( +

    + {showAlert.uppercaseChar ? ( + + + + ) : ( + + + + )} + {t('uppercase_check')} +

    + )} + {isInputFocused && ( +

    + {showAlert.numericValue ? ( + + + + ) : ( + + + + )} + {t('numeric_value_check')} +

    + )} + {isInputFocused && ( +

    + {showAlert.specialChar ? ( + + + + ) : ( + + + + )} + {t('special_char_check')} +

    + )} +
    +
    +
    + {tCommon('confirmPassword')} +
    + { + setSignFormState({ + ...signformState, + cPassword: e.target.value, + }); + }} + data-testid="cpassword" + autoComplete="new-password" + /> + +
    + {signformState.cPassword.length > 0 && + signformState.signPassword !== + signformState.cPassword && ( +
    + {t('Password_and_Confirm_password_mismatches.')} +
    + )} +
    +
    + {t('selectOrg')} +
    + { + setSignFormState({ + ...signformState, + signOrg: value?.id ?? '', + }); + }} + options={organizations} + renderInput={(params) => ( + + )} + /> +
    +
    + {REACT_APP_USE_RECAPTCHA === 'yes' ? ( +
    + +
    + ) : ( + /* istanbul ignore next */ + <> + )} + +
    +
    + {tCommon('OR')} +
    + +
    +
    +
    + +
    +
    + + ); +}; + +export default loginPage; diff --git a/src/screens/ManageTag/EditUserTagModal.tsx b/src/screens/ManageTag/EditUserTagModal.tsx new file mode 100644 index 0000000000..5fa8ae2771 --- /dev/null +++ b/src/screens/ManageTag/EditUserTagModal.tsx @@ -0,0 +1,89 @@ +import type { TFunction } from 'i18next'; +import type { FormEvent } from 'react'; +import React from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; + +/** + * Edit UserTag Modal component for the Manage Tag screen. + */ + +export interface InterfaceEditUserTagModalProps { + editUserTagModalIsOpen: boolean; + hideEditUserTagModal: () => void; + newTagName: string; + setNewTagName: (state: React.SetStateAction) => void; + handleEditUserTag: (e: FormEvent) => Promise; + t: TFunction<'translation', 'manageTag'>; + tCommon: TFunction<'common', undefined>; +} + +const EditUserTagModal: React.FC = ({ + editUserTagModalIsOpen, + hideEditUserTagModal, + newTagName, + handleEditUserTag, + setNewTagName, + t, + tCommon, +}) => { + return ( + <> + + + {t('tagDetails')} + +
    ): void => { + e.preventDefault(); + if (newTagName.trim()) { + handleEditUserTag(e); + } + }} + > + + {t('tagName')} + { + setNewTagName(e.target.value); + }} + /> + + + + + + +
    +
    + + ); +}; + +export default EditUserTagModal; diff --git a/src/screens/ManageTag/ManageTag.module.css b/src/screens/ManageTag/ManageTag.module.css new file mode 100644 index 0000000000..deecd4a9b7 --- /dev/null +++ b/src/screens/ManageTag/ManageTag.module.css @@ -0,0 +1,127 @@ +.btnsContainer { + display: flex; + margin: 2rem 0; +} + +.btnsContainer .btnsBlock { + display: flex; + width: max-content; +} + +.btnsContainer .btnsBlock button { + margin-left: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.btnsContainer .input { + flex: 1; + position: relative; + max-width: 60%; + justify-content: space-between; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); +} + +.btnsContainer .input button { + width: 52px; +} + +@media (max-width: 1020px) { + .btnsContainer { + flex-direction: column; + margin: 1.5rem 0; + } + + .btnsContainer .btnsBlock { + margin: 1.5rem 0 0 0; + justify-content: space-between; + } + + .btnsContainer .btnsBlock button { + margin: 0; + } + + .btnsContainer .btnsBlock div button { + margin-right: 1.5rem; + } +} + +/* For mobile devices */ + +@media (max-width: 520px) { + .btnsContainer { + margin-bottom: 0; + } + + .btnsContainer .btnsBlock { + display: block; + margin-top: 1rem; + margin-right: 0; + } + + .btnsContainer .btnsBlock div { + flex: 1; + } + + .btnsContainer .btnsBlock div[title='Sort organizations'] { + margin-right: 0.5rem; + } + + .btnsContainer .btnsBlock button { + margin-bottom: 1rem; + margin-right: 0; + width: 100%; + } +} + +.errorContainer { + min-height: 100vh; +} + +.errorMessage { + margin-top: 25%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.errorIcon { + transform: scale(1.5); + color: var(--bs-danger); + margin-bottom: 1rem; +} + +.tableHeader { + background-color: var(--bs-primary); + color: var(--bs-white); + font-size: 1rem; +} + +.rowBackground { + background-color: var(--bs-white); + max-height: 120px; +} + +.tagsBreadCrumbs { + color: var(--bs-gray); + cursor: pointer; +} + +.tagsBreadCrumbs:hover { + color: var(--bs-blue); + font-weight: 600; + text-decoration: underline; +} + +.manageTagScrollableDiv { + scrollbar-width: thin; + scrollbar-color: var(--bs-gray-400) var(--bs-white); + + max-height: calc(100vh - 18rem); + overflow: auto; +} diff --git a/src/screens/ManageTag/ManageTag.test.tsx b/src/screens/ManageTag/ManageTag.test.tsx new file mode 100644 index 0000000000..598a15cc9a --- /dev/null +++ b/src/screens/ManageTag/ManageTag.test.tsx @@ -0,0 +1,507 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import type { RenderResult } from '@testing-library/react'; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import ManageTag from './ManageTag'; +import { MOCKS, MOCKS_ERROR_ASSIGNED_MEMBERS } from './ManageTagMocks'; +import { type ApolloLink } from '@apollo/client'; + +const translations = { + ...JSON.parse( + JSON.stringify(i18n.getDataByLanguage('en')?.translation.manageTag ?? {}), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(MOCKS_ERROR_ASSIGNED_MEMBERS, true); + +async function wait(ms = 500): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }, +})); + +/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ +jest.mock('../../components/AddPeopleToTag/AddPeopleToTag', () => { + return require('./ManageTagMockComponents/MockAddPeopleToTag').default; +}); + +jest.mock('../../components/TagActions/TagActions', () => { + return require('./ManageTagMockComponents/MockTagActions').default; +}); +/* eslint-enable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ + +const renderManageTag = (link: ApolloLink): RenderResult => { + return render( + + + + + + } + /> + } + /> + } + /> + } + /> + + + + + , + ); +}; + +describe('Manage Tag Page', () => { + beforeEach(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + test('Component loads correctly', async () => { + const { getByText } = renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.addPeopleToTag)).toBeInTheDocument(); + }); + }); + + test('renders error component on unsuccessful userTag assigned members query', async () => { + const { queryByText } = renderManageTag(link2); + + await wait(); + + await waitFor(() => { + expect(queryByText(translations.addPeopleToTag)).not.toBeInTheDocument(); + }); + }); + + test('opens and closes the add people to tag modal', async () => { + renderManageTag(link); + + await waitFor(() => { + expect(screen.getByTestId('addPeopleToTagBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('addPeopleToTagBtn')); + + await waitFor(() => { + expect(screen.getByTestId('addPeopleToTagModal')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('closeAddPeopleToTagModal')); + + await waitFor(() => { + expect( + screen.queryByTestId('addPeopleToTagModal'), + ).not.toBeInTheDocument(); + }); + }); + + test('opens and closes the unassign tag modal', async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('unassignTagBtn')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('unassignTagBtn')[0]); + + await waitFor(() => { + return expect( + screen.findByTestId('unassignTagModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('unassignTagModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('unassignTagModalCloseBtn'), + ); + }); + + test('opens and closes the assignToTags modal', async () => { + renderManageTag(link); + + // Wait for the assignToTags button to be present + await waitFor(() => { + expect(screen.getByTestId('assignToTags')).toBeInTheDocument(); + }); + + // Click the assignToTags button to open the modal + userEvent.click(screen.getByTestId('assignToTags')); + + // Wait for the close button in the modal to be present + await waitFor(() => { + expect(screen.getByTestId('closeTagActionsModalBtn')).toBeInTheDocument(); + }); + + // Click the close button to close the modal + userEvent.click(screen.getByTestId('closeTagActionsModalBtn')); + + // Wait for the modal to be removed from the document + await waitFor(() => { + expect(screen.queryByTestId('tagActionsModal')).not.toBeInTheDocument(); + }); + }); + + test('opens and closes the removeFromTags modal', async () => { + renderManageTag(link); + + // Wait for the removeFromTags button to be present + await waitFor(() => { + expect(screen.getByTestId('removeFromTags')).toBeInTheDocument(); + }); + + // Click the removeFromTags button to open the modal + userEvent.click(screen.getByTestId('removeFromTags')); + + // Wait for the close button in the modal to be present + await waitFor(() => { + expect(screen.getByTestId('closeTagActionsModalBtn')).toBeInTheDocument(); + }); + + // Click the close button to close the modal + userEvent.click(screen.getByTestId('closeTagActionsModalBtn')); + + // Wait for the modal to be removed from the document + await waitFor(() => { + expect(screen.queryByTestId('tagActionsModal')).not.toBeInTheDocument(); + }); + }); + + test('opens and closes the edit tag modal', async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('editUserTag')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('editUserTag')); + + await waitFor(() => { + return expect( + screen.findByTestId('closeEditTagModalBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('closeEditTagModalBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('closeEditTagModalBtn'), + ); + }); + + test('opens and closes the remove tag modal', async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('removeTag')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('removeTag')); + + await waitFor(() => { + return expect( + screen.findByTestId('removeUserTagModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('removeUserTagModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('removeUserTagModalCloseBtn'), + ); + }); + + test("navigates to the member's profile after clicking the view option", async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('viewProfileBtn')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('viewProfileBtn')[0]); + + await waitFor(() => { + expect(screen.getByTestId('memberProfileScreen')).toBeInTheDocument(); + }); + }); + + test('navigates to the subTags screen after clicking the subTags option', async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('subTagsBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('subTagsBtn')); + + await waitFor(() => { + expect(screen.getByTestId('subTagsScreen')).toBeInTheDocument(); + }); + }); + + test('navigates to the manageTag screen after clicking a tag in the breadcrumbs', async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect( + screen.getAllByTestId('redirectToManageTag')[0], + ).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('redirectToManageTag')[0]); + + await waitFor(() => { + expect(screen.getByTestId('addPeopleToTagBtn')).toBeInTheDocument(); + }); + }); + + test('navigates to organization tags screen screen after clicking tha all tags option in the breadcrumbs', async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('allTagsBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('allTagsBtn')); + + await waitFor(() => { + expect(screen.getByTestId('organizationTagsScreen')).toBeInTheDocument(); + }); + }); + + test('searchs for tags where the name matches the provided search input', async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect( + screen.getByPlaceholderText(translations.searchByName), + ).toBeInTheDocument(); + }); + const input = screen.getByPlaceholderText(translations.searchByName); + fireEvent.change(input, { target: { value: 'assigned user' } }); + + // should render the two users from the mock data + // where firstName starts with "assigned" and lastName starts with "user" + await waitFor(() => { + const buttons = screen.getAllByTestId('viewProfileBtn'); + expect(buttons.length).toEqual(2); + }); + }); + + test('fetches the tags by the sort order, i.e. latest or oldest first', async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect( + screen.getByPlaceholderText(translations.searchByName), + ).toBeInTheDocument(); + }); + const input = screen.getByPlaceholderText(translations.searchByName); + fireEvent.change(input, { target: { value: 'assigned user' } }); + + // should render the two searched tags from the mock data + // where name starts with "searchUserTag" + await waitFor(() => { + expect(screen.getAllByTestId('memberName')[0]).toHaveTextContent( + 'assigned user1', + ); + }); + + // now change the sorting order + await waitFor(() => { + expect(screen.getByTestId('sortPeople')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('sortPeople')); + + await waitFor(() => { + expect(screen.getByTestId('oldest')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('oldest')); + + // returns the tags in reverse order + await waitFor(() => { + expect(screen.getAllByTestId('memberName')[0]).toHaveTextContent( + 'assigned user2', + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('sortPeople')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('sortPeople')); + + await waitFor(() => { + expect(screen.getByTestId('latest')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('latest')); + + // reverse the order again + await waitFor(() => { + expect(screen.getAllByTestId('memberName')[0]).toHaveTextContent( + 'assigned user1', + ); + }); + }); + + test('Fetches more assigned members with infinite scroll', async () => { + const { getByText } = renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.addPeopleToTag)).toBeInTheDocument(); + }); + + const manageTagScrollableDiv = screen.getByTestId('manageTagScrollableDiv'); + + // Get the initial number of tags loaded + const initialAssignedMembersDataLength = + screen.getAllByTestId('viewProfileBtn').length; + + // Set scroll position to the bottom + fireEvent.scroll(manageTagScrollableDiv, { + target: { scrollY: manageTagScrollableDiv.scrollHeight }, + }); + + await waitFor(() => { + const finalAssignedMembersDataLength = + screen.getAllByTestId('viewProfileBtn').length; + expect(finalAssignedMembersDataLength).toBeGreaterThan( + initialAssignedMembersDataLength, + ); + + expect(getByText(translations.addPeopleToTag)).toBeInTheDocument(); + }); + }); + + test('unassigns a tag from a member', async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('unassignTagBtn')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('unassignTagBtn')[0]); + + userEvent.click(screen.getByTestId('unassignTagModalSubmitBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + translations.successfullyUnassigned, + ); + }); + }); + + test('successfully edits the tag name', async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('editUserTag')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('editUserTag')); + + userEvent.click(screen.getByTestId('editTagSubmitBtn')); + + await waitFor(() => { + expect(toast.info).toHaveBeenCalledWith(translations.changeNameToEdit); + }); + + const tagNameInput = screen.getByTestId('tagNameInput'); + await userEvent.clear(tagNameInput); + await userEvent.type(tagNameInput, 'tag 1 edited'); + expect(tagNameInput).toHaveValue('tag 1 edited'); + + userEvent.click(screen.getByTestId('editTagSubmitBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + translations.tagUpdationSuccess, + ); + }); + }); + + test('successfully removes the tag and redirects to orgTags page', async () => { + renderManageTag(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('removeTag')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('removeTag')); + + userEvent.click(screen.getByTestId('removeUserTagSubmitBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + translations.tagRemovalSuccess, + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('organizationTagsScreen')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/screens/ManageTag/ManageTag.tsx b/src/screens/ManageTag/ManageTag.tsx new file mode 100644 index 0000000000..428dad7981 --- /dev/null +++ b/src/screens/ManageTag/ManageTag.tsx @@ -0,0 +1,607 @@ +import type { FormEvent } from 'react'; +import React, { useEffect, useState } from 'react'; +import { useMutation, useQuery } from '@apollo/client'; +import { Search, WarningAmberRounded } from '@mui/icons-material'; +import SortIcon from '@mui/icons-material/Sort'; +import Loader from 'components/Loader/Loader'; +import IconComponent from 'components/IconComponent/IconComponent'; +import { useNavigate, useParams, Link } from 'react-router-dom'; +import { Col, Form } from 'react-bootstrap'; +import Button from 'react-bootstrap/Button'; +import Dropdown from 'react-bootstrap/Dropdown'; +import Row from 'react-bootstrap/Row'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import type { InterfaceQueryUserTagsAssignedMembers } from 'utils/interfaces'; +import styles from '../../style/app.module.css'; +import { DataGrid } from '@mui/x-data-grid'; +import type { + InterfaceTagAssignedMembersQuery, + SortedByType, + TagActionType, +} from 'utils/organizationTagsUtils'; +import { + TAGS_QUERY_DATA_CHUNK_SIZE, + dataGridStyle, +} from 'utils/organizationTagsUtils'; +import type { GridCellParams, GridColDef } from '@mui/x-data-grid'; +import { Stack } from '@mui/material'; +import { + REMOVE_USER_TAG, + UNASSIGN_USER_TAG, + UPDATE_USER_TAG, +} from 'GraphQl/Mutations/TagMutations'; +import { USER_TAGS_ASSIGNED_MEMBERS } from 'GraphQl/Queries/userTagQueries'; +import AddPeopleToTag from 'components/AddPeopleToTag/AddPeopleToTag'; +import TagActions from 'components/TagActions/TagActions'; +import InfiniteScroll from 'react-infinite-scroll-component'; +import InfiniteScrollLoader from 'components/InfiniteScrollLoader/InfiniteScrollLoader'; +import EditUserTagModal from './EditUserTagModal'; +import RemoveUserTagModal from './RemoveUserTagModal'; +import UnassignUserTagModal from './UnassignUserTagModal'; + +/** + * Component that renders the Manage Tag screen when the app navigates to '/orgtags/:orgId/manageTag/:tagId'. + */ + +function ManageTag(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'manageTag', + }); + const { t: tCommon } = useTranslation('common'); + const { orgId, tagId: currentTagId } = useParams(); + const navigate = useNavigate(); + + const [unassignUserTagModalIsOpen, setUnassignUserTagModalIsOpen] = + useState(false); + const [addPeopleToTagModalIsOpen, setAddPeopleToTagModalIsOpen] = + useState(false); + const [tagActionsModalIsOpen, setTagActionsModalIsOpen] = useState(false); + const [editUserTagModalIsOpen, setEditUserTagModalIsOpen] = useState(false); + const [removeUserTagModalIsOpen, setRemoveUserTagModalIsOpen] = + useState(false); + const [unassignUserId, setUnassignUserId] = useState(null); + const [assignedMemberSearchInput, setAssignedMemberSearchInput] = + useState(''); + const [assignedMemberSearchFirstName, setAssignedMemberSearchFirstName] = + useState(''); + const [assignedMemberSearchLastName, setAssignedMemberSearchLastName] = + useState(''); + const [assignedMemberSortOrder, setAssignedMemberSortOrder] = + useState('DESCENDING'); + // a state to specify whether we're assigning to tags or removing from tags + const [tagActionType, setTagActionType] = + useState('assignToTags'); + + const toggleRemoveUserTagModal = (): void => { + setRemoveUserTagModalIsOpen(!removeUserTagModalIsOpen); + }; + const showAddPeopleToTagModal = (): void => { + setAddPeopleToTagModalIsOpen(true); + }; + const hideAddPeopleToTagModal = (): void => { + setAddPeopleToTagModalIsOpen(false); + }; + const showTagActionsModal = (): void => { + setTagActionsModalIsOpen(true); + }; + const hideTagActionsModal = (): void => { + setTagActionsModalIsOpen(false); + }; + const showEditUserTagModal = (): void => { + setEditUserTagModalIsOpen(true); + }; + const hideEditUserTagModal = (): void => { + setEditUserTagModalIsOpen(false); + }; + + const { + data: userTagAssignedMembersData, + loading: userTagAssignedMembersLoading, + error: userTagAssignedMembersError, + refetch: userTagAssignedMembersRefetch, + fetchMore: fetchMoreAssignedMembers, + }: InterfaceTagAssignedMembersQuery = useQuery(USER_TAGS_ASSIGNED_MEMBERS, { + variables: { + id: currentTagId, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: assignedMemberSearchFirstName }, + lastName: { starts_with: assignedMemberSearchLastName }, + }, + sortedBy: { id: assignedMemberSortOrder }, + }, + fetchPolicy: 'no-cache', + }); + + const loadMoreAssignedMembers = (): void => { + fetchMoreAssignedMembers({ + variables: { + first: TAGS_QUERY_DATA_CHUNK_SIZE, + after: + userTagAssignedMembersData?.getAssignedUsers.usersAssignedTo.pageInfo + .endCursor, + }, + updateQuery: ( + prevResult: { getAssignedUsers: InterfaceQueryUserTagsAssignedMembers }, + { + fetchMoreResult, + }: { + fetchMoreResult: { + getAssignedUsers: InterfaceQueryUserTagsAssignedMembers; + }; + }, + ) => { + if (!fetchMoreResult) /* istanbul ignore next */ return prevResult; + + return { + getAssignedUsers: { + ...fetchMoreResult.getAssignedUsers, + usersAssignedTo: { + ...fetchMoreResult.getAssignedUsers.usersAssignedTo, + edges: [ + ...prevResult.getAssignedUsers.usersAssignedTo.edges, + ...fetchMoreResult.getAssignedUsers.usersAssignedTo.edges, + ], + }, + }, + }; + }, + }); + }; + + useEffect(() => { + const [firstName, ...lastNameParts] = assignedMemberSearchInput + .trim() + .split(/\s+/); + const lastName = lastNameParts.join(' '); // Joins everything after the first word + setAssignedMemberSearchFirstName(firstName); + setAssignedMemberSearchLastName(lastName); + }, [assignedMemberSearchInput]); + + const [unassignUserTag] = useMutation(UNASSIGN_USER_TAG); + + const handleUnassignUserTag = async (): Promise => { + try { + await unassignUserTag({ + variables: { + tagId: currentTagId, + userId: unassignUserId, + }, + }); + + userTagAssignedMembersRefetch(); + toggleUnassignUserTagModal(); + toast.success(t('successfullyUnassigned') as string); + } catch (error: unknown) { + /* istanbul ignore next */ + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + const [edit] = useMutation(UPDATE_USER_TAG); + + const [newTagName, setNewTagName] = useState(''); + const currentTagName = + userTagAssignedMembersData?.getAssignedUsers.name ?? ''; + + useEffect(() => { + setNewTagName(userTagAssignedMembersData?.getAssignedUsers.name ?? ''); + }, [userTagAssignedMembersData]); + + const handleEditUserTag = async ( + e: FormEvent, + ): Promise => { + e.preventDefault(); + + if (newTagName === currentTagName) { + toast.info(t('changeNameToEdit')); + return; + } + + try { + const { data } = await edit({ + variables: { + tagId: currentTagId, + name: newTagName, + }, + }); + + if (data) { + toast.success(t('tagUpdationSuccess')); + userTagAssignedMembersRefetch(); + setEditUserTagModalIsOpen(false); + } + } catch (error: unknown) { + /* istanbul ignore next */ + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + const [removeUserTag] = useMutation(REMOVE_USER_TAG); + const handleRemoveUserTag = async (): Promise => { + try { + await removeUserTag({ + variables: { + id: currentTagId, + }, + }); + + navigate(`/orgtags/${orgId}`); + toggleRemoveUserTagModal(); + toast.success(t('tagRemovalSuccess') as string); + } catch (error: unknown) { + /* istanbul ignore next */ + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + if (userTagAssignedMembersError) { + return ( +
    +
    + +
    + Error occured while loading assigned users +
    +
    +
    + ); + } + + const userTagAssignedMembers = + userTagAssignedMembersData?.getAssignedUsers.usersAssignedTo.edges.map( + (edge) => edge.node, + ) ?? /* istanbul ignore next */ []; + + // get the ancestorTags array and push the current tag in it + // used for the tag breadcrumbs + const orgUserTagAncestors = [ + ...(userTagAssignedMembersData?.getAssignedUsers.ancestorTags ?? []), + { + _id: currentTagId, + name: currentTagName, + }, + ]; + + const redirectToSubTags = (tagId: string): void => { + navigate(`/orgtags/${orgId}/subTags/${tagId}`); + }; + const redirectToManageTag = (tagId: string): void => { + navigate(`/orgtags/${orgId}/manageTag/${tagId}`); + }; + const toggleUnassignUserTagModal = (): void => { + if (unassignUserTagModalIsOpen) { + setUnassignUserId(null); + } + setUnassignUserTagModalIsOpen(!unassignUserTagModalIsOpen); + }; + + const columns: GridColDef[] = [ + { + field: 'id', + headerName: '#', + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return
    {params.row.id}
    ; + }, + }, + { + field: 'userName', + headerName: 'User Name', + flex: 2, + minWidth: 100, + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
    + {params.row.firstName + ' ' + params.row.lastName} +
    + ); + }, + }, + { + field: 'actions', + headerName: 'Actions', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
    + +
    + {t('viewProfile')} +
    + + + +
    + ); + }, + }, + ]; + + return ( + <> + +
    +
    +
    + + setAssignedMemberSearchInput(e.target.value.trim()) + } + data-testid="searchByName" + autoComplete="off" + /> + +
    +
    + + +
    + +
    + + {userTagAssignedMembersLoading ? ( + + ) : ( + + +
    +
    + +
    +
    navigate(`/orgtags/${orgId}`)} + className={`fs-6 ms-3 my-1 ${styles.tagsBreadCrumbs}`} + data-testid="allTagsBtn" + > + {'Tags'} + +
    + {orgUserTagAncestors?.map((tag, index) => ( +
    redirectToManageTag(tag._id as string)} + data-testid="redirectToManageTag" + > + {tag.name} + {orgUserTagAncestors.length - 1 !== index && ( + /* istanbul ignore next */ + + )} +
    + ))} +
    +
    + } + scrollableTarget="manageTagScrollableDiv" + > + row.id} + slots={{ + noRowsOverlay: /* istanbul ignore next */ () => ( + + {t('noAssignedMembersFound')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={userTagAssignedMembers?.map( + (assignedMembers, index) => ({ + id: index + 1, + ...assignedMembers, + }), + )} + columns={columns} + isRowSelectable={() => false} + /> + +
    + + +
    +
    {'Actions'}
    +
    +
    +
    { + setTagActionType('assignToTags'); + showTagActionsModal(); + }} + className="my-2 btn btn-primary btn-sm w-75" + data-testid="assignToTags" + > + {t('assignToTags')} +
    +
    { + setTagActionType('removeFromTags'); + showTagActionsModal(); + }} + className="mb-1 btn btn-danger btn-sm w-75" + data-testid="removeFromTags" + > + {t('removeFromTags')} +
    +
    +
    + {tCommon('edit')} +
    +
    + {tCommon('remove')} +
    +
    + +
    + )} +
    +
    + + {/* Add People To Tag Modal */} + + {/* Assign People To Tags Modal */} + + {/* Unassign User Tag Modal */} + + {/* Edit User Tag Modal */} + + {/* Remove User Tag Modal */} + + + ); +} +export default ManageTag; diff --git a/src/screens/ManageTag/ManageTagMockComponents/MockAddPeopleToTag.tsx b/src/screens/ManageTag/ManageTagMockComponents/MockAddPeopleToTag.tsx new file mode 100644 index 0000000000..7e62c47f86 --- /dev/null +++ b/src/screens/ManageTag/ManageTagMockComponents/MockAddPeopleToTag.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import type { InterfaceAddPeopleToTagProps } from '../../../components/AddPeopleToTag/AddPeopleToTag'; + +/** + * Component that mocks the AddPeopleToTag component for the Manage Tag screen. + */ + +const TEST_IDS = { + MODAL: 'addPeopleToTagModal', + CLOSE_BUTTON: 'closeAddPeopleToTagModal', +} as const; +const MockAddPeopleToTag: React.FC = ({ + addPeopleToTagModalIsOpen, + hideAddPeopleToTagModal, +}) => { + return ( + <> + {addPeopleToTagModalIsOpen && ( +
    + + +
    + )} + + ); +}; + +export default MockAddPeopleToTag; diff --git a/src/screens/ManageTag/ManageTagMockComponents/MockTagActions.tsx b/src/screens/ManageTag/ManageTagMockComponents/MockTagActions.tsx new file mode 100644 index 0000000000..3d2d6cc880 --- /dev/null +++ b/src/screens/ManageTag/ManageTagMockComponents/MockTagActions.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import type { InterfaceTagActionsProps } from '../../../components/TagActions/TagActions'; + +/** + * Component that mocks the TagActions component for the Manage Tag screen. + */ + +const MockTagActions: React.FC = ({ + tagActionsModalIsOpen, + hideTagActionsModal, +}) => { + return ( + <> + {tagActionsModalIsOpen && ( +
    +

    + Tag Actions +

    + +
    + )} + + ); +}; + +export default MockTagActions; diff --git a/src/screens/ManageTag/ManageTagMocks.ts b/src/screens/ManageTag/ManageTagMocks.ts new file mode 100644 index 0000000000..5ce1e62595 --- /dev/null +++ b/src/screens/ManageTag/ManageTagMocks.ts @@ -0,0 +1,336 @@ +import { + REMOVE_USER_TAG, + UNASSIGN_USER_TAG, + UPDATE_USER_TAG, +} from 'GraphQl/Mutations/TagMutations'; +import { USER_TAGS_ASSIGNED_MEMBERS } from 'GraphQl/Queries/userTagQueries'; +import { TAGS_QUERY_DATA_CHUNK_SIZE } from 'utils/organizationTagsUtils'; + +export const MOCKS = [ + { + request: { + query: USER_TAGS_ASSIGNED_MEMBERS, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: '' }, + lastName: { starts_with: '' }, + }, + sortedBy: { id: 'DESCENDING' }, + }, + }, + result: { + data: { + getAssignedUsers: { + name: 'tag1', + usersAssignedTo: { + edges: [ + { + node: { + _id: '1', + firstName: 'member', + lastName: '1', + }, + cursor: '1', + }, + { + node: { + _id: '2', + firstName: 'member', + lastName: '2', + }, + cursor: '2', + }, + { + node: { + _id: '3', + firstName: 'member', + lastName: '3', + }, + cursor: '3', + }, + { + node: { + _id: '4', + firstName: 'member', + lastName: '4', + }, + cursor: '4', + }, + { + node: { + _id: '5', + firstName: 'member', + lastName: '5', + }, + cursor: '5', + }, + { + node: { + _id: '6', + firstName: 'member', + lastName: '6', + }, + cursor: '6', + }, + { + node: { + _id: '7', + firstName: 'member', + lastName: '7', + }, + cursor: '7', + }, + { + node: { + _id: '8', + firstName: 'member', + lastName: '8', + }, + cursor: '8', + }, + { + node: { + _id: '9', + firstName: 'member', + lastName: '9', + }, + cursor: '9', + }, + { + node: { + _id: '10', + firstName: 'member', + lastName: '10', + }, + cursor: '10', + }, + ], + pageInfo: { + startCursor: '1', + endCursor: '10', + hasNextPage: true, + hasPreviousPage: false, + }, + totalCount: 12, + }, + ancestorTags: [], + }, + }, + }, + }, + { + request: { + query: USER_TAGS_ASSIGNED_MEMBERS, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + after: '10', + where: { + firstName: { starts_with: '' }, + lastName: { starts_with: '' }, + }, + sortedBy: { id: 'DESCENDING' }, + }, + }, + result: { + data: { + getAssignedUsers: { + name: 'tag1', + usersAssignedTo: { + edges: [ + { + node: { + _id: '11', + firstName: 'member', + lastName: '11', + }, + cursor: '11', + }, + { + node: { + _id: '12', + firstName: 'member', + lastName: '12', + }, + cursor: '12', + }, + ], + pageInfo: { + startCursor: '11', + endCursor: '12', + hasNextPage: false, + hasPreviousPage: true, + }, + totalCount: 12, + }, + ancestorTags: [], + }, + }, + }, + }, + { + request: { + query: USER_TAGS_ASSIGNED_MEMBERS, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: 'assigned' }, + lastName: { starts_with: 'user' }, + }, + sortedBy: { id: 'DESCENDING' }, + }, + }, + result: { + data: { + getAssignedUsers: { + name: 'tag1', + usersAssignedTo: { + edges: [ + { + node: { + _id: '1', + firstName: 'assigned', + lastName: 'user1', + }, + cursor: '1', + }, + { + node: { + _id: '2', + firstName: 'assigned', + lastName: 'user2', + }, + cursor: '2', + }, + ], + pageInfo: { + startCursor: '1', + endCursor: '2', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 2, + }, + ancestorTags: [], + }, + }, + }, + }, + { + request: { + query: USER_TAGS_ASSIGNED_MEMBERS, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: 'assigned' }, + lastName: { starts_with: 'user' }, + }, + sortedBy: { id: 'ASCENDING' }, + }, + }, + result: { + data: { + getAssignedUsers: { + name: 'tag1', + usersAssignedTo: { + edges: [ + { + node: { + _id: '2', + firstName: 'assigned', + lastName: 'user2', + }, + cursor: '2', + }, + { + node: { + _id: '1', + firstName: 'assigned', + lastName: 'user1', + }, + cursor: '1', + }, + ], + pageInfo: { + startCursor: '2', + endCursor: '1', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 2, + }, + ancestorTags: [], + }, + }, + }, + }, + { + request: { + query: UNASSIGN_USER_TAG, + variables: { + tagId: '1', + userId: '1', + }, + }, + result: { + data: { + unassignUserTag: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: UPDATE_USER_TAG, + variables: { + tagId: '1', + name: 'tag 1 edited', + }, + }, + result: { + data: { + updateUserTag: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: REMOVE_USER_TAG, + variables: { + id: '1', + }, + }, + result: { + data: { + removeUserTag: { + _id: '1', + }, + }, + }, + }, +]; + +export const MOCKS_ERROR_ASSIGNED_MEMBERS = [ + { + request: { + query: USER_TAGS_ASSIGNED_MEMBERS, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { + firstName: { starts_with: '' }, + lastName: { starts_with: '' }, + }, + sortedBy: { id: 'DESCENDING' }, + }, + }, + error: new Error('Mock Graphql Error'), + }, +]; diff --git a/src/screens/ManageTag/RemoveUserTagModal.tsx b/src/screens/ManageTag/RemoveUserTagModal.tsx new file mode 100644 index 0000000000..dc000f443c --- /dev/null +++ b/src/screens/ManageTag/RemoveUserTagModal.tsx @@ -0,0 +1,72 @@ +import type { TFunction } from 'i18next'; +import React from 'react'; +import { Button, Modal } from 'react-bootstrap'; + +/** + * Remove UserTag Modal component for the Manage Tag screen. + */ + +export interface InterfaceRemoveUserTagModalProps { + removeUserTagModalIsOpen: boolean; + toggleRemoveUserTagModal: () => void; + handleRemoveUserTag: () => Promise; + t: TFunction<'translation', 'manageTag'>; + tCommon: TFunction<'common', undefined>; +} + +const RemoveUserTagModal: React.FC = ({ + removeUserTagModalIsOpen, + toggleRemoveUserTagModal, + handleRemoveUserTag, + t, + tCommon, +}) => { + return ( + <> + + + + {t('removeUserTag')} + + + + {t('removeUserTagMessage')} + + + + + + + + ); +}; + +export default RemoveUserTagModal; diff --git a/src/screens/ManageTag/UnassignUserTagModal.tsx b/src/screens/ManageTag/UnassignUserTagModal.tsx new file mode 100644 index 0000000000..9d926790c3 --- /dev/null +++ b/src/screens/ManageTag/UnassignUserTagModal.tsx @@ -0,0 +1,80 @@ +import type { TFunction } from 'i18next'; +import React from 'react'; +import { Button, Modal } from 'react-bootstrap'; + +/** + * Unassign UserTag Modal component for the Manage Tag screen. + */ + +export interface InterfaceUnassignUserTagModalProps { + unassignUserTagModalIsOpen: boolean; + toggleUnassignUserTagModal: () => void; + handleUnassignUserTag: () => Promise; + t: TFunction<'translation', 'manageTag' | 'memberDetail'>; + tCommon: TFunction<'common', undefined>; +} + +const UnassignUserTagModal: React.FC = ({ + unassignUserTagModalIsOpen, + toggleUnassignUserTagModal, + handleUnassignUserTag, + t, + tCommon, +}) => { + return ( + <> + + + + {t('unassignUserTag')} + + + {t('unassignUserTagMessage')} + + + + + + + ); +}; + +export default UnassignUserTagModal; diff --git a/src/screens/MemberDetail/MemberDetail.module.css b/src/screens/MemberDetail/MemberDetail.module.css new file mode 100644 index 0000000000..7b421adcf8 --- /dev/null +++ b/src/screens/MemberDetail/MemberDetail.module.css @@ -0,0 +1,686 @@ +.mainpage { + display: flex; + flex-direction: row; +} + +.sidebar { + z-index: 0; + padding-top: 5px; + margin: 0; + height: 100%; +} + +.editIcon { + position: absolute; + top: 10px; + left: 20px; + cursor: pointer; +} +.selectWrapper { + position: relative; +} + +.selectWithChevron { + appearance: none; + padding-right: 30px; +} + +.selectWrapper::after { + content: '\25BC'; + position: absolute; + top: 50%; + right: 10px; + transform: translateY(-50%); + pointer-events: none; +} + +.sidebar:after { + content: ''; + background-color: #f7f7f7; + position: absolute; + width: 2px; + height: 600px; + top: 10px; + left: 94%; + display: block; +} + +.sidebarsticky { + padding: 0 2rem; + text-overflow: ellipsis; + /* overflow-x: hidden; */ +} + +/* .sidebarsticky:hover{ + overflow-x:visible; + transition: all 0.4s ease; + background-color: #707070; + +} */ +.sidebarsticky > p { + margin-top: -10px; +} + +.navitem { + padding-left: 27%; + padding-top: 12px; + padding-bottom: 12px; + cursor: pointer; +} + +.searchtitle { + color: #707070; + font-weight: 600; + font-size: 18px; + margin-top: 60px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 60%; +} + +.contact { + width: 100%; +} + +.sidebarsticky > input { + text-decoration: none; + margin-bottom: 50px; + border-color: #e8e5e5; + width: 80%; + border-radius: 7px; + padding-top: 5px; + padding-bottom: 5px; + padding-right: 10px; + padding-left: 10px; + box-shadow: none; +} + +.logintitle { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 30px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 30%; +} + +.logintitleadmin { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-top: 50px; + margin-bottom: 40px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 60%; +} +.cardBody { + height: 35vh; + overflow-y: scroll; +} + +.admindetails { + display: flex; + justify-content: space-between; +} + +.admindetails > p { + margin-top: -12px; + margin-right: 30px; +} + +.mainpageright > hr { + margin-top: 20px; + width: 100%; + margin-left: -15px; + margin-right: -15px; + margin-bottom: 20px; +} + +.justifysp { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + /* gap : 2px; */ +} + +.flexclm { + display: flex; + flex-direction: column; +} + +.btngroup { + display: flex; + gap: 2rem; + margin-bottom: 2rem; +} +@media screen and (max-width: 1200px) { + .justifysp { + padding-left: 55px; + display: flex; + justify-content: space-evenly; + } + + .mainpageright { + width: 100%; + } + + .invitebtn { + position: relative; + right: 15px; + } +} + +.invitebtn { + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + border-radius: 5px; + font-size: 16px; + height: 60%; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + background-color: #31bb6b; + margin-right: 13px; +} + +.flexdir { + display: flex; + flex-direction: row; + justify-content: space-between; + border: none; +} + +.form_wrapper { + margin-top: 27px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + position: absolute; + display: flex; + flex-direction: column; + width: 30%; + padding: 40px 30px; + background: #ffffff; + border-color: #e8e5e5; + border-width: 5px; + border-radius: 10px; + max-height: 86vh; + overflow: auto; +} + +.form_wrapper form { + display: flex; + align-items: left; + justify-content: left; + flex-direction: column; +} + +.titlemodal { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 65%; +} + +.checkboxdiv > label { + margin-right: 50px; +} + +.checkboxdiv > label > input { + margin-left: 10px; +} + +.orgphoto { + margin-top: 5px; +} + +.orgphoto > input { + margin-top: 10px; + cursor: pointer; + margin-bottom: 5px; +} + +.cancel > i { + margin-top: 5px; + transform: scale(1.2); + cursor: pointer; + color: #707070; +} + +.modalbody { + width: 50px; +} + +.greenregbtn { + margin: 1rem 0 0; + margin-top: 10px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 10px; + border-radius: 5px; + background-color: #31bb6b; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; +} +.whiteregbtn { + margin: 1rem 0 0; + margin-right: 2px; + margin-top: 10px; + border: 1px solid #31bb6b; + padding: 10px 10px; + border-radius: 5px; + background-color: white; + font-size: 16px; + color: #31bb6b; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; +} + +.loader, +.loader:after { + border-radius: 50%; + width: 10em; + height: 10em; +} + +.loader { + margin: 60px auto; + margin-top: 35vh !important; + font-size: 10px; + position: relative; + text-indent: -9999em; + border-top: 1.1em solid rgba(255, 255, 255, 0.2); + border-right: 1.1em solid rgba(255, 255, 255, 0.2); + border-bottom: 1.1em solid rgba(255, 255, 255, 0.2); + border-left: 1.1em solid #febc59; + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); + -webkit-animation: load8 1.1s infinite linear; + animation: load8 1.1s infinite linear; +} + +@-webkit-keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +@keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +.list_box { + height: 70vh; + overflow-y: auto; + width: auto; + padding-right: 50px; +} + +.dispflex { + display: flex; +} + +.dispflex > input { + width: 20%; + border: none; + box-shadow: none; + margin-top: 5px; +} + +.checkboxdiv { + display: flex; +} + +.checkboxdiv > div { + width: 50%; +} + +@media only screen and (max-width: 600px) { + .sidebar { + position: relative; + bottom: 18px; + } + + .invitebtn { + width: 135px; + position: relative; + right: 10px; + } + + .form_wrapper { + width: 90%; + } + + .searchtitle { + margin-top: 30px; + } +} + +/* User page */ + +.memberfontcreatedbtn { + border-radius: 7px; + border-color: #31bb6b; + background-color: #31bb6b; + color: white; + box-shadow: none; + height: 2.5rem; + width: max-content; + display: flex; + justify-content: center; + align-items: center; +} + +.userImage { + width: 180px; + height: 180px; + object-fit: cover; + border-radius: 8px; +} + +@media only screen and (max-width: 1200px) { + .userImage { + width: 100px; + height: 100px; + } +} + +.activeBtn { + width: 100%; + display: flex; + color: #fff; + border: 1px solid #000; + background-color: #31bb6b; + transition: 0.5s; +} + +.activeBtn:hover { + color: #fff; + background: #23864c; + transition: 0.5s; +} + +.inactiveBtn { + width: 100%; + display: flex; + color: #31bb6b; + border: 1px solid #31bb6a60; + background-color: #fff; + transition: 0.5s; +} + +.inactiveBtn:hover { + color: #fff; + background: #31bb6b; + transition: 0.5s; +} + +.sidebarsticky > button { + display: flex; + align-items: center; + text-align: start; + padding: 0 1.5rem; + height: 3.25rem; + margin: 0 0 1.5rem 0; + font-weight: bold; + border-radius: 50px; +} + +.bgFill { + height: 2rem; + width: 2rem; + border-radius: 50%; + margin-right: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.activeBtn .bgFill { + background-color: #fff; +} + +.activeBtn i { + color: #31bb6b; +} + +.inactiveBtn .bgFill { + background-color: #31bb6b; +} + +.inactiveBtn:hover .bgFill { + background-color: #fff; +} + +.inactiveBtn i { + color: #fff; +} + +.inactiveBtn:hover i { + color: #31bb6b; +} + +.topRadius { + border-top-left-radius: 16px; + border-top-right-radius: 16px; +} +.eventContainer { + display: flex; + align-items: start; +} + +.eventDetailsBox { + position: relative; + box-sizing: border-box; + background: #ffffff; + width: 66%; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1); + border-radius: 20px; + margin-bottom: 0; + margin-top: 20px; +} +.ctacards { + padding: 20px; + width: 100%; + display: flex; + margin: 0 4px; + justify-content: space-between; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1); + align-items: center; + border-radius: 20px; +} +.ctacards span { + color: rgb(181, 181, 181); + font-size: small; +} +/* .eventDetailsBox::before { + content: ''; + position: absolute; + top: 0; + height: 100%; + width: 6px; + background-color: #31bb6b; + border-radius: 20px; +} */ + +.time { + display: flex; + justify-content: space-between; + padding: 15px; + padding-bottom: 0px; + width: 33%; + + box-sizing: border-box; + background: #ffffff; + box-shadow: 0 3px 8px rgba(0, 0, 0, 0.1); + border-radius: 20px; + margin-bottom: 0; + margin-top: 20px; + margin-left: 10px; +} + +.startTime, +.endTime { + display: flex; + font-size: 20px; +} + +.to { + padding-right: 10px; +} + +.startDate, +.endDate { + color: #808080; + font-size: 14px; +} + +.titlename { + font-weight: 600; + font-size: 25px; + padding: 15px; + padding-bottom: 0px; + width: 50%; +} + +.description { + color: #737373; + font-weight: 300; + font-size: 14px; + word-wrap: break-word; + padding: 15px; + padding-bottom: 0px; +} + +.toporgloc { + font-size: 16px; + padding: 15px; + padding-bottom: 0px; +} + +.toporgloc span { + color: #737373; +} + +.inputColor { + background: #f1f3f6; +} +.cardHeader { + display: flex; + justify-content: space-between; + align-items: center; +} +.width60 { + width: 60%; +} + +.maxWidth40 { + max-width: 40%; +} +.maxWidth50 { + max-width: 50%; +} + +.allRound { + border-radius: 16px; +} + +.WidthFit { + width: fit-content; +} + +.datebox { + border-radius: 7px; + border-color: #e8e5e5; + outline: none; + box-shadow: none; + padding-top: 2px; + padding-bottom: 2px; + padding-right: 5px; + padding-left: 5px; + margin-right: 5px; + margin-left: 5px; +} + +.datebox > div > input { + padding: 0.5rem 0 0.5rem 0.5rem !important; /* top, right, bottom, left */ + background-color: #f1f3f6; + border-radius: var(--bs-border-radius) !important; + border: none !important; +} + +.datebox > div > div { + margin-left: 0px !important; +} + +.datebox > div > fieldset { + border: none !important; + /* background-color: #f1f3f6; */ + border-radius: var(--bs-border-radius) !important; +} + +.datebox > div { + margin: 0.5rem !important; + background-color: #f1f3f6; +} + +input::file-selector-button { + background-color: black; + color: white; +} + +.noOutline { + outline: none; +} + +.Outline { + outline: 1px solid var(--bs-gray-400); +} + +.tagLink { + font-weight: 600; + color: var(--bs-gray-700); + cursor: pointer; +} + +.tagLink:hover { + font-weight: 800; + color: var(--bs-blue); + text-decoration: underline; +} diff --git a/src/screens/MemberDetail/MemberDetail.test.tsx b/src/screens/MemberDetail/MemberDetail.test.tsx new file mode 100644 index 0000000000..b0514701f5 --- /dev/null +++ b/src/screens/MemberDetail/MemberDetail.test.tsx @@ -0,0 +1,400 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import userEvent from '@testing-library/user-event'; +import { BrowserRouter, MemoryRouter, Route, Routes } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import { I18nextProvider } from 'react-i18next'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import MemberDetail, { getLanguageName, prettyDate } from './MemberDetail'; +import { MOCKS1, MOCKS2, MOCKS3 } from './MemberDetailMocks'; +import type { ApolloLink } from '@apollo/client'; +import { toast } from 'react-toastify'; + +const link1 = new StaticMockLink(MOCKS1, true); +const link2 = new StaticMockLink(MOCKS2, true); +const link3 = new StaticMockLink(MOCKS3, true); + +async function wait(ms = 500): Promise { + await act(() => new Promise((resolve) => setTimeout(resolve, ms))); +} + +const translations = { + ...JSON.parse( + JSON.stringify( + i18nForTest.getDataByLanguage('en')?.translation.memberDetail ?? {}, + ), + ), + ...JSON.parse( + JSON.stringify(i18nForTest.getDataByLanguage('en')?.common ?? {}), + ), + ...JSON.parse( + JSON.stringify(i18nForTest.getDataByLanguage('en')?.errors ?? {}), + ), +}; + +jest.mock('@mui/x-date-pickers/DateTimePicker', () => { + return { + DateTimePicker: jest.requireActual( + '@mui/x-date-pickers/DesktopDateTimePicker', + ).DesktopDateTimePicker, + }; +}); + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const props = { + id: 'rishav-jha-mech', +}; + +const renderMemberDetailScreen = (link: ApolloLink): RenderResult => { + return render( + + + + + + } + /> + } + /> + + + + + , + ); +}; + +describe('MemberDetail', () => { + global.alert = jest.fn(); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + test('should render the elements', async () => { + renderMemberDetailScreen(link1); + + await wait(); + + expect(screen.queryByText('Loading data...')).not.toBeInTheDocument(); + expect(screen.getAllByText(/Email/i)).toBeTruthy(); + expect(screen.getAllByText(/First name/i)).toBeTruthy(); + expect(screen.getAllByText(/Last name/i)).toBeTruthy(); + // expect(screen.getAllByText(/Language/i)).toBeTruthy(); + // expect(screen.getByText(/Plugin creation allowed/i)).toBeInTheDocument(); + // expect(screen.getAllByText(/Joined on/i)).toBeTruthy(); + // expect(screen.getAllByText(/Joined On/i)).toHaveLength(1); + expect(screen.getAllByText(/Profile Details/i)).toHaveLength(1); + // expect(screen.getAllByText(/Actions/i)).toHaveLength(1); + expect(screen.getAllByText(/Contact Information/i)).toHaveLength(1); + expect(screen.getAllByText(/Events Attended/i)).toHaveLength(2); + }); + + test('prettyDate function should work properly', () => { + // If the date is provided + const datePretty = jest.fn(prettyDate); + expect(datePretty('2023-02-18T09:22:27.969Z')).toBe( + prettyDate('2023-02-18T09:22:27.969Z'), + ); + // If there's some error in formatting the date + expect(datePretty('')).toBe('Unavailable'); + }); + + test('getLanguageName function should work properly', () => { + const getLangName = jest.fn(getLanguageName); + // If the language code is provided + expect(getLangName('en')).toBe('English'); + // If the language code is not provided + expect(getLangName('')).toBe('Unavailable'); + }); + + test('should render props and text elements test for the page component', async () => { + const formData = { + firstName: 'Ansh', + lastName: 'Goyal', + email: 'ansh@gmail.com', + image: new File(['hello'], 'hello.png', { type: 'image/png' }), + address: 'abc', + countryCode: 'IN', + state: 'abc', + city: 'abc', + phoneNumber: '1234567890', + birthDate: '03/28/2022', + }; + renderMemberDetailScreen(link2); + + await wait(); + + expect(screen.queryByText('Loading data...')).not.toBeInTheDocument(); + expect(screen.getAllByText(/Email/i)).toBeTruthy(); + expect(screen.getByText('User')).toBeInTheDocument(); + const birthDateDatePicker = screen.getByTestId('birthDate'); + fireEvent.change(birthDateDatePicker, { + target: { value: formData.birthDate }, + }); + + userEvent.clear(screen.getByPlaceholderText(/First Name/i)); + userEvent.type( + screen.getByPlaceholderText(/First Name/i), + formData.firstName, + ); + + userEvent.clear(screen.getByPlaceholderText(/Last Name/i)); + userEvent.type( + screen.getByPlaceholderText(/Last Name/i), + formData.lastName, + ); + + userEvent.clear(screen.getByPlaceholderText(/Address/i)); + userEvent.type(screen.getByPlaceholderText(/Address/i), formData.address); + + userEvent.clear(screen.getByPlaceholderText(/Country Code/i)); + userEvent.type( + screen.getByPlaceholderText(/Country Code/i), + formData.countryCode, + ); + + userEvent.clear(screen.getByPlaceholderText(/State/i)); + userEvent.type(screen.getByPlaceholderText(/State/i), formData.state); + + userEvent.clear(screen.getByPlaceholderText(/City/i)); + userEvent.type(screen.getByPlaceholderText(/City/i), formData.city); + + userEvent.clear(screen.getByPlaceholderText(/Email/i)); + userEvent.type(screen.getByPlaceholderText(/Email/i), formData.email); + + userEvent.clear(screen.getByPlaceholderText(/Phone/i)); + userEvent.type(screen.getByPlaceholderText(/Phone/i), formData.phoneNumber); + + // userEvent.click(screen.getByPlaceholderText(/pluginCreationAllowed/i)); + // userEvent.selectOptions(screen.getByTestId('applangcode'), 'Français'); + // userEvent.upload(screen.getByLabelText(/Display Image:/i), formData.image); + await wait(); + + userEvent.click(screen.getByText(/Save Changes/i)); + + expect(screen.getByPlaceholderText(/First Name/i)).toHaveValue( + formData.firstName, + ); + expect(screen.getByPlaceholderText(/Last Name/i)).toHaveValue( + formData.lastName, + ); + expect(birthDateDatePicker).toHaveValue(formData.birthDate); + expect(screen.getByPlaceholderText(/Email/i)).toHaveValue(formData.email); + expect(screen.getByPlaceholderText(/First Name/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/Last Name/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/Email/i)).toBeInTheDocument(); + // expect(screen.getByText(/Display Image/i)).toBeInTheDocument(); + }); + + test('display admin', async () => { + renderMemberDetailScreen(link1); + await wait(); + expect(screen.queryByText('Loading data...')).not.toBeInTheDocument(); + expect(screen.getByText('Admin')).toBeInTheDocument(); + }); + + test('display super admin', async () => { + renderMemberDetailScreen(link3); + await wait(); + expect(screen.queryByText('Loading data...')).not.toBeInTheDocument(); + expect(screen.getByText('Super Admin')).toBeInTheDocument(); + }); + + test('Should display dicebear image if image is null', async () => { + renderMemberDetailScreen(link1); + + expect(screen.queryByText('Loading data...')).not.toBeInTheDocument(); + + const dicebearUrl = `mocked-data-uri`; + + const userImage = await screen.findByTestId('userImageAbsent'); + expect(userImage).toBeInTheDocument(); + expect(userImage.getAttribute('src')).toBe(dicebearUrl); + }); + + test('Should display image if image is present', async () => { + renderMemberDetailScreen(link2); + + expect(screen.queryByText('Loading data...')).not.toBeInTheDocument(); + + const user = MOCKS2[0].result?.data?.user?.user; + const userImage = await screen.findByTestId('userImagePresent'); + expect(userImage).toBeInTheDocument(); + expect(userImage.getAttribute('src')).toBe(user?.image); + }); + + test('resetChangesBtn works properly', async () => { + renderMemberDetailScreen(link1); + + await waitFor(() => { + expect(screen.getByPlaceholderText(/Address/i)).toBeInTheDocument(); + }); + + userEvent.type(screen.getByPlaceholderText(/Address/i), 'random'); + userEvent.type(screen.getByPlaceholderText(/State/i), 'random'); + + userEvent.click(screen.getByTestId('resetChangesBtn')); + await wait(); + expect(screen.getByPlaceholderText(/First Name/i)).toHaveValue('Aditya'); + expect(screen.getByPlaceholderText(/Last Name/i)).toHaveValue('Agarwal'); + expect(screen.getByPlaceholderText(/Phone/i)).toHaveValue(''); + expect(screen.getByPlaceholderText(/Address/i)).toHaveValue(''); + expect(screen.getByPlaceholderText(/State/i)).toHaveValue(''); + expect(screen.getByPlaceholderText(/Country Code/i)).toHaveValue(''); + expect(screen.getByTestId('birthDate')).toHaveValue('03/14/2024'); + }); + + test('should be redirected to / if member id is undefined', async () => { + render( + + + + + + + + + , + ); + expect(window.location.pathname).toEqual('/'); + }); + + test('renders events attended card correctly and show a message', async () => { + renderMemberDetailScreen(link3); + await waitFor(() => { + expect(screen.getByText('Events Attended')).toBeInTheDocument(); + }); + // Check for empty state immediately + expect(screen.getByText('No Events Attended')).toBeInTheDocument(); + }); + + test('opens "Events Attended List" modal when View All button is clicked', async () => { + renderMemberDetailScreen(link2); + + await wait(); + + // Find and click the "View All" button + const viewAllButton = screen.getByText('View All'); + userEvent.click(viewAllButton); + + // Check if the modal with the title "Events Attended List" is now visible + const modalTitle = await screen.findByText('Events Attended List'); + expect(modalTitle).toBeInTheDocument(); + }); + + test('lists all the tags assigned to the user', async () => { + renderMemberDetailScreen(link1); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('tagName')).toHaveLength(10); + }); + }); + + test('navigates to manage tag screen after clicking manage tag option', async () => { + renderMemberDetailScreen(link1); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('tagName')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('tagName')[0]); + + await waitFor(() => { + expect(screen.getByTestId('manageTagScreen')).toBeInTheDocument(); + }); + }); + + test('loads more assigned tags with infinite scroll', async () => { + renderMemberDetailScreen(link1); + + await wait(); + + // now scroll to the bottom of the div + const tagsAssignedScrollableDiv = screen.getByTestId( + 'tagsAssignedScrollableDiv', + ); + + // Get the initial number of tags loaded + const initialTagsAssignedLength = screen.getAllByTestId('tagName').length; + + // Set scroll position to the bottom + fireEvent.scroll(tagsAssignedScrollableDiv, { + target: { scrollY: tagsAssignedScrollableDiv.scrollHeight }, + }); + + await waitFor(() => { + const finalTagsAssignedLength = screen.getAllByTestId('tagName').length; + expect(finalTagsAssignedLength).toBeGreaterThan( + initialTagsAssignedLength, + ); + }); + }); + + test('opens and closes the unassign tag modal', async () => { + renderMemberDetailScreen(link1); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('unassignTagBtn')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('unassignTagBtn')[0]); + + await waitFor(() => { + return expect( + screen.findByTestId('unassignTagModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('unassignTagModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('unassignTagModalCloseBtn'), + ); + }); + + test('unassigns a tag from a member', async () => { + renderMemberDetailScreen(link1); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('unassignTagBtn')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('unassignTagBtn')[0]); + + userEvent.click(screen.getByTestId('unassignTagModalSubmitBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + translations.successfullyUnassigned, + ); + }); + }); +}); diff --git a/src/screens/MemberDetail/MemberDetail.tsx b/src/screens/MemberDetail/MemberDetail.tsx new file mode 100644 index 0000000000..cd552c80a0 --- /dev/null +++ b/src/screens/MemberDetail/MemberDetail.tsx @@ -0,0 +1,762 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { useMutation, useQuery } from '@apollo/client'; +import Button from 'react-bootstrap/Button'; +import { useTranslation } from 'react-i18next'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import styles from './MemberDetail.module.css'; +import { languages } from 'utils/languages'; +import { UPDATE_USER_MUTATION } from 'GraphQl/Mutations/mutations'; +import { USER_DETAILS } from 'GraphQl/Queries/Queries'; +import { toast } from 'react-toastify'; +import { errorHandler } from 'utils/errorHandler'; +import { Card, Row, Col } from 'react-bootstrap'; +import Loader from 'components/Loader/Loader'; +import useLocalStorage from 'utils/useLocalstorage'; +import Avatar from 'components/Avatar/Avatar'; +import EventsAttendedByMember from '../../components/MemberDetail/EventsAttendedByMember'; +import MemberAttendedEventsModal from '../../components/MemberDetail/EventsAttendedMemberModal'; +import { DatePicker, LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import convertToBase64 from 'utils/convertToBase64'; +import type { Dayjs } from 'dayjs'; +import dayjs from 'dayjs'; +import { + educationGradeEnum, + maritalStatusEnum, + genderEnum, + employmentStatusEnum, +} from 'utils/formEnumFields'; +import DynamicDropDown from 'components/DynamicDropDown/DynamicDropDown'; +import type { InterfaceEvent } from 'components/EventManagement/EventAttendance/InterfaceEvents'; +import type { InterfaceTagData } from 'utils/interfaces'; +import InfiniteScroll from 'react-infinite-scroll-component'; +import InfiniteScrollLoader from 'components/InfiniteScrollLoader/InfiniteScrollLoader'; +import UnassignUserTagModal from 'screens/ManageTag/UnassignUserTagModal'; +import { UNASSIGN_USER_TAG } from 'GraphQl/Mutations/TagMutations'; +import { TAGS_QUERY_DATA_CHUNK_SIZE } from 'utils/organizationTagsUtils'; + +type MemberDetailProps = { + id?: string; +}; + +/** + * MemberDetail component is used to display the details of a user. + * It also allows the user to update the details. It uses the UPDATE_USER_MUTATION to update the user details. + * It uses the USER_DETAILS query to get the user details. It uses the useLocalStorage hook to store the user + * details in the local storage. + * @param id - The id of the user whose details are to be displayed. + * @returns React component + * + */ +const MemberDetail: React.FC = ({ id }): JSX.Element => { + const { t } = useTranslation('translation', { + keyPrefix: 'memberDetail', + }); + const fileInputRef = useRef(null); + const { t: tCommon } = useTranslation('common'); + const location = useLocation(); + const isMounted = useRef(true); + const { getItem, setItem } = useLocalStorage(); + const [show, setShow] = useState(false); + const currentUrl = location.state?.id || getItem('id') || id; + + const { orgId } = useParams(); + const navigate = useNavigate(); + + const [unassignUserTagModalIsOpen, setUnassignUserTagModalIsOpen] = + useState(false); + + document.title = t('title'); + const [formState, setFormState] = useState({ + firstName: '', + lastName: '', + email: '', + appLanguageCode: '', + image: '', + gender: '', + birthDate: '2024-03-14', + grade: '', + empStatus: '', + maritalStatus: '', + phoneNumber: '', + address: '', + state: '', + city: '', + country: '', + pluginCreationAllowed: false, + }); + const handleDateChange = (date: Dayjs | null): void => { + if (date) { + setisUpdated(true); + setFormState((prevState) => ({ + ...prevState, + birthDate: dayjs(date).format('YYYY-MM-DD'), + })); + } + }; + + /*istanbul ignore next*/ + const handleEditIconClick = (): void => { + fileInputRef.current?.click(); + }; + const [updateUser] = useMutation(UPDATE_USER_MUTATION); + const { + data: user, + loading, + refetch: refetchUserDetails, + fetchMore: fetchMoreAssignedTags, + } = useQuery(USER_DETAILS, { + variables: { id: currentUrl, first: TAGS_QUERY_DATA_CHUNK_SIZE }, + }); + const userData = user?.user; + const [isUpdated, setisUpdated] = useState(false); + useEffect(() => { + if (userData && isMounted.current) { + setFormState({ + ...formState, + firstName: userData?.user?.firstName, + lastName: userData?.user?.lastName, + email: userData?.user?.email, + appLanguageCode: userData?.appUserProfile?.appLanguageCode, + gender: userData?.user?.gender, + birthDate: userData?.user?.birthDate || ' ', + grade: userData?.user?.educationGrade, + empStatus: userData?.user?.employmentStatus, + maritalStatus: userData?.user?.maritalStatus, + phoneNumber: userData?.user?.phone?.mobile, + address: userData.user?.address?.line1, + state: userData?.user?.address?.state, + city: userData?.user?.address?.city, + country: userData?.user?.address?.countryCode, + pluginCreationAllowed: userData?.appUserProfile?.pluginCreationAllowed, + image: userData?.user?.image || '', + }); + } + }, [userData, user]); + useEffect(() => { + // check component is mounted or not + return () => { + isMounted.current = false; + }; + }, []); + + const tagsAssigned = + userData?.user?.tagsAssignedWith.edges.map( + (edge: { node: InterfaceTagData; cursor: string }) => edge.node, + ) ?? /* istanbul ignore next */ []; + + const loadMoreAssignedTags = (): void => { + fetchMoreAssignedTags({ + variables: { + first: TAGS_QUERY_DATA_CHUNK_SIZE, + after: + user?.user?.user?.tagsAssignedWith?.pageInfo?.endCursor ?? + /* istanbul ignore next */ + null, + }, + updateQuery: (prevResult, { fetchMoreResult }) => { + if (!fetchMoreResult) /* istanbul ignore next */ return prevResult; + + return { + user: { + ...prevResult.user, + user: { + ...prevResult.user.user, + tagsAssignedWith: { + edges: [ + ...prevResult.user.user.tagsAssignedWith.edges, + ...fetchMoreResult.user.user.tagsAssignedWith.edges, + ], + pageInfo: fetchMoreResult.user.user.tagsAssignedWith.pageInfo, + totalCount: fetchMoreResult.user.user.tagsAssignedWith.pageInfo, + }, + }, + }, + }; + }, + }); + }; + + const [unassignUserTag] = useMutation(UNASSIGN_USER_TAG); + const [unassignTagId, setUnassignTagId] = useState(null); + + const handleUnassignUserTag = async (): Promise => { + try { + await unassignUserTag({ + variables: { + tagId: unassignTagId, + userId: currentUrl, + }, + }); + + refetchUserDetails(); + toggleUnassignUserTagModal(); + toast.success(t('successfullyUnassigned')); + } catch (error: unknown) { + /* istanbul ignore next */ + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + const toggleUnassignUserTagModal = (): void => { + if (unassignUserTagModalIsOpen) { + setUnassignTagId(null); + } + setUnassignUserTagModalIsOpen(!unassignUserTagModalIsOpen); + }; + + const handleChange = async ( + e: React.ChangeEvent, + ): Promise => { + const { name, value } = e.target; + /*istanbul ignore next*/ + if ( + name === 'photo' && + 'files' in e.target && + e.target.files && + e.target.files[0] + ) { + const file = e.target.files[0]; + const base64 = await convertToBase64(file); + setFormState((prevState) => ({ + ...prevState, + image: base64 as string, + })); + } else { + setFormState((prevState) => ({ + ...prevState, + [name]: value, + })); + } + setisUpdated(true); + }; + const handleEventsAttendedModal = (): void => { + setShow(!show); + }; + + const loginLink = async (): Promise => { + try { + const firstName = formState.firstName; + const lastName = formState.lastName; + const email = formState.email; + // const appLanguageCode = formState.appLanguageCode; + const image = formState.image; + // const gender = formState.gender; + try { + const { data } = await updateUser({ + variables: { + id: currentUrl, + ...formState, + }, + }); + /* istanbul ignore next */ + if (data) { + setisUpdated(false); + if (getItem('id') === currentUrl) { + setItem('FirstName', firstName); + setItem('LastName', lastName); + setItem('Email', email); + setItem('UserImage', image); + } + toast.success(tCommon('successfullyUpdated') as string); + } + } catch (error: unknown) { + if (error instanceof Error) { + errorHandler(t, error); + } + } + } catch (error: unknown) { + /* istanbul ignore next */ + if (error instanceof Error) { + errorHandler(t, error); + } + } + }; + const resetChanges = (): void => { + /*istanbul ignore next*/ + setFormState({ + firstName: userData?.user?.firstName || '', + lastName: userData?.user?.lastName || '', + email: userData?.user?.email || '', + appLanguageCode: userData?.appUserProfile?.appLanguageCode || '', + image: userData?.user?.image || '', + gender: userData?.user?.gender || '', + empStatus: userData?.user?.employmentStatus || '', + maritalStatus: userData?.user?.maritalStatus || '', + phoneNumber: userData?.user?.phone?.mobile || '', + address: userData?.user?.address?.line1 || '', + country: userData?.user?.address?.countryCode || '', + city: userData?.user?.address?.city || '', + state: userData?.user?.address?.state || '', + birthDate: userData?.user?.birthDate || '', + grade: userData?.user?.educationGrade || '', + pluginCreationAllowed: + userData?.appUserProfile?.pluginCreationAllowed || false, + }); + setisUpdated(false); + }; + + if (loading) { + return ; + } + + return ( + + {show && ( + + )} + + + + +

    {t('personalDetailsHeading')}

    + +
    + +
    + {formState?.image ? ( +
    + User + e.key === 'Enter' && handleEditIconClick() + } + /> +
    + ) : ( +
    + + +
    + )} + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    + + + + +

    {t('contactInfoHeading')}

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + {isUpdated && ( + + + + + + + )} +
    + + + + + +

    + {t('tagsAssigned')} +

    +
    + + {!tagsAssigned.length ? ( +
    + {t('noTagsAssigned')} +
    + ) : ( + } + scrollableTarget="tagsAssignedScrollableDiv" + > + {tagsAssigned.map((tag: InterfaceTagData, index: number) => ( +
    +
    +
    + navigate(`/orgtags/${orgId}/manageTag/${tag._id}`) + } + > + {tag.parentTag ? ( + <> + + ... + + ) : ( + + )} + {tag.name} +
    + +
    + {index + 1 !== tagsAssigned.length && ( +
    + )} +
    + ))} +
    + )} +
    +
    + + + + + +

    + {t('eventsAttended')} +

    + +
    + + {!userData?.user.eventsAttended?.length ? ( +
    + {t('noeventsAttended')} +
    + ) : ( + userData.user.eventsAttended.map( + (event: InterfaceEvent, index: number) => ( + + + + ), + ) + )} +
    +
    + +
    + + {/* Unassign User Tag Modal */} + +
    + ); +}; + +export const prettyDate = (param: string): string => { + const date = new Date(param); + if (date?.toDateString() === 'Invalid Date') { + return 'Unavailable'; + } + const day = date.getDate(); + const month = date.toLocaleString('default', { month: 'long' }); + const year = date.getFullYear(); + return `${day} ${month} ${year}`; +}; +export const getLanguageName = (code: string): string => { + let language = 'Unavailable'; + languages.map((data) => { + if (data.code == code) { + language = data.name; + } + }); + return language; +}; + +export default MemberDetail; diff --git a/src/screens/MemberDetail/MemberDetailMocks.ts b/src/screens/MemberDetail/MemberDetailMocks.ts new file mode 100644 index 0000000000..df72ee703c --- /dev/null +++ b/src/screens/MemberDetail/MemberDetailMocks.ts @@ -0,0 +1,536 @@ +import { UNASSIGN_USER_TAG } from 'GraphQl/Mutations/TagMutations'; +import { USER_DETAILS } from 'GraphQl/Queries/Queries'; +import { TAGS_QUERY_DATA_CHUNK_SIZE } from 'utils/organizationTagsUtils'; + +export const MOCKS1 = [ + { + request: { + query: USER_DETAILS, + variables: { + id: 'rishav-jha-mech', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + }, + }, + result: { + data: { + user: { + __typename: 'UserData', + appUserProfile: { + _id: '1', + __typename: 'AppUserProfile', + adminFor: [ + { + __typename: 'Organization', + _id: '65e0df0906dd1228350cfd4a', + }, + { + __typename: 'Organization', + _id: '65e0e2abb92c9f3e29503d4e', + }, + ], + isSuperAdmin: false, + appLanguageCode: 'en', + createdEvents: [ + { + __typename: 'Event', + _id: '65e32a5b2a1f4288ca1f086a', + }, + ], + createdOrganizations: [ + { + __typename: 'Organization', + _id: '65e0df0906dd1228350cfd4a', + }, + { + __typename: 'Organization', + _id: '65e0e2abb92c9f3e29503d4e', + }, + ], + eventAdmin: [ + { + __typename: 'Event', + _id: '65e32a5b2a1f4288ca1f086a', + }, + ], + pluginCreationAllowed: true, + }, + user: { + _id: '1', + __typename: 'User', + createdAt: '2024-02-26T10:36:33.098Z', + email: 'adi790u@gmail.com', + firstName: 'Aditya', + image: null, + lastName: 'Agarwal', + gender: '', + birthDate: '2024-03-14', + educationGrade: '', + employmentStatus: '', + maritalStatus: '', + address: { + line1: '', + countryCode: '', + city: '', + state: '', + }, + phone: { + mobile: '', + }, + eventsAttended: [], + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '65e0df0906dd1228350cfd4a', + }, + { + __typename: 'Organization', + _id: '65e0e2abb92c9f3e29503d4e', + }, + ], + tagsAssignedWith: { + edges: [ + { + node: { + _id: '1', + name: 'userTag 1', + parentTag: null, + }, + cursor: '1', + }, + { + node: { + _id: '2', + name: 'userTag 2', + parentTag: null, + }, + cursor: '2', + }, + { + node: { + _id: '3', + name: 'userTag 3', + parentTag: null, + }, + cursor: '3', + }, + { + node: { + _id: '4', + name: 'userTag 4', + parentTag: null, + }, + cursor: '4', + }, + { + node: { + _id: '5', + name: 'userTag 5', + parentTag: null, + }, + cursor: '5', + }, + { + node: { + _id: '6', + name: 'userTag 6', + parentTag: null, + }, + cursor: '6', + }, + { + node: { + _id: '7', + name: 'userTag 7', + parentTag: null, + }, + cursor: '7', + }, + { + node: { + _id: '8', + name: 'userTag 8', + parentTag: null, + }, + cursor: '8', + }, + { + node: { + _id: '9', + name: 'userTag 9', + parentTag: null, + }, + cursor: '9', + }, + { + node: { + _id: '10', + name: 'userTag 10', + parentTag: null, + }, + cursor: '10', + }, + ], + pageInfo: { + startCursor: '1', + endCursor: '10', + hasNextPage: true, + hasPreviousPage: false, + }, + totalCount: 12, + }, + membershipRequests: [], + organizationsBlockedBy: [], + registeredEvents: [ + { + __typename: 'Event', + _id: '65e32a5b2a1f4288ca1f086a', + }, + ], + }, + }, + }, + }, + }, + { + request: { + query: USER_DETAILS, + variables: { + id: 'rishav-jha-mech', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + after: '10', + }, + }, + result: { + data: { + user: { + __typename: 'UserData', + appUserProfile: { + _id: '1', + __typename: 'AppUserProfile', + adminFor: [ + { + __typename: 'Organization', + _id: '65e0df0906dd1228350cfd4a', + }, + { + __typename: 'Organization', + _id: '65e0e2abb92c9f3e29503d4e', + }, + ], + isSuperAdmin: false, + appLanguageCode: 'en', + createdEvents: [ + { + __typename: 'Event', + _id: '65e32a5b2a1f4288ca1f086a', + }, + ], + createdOrganizations: [ + { + __typename: 'Organization', + _id: '65e0df0906dd1228350cfd4a', + }, + { + __typename: 'Organization', + _id: '65e0e2abb92c9f3e29503d4e', + }, + ], + eventAdmin: [ + { + __typename: 'Event', + _id: '65e32a5b2a1f4288ca1f086a', + }, + ], + pluginCreationAllowed: true, + }, + user: { + _id: '1', + __typename: 'User', + createdAt: '2024-02-26T10:36:33.098Z', + email: 'adi790u@gmail.com', + firstName: 'Aditya', + image: null, + lastName: 'Agarwal', + gender: '', + birthDate: '2024-03-14', + educationGrade: '', + employmentStatus: '', + maritalStatus: '', + address: { + line1: '', + countryCode: '', + city: '', + state: '', + }, + phone: { + mobile: '', + }, + eventsAttended: [], + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '65e0df0906dd1228350cfd4a', + }, + { + __typename: 'Organization', + _id: '65e0e2abb92c9f3e29503d4e', + }, + ], + tagsAssignedWith: { + edges: [ + { + node: { + _id: '11', + name: 'userTag 11', + parentTag: null, + }, + cursor: '11', + }, + { + node: { + _id: '12', + name: 'subTag 1', + parentTag: { _id: '1' }, + }, + cursor: '12', + }, + ], + pageInfo: { + startCursor: '11', + endCursor: '12', + hasNextPage: false, + hasPreviousPage: true, + }, + totalCount: 12, + }, + membershipRequests: [], + organizationsBlockedBy: [], + registeredEvents: [ + { + __typename: 'Event', + _id: '65e32a5b2a1f4288ca1f086a', + }, + ], + }, + }, + }, + }, + }, + { + request: { + query: UNASSIGN_USER_TAG, + variables: { + tagId: '1', + userId: 'rishav-jha-mech', + }, + }, + result: { + data: { + unassignUserTag: { + _id: '1', + }, + }, + }, + }, +]; + +export const MOCKS2 = [ + { + request: { + query: USER_DETAILS, + variables: { + id: 'rishav-jha-mech', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + }, + }, + result: { + data: { + user: { + __typename: 'UserData', + appUserProfile: { + _id: '1', + __typename: 'AppUserProfile', + adminFor: [], + isSuperAdmin: false, + appLanguageCode: 'en', + createdEvents: [ + { + __typename: 'Event', + _id: '65e32a5b2a1f4288ca1f086a', + }, + ], + createdOrganizations: [ + { + __typename: 'Organization', + _id: '65e0df0906dd1228350cfd4a', + }, + { + __typename: 'Organization', + _id: '65e0e2abb92c9f3e29503d4e', + }, + ], + eventAdmin: [ + { + __typename: 'Event', + _id: '65e32a5b2a1f4288ca1f086a', + }, + ], + pluginCreationAllowed: true, + }, + user: { + _id: '1', + __typename: 'User', + createdAt: '2024-02-26T10:36:33.098Z', + email: 'adi790u@gmail.com', + firstName: 'Aditya', + image: 'https://placeholder.com/200x200', + lastName: 'Agarwal', + gender: '', + birthDate: '2024-03-14', + educationGrade: '', + employmentStatus: '', + maritalStatus: '', + address: { + line1: '', + countryCode: '', + city: '', + state: '', + }, + phone: { + mobile: '', + }, + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '65e0df0906dd1228350cfd4a', + }, + { + __typename: 'Organization', + _id: '65e0e2abb92c9f3e29503d4e', + }, + ], + tagsAssignedWith: { + edges: [], + pageInfo: { + startCursor: null, + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 0, + }, + eventsAttended: [{ _id: 'event1' }, { _id: 'event2' }], + membershipRequests: [], + organizationsBlockedBy: [], + registeredEvents: [ + { + __typename: 'Event', + _id: '65e32a5b2a1f4288ca1f086a', + }, + ], + }, + }, + }, + }, + }, +]; +export const MOCKS3 = [ + { + request: { + query: USER_DETAILS, + variables: { + id: 'rishav-jha-mech', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + }, + }, + result: { + data: { + user: { + __typename: 'UserData', + appUserProfile: { + _id: '1', + __typename: 'AppUserProfile', + adminFor: [], + isSuperAdmin: true, + appLanguageCode: 'en', + createdEvents: [ + { + __typename: 'Event', + _id: '65e32a5b2a1f4288ca1f086a', + }, + ], + createdOrganizations: [ + { + __typename: 'Organization', + _id: '65e0df0906dd1228350cfd4a', + }, + { + __typename: 'Organization', + _id: '65e0e2abb92c9f3e29503d4e', + }, + ], + eventAdmin: [ + { + __typename: 'Event', + _id: '65e32a5b2a1f4288ca1f086a', + }, + ], + pluginCreationAllowed: true, + }, + user: { + _id: '1', + __typename: 'User', + createdAt: '2024-02-26T10:36:33.098Z', + email: 'adi790u@gmail.com', + firstName: 'Aditya', + image: 'https://placeholder.com/200x200', + lastName: 'Agarwal', + gender: '', + birthDate: '2024-03-14', + educationGrade: '', + employmentStatus: '', + maritalStatus: '', + address: { + line1: '', + countryCode: '', + city: '', + state: '', + }, + phone: { + mobile: '', + }, + tagsAssignedWith: { + edges: [], + pageInfo: { + startCursor: null, + endCursor: null, + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 0, + }, + eventsAttended: [], + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '65e0df0906dd1228350cfd4a', + }, + { + __typename: 'Organization', + _id: '65e0e2abb92c9f3e29503d4e', + }, + ], + membershipRequests: [], + organizationsBlockedBy: [], + registeredEvents: [ + { + __typename: 'Event', + _id: '65e32a5b2a1f4288ca1f086a', + }, + ], + }, + }, + }, + }, + }, +]; diff --git a/src/screens/OrgContribution/OrgContribution.module.css b/src/screens/OrgContribution/OrgContribution.module.css new file mode 100644 index 0000000000..7ca9333bf7 --- /dev/null +++ b/src/screens/OrgContribution/OrgContribution.module.css @@ -0,0 +1,261 @@ +.navbarbg { + height: 60px; + background-color: white; + display: flex; + margin-bottom: 30px; + z-index: 1; + position: relative; + flex-direction: row; + justify-content: space-between; + box-shadow: 0px 0px 8px 2px #c8c8c8; +} + +.logo { + color: #707070; + margin-left: 0; + display: flex; + align-items: center; + text-decoration: none; +} + +.logo img { + margin-top: 0px; + margin-left: 10px; + height: 64px; + width: 70px; +} + +.logo > strong { + line-height: 1.5rem; + margin-left: -5px; + font-family: sans-serif; + font-size: 19px; + color: #707070; +} +.mainpage { + display: flex; + flex-direction: row; +} +.sidebar { + z-index: 0; + padding-top: 5px; + margin: 0; + height: 100%; +} +.sidebar:after { + background-color: #f7f7f7; + position: absolute; + width: 2px; + height: 600px; + top: 10px; + left: 94%; + display: block; +} +.sidebarsticky { + padding-left: 45px; + margin-top: 7px; +} +.sidebarsticky > p { + margin-top: -10px; +} + +.navitem { + padding-left: 27%; + padding-top: 12px; + padding-bottom: 12px; + cursor: pointer; +} + +.logintitle { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 30px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 15%; +} +.searchtitle { + color: #707070; + font-weight: 600; + font-size: 18px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 60%; +} +.logintitleadmin { + color: #707070; + font-weight: 600; + font-size: 18px; + margin-top: 50px; + margin-bottom: 40px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 30%; +} +.admindetails { + display: flex; + justify-content: space-between; +} +.admindetails > p { + margin-top: -12px; + margin-right: 30px; +} + +.mainpageright > hr { + margin-top: 20px; + width: 100%; + margin-left: -15px; + margin-right: -15px; + margin-bottom: 20px; +} +.justifysp { + display: flex; + justify-content: space-between; +} +@media screen and (max-width: 575.5px) { + .justifysp { + padding-left: 55px; + display: flex; + justify-content: space-between; + width: 100%; + } +} +.addbtn { + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + border-radius: 5px; + background-color: #31bb6b; + width: 15%; + height: 40px; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; +} +.flexdir { + display: flex; + flex-direction: row; + justify-content: space-between; + border: none; +} + +.form_wrapper { + margin-top: 27px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + position: absolute; + display: flex; + flex-direction: column; + width: 30%; + padding: 40px 30px; + background: #ffffff; + border-color: #e8e5e5; + border-width: 5px; + border-radius: 10px; +} + +.form_wrapper form { + display: flex; + align-items: left; + justify-content: left; + flex-direction: column; +} +.logintitleinvite { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 40%; +} +.cancel > i { + margin-top: 5px; + transform: scale(1.2); + cursor: pointer; + color: #707070; +} +.modalbody { + width: 50px; +} +.greenregbtn { + margin: 1rem 0 0; + margin-top: 10px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 10px; + border-radius: 5px; + background-color: #31bb6b; + width: 100%; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; +} +.sidebarsticky > input { + text-decoration: none; + margin-bottom: 50px; + border-color: #e8e5e5; + width: 80%; + border-radius: 7px; + padding-top: 5px; + padding-bottom: 5px; + padding-right: 10px; + padding-left: 10px; + box-shadow: none; +} + +.loader, +.loader:after { + border-radius: 50%; + width: 10em; + height: 10em; +} +.loader { + margin: 60px auto; + margin-top: 35vh !important; + font-size: 10px; + position: relative; + text-indent: -9999em; + border-top: 1.1em solid rgba(255, 255, 255, 0.2); + border-right: 1.1em solid rgba(255, 255, 255, 0.2); + border-bottom: 1.1em solid rgba(255, 255, 255, 0.2); + border-left: 1.1em solid #febc59; + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); + -webkit-animation: load8 1.1s infinite linear; + animation: load8 1.1s infinite linear; +} +@-webkit-keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} diff --git a/src/screens/OrgContribution/OrgContribution.test.tsx b/src/screens/OrgContribution/OrgContribution.test.tsx new file mode 100644 index 0000000000..f9f63c6807 --- /dev/null +++ b/src/screens/OrgContribution/OrgContribution.test.tsx @@ -0,0 +1,47 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { render } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; + +import OrgContribution from './OrgContribution'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +const link = new StaticMockLink([], true); +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Organisation Contribution Page', () => { + test('should render props and text elements test for the screen', async () => { + window.location.assign('/orglist'); + + const { container } = render( + + + + + + + + + , + ); + + expect(container.textContent).not.toBe('Loading data...'); + await wait(); + + expect(container.textContent).toMatch('Filter by Name'); + expect(container.textContent).toMatch('Filter by Trans. ID'); + expect(container.textContent).toMatch('Recent Stats'); + expect(container.textContent).toMatch('Contribution'); + expect(window.location).toBeAt('/orglist'); + }); +}); diff --git a/src/screens/OrgContribution/OrgContribution.tsx b/src/screens/OrgContribution/OrgContribution.tsx new file mode 100644 index 0000000000..db155cdb47 --- /dev/null +++ b/src/screens/OrgContribution/OrgContribution.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import Col from 'react-bootstrap/Col'; +import Row from 'react-bootstrap/Row'; +import { useTranslation } from 'react-i18next'; + +import ContriStats from 'components/ContriStats/ContriStats'; +import OrgContriCards from 'components/OrgContriCards/OrgContriCards'; +import { Form } from 'react-bootstrap'; +import styles from './OrgContribution.module.css'; + +/** + * The `orgContribution` component displays the contributions to an organization. + * It includes a sidebar for filtering contributions by organization name and transaction ID. + * Additionally, it shows recent contribution statistics and a list of contribution cards. + * + */ +function orgContribution(): JSX.Element { + // Hook to get translation functions and translation text + const { t } = useTranslation('translation', { + keyPrefix: 'orgContribution', + }); + + // Set the document title based on the translated title for this page + document.title = t('title'); + + return ( + <> + + +
    +
    + {/* Input for filtering by organization name */} +
    {t('filterByName')}
    + + + {/* Input for filtering by transaction ID */} +
    {t('filterByTransId')}
    + + + {/* Section displaying recent contribution statistics */} +
    {t('recentStats')}
    + +
    +
    + + +
    + +

    {t('contribution')}

    +
    + {/* Section displaying a list of contribution cards */} + +
    + +
    + + ); +} + +export default orgContribution; diff --git a/src/screens/OrgList/OrgList.module.css b/src/screens/OrgList/OrgList.module.css new file mode 100644 index 0000000000..9da1ecbb70 --- /dev/null +++ b/src/screens/OrgList/OrgList.module.css @@ -0,0 +1,323 @@ +.btnsContainer { + display: flex; + margin: 2.5rem 0 2.5rem 0; +} + +.btnsContainer .btnsBlock { + display: flex; +} + +.orgCreationBtn { + width: 100%; + border: None; +} + +.enableEverythingBtn { + width: 100%; + border: None; +} + +.pluginStoreBtn { + width: 100%; + background-color: white; + color: #31bb6b; + border: 0.5px solid #31bb6b; +} + +.pluginStoreBtn:hover, +.pluginStoreBtn:focus { + background-color: #dfe1e2 !important; + color: #31bb6b !important; + border-color: #31bb6b !important; +} + +.line::before { + content: ''; + display: inline-block; + width: 100px; + border-top: 1px solid #000; + margin: 0 10px; +} + +.line::before { + left: 0; +} + +.line::after { + right: 0; +} + +.flexContainer { + display: flex; + justify-content: center; + align-items: center; + width: 100%; +} + +.orText { + display: block; + position: absolute; + top: calc(-0.7rem + 0.5rem); + left: calc(50% - 2.6rem); + margin: 0 auto; + padding: 0.5rem 2rem; + z-index: 100; + background: var(--bs-white); + color: var(--bs-secondary); +} +.sampleOrgSection { + display: grid; + grid-template-columns: repeat(1, 1fr); + row-gap: 1em; +} + +.sampleOrgCreationBtn { + width: 100%; + background-color: transparent; + color: #707070; + border-color: #707070; + display: flex; + justify-content: center; + align-items: center; +} + +.sampleHover:hover { + border-color: grey; + color: grey; +} + +.sampleOrgSection { + font-family: Arial, Helvetica, sans-serif; + width: 100%; + display: grid; + grid-auto-columns: repeat(1, 1fr); + justify-content: center; + flex-direction: column; + align-items: center; +} + +.sampleModalTitle { + background-color: green; +} + +.btnsContainer .btnsBlock button { + margin-left: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.btnsContainer .input { + flex: 1; + position: relative; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); +} + +.btnsContainer .input button { + width: 52px; +} + +.listBox { + display: flex; + flex-wrap: wrap; + overflow: unset !important; +} + +.listBox .itemCard { + width: 50%; +} + +.notFound { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +@media (max-width: 1440px) { + .contract { + padding-left: calc(250px + 2rem + 1.5rem); + } + + .listBox .itemCard { + width: 100%; + } +} + +@media (max-width: 1020px) { + .btnsContainer { + flex-direction: column; + margin: 1.5rem 0; + } + + .btnsContainer .btnsBlock { + margin: 1.5rem 0 0 0; + justify-content: space-between; + } + + .btnsContainer .btnsBlock button { + margin: 0; + } + + .btnsContainer .btnsBlock div button { + margin-right: 1.5rem; + } +} + +/* For mobile devices */ + +@media (max-width: 520px) { + .btnsContainer { + margin-bottom: 0; + } + + .btnsContainer .btnsBlock { + display: block; + margin-top: 1rem; + margin-right: 0; + } + + .btnsContainer .btnsBlock div { + flex: 1; + } + + .btnsContainer .btnsBlock div[title='Sort organizations'] { + margin-right: 0.5rem; + } + + .btnsContainer .btnsBlock button { + margin-bottom: 1rem; + margin-right: 0; + width: 100%; + } +} + +/* Loading OrgList CSS */ +.itemCard .loadingWrapper { + background-color: var(--bs-white); + margin: 0.5rem; + height: calc(120px + 2rem); + padding: 1rem; + border-radius: 8px; + outline: 1px solid var(--bs-gray-200); + position: relative; +} + +.itemCard .loadingWrapper .innerContainer { + display: flex; +} + +.itemCard .loadingWrapper .innerContainer .orgImgContainer { + width: 120px; + height: 120px; + border-radius: 4px; +} + +.itemCard .loadingWrapper .innerContainer .content { + flex: 1; + display: flex; + flex-direction: column; + margin-left: 1rem; +} + +.titlemodaldialog { + color: #707070; + font-size: 20px; + margin-bottom: 20px; + padding-bottom: 5px; +} + +form label { + font-weight: bold; + padding-bottom: 1px; + font-size: 14px; + color: #707070; +} + +form > input { + display: block; + margin-bottom: 20px; + border: 1px solid #e8e5e5; + box-shadow: 2px 1px #e8e5e5; + padding: 10px 20px; + border-radius: 5px; + background: none; + width: 100%; + transition: all 0.3s ease-in-out; + -webkit-transition: all 0.3s ease-in-out; + -moz-transition: all 0.3s ease-in-out; + -ms-transition: all 0.3s ease-in-out; + -o-transition: all 0.3s ease-in-out; +} + +.itemCard .loadingWrapper .innerContainer .content h5 { + height: 24px; + width: 60%; + margin-bottom: 0.8rem; +} + +.modalbody { + width: 50px; +} + +.pluginStoreBtnContainer { + display: flex; + gap: 1rem; +} + +.itemCard .loadingWrapper .innerContainer .content h6[title='Location'] { + display: block; + width: 45%; + height: 18px; +} + +.itemCard .loadingWrapper .innerContainer .content h6 { + display: block; + width: 30%; + height: 16px; + margin-bottom: 0.8rem; +} + +.itemCard .loadingWrapper .button { + position: absolute; + height: 48px; + width: 92px; + bottom: 1rem; + right: 1rem; + z-index: 1; +} + +@media (max-width: 450px) { + .itemCard .loadingWrapper { + height: unset; + margin: 0.5rem 0; + padding: 1.25rem 1.5rem; + } + + .itemCard .loadingWrapper .innerContainer { + flex-direction: column; + } + + .itemCard .loadingWrapper .innerContainer .orgImgContainer { + height: 200px; + width: 100%; + margin-bottom: 0.8rem; + } + + .itemCard .loadingWrapper .innerContainer .content { + margin-left: 0; + } + + .itemCard .loadingWrapper .button { + bottom: 0; + right: 0; + border-radius: 0.5rem; + position: relative; + margin-left: auto; + display: block; + } +} diff --git a/src/screens/OrgList/OrgList.test.tsx b/src/screens/OrgList/OrgList.test.tsx new file mode 100644 index 0000000000..5b889ff07d --- /dev/null +++ b/src/screens/OrgList/OrgList.test.tsx @@ -0,0 +1,535 @@ +// SKIP_LOCALSTORAGE_CHECK +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { + act, + render, + screen, + fireEvent, + cleanup, + waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import 'jest-localstorage-mock'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18nForTest from 'utils/i18nForTest'; +import OrgList from './OrgList'; + +import { + MOCKS, + MOCKS_ADMIN, + MOCKS_EMPTY, + MOCKS_WITH_ERROR, +} from './OrgListMocks'; +import { ToastContainer, toast } from 'react-toastify'; + +jest.setTimeout(30000); +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem } = useLocalStorage(); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +afterEach(() => { + localStorage.clear(); + cleanup(); +}); + +describe('Organisations Page testing as SuperAdmin', () => { + setItem('id', '123'); + + const link = new StaticMockLink(MOCKS, true); + const link2 = new StaticMockLink(MOCKS_EMPTY, true); + const link3 = new StaticMockLink(MOCKS_WITH_ERROR, true); + + const formData = { + name: 'Dummy Organization', + description: 'This is a dummy organization', + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + image: new File(['hello'], 'hello.png', { type: 'image/png' }), + }; + test('Should display organisations for superAdmin even if admin For field is empty', async () => { + window.location.assign('/'); + setItem('id', '123'); + setItem('SuperAdmin', true); + setItem('AdminFor', []); + + render( + + + + + + + + + , + ); + + await wait(); + expect( + screen.queryByText('Organizations Not Found'), + ).not.toBeInTheDocument(); + }); + + test('Testing search functionality by pressing enter', async () => { + setItem('id', '123'); + setItem('SuperAdmin', true); + setItem('AdminFor', [{ name: 'adi', _id: '1234', image: '' }]); + + render( + + + + + + + + + , + ); + await wait(); + + // Test that the search bar filters organizations by name + const searchBar = screen.getByTestId(/searchByName/i); + expect(searchBar).toBeInTheDocument(); + userEvent.type(searchBar, 'Dummy{enter}'); + }); + + test('Testing search functionality by Btn click', async () => { + setItem('id', '123'); + setItem('SuperAdmin', true); + setItem('AdminFor', [{ name: 'adi', _id: '1234', image: '' }]); + render( + + + + + + + + + , + ); + await wait(); + + const searchBar = screen.getByTestId('searchByName'); + const searchBtn = screen.getByTestId('searchBtn'); + userEvent.type(searchBar, 'Dummy'); + fireEvent.click(searchBtn); + }); + + test('Should render no organisation warning alert when there are no organization', async () => { + window.location.assign('/'); + setItem('id', '123'); + setItem('SuperAdmin', true); + setItem('AdminFor', [{ name: 'adi', _id: '1234', image: '' }]); + + render( + + + + + + + + + , + ); + + await wait(); + expect(screen.queryByText('Organizations Not Found')).toBeInTheDocument(); + expect( + screen.queryByText('Please create an organization through dashboard'), + ).toBeInTheDocument(); + expect(window.location).toBeAt('/'); + }); + + test('Testing Organization data is not present', async () => { + setItem('id', '123'); + setItem('SuperAdmin', false); + setItem('AdminFor', [{ name: 'adi', _id: '1234', image: '' }]); + + render( + + + + + + + , + ); + + await wait(); + }); + + test('Testing create organization modal', async () => { + setItem('id', '123'); + setItem('SuperAdmin', true); + setItem('AdminFor', [{ name: 'adi', _id: '1234', image: '' }]); + + render( + + + + + + + + + , + ); + + screen.debug(); + + expect(localStorage.setItem).toHaveBeenLastCalledWith( + 'Talawa-admin_AdminFor', + JSON.stringify([{ name: 'adi', _id: '1234', image: '' }]), + ); + + expect(screen.getByTestId(/createOrganizationBtn/i)).toBeInTheDocument(); + }); + + test('Create organization model should work properly', async () => { + setItem('id', '123'); + setItem('SuperAdmin', true); + setItem('AdminFor', [{ name: 'adi', _id: '1234', image: '' }]); + + render( + + + + + + + + + + , + ); + + await wait(500); + + expect(localStorage.setItem).toHaveBeenLastCalledWith( + 'Talawa-admin_AdminFor', + JSON.stringify([{ name: 'adi', _id: '1234', image: '' }]), + ); + + userEvent.click(screen.getByTestId(/createOrganizationBtn/i)); + + userEvent.type(screen.getByTestId(/modalOrganizationName/i), formData.name); + userEvent.type( + screen.getByPlaceholderText(/Description/i), + formData.description, + ); + userEvent.type(screen.getByPlaceholderText(/City/i), formData.address.city); + userEvent.type( + screen.getByPlaceholderText(/Postal Code/i), + formData.address.postalCode, + ); + userEvent.type( + screen.getByPlaceholderText(/State \/ Province/i), + formData.address.state, + ); + + userEvent.selectOptions( + screen.getByTestId('countrycode'), + formData.address.countryCode, + ); + userEvent.type( + screen.getByPlaceholderText(/Line 1/i), + formData.address.line1, + ); + userEvent.type( + screen.getByPlaceholderText(/Line 2/i), + formData.address.line2, + ); + userEvent.type( + screen.getByPlaceholderText(/Sorting Code/i), + formData.address.sortingCode, + ); + userEvent.type( + screen.getByPlaceholderText(/Dependent Locality/i), + formData.address.dependentLocality, + ); + userEvent.click(screen.getByTestId(/userRegistrationRequired/i)); + userEvent.click(screen.getByTestId(/visibleInSearch/i)); + + expect(screen.getByTestId(/modalOrganizationName/i)).toHaveValue( + formData.name, + ); + expect(screen.getByPlaceholderText(/Description/i)).toHaveValue( + formData.description, + ); + //Checking the fields for the address object in the formdata. + const { address } = formData; + expect(screen.getByPlaceholderText(/City/i)).toHaveValue(address.city); + expect(screen.getByPlaceholderText(/State \/ Province/i)).toHaveValue( + address.state, + ); + expect(screen.getByPlaceholderText(/Dependent Locality/i)).toHaveValue( + address.dependentLocality, + ); + expect(screen.getByPlaceholderText(/Line 1/i)).toHaveValue(address.line1); + expect(screen.getByPlaceholderText(/Line 2/i)).toHaveValue(address.line2); + expect(screen.getByPlaceholderText(/Postal Code/i)).toHaveValue( + address.postalCode, + ); + expect(screen.getByTestId(/countrycode/i)).toHaveValue(address.countryCode); + expect(screen.getByPlaceholderText(/Sorting Code/i)).toHaveValue( + address.sortingCode, + ); + expect(screen.getByTestId(/userRegistrationRequired/i)).not.toBeChecked(); + expect(screen.getByTestId(/visibleInSearch/i)).toBeChecked(); + expect(screen.getByLabelText(/Display Image/i)).toBeTruthy(); + const displayImage = screen.getByTestId('organisationImage'); + userEvent.upload(displayImage, formData.image); + userEvent.click(screen.getByTestId(/submitOrganizationForm/i)); + await waitFor(() => { + expect( + screen.queryByText(/Congratulation the Organization is created/i), + ).toBeInTheDocument(); + }); + }); + + test('Plugin Notification model should work properly', async () => { + setItem('id', '123'); + setItem('SuperAdmin', true); + setItem('AdminFor', [{ name: 'adi', _id: '1234', image: '' }]); + + render( + + + + + + + + + + , + ); + + await wait(500); + + expect(localStorage.setItem).toHaveBeenLastCalledWith( + 'Talawa-admin_AdminFor', + JSON.stringify([{ name: 'adi', _id: '1234', image: '' }]), + ); + + userEvent.click(screen.getByTestId(/createOrganizationBtn/i)); + + userEvent.type(screen.getByTestId(/modalOrganizationName/i), formData.name); + userEvent.type( + screen.getByPlaceholderText(/Description/i), + formData.description, + ); + userEvent.type(screen.getByPlaceholderText(/City/i), formData.address.city); + userEvent.type( + screen.getByPlaceholderText(/State \/ Province/i), + formData.address.state, + ); + userEvent.type( + screen.getByPlaceholderText(/Postal Code/i), + formData.address.postalCode, + ); + userEvent.selectOptions( + screen.getByTestId('countrycode'), + formData.address.countryCode, + ); + userEvent.type( + screen.getByPlaceholderText(/Line 1/i), + formData.address.line1, + ); + userEvent.type( + screen.getByPlaceholderText(/Line 2/i), + formData.address.line2, + ); + userEvent.type( + screen.getByPlaceholderText(/Sorting Code/i), + formData.address.sortingCode, + ); + userEvent.type( + screen.getByPlaceholderText(/Dependent Locality/i), + formData.address.dependentLocality, + ); + userEvent.click(screen.getByTestId(/userRegistrationRequired/i)); + userEvent.click(screen.getByTestId(/visibleInSearch/i)); + + expect(screen.getByTestId(/modalOrganizationName/i)).toHaveValue( + formData.name, + ); + expect(screen.getByPlaceholderText(/Description/i)).toHaveValue( + formData.description, + ); + //Checking the fields for the address object in the formdata. + const { address } = formData; + expect(screen.getByPlaceholderText(/City/i)).toHaveValue(address.city); + expect(screen.getByPlaceholderText(/State \/ Province/i)).toHaveValue( + address.state, + ); + expect(screen.getByPlaceholderText(/Dependent Locality/i)).toHaveValue( + address.dependentLocality, + ); + expect(screen.getByPlaceholderText(/Line 1/i)).toHaveValue(address.line1); + expect(screen.getByPlaceholderText(/Line 2/i)).toHaveValue(address.line2); + expect(screen.getByPlaceholderText(/Postal Code/i)).toHaveValue( + address.postalCode, + ); + expect(screen.getByTestId(/countrycode/i)).toHaveValue(address.countryCode); + expect(screen.getByPlaceholderText(/Sorting Code/i)).toHaveValue( + address.sortingCode, + ); + expect(screen.getByTestId(/userRegistrationRequired/i)).not.toBeChecked(); + expect(screen.getByTestId(/visibleInSearch/i)).toBeChecked(); + expect(screen.getByLabelText(/Display Image/i)).toBeTruthy(); + + userEvent.click(screen.getByTestId(/submitOrganizationForm/i)); + // await act(async () => { + // await new Promise((resolve) => setTimeout(resolve, 1000)); + // }); + await waitFor(() => + expect( + screen.queryByText(/Congratulation the Organization is created/i), + ).toBeInTheDocument(), + ); + await waitFor(() => { + screen.findByTestId(/pluginNotificationHeader/i); + }); + // userEvent.click(screen.getByTestId(/enableEverythingForm/i)); + userEvent.click(screen.getByTestId(/enableEverythingForm/i)); + }); + + test('Testing create sample organization working properly', async () => { + setItem('id', '123'); + setItem('SuperAdmin', true); + setItem('AdminFor', [{ name: 'adi', _id: '1234', image: '' }]); + + render( + + + + + + + + + + , + ); + await wait(); + userEvent.click(screen.getByTestId(/createOrganizationBtn/i)); + userEvent.click(screen.getByTestId(/createSampleOrganizationBtn/i)); + await waitFor(() => + expect( + screen.queryByText(/Sample Organization Successfully created/i), + ).toBeInTheDocument(), + ); + }); + test('Testing error handling for CreateSampleOrg', async () => { + setItem('id', '123'); + setItem('SuperAdmin', true); + setItem('AdminFor', [{ name: 'adi', _id: '1234', image: '' }]); + + jest.spyOn(toast, 'error'); + render( + + + + + + + + , + ); + await wait(); + userEvent.click(screen.getByTestId(/createOrganizationBtn/i)); + userEvent.click(screen.getByTestId(/createSampleOrganizationBtn/i)); + await waitFor(() => + expect( + screen.queryByText(/Only one sample organization allowed/i), + ).toBeInTheDocument(), + ); + }); +}); + +describe('Organisations Page testing as Admin', () => { + const link = new StaticMockLink(MOCKS_ADMIN, true); + + test('Create organization modal should not be present in the page for Admin', async () => { + setItem('id', '123'); + setItem('SuperAdmin', false); + setItem('AdminFor', [{ name: 'adi', _id: '1234', image: '' }]); + + render( + + + + + + + + + , + ); + await waitFor(() => { + expect(screen.queryByText(/Create Organization/i)).toBeNull(); + }); + }); + test('Testing sort latest and oldest toggle', async () => { + setItem('id', '123'); + setItem('SuperAdmin', false); + setItem('AdminFor', [{ name: 'adi', _id: 'a0', image: '' }]); + + await act(async () => { + render( + + + + + + + + + , + ); + + await wait(); + }); + const sortDropdown = await waitFor(() => screen.getByTestId('sort')); + expect(sortDropdown).toBeInTheDocument(); + + const sortToggle = screen.getByTestId('sortOrgs'); + + fireEvent.click(sortToggle); + const latestOption = await waitFor(() => screen.getByTestId('latest')); + + fireEvent.click(latestOption); + + expect(sortDropdown).toBeInTheDocument(); + fireEvent.click(sortToggle); + const oldestOption = await waitFor(() => screen.getByTestId('oldest')); + fireEvent.click(oldestOption); + expect(sortDropdown).toBeInTheDocument(); + }); +}); diff --git a/src/screens/OrgList/OrgList.tsx b/src/screens/OrgList/OrgList.tsx new file mode 100644 index 0000000000..7023bfa9b1 --- /dev/null +++ b/src/screens/OrgList/OrgList.tsx @@ -0,0 +1,577 @@ +import { useMutation, useQuery } from '@apollo/client'; +import { Search } from '@mui/icons-material'; +import SortIcon from '@mui/icons-material/Sort'; +import { + CREATE_ORGANIZATION_MUTATION, + CREATE_SAMPLE_ORGANIZATION_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import { + ORGANIZATION_CONNECTION_LIST, + USER_ORGANIZATION_LIST, +} from 'GraphQl/Queries/Queries'; + +import OrgListCard from 'components/OrgListCard/OrgListCard'; +import type { ChangeEvent } from 'react'; +import React, { useEffect, useState } from 'react'; +import { Dropdown, Form } from 'react-bootstrap'; +import Button from 'react-bootstrap/Button'; +import Modal from 'react-bootstrap/Modal'; +import { useTranslation } from 'react-i18next'; +import InfiniteScroll from 'react-infinite-scroll-component'; +import { Link } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { errorHandler } from 'utils/errorHandler'; +import type { + InterfaceOrgConnectionInfoType, + InterfaceOrgConnectionType, + InterfaceUserType, +} from 'utils/interfaces'; +import useLocalStorage from 'utils/useLocalstorage'; +import styles from './OrgList.module.css'; +import OrganizationModal from './OrganizationModal'; + +function orgList(): JSX.Element { + const { t } = useTranslation('translation', { keyPrefix: 'orgList' }); + const { t: tCommon } = useTranslation('common'); + const [dialogModalisOpen, setdialogModalIsOpen] = useState(false); + const [dialogRedirectOrgId, setDialogRedirectOrgId] = useState(''); + + function openDialogModal(redirectOrgId: string): void { + setDialogRedirectOrgId(redirectOrgId); + // console.log(redirectOrgId, dialogRedirectOrgId); + setdialogModalIsOpen(true); + } + + const { getItem } = useLocalStorage(); + const superAdmin = getItem('SuperAdmin'); + const adminFor = getItem('AdminFor'); + + function closeDialogModal(): void { + setdialogModalIsOpen(false); + } + const toggleDialogModal = /* istanbul ignore next */ (): void => + setdialogModalIsOpen(!dialogModalisOpen); + document.title = t('title'); + + const perPageResult = 8; + const [isLoading, setIsLoading] = useState(true); + const [sortingState, setSortingState] = useState({ + option: '', + selectedOption: t('sort'), + }); + const [hasMore, sethasMore] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [searchByName, setSearchByName] = useState(''); + const [showModal, setShowModal] = useState(false); + const [formState, setFormState] = useState({ + name: '', + descrip: '', + userRegistrationRequired: true, + visible: false, + address: { + city: '', + countryCode: '', + dependentLocality: '', + line1: '', + line2: '', + postalCode: '', + sortingCode: '', + state: '', + }, + image: '', + }); + + const toggleModal = (): void => setShowModal(!showModal); + + const [create] = useMutation(CREATE_ORGANIZATION_MUTATION); + + const [createSampleOrganization] = useMutation( + CREATE_SAMPLE_ORGANIZATION_MUTATION, + ); + + const { + data: userData, + error: errorUser, + }: { + data: InterfaceUserType | undefined; + loading: boolean; + error?: Error | undefined; + } = useQuery(USER_ORGANIZATION_LIST, { + variables: { userId: getItem('id') }, + context: { + headers: { authorization: `Bearer ${getItem('token')}` }, + }, + }); + + const { + data: orgsData, + loading, + error: errorList, + refetch: refetchOrgs, + fetchMore, + } = useQuery(ORGANIZATION_CONNECTION_LIST, { + variables: { + first: perPageResult, + skip: 0, + filter: searchByName, + orderBy: + sortingState.option === 'Latest' ? 'createdAt_DESC' : 'createdAt_ASC', + }, + notifyOnNetworkStatusChange: true, + }); + + // To clear the search field and form fields on unmount + useEffect(() => { + return () => { + setSearchByName(''); + setFormState({ + name: '', + descrip: '', + userRegistrationRequired: true, + visible: false, + address: { + city: '', + countryCode: '', + dependentLocality: '', + line1: '', + line2: '', + postalCode: '', + sortingCode: '', + state: '', + }, + image: '', + }); + }; + }, []); + + useEffect(() => { + setIsLoading(loading && isLoadingMore); + }, [loading]); + + /* istanbul ignore next */ + const isAdminForCurrentOrg = ( + currentOrg: InterfaceOrgConnectionInfoType, + ): boolean => { + if (adminFor.length === 1) { + // If user is admin for one org only then check if that org is current org + return adminFor[0]._id === currentOrg._id; + } else { + // If user is admin for more than one org then check if current org is present in adminFor array + return ( + adminFor.some( + (org: { _id: string; name: string; image: string | null }) => + org._id === currentOrg._id, + ) ?? false + ); + } + }; + + const triggerCreateSampleOrg = (): void => { + createSampleOrganization() + .then(() => { + toast.success(t('sampleOrgSuccess') as string); + window.location.reload(); + }) + .catch(() => { + toast.error(t('sampleOrgDuplicate') as string); + }); + }; + + const createOrg = async (e: ChangeEvent): Promise => { + e.preventDefault(); + + const { + name: _name, + descrip: _descrip, + address: _address, + visible, + userRegistrationRequired, + image, + } = formState; + + const name = _name.trim(); + const descrip = _descrip.trim(); + const address = _address; + + try { + const { data } = await create({ + variables: { + name: name, + description: descrip, + address: address, + visibleInSearch: visible, + userRegistrationRequired: userRegistrationRequired, + image: image, + }, + }); + + /* istanbul ignore next */ + if (data) { + toast.success('Congratulation the Organization is created'); + refetchOrgs(); + openDialogModal(data.createOrganization._id); + setFormState({ + name: '', + descrip: '', + userRegistrationRequired: true, + visible: false, + address: { + city: '', + countryCode: '', + dependentLocality: '', + line1: '', + line2: '', + postalCode: '', + sortingCode: '', + state: '', + }, + image: '', + }); + toggleModal(); + } + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }; + + /* istanbul ignore next */ + if (errorList || errorUser) { + window.location.assign('/'); + } + + /* istanbul ignore next */ + const resetAllParams = (): void => { + refetchOrgs({ + filter: '', + first: perPageResult, + skip: 0, + orderBy: + sortingState.option === 'Latest' ? 'createdAt_DESC' : 'createdAt_ASC', + }); + sethasMore(true); + }; + + /* istanbul ignore next */ + const handleSearch = (value: string): void => { + setSearchByName(value); + if (value === '') { + resetAllParams(); + return; + } + refetchOrgs({ + filter: value, + }); + }; + + const handleSearchByEnter = ( + e: React.KeyboardEvent, + ): void => { + if (e.key === 'Enter') { + const { value } = e.currentTarget; + handleSearch(value); + } + }; + + const handleSearchByBtnClick = (): void => { + const inputElement = document.getElementById( + 'searchOrgname', + ) as HTMLInputElement; + const inputValue = inputElement?.value || ''; + handleSearch(inputValue); + }; + /* istanbul ignore next */ + const loadMoreOrganizations = (): void => { + console.log('loadMoreOrganizations'); + setIsLoadingMore(true); + fetchMore({ + variables: { + skip: orgsData?.organizationsConnection.length || 0, + }, + updateQuery: ( + prev: + | { organizationsConnection: InterfaceOrgConnectionType[] } + | undefined, + { + fetchMoreResult, + }: { + fetchMoreResult: + | { organizationsConnection: InterfaceOrgConnectionType[] } + | undefined; + }, + ): + | { organizationsConnection: InterfaceOrgConnectionType[] } + | undefined => { + setIsLoadingMore(false); + if (!fetchMoreResult) return prev; + if (fetchMoreResult.organizationsConnection.length < perPageResult) { + sethasMore(false); + } + return { + organizationsConnection: [ + ...(prev?.organizationsConnection || []), + ...(fetchMoreResult.organizationsConnection || []), + ], + }; + }, + }); + }; + + const handleSorting = (option: string): void => { + setSortingState({ + option, + selectedOption: t(option), + }); + + const orderBy = option === 'Latest' ? 'createdAt_DESC' : 'createdAt_ASC'; + + refetchOrgs({ + first: perPageResult, + skip: 0, + filter: searchByName, + orderBy, + }); + }; + + return ( + <> + {/* Buttons Container */} +
    +
    + + +
    +
    +
    + +
    + {superAdmin && ( + + )} +
    +
    + {/* Text Infos for list */} + {!isLoading && + (!orgsData?.organizationsConnection || + orgsData.organizationsConnection.length === 0) && + searchByName.length === 0 && + (!userData || adminFor.length === 0 || superAdmin) ? ( +
    +

    {t('noOrgErrorTitle')}

    +
    {t('noOrgErrorDescription')}
    +
    + ) : !isLoading && + orgsData?.organizationsConnection.length == 0 && + /* istanbul ignore next */ + searchByName.length > 0 ? ( + /* istanbul ignore next */ +
    +

    + {tCommon('noResultsFoundFor')} "{searchByName}" +

    +
    + ) : ( + <> + + {[...Array(perPageResult)].map((_, index) => ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ))} + + } + hasMore={hasMore} + className={styles.listBox} + data-testid="organizations-list" + endMessage={ +
    +
    {tCommon('endOfResults')}
    +
    + } + > + {userData && superAdmin + ? orgsData?.organizationsConnection.map( + (item: InterfaceOrgConnectionInfoType) => { + return ( +
    + +
    + ); + }, + ) + : userData && + adminFor.length > 0 && + orgsData?.organizationsConnection.map( + (item: InterfaceOrgConnectionInfoType) => { + if (isAdminForCurrentOrg(item)) { + return ( +
    + +
    + ); + } + }, + )} + + {isLoading && ( + <> + {[...Array(perPageResult)].map((_, index) => ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ))} + + )} + + )} + {/* Create Organization Modal */} + {/** + * Renders the `OrganizationModal` component. + * + * @param showModal - A boolean indicating whether the modal should be displayed. + * @param toggleModal - A function to toggle the visibility of the modal. + * @param formState - The state of the form in the organization modal. + * @param setFormState - A function to update the state of the form in the organization modal. + * @param createOrg - A function to handle the submission of the organization creation form. + * @param t - A translation function for localization. + * @param userData - Information about the current user. + * @param triggerCreateSampleOrg - A function to trigger the creation of a sample organization. + * @returns JSX element representing the `OrganizationModal`. + */} + + {/* Plugin Notification Modal after Org is Created */} + + + + {t('manageFeatures')} + + + +
    +
    +

    + {t('manageFeaturesInfo')} +

    + +
    + + {t('goToStore')} + + {/* */} + +
    +
    +
    +
    +
    + + ); +} +export default orgList; diff --git a/src/screens/OrgList/OrgListMocks.ts b/src/screens/OrgList/OrgListMocks.ts new file mode 100644 index 0000000000..380313ffd5 --- /dev/null +++ b/src/screens/OrgList/OrgListMocks.ts @@ -0,0 +1,258 @@ +import { + CREATE_ORGANIZATION_MUTATION, + CREATE_SAMPLE_ORGANIZATION_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import { + ORGANIZATION_CONNECTION_LIST, + USER_ORGANIZATION_LIST, +} from 'GraphQl/Queries/Queries'; +import 'jest-location-mock'; +import type { + InterfaceOrgConnectionInfoType, + InterfaceUserType, +} from 'utils/interfaces'; + +const superAdminUser: InterfaceUserType = { + user: { + firstName: 'John', + lastName: 'Doe', + email: 'john.doe@akatsuki.com', + image: null, + }, +}; + +const adminUser: InterfaceUserType = { + user: { + ...superAdminUser.user, + }, +}; + +const organizations: InterfaceOrgConnectionInfoType[] = [ + { + _id: '1', + creator: { _id: 'xyz', firstName: 'John', lastName: 'Doe' }, + image: '', + name: 'Palisadoes Foundation', + createdAt: '02/02/2022', + admins: [ + { + _id: '123', + }, + ], + members: [ + { + _id: '234', + }, + ], + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + }, +]; + +for (let x = 0; x < 1; x++) { + organizations.push({ + _id: 'a' + x, + image: '', + name: 'name', + creator: { + _id: '123', + firstName: 'firstName', + lastName: 'lastName', + }, + admins: [ + { + _id: x + '1', + }, + ], + members: [ + { + _id: x + '2', + }, + ], + createdAt: new Date().toISOString(), + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + }); +} + +// MOCKS FOR SUPERADMIN +const MOCKS = [ + { + request: { + query: ORGANIZATION_CONNECTION_LIST, + variables: { + first: 8, + skip: 0, + filter: '', + orderBy: 'createdAt_ASC', + }, + notifyOnNetworkStatusChange: true, + }, + result: { + data: { + organizationsConnection: organizations, + }, + }, + }, + { + request: { + query: USER_ORGANIZATION_LIST, + variables: { userId: '123' }, + }, + result: { + data: { user: superAdminUser }, + }, + }, + { + request: { + query: CREATE_SAMPLE_ORGANIZATION_MUTATION, + }, + result: { + data: { + createSampleOrganization: { + id: '1', + name: 'Sample Organization', + }, + }, + }, + }, + { + request: { + query: CREATE_ORGANIZATION_MUTATION, + variables: { + description: 'This is a dummy organization', + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + name: 'Dummy Organization', + visibleInSearch: true, + userRegistrationRequired: false, + image: '', + }, + }, + result: { + data: { + createOrganization: { + _id: '1', + }, + }, + }, + }, +]; +const MOCKS_EMPTY = [ + { + request: { + query: ORGANIZATION_CONNECTION_LIST, + variables: { + first: 8, + skip: 0, + filter: '', + orderBy: 'createdAt_ASC', + }, + notifyOnNetworkStatusChange: true, + }, + result: { + data: { + organizationsConnection: [], + }, + }, + }, + { + request: { + query: USER_ORGANIZATION_LIST, + variables: { userId: '123' }, + }, + result: { + data: { user: superAdminUser }, + }, + }, +]; +const MOCKS_WITH_ERROR = [ + { + request: { + query: ORGANIZATION_CONNECTION_LIST, + variables: { + first: 8, + skip: 0, + filter: '', + orderBy: 'createdAt_ASC', + }, + notifyOnNetworkStatusChange: true, + }, + result: { + data: { + organizationsConnection: organizations, + }, + }, + }, + { + request: { + query: USER_ORGANIZATION_LIST, + variables: { userId: '123' }, + }, + result: { + data: { user: superAdminUser }, + }, + }, + { + request: { + query: CREATE_SAMPLE_ORGANIZATION_MUTATION, + }, + error: new Error('Failed to create sample organization'), + }, +]; + +// MOCKS FOR ADMIN +const MOCKS_ADMIN = [ + { + request: { + query: ORGANIZATION_CONNECTION_LIST, + variables: { + first: 8, + skip: 0, + filter: '', + orderBy: 'createdAt_ASC', + }, + notifyOnNetworkStatusChange: true, + }, + result: { + data: { + organizationsConnection: organizations, + }, + }, + }, + { + request: { + query: USER_ORGANIZATION_LIST, + variables: { userId: '123' }, + }, + result: { + data: { user: adminUser }, + }, + }, +]; + +export { MOCKS, MOCKS_ADMIN, MOCKS_EMPTY, MOCKS_WITH_ERROR }; diff --git a/src/screens/OrgList/OrganizationModal.tsx b/src/screens/OrgList/OrganizationModal.tsx new file mode 100644 index 0000000000..8a44d2b851 --- /dev/null +++ b/src/screens/OrgList/OrganizationModal.tsx @@ -0,0 +1,322 @@ +import React from 'react'; +import { Modal, Form, Row, Col, Button } from 'react-bootstrap'; +import convertToBase64 from 'utils/convertToBase64'; +import type { ChangeEvent } from 'react'; +import styles from './OrgList.module.css'; +import type { InterfaceAddress } from 'utils/interfaces'; +import { countryOptions } from 'utils/formEnumFields'; +import useLocalStorage from 'utils/useLocalstorage'; + +/** + * Represents the state of the form in the organization modal. + */ +interface InterfaceFormStateType { + name: string; + descrip: string; + userRegistrationRequired: boolean; + visible: boolean; + address: InterfaceAddress; + image: string; +} + +/** + * Represents a user type. + */ +interface InterfaceUserType { + user: { + firstName: string; + lastName: string; + image: string | null; + email: string; + }; + + // Add more properties if needed +} + +/** + * Represents the properties of the OrganizationModal component. + */ +interface InterfaceOrganizationModalProps { + showModal: boolean; + toggleModal: () => void; + formState: InterfaceFormStateType; + setFormState: (state: React.SetStateAction) => void; + createOrg: (e: ChangeEvent) => Promise; + t: (key: string) => string; + tCommon: (key: string) => string; + userData: InterfaceUserType | undefined; + triggerCreateSampleOrg: () => void; +} + +/** + * Represents the organization modal component. + */ + +const OrganizationModal: React.FC = ({ + showModal, + toggleModal, + formState, + setFormState, + createOrg, + t, + tCommon, + triggerCreateSampleOrg, +}) => { + // function to update the state of the parameters inside address. + const { getItem } = useLocalStorage(); + const superAdmin = getItem('SuperAdmin'); + const adminFor = getItem('AdminFor'); + + const handleInputChange = (fieldName: string, value: string): void => { + setFormState((prevState) => ({ + ...prevState, + address: { + ...prevState.address, + [fieldName]: value, + }, + })); + }; + return ( + + + + {t('createOrganization')} + + +
    + + {tCommon('name')} + { + const inputText = e.target.value; + if (inputText.length < 50) { + setFormState({ + ...formState, + name: e.target.value, + }); + } + }} + /> + {tCommon('description')} + { + const descriptionText = e.target.value; + if (descriptionText.length < 200) { + setFormState({ + ...formState, + descrip: e.target.value, + }); + } + }} + /> + {tCommon('address')} + + + { + const countryCode = e.target.value; + handleInputChange('countryCode', countryCode); + }} + > + + {countryOptions.map((country) => ( + + ))} + + + + handleInputChange('city', e.target.value)} + /> + + + + + handleInputChange('state', e.target.value)} + /> + + + + handleInputChange('dependentLocality', e.target.value) + } + /> + + + + + handleInputChange('line1', e.target.value)} + /> + + + handleInputChange('line2', e.target.value)} + /> + + + + + + handleInputChange('postalCode', e.target.value) + } + /> + + + + handleInputChange('sortingCode', e.target.value) + } + /> + + + + + + {t('userRegistrationRequired')} + + + setFormState({ + ...formState, + userRegistrationRequired: + !formState.userRegistrationRequired, + }) + } + /> + + + + {t('visibleInSearch')} + + + setFormState({ + ...formState, + visible: !formState.visible, + }) + } + /> + + + {tCommon('displayImage')} + => { + const target = e.target as HTMLInputElement; + const file = target.files && target.files[0]; + /* istanbul ignore else */ + if (file) + setFormState({ + ...formState, + image: await convertToBase64(file), + }); + }} + data-testid="organisationImage" + /> + + + +
    +
    + {tCommon('OR')} +
    + {(adminFor.length > 0 || superAdmin) && ( +
    + +
    + )} + +
    +
    +
    + ); +}; + +export default OrganizationModal; diff --git a/src/screens/OrgPost/OrgPost.module.css b/src/screens/OrgPost/OrgPost.module.css new file mode 100644 index 0000000000..e674efbc7a --- /dev/null +++ b/src/screens/OrgPost/OrgPost.module.css @@ -0,0 +1,325 @@ +.mainpage { + display: flex; + flex-direction: row; +} +.btnsContainer { + display: flex; + margin: 2.5rem 0 2.5rem 0; +} + +.btnsContainer .btnsBlock { + display: flex; +} + +.btnsContainer .btnsBlock button { + margin-left: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.btnsContainer .input { + flex: 1; + position: relative; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); +} + +.btnsContainer .input button { + width: 52px; +} + +.preview { + display: flex; + position: relative; + width: 100%; + margin-top: 10px; + justify-content: center; +} +.preview img { + width: 400px; + height: auto; +} +.preview video { + width: 400px; + height: auto; +} +.logintitle { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 30px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 15%; +} +.logintitleadmin { + color: #707070; + font-weight: 600; + font-size: 18px; + margin-top: 50px; + margin-bottom: 40px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 30%; +} +.admindetails { + display: flex; + justify-content: space-between; +} +.admindetails > p { + margin-top: -12px; + margin-right: 30px; +} + +.mainpageright > hr { + margin-top: 20px; + width: 100%; + margin-left: -15px; + margin-right: -15px; + margin-bottom: 20px; +} +.justifysp { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 3rem; +} + +@media (max-width: 1120px) { + .contract { + padding-left: calc(250px + 2rem + 1.5rem); + } + + .listBox .itemCard { + width: 100%; + } +} + +@media (max-width: 1020px) { + .btnsContainer { + flex-direction: column; + margin: 1.5rem 0; + } + + .btnsContainer .btnsBlock { + margin: 1.5rem 0 0 0; + justify-content: space-between; + } + + .btnsContainer .btnsBlock button { + margin: 0; + } + + .btnsContainer .btnsBlock div button { + margin-right: 1.5rem; + } +} + +/* For mobile devices */ + +@media (max-width: 520px) { + .btnsContainer { + margin-bottom: 0; + } + + .btnsContainer .btnsBlock { + display: block; + margin-top: 1rem; + margin-right: 0; + } + + .btnsContainer .btnsBlock div { + flex: 1; + } + + .btnsContainer .btnsBlock div[title='Sort organizations'] { + margin-right: 0.5rem; + } + + .btnsContainer .btnsBlock button { + margin-bottom: 1rem; + margin-right: 0; + width: 100%; + } +} +@media screen and (max-width: 575.5px) { + .justifysp { + display: flex; + justify-content: space-between; + width: 100%; + } + + .mainpageright { + width: 98%; + } +} +.addbtn { + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + border-radius: 5px; + font-size: 16px; + height: 60%; + width: 60%; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; +} +.flexdir { + display: flex; + flex-direction: row; + justify-content: space-between; + border: none; +} +.form_wrapper { + margin-top: 27px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + position: absolute; + display: flex; + flex-direction: column; + padding: 20px 30px; + background: #ffffff; + border-color: #e8e5e5; + border-width: 5px; + border-radius: 10px; +} + +.form_wrapper form { + display: flex; + align-items: left; + justify-content: left; + flex-direction: column; +} +.logintitleinvite { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 40%; +} +.postinfo { + height: 80px; +} + +.postinfo { + height: 80px; + margin-bottom: 20px; +} +.titlemodal { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 65%; +} +.cancel > i { + margin-top: 5px; + transform: scale(1.2); + cursor: pointer; + color: #707070; +} +.modalbody { + width: 50px; +} + +.closeButton { + position: absolute; + top: 0px; + right: 0px; + background: transparent; + transform: scale(1.2); + cursor: pointer; + border: none; + color: #707070; + font-weight: 600; + font-size: 16px; + cursor: pointer; +} +.sidebarsticky > input { + text-decoration: none; + margin-bottom: 50px; + border: solid 1.5px #d3d3d3; + border-radius: 5px; + width: 80%; + border-radius: 7px; + padding-top: 5px; + padding-bottom: 5px; + padding-right: 10px; + padding-left: 10px; + text-decoration: none; + box-shadow: none; +} + +.sidebarsticky > input:focus { + border-color: #fff; + box-shadow: 0 0 5pt 0.5pt #d3d3d3; + outline: none; +} +button[data-testid='createPostBtn'] { + display: block; +} +.loader, +.loader:after { + border-radius: 50%; + width: 10em; + height: 10em; +} +.loader { + margin: 60px auto; + margin-top: 35vh !important; + font-size: 10px; + position: relative; + text-indent: -9999em; + border-top: 1.1em solid rgba(255, 255, 255, 0.2); + border-right: 1.1em solid rgba(255, 255, 255, 0.2); + border-bottom: 1.1em solid rgba(255, 255, 255, 0.2); + border-left: 1.1em solid #febc59; + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); + -webkit-animation: load8 1.1s infinite linear; + animation: load8 1.1s infinite linear; +} +@-webkit-keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +.list_box { + height: 70vh; + overflow-y: auto; + width: auto; +} +@media only screen and (max-width: 600px) { + .form_wrapper { + width: 90%; + top: 45%; + } +} diff --git a/src/screens/OrgPost/OrgPost.test.tsx b/src/screens/OrgPost/OrgPost.test.tsx new file mode 100644 index 0000000000..9829589350 --- /dev/null +++ b/src/screens/OrgPost/OrgPost.test.tsx @@ -0,0 +1,689 @@ +import { MockedProvider } from '@apollo/react-testing'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import React, { act } from 'react'; +import { CREATE_POST_MUTATION } from 'GraphQl/Mutations/mutations'; +import { ORGANIZATION_POST_LIST } from 'GraphQl/Queries/Queries'; +import { ToastContainer } from 'react-toastify'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18nForTest from 'utils/i18nForTest'; +import OrgPost from './OrgPost'; +const MOCKS = [ + { + request: { + query: ORGANIZATION_POST_LIST, + variables: { + id: undefined, + after: null, + before: null, + first: 6, + last: null, + }, + }, + result: { + data: { + organizations: [ + { + posts: { + edges: [ + { + node: { + _id: '6411e53835d7ba2344a78e21', + title: 'postone', + text: 'This is the first post', + imageUrl: null, + videoUrl: null, + createdAt: '2023-08-24T09:26:56.524+00:00', + creator: { + _id: '640d98d9eb6a743d75341067', + firstName: 'Aditya', + lastName: 'Shelke', + email: 'adidacreator1@gmail.com', + }, + likeCount: 0, + commentCount: 0, + comments: [], + pinned: true, + likedBy: [], + }, + cursor: '6411e53835d7ba2344a78e21', + }, + { + node: { + _id: '6411e54835d7ba2344a78e29', + title: 'posttwo', + text: 'Tis is the post two', + imageUrl: null, + videoUrl: null, + createdAt: '2023-08-24T09:26:56.524+00:00', + creator: { + _id: '640d98d9eb6a743d75341067', + firstName: 'Aditya', + lastName: 'Shelke', + email: 'adidacreator1@gmail.com', + }, + likeCount: 0, + commentCount: 0, + pinned: false, + likedBy: [], + comments: [], + }, + cursor: '6411e54835d7ba2344a78e29', + }, + { + node: { + _id: '6411e54835d7ba2344a78e30', + title: 'posttwo', + text: 'Tis is the post two', + imageUrl: null, + videoUrl: null, + createdAt: '2023-08-24T09:26:56.524+00:00', + creator: { + _id: '640d98d9eb6a743d75341067', + firstName: 'Aditya', + lastName: 'Shelke', + email: 'adidacreator1@gmail.com', + }, + likeCount: 0, + commentCount: 0, + pinned: true, + likedBy: [], + comments: [], + }, + cursor: '6411e54835d7ba2344a78e30', + }, + { + node: { + _id: '6411e54835d7ba2344a78e31', + title: 'posttwo', + text: 'Tis is the post two', + imageUrl: null, + videoUrl: null, + createdAt: '2023-08-24T09:26:56.524+00:00', + creator: { + _id: '640d98d9eb6a743d75341067', + firstName: 'Aditya', + lastName: 'Shelke', + email: 'adidacreator1@gmail.com', + }, + likeCount: 0, + commentCount: 0, + pinned: false, + likedBy: [], + comments: [], + }, + cursor: '6411e54835d7ba2344a78e31', + }, + ], + pageInfo: { + startCursor: '6411e53835d7ba2344a78e21', + endCursor: '6411e54835d7ba2344a78e31', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 4, + }, + }, + ], + }, + }, + }, + { + request: { + query: CREATE_POST_MUTATION, + variables: { + title: 'Dummy Post', + text: 'This is dummy text', + organizationId: '123', + }, + result: { + data: { + createPost: { + _id: '453', + }, + }, + }, + }, + }, + { + request: { + query: CREATE_POST_MUTATION, + variables: { + title: 'Dummy Post', + text: 'This is dummy text', + organizationId: '123', + }, + result: { + data: { + createPost: { + _id: '453', + }, + }, + }, + }, + }, +]; +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 500): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Organisation Post Page', () => { + const formData = { + posttitle: 'dummy post', + postinfo: 'This is a dummy post', + postImage: new File(['hello'], 'hello.png', { type: 'image/png' }), + postVideo: new File(['hello'], 'hello.mp4', { type: 'video/mp4' }), + }; + + test('correct mock data should be queried', async () => { + const dataQuery1 = MOCKS[0]?.result?.data?.organizations[0].posts.edges[0]; + + expect(dataQuery1).toEqual({ + node: { + _id: '6411e53835d7ba2344a78e21', + title: 'postone', + text: 'This is the first post', + imageUrl: null, + videoUrl: null, + createdAt: '2023-08-24T09:26:56.524+00:00', + creator: { + _id: '640d98d9eb6a743d75341067', + firstName: 'Aditya', + lastName: 'Shelke', + email: 'adidacreator1@gmail.com', + }, + likeCount: 0, + commentCount: 0, + pinned: true, + likedBy: [], + comments: [], + }, + cursor: '6411e53835d7ba2344a78e21', + }); + }); + + test('Testing create post functionality', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('createPostModalBtn')); + + userEvent.type(screen.getByTestId('modalTitle'), formData.posttitle); + + userEvent.type(screen.getByTestId('modalinfo'), formData.postinfo); + userEvent.upload(screen.getByTestId('addMediaField'), formData.postImage); + userEvent.upload(screen.getByTestId('addMediaField'), formData.postVideo); + userEvent.upload(screen.getByTestId('addMediaField'), formData.postImage); + userEvent.upload(screen.getByTestId('addMediaField'), formData.postVideo); + userEvent.click(screen.getByTestId('pinPost')); + expect(screen.getByTestId('pinPost')).toBeChecked(); + + userEvent.click(screen.getByTestId('createPostBtn')); + + await wait(); + + userEvent.click(screen.getByTestId('closeOrganizationModal')); + }, 15000); + + test('Testing search functionality', async () => { + render( + + + + + + + + + , + ); + async function debounceWait(ms = 200): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); + } + await debounceWait(); + userEvent.type(screen.getByPlaceholderText(/Search By/i), 'postone{enter}'); + await debounceWait(); + const sortDropdown = screen.getByTestId('sort'); + userEvent.click(sortDropdown); + }); + test('Testing search text and title toggle', async () => { + await act(async () => { + // Wrap the test code in act + render( + + + + + + + + + , + ); + }); + await wait(); + + const searchInput = screen.getByTestId('searchByName'); + expect(searchInput).toHaveAttribute('placeholder', 'Search By Title'); + + const inputText = screen.getByTestId('searchBy'); + + await act(async () => { + fireEvent.click(inputText); + }); + + const toggleText = screen.getByTestId('Text'); + + await act(async () => { + fireEvent.click(toggleText); + }); + + expect(searchInput).toHaveAttribute('placeholder', 'Search By Text'); + await act(async () => { + fireEvent.click(inputText); + }); + const toggleTite = screen.getByTestId('searchTitle'); + await act(async () => { + fireEvent.click(toggleTite); + }); + + expect(searchInput).toHaveAttribute('placeholder', 'Search By Title'); + }); + test('Testing search latest and oldest toggle', async () => { + await act(async () => { + // Wrap the test code in act + render( + + + + + + + + + , + ); + }); + await wait(); + + const searchInput = screen.getByTestId('sort'); + expect(searchInput).toBeInTheDocument(); + + const inputText = screen.getByTestId('sortpost'); + + await act(async () => { + fireEvent.click(inputText); + }); + + const toggleText = screen.getByTestId('latest'); + + await act(async () => { + fireEvent.click(toggleText); + }); + + expect(searchInput).toBeInTheDocument(); + await act(async () => { + fireEvent.click(inputText); + }); + + const toggleTite = screen.getByTestId('oldest'); + await act(async () => { + fireEvent.click(toggleTite); + }); + expect(searchInput).toBeInTheDocument(); + }); + test('After creating a post, the data should be refetched', async () => { + const refetchMock = jest.fn(); + + render( + + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('createPostModalBtn')); + + // Fill in post form fields... + + userEvent.click(screen.getByTestId('createPostBtn')); + + await wait(); + + expect(refetchMock).toHaveBeenCalledTimes(0); + }); + + test('Create post without media', async () => { + render( + + + + + + + + + + , + ); + + await wait(); + userEvent.click(screen.getByTestId('createPostModalBtn')); + + const postTitleInput = screen.getByTestId('modalTitle'); + fireEvent.change(postTitleInput, { target: { value: 'Test Post' } }); + + const postInfoTextarea = screen.getByTestId('modalinfo'); + fireEvent.change(postInfoTextarea, { + target: { value: 'Test post information' }, + }); + + const createPostBtn = screen.getByTestId('createPostBtn'); + fireEvent.click(createPostBtn); + }, 15000); + + test('Create post and preview', async () => { + render( + + + + + + + + + + , + ); + + await wait(); + userEvent.click(screen.getByTestId('createPostModalBtn')); + + const postTitleInput = screen.getByTestId('modalTitle'); + fireEvent.change(postTitleInput, { target: { value: 'Test Post' } }); + + const postInfoTextarea = screen.getByTestId('modalinfo'); + fireEvent.change(postInfoTextarea, { + target: { value: 'Test post information' }, + }); + + // Simulate uploading an image + const imageFile = new File(['image content'], 'image.png', { + type: 'image/png', + }); + const imageInput = screen.getByTestId('addMediaField'); + userEvent.upload(imageInput, imageFile); + + // Check if the image is displayed + const imagePreview = await screen.findByAltText('Post Image Preview'); + expect(imagePreview).toBeInTheDocument(); + + // Check if the close button for the image works + const closeButton = screen.getByTestId('mediaCloseButton'); + fireEvent.click(closeButton); + + // Check if the image is removed from the preview + expect(imagePreview).not.toBeInTheDocument(); + }, 15000); + + test('Modal opens and closes', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + const createPostModalBtn = screen.getByTestId('createPostModalBtn'); + + userEvent.click(createPostModalBtn); + + const modalTitle = screen.getByTestId('modalOrganizationHeader'); + expect(modalTitle).toBeInTheDocument(); + + const closeButton = screen.getByTestId(/modalOrganizationHeader/i); + userEvent.click(closeButton); + + await wait(); + + const closedModalTitle = screen.queryByText(/postDetail/i); + expect(closedModalTitle).not.toBeInTheDocument(); + }); + it('renders the form with input fields and buttons', async () => { + render( + + + + + + + + + + , + ); + + await wait(); + userEvent.click(screen.getByTestId('createPostModalBtn')); + + // Check if input fields and buttons are present + expect(screen.getByTestId('modalTitle')).toBeInTheDocument(); + expect(screen.getByTestId('modalinfo')).toBeInTheDocument(); + expect(screen.getByTestId('createPostBtn')).toBeInTheDocument(); + }); + + it('allows users to input data into the form fields', async () => { + render( + + + + + + + + + + , + ); + + await wait(); + userEvent.click(screen.getByTestId('createPostModalBtn')); + + // Simulate user input + fireEvent.change(screen.getByTestId('modalTitle'), { + target: { value: 'Test Title' }, + }); + fireEvent.change(screen.getByTestId('modalinfo'), { + target: { value: 'Test Info' }, + }); + + // Check if input values are set correctly + expect(screen.getByTestId('modalTitle')).toHaveValue('Test Title'); + expect(screen.getByTestId('modalinfo')).toHaveValue('Test Info'); + }); + + test('allows users to upload an image', async () => { + render( + + + + + + + + + + , + ); + + await wait(); + userEvent.click(screen.getByTestId('createPostModalBtn')); + + const postTitleInput = screen.getByTestId('modalTitle'); + fireEvent.change(postTitleInput, { target: { value: 'Test Post' } }); + + const postInfoTextarea = screen.getByTestId('modalinfo'); + fireEvent.change(postInfoTextarea, { + target: { value: 'Test post information' }, + }); + const file = new File(['image content'], 'image.png', { + type: 'image/png', + }); + const input = screen.getByTestId('addMediaField'); + userEvent.upload(input, file); + + await screen.findByAltText('Post Image Preview'); + expect(screen.getByAltText('Post Image Preview')).toBeInTheDocument(); + + const closeButton = screen.getByTestId('mediaCloseButton'); + fireEvent.click(closeButton); + }, 15000); + test('Create post, preview image, and close preview', async () => { + await act(async () => { + render( + + + + + + + + + + , + ); + }); + await wait(); + + await act(async () => { + userEvent.click(screen.getByTestId('createPostModalBtn')); + }); + + const postTitleInput = screen.getByTestId('modalTitle'); + await act(async () => { + fireEvent.change(postTitleInput, { target: { value: 'Test Post' } }); + }); + + const postInfoTextarea = screen.getByTestId('modalinfo'); + await act(async () => { + fireEvent.change(postInfoTextarea, { + target: { value: 'Test post information' }, + }); + }); + + const videoFile = new File(['video content'], 'video.mp4', { + type: 'video/mp4', + }); + + await act(async () => { + userEvent.upload(screen.getByTestId('addMediaField'), videoFile); + }); + + // Check if the video is displayed + const videoPreview = await screen.findByTestId('videoPreview'); + expect(videoPreview).toBeInTheDocument(); + + // Check if the close button for the video works + const closeVideoPreviewButton = screen.getByTestId('mediaCloseButton'); + await act(async () => { + fireEvent.click(closeVideoPreviewButton); + }); + expect(videoPreview).not.toBeInTheDocument(); + }); + test('Sorting posts by pinned status', async () => { + // Mocked data representing posts with different pinned statuses + const mockedPosts = [ + { + _id: '1', + title: 'Post 1', + pinned: true, + }, + { + _id: '2', + title: 'Post 2', + pinned: false, + }, + { + _id: '3', + title: 'Post 3', + pinned: true, + }, + { + _id: '4', + title: 'Post 4', + pinned: true, + }, + ]; + + // Render the OrgPost component and pass the mocked data to it + render( + + + + + + + + + + , + ); + + await wait(); + + const sortedPosts = screen.getAllByTestId('post-item'); + + // Assert that the posts are sorted correctly + expect(sortedPosts).toHaveLength(mockedPosts.length); + expect(sortedPosts[0]).toHaveTextContent( + 'postoneThis is the first po... Aditya Shelke', + ); + expect(sortedPosts[1]).toHaveTextContent( + 'posttwoTis is the post two Aditya Shelke', + ); + expect(sortedPosts[2]).toHaveTextContent( + 'posttwoTis is the post two Aditya Shelke', + ); + }); +}); diff --git a/src/screens/OrgPost/OrgPost.tsx b/src/screens/OrgPost/OrgPost.tsx new file mode 100644 index 0000000000..1931f6c76a --- /dev/null +++ b/src/screens/OrgPost/OrgPost.tsx @@ -0,0 +1,574 @@ +import { useMutation, useQuery, type ApolloError } from '@apollo/client'; +import { Search } from '@mui/icons-material'; +import SortIcon from '@mui/icons-material/Sort'; +import { CREATE_POST_MUTATION } from 'GraphQl/Mutations/mutations'; +import { ORGANIZATION_POST_LIST } from 'GraphQl/Queries/Queries'; +import Loader from 'components/Loader/Loader'; +import NotFound from 'components/NotFound/NotFound'; +import OrgPostCard from 'components/OrgPostCard/OrgPostCard'; +import { useNavigate, useParams } from 'react-router-dom'; +import type { ChangeEvent } from 'react'; +import React, { useEffect, useState } from 'react'; +import { Form } from 'react-bootstrap'; +import Button from 'react-bootstrap/Button'; +import Dropdown from 'react-bootstrap/Dropdown'; +import Modal from 'react-bootstrap/Modal'; +import Row from 'react-bootstrap/Row'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import convertToBase64 from 'utils/convertToBase64'; +import { errorHandler } from 'utils/errorHandler'; +import type { InterfaceQueryOrganizationPostListItem } from 'utils/interfaces'; +import styles from './OrgPost.module.css'; + +interface InterfaceOrgPost { + _id: string; + title: string; + text: string; + imageUrl: string | null; + videoUrl: string | null; + creator: { _id: string; firstName: string; lastName: string; email: string }; + pinned: boolean; + createdAt: string; + likeCount: number; + commentCount: number; + likedBy: { _id: string }[]; + comments: { + _id: string; + text: string; + creator: { _id: string }; + createdAt: string; + likeCount: number; + likedBy: { _id: string }[]; + }[]; +} + +/** + * This function is used to display the posts of the organization. It displays the posts in a card format. + * It also provides the functionality to create a new post. The user can also sort the posts based on the date of creation. + * The user can also search for a post based on the title of the post. + * @returns JSX.Element which contains the posts of the organization. + */ +function orgPost(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'orgPost', + }); + const { t: tCommon } = useTranslation('common'); + + document.title = t('title'); + const [postmodalisOpen, setPostModalIsOpen] = useState(false); + const [postformState, setPostFormState] = useState({ + posttitle: '', + postinfo: '', + postImage: '', + postVideo: '', + addMedia: '', + pinPost: false, + }); + const [sortingOption, setSortingOption] = useState('latest'); + const [file, setFile] = useState(null); + const { orgId: currentUrl } = useParams(); + const navigate = useNavigate(); + const [showTitle, setShowTitle] = useState(true); + const [after, setAfter] = useState(null); + const [before, setBefore] = useState(null); + const [first, setFirst] = useState(6); + const [last, setLast] = useState(null); + + const showInviteModal = (): void => { + setPostModalIsOpen(true); + }; + + const hideInviteModal = (): void => { + setPostModalIsOpen(false); + setPostFormState({ + posttitle: '', + postinfo: '', + postImage: '', + postVideo: '', + addMedia: '', + pinPost: false, + }); + }; + + const { + data: orgPostListData, + loading: orgPostListLoading, + error: orgPostListError, + refetch, + }: { + data?: { + organizations: InterfaceQueryOrganizationPostListItem[]; + }; + loading: boolean; + error?: ApolloError; + refetch: (filterData?: { + id: string | undefined; + // title_contains: string | null; + // text_contains: string | null; + after: string | null | undefined; + before: string | null | undefined; + first: number | null; + last: number | null; + }) => void; + } = useQuery(ORGANIZATION_POST_LIST, { + variables: { + id: currentUrl as string, + after: after ?? null, + before: before ?? null, + first: first, + last: last, + }, + }); + const [create, { loading: createPostLoading }] = + useMutation(CREATE_POST_MUTATION); + const [displayedPosts, setDisplayedPosts] = useState( + orgPostListData?.organizations[0].posts.edges.map((edge) => edge.node) || + [], + ); + + // ... + + useEffect(() => { + if (orgPostListData && orgPostListData.organizations) { + const newDisplayedPosts: InterfaceOrgPost[] = sortPosts( + orgPostListData.organizations[0].posts.edges.map((edge) => edge.node), + sortingOption, + ); + setDisplayedPosts(newDisplayedPosts); + } + }, [orgPostListData, sortingOption]); + + const createPost = async (e: ChangeEvent): Promise => { + e.preventDefault(); + + const { + posttitle: _posttitle, + postinfo: _postinfo, + postImage, + postVideo, + pinPost, + } = postformState; + + const posttitle = _posttitle.trim(); + const postinfo = _postinfo.trim(); + + try { + if (!posttitle || !postinfo) { + throw new Error('Text fields cannot be empty strings'); + } + + const { data } = await create({ + variables: { + title: posttitle, + text: postinfo, + organizationId: currentUrl, + file: postImage || postVideo || postformState.addMedia, + pinned: pinPost, + }, + }); + + /* istanbul ignore next */ + if (data) { + toast.success(t('postCreatedSuccess') as string); + refetch(); + setPostFormState({ + posttitle: '', + postinfo: '', + postImage: '', + postVideo: '', + addMedia: '', + pinPost: false, + }); + setPostModalIsOpen(false); + } + } catch (error: unknown) { + errorHandler(t, error); + } + }; + + useEffect(() => { + if (orgPostListError) { + navigate('/orglist'); + } + }, [orgPostListError]); + + if (createPostLoading || orgPostListLoading) { + return ; + } + + const handleAddMediaChange = async ( + e: React.ChangeEvent, + ): Promise => { + setPostFormState((prevPostFormState) => ({ + ...prevPostFormState, + addMedia: '', + })); + + const selectedFile = e.target.files?.[0]; + + if (selectedFile) { + setFile(selectedFile); + setPostFormState({ + ...postformState, + addMedia: await convertToBase64(selectedFile), + }); + } + }; + + const handleSearch = (e: React.ChangeEvent): void => { + const { value } = e.target; + const filterData = { + id: currentUrl, + title_contains: showTitle ? value : null, + text_contains: !showTitle ? value : null, + after: after || null, + before: before || null, + first: first || null, + last: last || null, + }; + refetch(filterData); + }; + + const debouncedHandleSearch = handleSearch; + + const handleSorting = (option: string): void => { + setSortingOption(option); + }; + const handleNextPage = (): void => { + setAfter(orgPostListData?.organizations[0].posts.pageInfo.endCursor); + setBefore(null); + setFirst(6); + setLast(null); + }; + const handlePreviousPage = (): void => { + setBefore(orgPostListData?.organizations[0].posts.pageInfo.startCursor); + setAfter(null); + setFirst(null); + setLast(6); + }; + // console.log(orgPostListData?.organizations[0].posts); + const sortPosts = ( + posts: InterfaceOrgPost[], + sortingOption: string, + ): InterfaceOrgPost[] => { + const sortedPosts = [...posts]; + + if (sortingOption === 'latest') { + sortedPosts.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + } else if (sortingOption === 'oldest') { + sortedPosts.sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + } + + return sortedPosts; + }; + + const sortedPostsList: InterfaceOrgPost[] = [...displayedPosts]; + sortedPostsList.sort((a: InterfaceOrgPost, b: InterfaceOrgPost) => { + if (a.pinned === b.pinned) { + return 0; + } + /* istanbul ignore next */ + if (a.pinned) { + return -1; + } + return 1; + }); + + return ( + <> + +
    +
    +
    + + +
    +
    +
    + + +
    + + +
    +
    +
    + {sortedPostsList && sortedPostsList.length > 0 ? ( + sortedPostsList.map( + (datas: { + _id: string; + title: string; + text: string; + imageUrl: string | null; + videoUrl: string | null; + + creator: { firstName: string; lastName: string }; + pinned: boolean; + }) => ( + + ), + ) + ) : ( + + )} +
    +
    +
    +
    + +
    +
    + +
    +
    +
    + + + {t('postDetails')} + +
    + + {t('postTitle')} + { + setPostFormState({ + ...postformState, + posttitle: e.target.value, + }); + }} + /> + {t('information')} + { + setPostFormState({ + ...postformState, + postinfo: e.target.value, + }); + }} + /> + + + {t('addMedia')} + + + {postformState.addMedia && file && ( +
    + {/* Display preview for both image and video */} + {file.type.startsWith('image') ? ( + Post Image Preview + ) : ( + + )} + +
    + )} + + {t('pinPost')} + + + setPostFormState({ + ...postformState, + pinPost: !postformState.pinPost, + }) + } + /> +
    + + + + + +
    +
    + + ); +} + +export default orgPost; diff --git a/src/screens/OrgSettings/OrgSettings.mocks.ts b/src/screens/OrgSettings/OrgSettings.mocks.ts new file mode 100644 index 0000000000..02748dbf70 --- /dev/null +++ b/src/screens/OrgSettings/OrgSettings.mocks.ts @@ -0,0 +1,143 @@ +import { + ACTION_ITEM_CATEGORY_LIST, + AGENDA_ITEM_CATEGORY_LIST, + IS_SAMPLE_ORGANIZATION_QUERY, + ORGANIZATION_CUSTOM_FIELDS, + ORGANIZATIONS_LIST, +} from 'GraphQl/Queries/Queries'; + +export const MOCKS = [ + { + request: { + query: ORGANIZATIONS_LIST, + variables: { + id: 'orgId', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + image: null, + creator: { + firstName: 'Wilt', + lastName: 'Shepherd', + email: 'testsuperadmin@example.com', + __typename: 'User', + }, + name: 'Unity Foundation', + description: + 'A foundation aimed at uniting the world and making it a better place for all.', + address: { + city: 'Bronx', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 Random Street', + line2: 'Apartment 456', + postalCode: '10451', + sortingCode: 'ABC-123', + state: 'NYC', + __typename: 'Address', + }, + userRegistrationRequired: false, + visibleInSearch: true, + members: [ + { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + email: 'testsuperadmin@example.com', + __typename: 'User', + }, + ], + admins: [ + { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + ], + membershipRequests: [], + blockedUsers: [], + __typename: 'Organization', + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_CUSTOM_FIELDS, + variables: { customFieldsByOrganizationId: 'orgId' }, + }, + result: { + data: { + customFieldsByOrganization: [ + { + _id: 'adsdasdsa334343yiu423434', + type: 'fieldType', + name: 'fieldName', + }, + ], + }, + }, + }, + { + request: { + query: IS_SAMPLE_ORGANIZATION_QUERY, + variables: { isSampleOrganizationId: 'orgId' }, + }, + result: { + data: { + isSampleOrganization: false, + }, + }, + }, + + { + request: { + query: AGENDA_ITEM_CATEGORY_LIST, + variables: { organizationId: 'orgId' }, + }, + result: { + data: { + agendaItemCategoriesByOrganization: [], + }, + }, + }, + { + request: { + query: ACTION_ITEM_CATEGORY_LIST, + variables: { + organizationId: 'orgId', + where: { + name_contains: '', + }, + orderBy: 'createdAt_DESC', + }, + }, + result: { + data: { + actionItemCategoriesByOrganization: [ + { + _id: 'actionItemCategoryId1', + name: 'Test 3', + isDisabled: false, + createdAt: '2024-08-25', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + __typename: 'User', + }, + __typename: 'ActionItemCategory', + }, + ], + }, + }, + }, +]; diff --git a/src/screens/OrgSettings/OrgSettings.module.css b/src/screens/OrgSettings/OrgSettings.module.css new file mode 100644 index 0000000000..9952a9a459 --- /dev/null +++ b/src/screens/OrgSettings/OrgSettings.module.css @@ -0,0 +1,55 @@ +.headerBtn { + box-shadow: rgba(0, 0, 0, 0.2) 0px 2px 2px; +} +.settingsContainer { + min-height: 100vh; +} + +.settingsBody { + min-height: 100vh; + margin: 2.5rem 1rem; +} + +.cardHeader { + padding: 1.25rem 1rem 1rem 1rem; + border-bottom: 1px solid var(--bs-gray-200); + display: flex; + justify-content: space-between; + align-items: center; +} + +.cardHeader .cardTitle { + font-size: 1.2rem; + font-weight: 600; +} + +.cardBody { + min-height: 180px; +} + +.cardBody .textBox { + margin: 0 0 3rem 0; + color: var(--bs-secondary); +} + +hr { + border: none; + height: 1px; + background-color: var(--bs-gray-500); +} + +.settingsTabs { + display: none; +} + +@media (min-width: 577px) { + .settingsDropdown { + display: none; + } +} + +@media (min-width: 577px) { + .settingsTabs { + display: block; + } +} diff --git a/src/screens/OrgSettings/OrgSettings.test.tsx b/src/screens/OrgSettings/OrgSettings.test.tsx new file mode 100644 index 0000000000..a9aec5f33d --- /dev/null +++ b/src/screens/OrgSettings/OrgSettings.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18nForTest from 'utils/i18nForTest'; +import OrgSettings from './OrgSettings'; +import userEvent from '@testing-library/user-event'; +import type { ApolloLink } from '@apollo/client'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { MOCKS } from './OrgSettings.mocks'; + +const link1 = new StaticMockLink(MOCKS); + +const renderOrganisationSettings = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } /> +
    } + /> + + + + + + , + ); +}; + +describe('Organisation Settings Page', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> +
    } + /> + + + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + test('should render the organisation settings page', async () => { + renderOrganisationSettings(link1); + + await waitFor(() => { + expect(screen.getByTestId('generalSettings')).toBeInTheDocument(); + expect( + screen.getByTestId('actionItemCategoriesSettings'), + ).toBeInTheDocument(); + expect( + screen.getByTestId('agendaItemCategoriesSettings'), + ).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('generalSettings')); + + await waitFor(() => { + expect(screen.getByTestId('generalTab')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('actionItemCategoriesSettings')); + await waitFor(() => { + expect(screen.getByTestId('actionItemCategoriesTab')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('agendaItemCategoriesSettings')); + await waitFor(() => { + expect(screen.getByTestId('agendaItemCategoriesTab')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/screens/OrgSettings/OrgSettings.tsx b/src/screens/OrgSettings/OrgSettings.tsx new file mode 100644 index 0000000000..e4ae5424a6 --- /dev/null +++ b/src/screens/OrgSettings/OrgSettings.tsx @@ -0,0 +1,129 @@ +import React, { useState } from 'react'; +import { Button, Dropdown, Row, Col } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import styles from './OrgSettings.module.css'; +import OrgActionItemCategories from 'components/OrgSettings/ActionItemCategories/OrgActionItemCategories'; +import OrganizationAgendaCategory from 'components/OrgSettings/AgendaItemCategories/OrganizationAgendaCategory'; +import { Navigate, useParams } from 'react-router-dom'; +import GeneralSettings from 'components/OrgSettings/General/GeneralSettings'; + +// Type representing the different settings categories available +type SettingType = 'general' | 'actionItemCategories' | 'agendaItemCategories'; + +// List of available settings categories +const settingtabs: SettingType[] = [ + 'general', + 'actionItemCategories', + 'agendaItemCategories', +]; + +/** + * The `orgSettings` component provides a user interface for managing various settings related to an organization. + * It includes options for updating organization details, deleting the organization, changing language preferences, + * and managing custom fields and action item categories. + * + * The component renders different settings sections based on the user's selection from the tabs or dropdown menu. + * + * @returns The rendered component displaying the organization settings. + */ +function orgSettings(): JSX.Element { + // Translation hook for internationalization + const { t } = useTranslation('translation', { + keyPrefix: 'orgSettings', + }); + + const [tab, setTab] = useState('general'); + + // Set the document title using the translated title for this page + document.title = t('title'); + + // Get the organization ID from the URL parameters + const { orgId } = useParams(); + + if (!orgId) { + return ; + } + + return ( +
    + + +
    + {/* Render buttons for each settings category */} + {settingtabs.map((setting, index) => ( + + ))} +
    + + {/* Dropdown menu for selecting settings category */} + + + {t(tab)} + + + {/* Render dropdown items for each settings category */} + {settingtabs.map((setting, index) => ( + setTab(setting) + } + className={tab === setting ? 'text-secondary' : ''} + > + {t(setting)} + + ))} + + + + + +
    +
    +
    + + {/* Render content based on the selected settings category */} + {(() => { + switch (tab) { + case 'general': + return ( +
    + +
    + ); + case 'actionItemCategories': + return ( +
    + +
    + ); + case 'agendaItemCategories': + return ( +
    + +
    + ); + } + })()} +
    + ); +} + +export default orgSettings; diff --git a/src/screens/OrganizationActionItems/ItemDeleteModal.test.tsx b/src/screens/OrganizationActionItems/ItemDeleteModal.test.tsx new file mode 100644 index 0000000000..fffeebfd7f --- /dev/null +++ b/src/screens/OrganizationActionItems/ItemDeleteModal.test.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18nForTest from '../../utils/i18nForTest'; +import { MOCKS, MOCKS_ERROR } from './OrganizationActionItem.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import ItemDeleteModal, { + type InterfaceItemDeleteModalProps, +} from './ItemDeleteModal'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const t = JSON.parse( + JSON.stringify( + i18nForTest.getDataByLanguage('en')?.translation.organizationActionItems, + ), +); + +const itemProps: InterfaceItemDeleteModalProps = { + isOpen: true, + hide: jest.fn(), + actionItemsRefetch: jest.fn(), + actionItem: { + _id: 'actionItemId1', + assignee: null, + assigneeGroup: null, + assigneeType: 'User', + assigneeUser: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: undefined, + }, + actionItemCategory: { + _id: 'actionItemCategoryId1', + name: 'Category 1', + }, + preCompletionNotes: 'Notes 1', + postCompletionNotes: 'Cmp Notes 1', + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-08-30'), + completionDate: new Date('2044-09-03'), + isCompleted: true, + event: null, + allottedHours: 24, + assigner: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: undefined, + }, + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, +}; + +const renderItemDeleteModal = ( + link: ApolloLink, + props: InterfaceItemDeleteModalProps, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing ItemDeleteModal', () => { + it('should render ItemDeleteModal', () => { + renderItemDeleteModal(link1, itemProps); + expect(screen.getByText(t.deleteActionItem)).toBeInTheDocument(); + }); + + it('should successfully Delete Action Item', async () => { + renderItemDeleteModal(link1, itemProps); + expect(screen.getByTestId('deleteyesbtn')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('deleteyesbtn')); + + await waitFor(() => { + expect(itemProps.actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps.hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulDeletion); + }); + }); + + it('should fail to Delete Action Item', async () => { + renderItemDeleteModal(link2, itemProps); + expect(screen.getByTestId('deleteyesbtn')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('deleteyesbtn')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Mock Graphql Error'); + }); + }); +}); diff --git a/src/screens/OrganizationActionItems/ItemDeleteModal.tsx b/src/screens/OrganizationActionItems/ItemDeleteModal.tsx new file mode 100644 index 0000000000..2526486993 --- /dev/null +++ b/src/screens/OrganizationActionItems/ItemDeleteModal.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { Modal, Button } from 'react-bootstrap'; +import styles from './OrganizationActionItems.module.css'; +import { useMutation } from '@apollo/client'; +import { DELETE_ACTION_ITEM_MUTATION } from 'GraphQl/Mutations/ActionItemMutations'; +import { toast } from 'react-toastify'; +import { useTranslation } from 'react-i18next'; +import type { InterfaceActionItemInfo } from 'utils/interfaces'; + +/** + * Props for the `ItemDeleteModal` component. + */ +export interface InterfaceItemDeleteModalProps { + isOpen: boolean; + hide: () => void; + actionItem: InterfaceActionItemInfo | null; + actionItemsRefetch: () => void; +} + +/** + * A modal component for confirming the deletion of an action item. + * + * @param props - The properties passed to the component. + * @returns The `ItemDeleteModal` component. + */ +const ItemDeleteModal: React.FC = ({ + isOpen, + hide, + actionItem, + actionItemsRefetch, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'organizationActionItems', + }); + const { t: tCommon } = useTranslation('common'); + + const [removeActionItem] = useMutation(DELETE_ACTION_ITEM_MUTATION); + + /** + * Handles the action item deletion. + */ + const deleteActionItemHandler = async (): Promise => { + try { + await removeActionItem({ + variables: { + actionItemId: actionItem?._id, + }, + }); + + actionItemsRefetch(); + hide(); + toast.success(t('successfulDeletion')); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + return ( + <> + + +

    {t('deleteActionItem')}

    + +
    + +

    {t('deleteActionItemMsg')}

    +
    + + + + +
    + + ); +}; + +export default ItemDeleteModal; diff --git a/src/screens/OrganizationActionItems/ItemModal.test.tsx b/src/screens/OrganizationActionItems/ItemModal.test.tsx new file mode 100644 index 0000000000..a58496e6df --- /dev/null +++ b/src/screens/OrganizationActionItems/ItemModal.test.tsx @@ -0,0 +1,798 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18n from '../../utils/i18nForTest'; +import { MOCKS, MOCKS_ERROR } from './OrganizationActionItem.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { InterfaceItemModalProps } from './ItemModal'; +import ItemModal from './ItemModal'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + warning: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.organizationActionItems ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceItemModalProps[] = [ + { + isOpen: true, + hide: jest.fn(), + orgId: 'orgId', + eventId: undefined, + actionItemsRefetch: jest.fn(), + editMode: false, + actionItem: null, + }, + { + isOpen: true, + hide: jest.fn(), + orgId: 'orgId', + eventId: 'eventId', + actionItemsRefetch: jest.fn(), + editMode: false, + actionItem: null, + }, + { + isOpen: true, + hide: jest.fn(), + orgId: 'orgId', + eventId: undefined, + actionItemsRefetch: jest.fn(), + editMode: true, + actionItem: { + _id: 'actionItemId1', + assignee: null, + assigneeGroup: null, + assigneeType: 'User', + assigneeUser: { + _id: 'userId1', + firstName: 'Harve', + lastName: 'Lance', + image: '', + }, + actionItemCategory: { + _id: 'categoryId1', + name: 'Category 1', + }, + preCompletionNotes: 'Notes 1', + postCompletionNotes: 'Cmp Notes 1', + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-08-30'), + completionDate: new Date('2044-09-03'), + isCompleted: true, + event: null, + allottedHours: 24, + assigner: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: undefined, + }, + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + orgId: 'orgId', + eventId: undefined, + actionItemsRefetch: jest.fn(), + editMode: true, + actionItem: { + _id: 'actionItemId2', + assignee: null, + assigneeGroup: null, + assigneeType: 'User', + assigneeUser: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + actionItemCategory: { + _id: 'categoryId2', + name: 'Category 2', + }, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: null, + allottedHours: null, + assigner: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: 'wilt-image', + }, + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + orgId: 'orgId', + eventId: 'eventId', + actionItemsRefetch: jest.fn(), + editMode: true, + actionItem: { + _id: 'actionItemId2', + assigneeType: 'EventVolunteer', + assignee: { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 0, + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + assignments: [], + groups: [], + }, + assigneeGroup: null, + assigneeUser: null, + actionItemCategory: { + _id: 'categoryId2', + name: 'Category 2', + }, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: { + _id: 'eventId', + title: 'Event 1', + }, + allottedHours: null, + assigner: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: 'wilt-image', + }, + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + orgId: 'orgId', + eventId: 'eventId', + actionItemsRefetch: jest.fn(), + editMode: true, + actionItem: { + _id: 'actionItemId2', + assigneeType: 'EventVolunteerGroup', + assigneeGroup: { + _id: 'groupId1', + name: 'group1', + description: 'desc', + volunteersRequired: 10, + createdAt: '2024-10-27T15:34:15.889Z', + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, + }, + assignee: null, + assigneeUser: null, + actionItemCategory: { + _id: 'categoryId2', + name: 'Category 2', + }, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: { + _id: 'eventId', + title: 'Event 1', + }, + allottedHours: null, + assigner: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: 'wilt-image', + }, + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + }, +]; + +const renderItemModal = ( + link: ApolloLink, + props: InterfaceItemModalProps, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing ItemModal', () => { + it('Create Action Item (for Member)', async () => { + renderItemModal(link1, itemProps[0]); + expect(screen.getAllByText(t.createActionItem)).toHaveLength(2); + + // Select Category 1 + const categorySelect = await screen.findByTestId('categorySelect'); + expect(categorySelect).toBeInTheDocument(); + const inputField = within(categorySelect).getByRole('combobox'); + fireEvent.mouseDown(inputField); + + const categoryOption = await screen.findByText('Category 1'); + expect(categoryOption).toBeInTheDocument(); + fireEvent.click(categoryOption); + + // Select Assignee + const memberSelect = await screen.findByTestId('memberSelect'); + expect(memberSelect).toBeInTheDocument(); + const memberInputField = within(memberSelect).getByRole('combobox'); + fireEvent.mouseDown(memberInputField); + + const memberOption = await screen.findByText('Harve Lance'); + expect(memberOption).toBeInTheDocument(); + fireEvent.click(memberOption); + + // Select Due Date + fireEvent.change(screen.getByLabelText(t.dueDate), { + target: { value: '02/01/2044' }, + }); + + // Select Allotted Hours (try all options) + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '9']; + + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + }); + + // Add Pre Completion Notes + fireEvent.change(screen.getByLabelText(t.preCompletionNotes), { + target: { value: 'Notes' }, + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(itemProps[0].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[0].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulCreation); + }); + }); + + it('Create Action Item (for Volunteer)', async () => { + renderItemModal(link1, itemProps[1]); + expect(screen.getAllByText(t.createActionItem)).toHaveLength(2); + + // Select Category 1 + const categorySelect = await screen.findByTestId('categorySelect'); + expect(categorySelect).toBeInTheDocument(); + const inputField = within(categorySelect).getByRole('combobox'); + fireEvent.mouseDown(inputField); + + const categoryOption = await screen.findByText('Category 1'); + expect(categoryOption).toBeInTheDocument(); + fireEvent.click(categoryOption); + + // Select Volunteer Role + const groupRadio = await screen.findByText(t.groups); + const individualRadio = await screen.findByText(t.individuals); + expect(groupRadio).toBeInTheDocument(); + expect(individualRadio).toBeInTheDocument(); + fireEvent.click(individualRadio); + + // Select Individual Volunteer + const volunteerSelect = await screen.findByTestId('volunteerSelect'); + expect(volunteerSelect).toBeInTheDocument(); + const volunteerInputField = within(volunteerSelect).getByRole('combobox'); + fireEvent.mouseDown(volunteerInputField); + + const volunteerOption = await screen.findByText('Teresa Bradley'); + expect(volunteerOption).toBeInTheDocument(); + fireEvent.click(volunteerOption); + + // Select Due Date + fireEvent.change(screen.getByLabelText(t.dueDate), { + target: { value: '02/01/2044' }, + }); + + // Select Allotted Hours (try all options) + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '9']; + + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + }); + + // Add Pre Completion Notes + fireEvent.change(screen.getByLabelText(t.preCompletionNotes), { + target: { value: 'Notes' }, + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(itemProps[1].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[1].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulCreation); + }); + }); + + it('Create Action Item (for Group)', async () => { + renderItemModal(link1, itemProps[1]); + expect(screen.getAllByText(t.createActionItem)).toHaveLength(2); + + // Select Category 1 + const categorySelect = await screen.findByTestId('categorySelect'); + expect(categorySelect).toBeInTheDocument(); + const inputField = within(categorySelect).getByRole('combobox'); + fireEvent.mouseDown(inputField); + + const categoryOption = await screen.findByText('Category 1'); + expect(categoryOption).toBeInTheDocument(); + fireEvent.click(categoryOption); + + // Select Volunteer Role + const groupRadio = await screen.findByText(t.groups); + const individualRadio = await screen.findByText(t.individuals); + expect(groupRadio).toBeInTheDocument(); + expect(individualRadio).toBeInTheDocument(); + fireEvent.click(groupRadio); + + // Select Individual Volunteer + const groupSelect = await screen.findByTestId('volunteerGroupSelect'); + expect(groupSelect).toBeInTheDocument(); + const groupInputField = within(groupSelect).getByRole('combobox'); + fireEvent.mouseDown(groupInputField); + + const groupOption = await screen.findByText('group1'); + expect(groupOption).toBeInTheDocument(); + fireEvent.click(groupOption); + + // Select Due Date + fireEvent.change(screen.getByLabelText(t.dueDate), { + target: { value: '02/01/2044' }, + }); + + // Select Allotted Hours (try all options) + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '9']; + + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + }); + + // Add Pre Completion Notes + fireEvent.change(screen.getByLabelText(t.preCompletionNotes), { + target: { value: 'Notes' }, + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(itemProps[1].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[1].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulCreation); + }); + }); + + it('Update Action Item (completed)', async () => { + renderItemModal(link1, itemProps[2]); + expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2); + + // Update Category + const categorySelect = await screen.findByTestId('categorySelect'); + expect(categorySelect).toBeInTheDocument(); + const inputField = within(categorySelect).getByRole('combobox'); + fireEvent.mouseDown(inputField); + + const categoryOption = await screen.findByText('Category 2'); + expect(categoryOption).toBeInTheDocument(); + fireEvent.click(categoryOption); + + // Update Allotted Hours (try all options) + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '19']; + + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + }); + + // Update Post Completion Notes + fireEvent.change(screen.getByLabelText(t.postCompletionNotes), { + target: { value: 'Cmp Notes 2' }, + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(itemProps[2].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[2].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulUpdation); + }); + }); + + it('Update Action Item (Volunteer)', async () => { + renderItemModal(link1, itemProps[4]); + expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2); + + // Update Category + const categorySelect = await screen.findByTestId('categorySelect'); + expect(categorySelect).toBeInTheDocument(); + const inputField = within(categorySelect).getByRole('combobox'); + fireEvent.mouseDown(inputField); + + const categoryOption = await screen.findByText('Category 1'); + expect(categoryOption).toBeInTheDocument(); + fireEvent.click(categoryOption); + + // Select Volunteer Role + const groupRadio = await screen.findByText(t.groups); + const individualRadio = await screen.findByText(t.individuals); + expect(groupRadio).toBeInTheDocument(); + expect(individualRadio).toBeInTheDocument(); + fireEvent.click(individualRadio); + + // Select Individual Volunteer + const volunteerSelect = await screen.findByTestId('volunteerSelect'); + expect(volunteerSelect).toBeInTheDocument(); + const volunteerInputField = within(volunteerSelect).getByRole('combobox'); + fireEvent.mouseDown(volunteerInputField); + + const volunteerOption = await screen.findByText('Bruce Graza'); + expect(volunteerOption).toBeInTheDocument(); + fireEvent.click(volunteerOption); + + // Update Allotted Hours (try all options) + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '19']; + + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(itemProps[4].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[4].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulUpdation); + }); + }); + + it('Update Action Item (Group)', async () => { + renderItemModal(link1, itemProps[5]); + expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2); + + // Update Category + const categorySelect = await screen.findByTestId('categorySelect'); + expect(categorySelect).toBeInTheDocument(); + const inputField = within(categorySelect).getByRole('combobox'); + fireEvent.mouseDown(inputField); + + const categoryOption = await screen.findByText('Category 1'); + expect(categoryOption).toBeInTheDocument(); + fireEvent.click(categoryOption); + + // Select Volunteer Role + const groupRadio = await screen.findByText(t.groups); + const individualRadio = await screen.findByText(t.individuals); + expect(groupRadio).toBeInTheDocument(); + expect(individualRadio).toBeInTheDocument(); + fireEvent.click(groupRadio); + + // Select Individual Volunteer + const groupSelect = await screen.findByTestId('volunteerGroupSelect'); + expect(groupSelect).toBeInTheDocument(); + const groupInputField = within(groupSelect).getByRole('combobox'); + fireEvent.mouseDown(groupInputField); + + const groupOption = await screen.findByText('group2'); + expect(groupOption).toBeInTheDocument(); + fireEvent.click(groupOption); + + // Update Allotted Hours (try all options) + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '19']; + + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(itemProps[5].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[5].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulUpdation); + }); + }); + + it('Update Action Item (Volunteer -> Group)', async () => { + renderItemModal(link1, itemProps[4]); + expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2); + + // Update Category + const categorySelect = await screen.findByTestId('categorySelect'); + expect(categorySelect).toBeInTheDocument(); + const inputField = within(categorySelect).getByRole('combobox'); + fireEvent.mouseDown(inputField); + + const categoryOption = await screen.findByText('Category 1'); + expect(categoryOption).toBeInTheDocument(); + fireEvent.click(categoryOption); + + // Select Volunteer Role + const groupRadio = await screen.findByText(t.groups); + const individualRadio = await screen.findByText(t.individuals); + expect(groupRadio).toBeInTheDocument(); + expect(individualRadio).toBeInTheDocument(); + fireEvent.click(groupRadio); + + // Select Individual Volunteer + const groupSelect = await screen.findByTestId('volunteerGroupSelect'); + expect(groupSelect).toBeInTheDocument(); + const groupInputField = within(groupSelect).getByRole('combobox'); + fireEvent.mouseDown(groupInputField); + + const groupOption = await screen.findByText('group2'); + expect(groupOption).toBeInTheDocument(); + fireEvent.click(groupOption); + + // Update Allotted Hours (try all options) + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '19']; + + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(itemProps[4].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[4].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulUpdation); + }); + }); + + it('Update Action Item (not completed)', async () => { + renderItemModal(link1, itemProps[3]); + expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2); + + // Update Category + const categorySelect = await screen.findByTestId('categorySelect'); + expect(categorySelect).toBeInTheDocument(); + const inputField = within(categorySelect).getByRole('combobox'); + fireEvent.mouseDown(inputField); + + const categoryOption = await screen.findByText('Category 1'); + expect(categoryOption).toBeInTheDocument(); + fireEvent.click(categoryOption); + + // Update Assignee + const memberSelect = await screen.findByTestId('memberSelect'); + expect(memberSelect).toBeInTheDocument(); + const memberInputField = within(memberSelect).getByRole('combobox'); + fireEvent.mouseDown(memberInputField); + + const memberOption = await screen.findByText('Harve Lance'); + expect(memberOption).toBeInTheDocument(); + fireEvent.click(memberOption); + + // Update Allotted Hours (try all options) + const allottedHours = screen.getByLabelText(t.allottedHours); + const allottedHoursOptions = ['', '-1', '19']; + + allottedHoursOptions.forEach((option) => { + fireEvent.change(allottedHours, { target: { value: option } }); + expect(allottedHours).toHaveValue(parseInt(option) > 0 ? option : ''); + }); + + // Update Due Date + fireEvent.change(screen.getByLabelText(t.dueDate), { + target: { value: '02/01/2044' }, + }); + + // Update Pre Completion Notes + fireEvent.change(screen.getByLabelText(t.preCompletionNotes), { + target: { value: 'Notes 3' }, + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(itemProps[3].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[3].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulUpdation); + }); + }); + + it('Try adding negative Allotted Hours', async () => { + renderItemModal(link1, itemProps[0]); + expect(screen.getAllByText(t.createActionItem)).toHaveLength(2); + const allottedHours = screen.getByLabelText(t.allottedHours); + fireEvent.change(allottedHours, { target: { value: '-1' } }); + + await waitFor(() => { + expect(allottedHours).toHaveValue(''); + }); + + fireEvent.change(allottedHours, { target: { value: '' } }); + + await waitFor(() => { + expect(allottedHours).toHaveValue(''); + }); + + fireEvent.change(allottedHours, { target: { value: '0' } }); + await waitFor(() => { + expect(allottedHours).toHaveValue('0'); + }); + + fireEvent.change(allottedHours, { target: { value: '19' } }); + await waitFor(() => { + expect(allottedHours).toHaveValue('19'); + }); + }); + + it('should fail to Create Action Item', async () => { + renderItemModal(link2, itemProps[0]); + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Mock Graphql Error'); + }); + }); + + it('No Fields Updated while Updating', async () => { + renderItemModal(link2, itemProps[2]); + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(toast.warning).toHaveBeenCalledWith(t.noneUpdated); + }); + }); + + it('should fail to Update Action Item', async () => { + renderItemModal(link2, itemProps[2]); + expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2); + + // Update Post Completion Notes + fireEvent.change(screen.getByLabelText(t.postCompletionNotes), { + target: { value: 'Cmp Notes 2' }, + }); + + // Click Submit + const submitButton = screen.getByTestId('submitBtn'); + expect(submitButton).toBeInTheDocument(); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Mock Graphql Error'); + }); + }); +}); diff --git a/src/screens/OrganizationActionItems/ItemModal.tsx b/src/screens/OrganizationActionItems/ItemModal.tsx new file mode 100644 index 0000000000..b7555af121 --- /dev/null +++ b/src/screens/OrganizationActionItems/ItemModal.tsx @@ -0,0 +1,648 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Modal, Form, Button } from 'react-bootstrap'; +import type { ChangeEvent, FC } from 'react'; +import styles from '../../style/app.module.css'; +import { DatePicker } from '@mui/x-date-pickers'; +import dayjs from 'dayjs'; +import type { Dayjs } from 'dayjs'; + +import type { + InterfaceActionItemCategoryInfo, + InterfaceActionItemCategoryList, + InterfaceActionItemInfo, + InterfaceEventVolunteerInfo, + InterfaceMemberInfo, + InterfaceMembersList, + InterfaceVolunteerGroupInfo, +} from 'utils/interfaces'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { useMutation, useQuery } from '@apollo/client'; +import { + CREATE_ACTION_ITEM_MUTATION, + UPDATE_ACTION_ITEM_MUTATION, +} from 'GraphQl/Mutations/ActionItemMutations'; +import { ACTION_ITEM_CATEGORY_LIST } from 'GraphQl/Queries/ActionItemCategoryQueries'; +import { Autocomplete, FormControl, TextField } from '@mui/material'; +import { + EVENT_VOLUNTEER_GROUP_LIST, + EVENT_VOLUNTEER_LIST, +} from 'GraphQl/Queries/EventVolunteerQueries'; +import { HiUser, HiUserGroup } from 'react-icons/hi2'; +import { MEMBERS_LIST } from 'GraphQl/Queries/Queries'; + +/** + * Interface for the form state used in the `ItemModal` component. + */ +interface InterfaceFormStateType { + dueDate: Date; + assigneeType: 'EventVolunteer' | 'EventVolunteerGroup' | 'User'; + actionItemCategoryId: string; + assigneeId: string; + eventId?: string; + preCompletionNotes: string; + postCompletionNotes: string | null; + allottedHours: number | null; + isCompleted: boolean; +} + +/** + * Props for the `ItemModal` component. + */ +export interface InterfaceItemModalProps { + isOpen: boolean; + hide: () => void; + orgId: string; + eventId: string | undefined; + actionItemsRefetch: () => void; + actionItem: InterfaceActionItemInfo | null; + editMode: boolean; +} + +/** + * Initializes the form state for the `ItemModal` component. + * + * @param actionItem - The action item to be edited. + * @returns + */ + +const initializeFormState = ( + actionItem: InterfaceActionItemInfo | null, +): InterfaceFormStateType => ({ + dueDate: actionItem?.dueDate || new Date(), + actionItemCategoryId: actionItem?.actionItemCategory?._id || '', + assigneeId: + actionItem?.assignee?._id || + actionItem?.assigneeGroup?._id || + actionItem?.assigneeUser?._id || + '', + assigneeType: actionItem?.assigneeType || 'User', + preCompletionNotes: actionItem?.preCompletionNotes || '', + postCompletionNotes: actionItem?.postCompletionNotes || null, + allottedHours: actionItem?.allottedHours || null, + isCompleted: actionItem?.isCompleted || false, +}); + +/** + * A modal component for creating action items. + * + * @param props - The properties passed to the component. + * @returns The `ItemModal` component. + */ +const ItemModal: FC = ({ + isOpen, + hide, + orgId, + eventId, + actionItem, + editMode, + actionItemsRefetch, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'organizationActionItems', + }); + + const [actionItemCategory, setActionItemCategory] = + useState(null); + const [assignee, setAssignee] = useState( + null, + ); + const [assigneeGroup, setAssigneeGroup] = + useState(null); + + const [assigneeUser, setAssigneeUser] = useState( + null, + ); + + const [formState, setFormState] = useState( + initializeFormState(actionItem), + ); + + const { + dueDate, + assigneeType, + actionItemCategoryId, + assigneeId, + preCompletionNotes, + postCompletionNotes, + allottedHours, + isCompleted, + } = formState; + + /** + * Query to fetch action item categories for the organization. + */ + const { + data: actionItemCategoriesData, + }: { + data: InterfaceActionItemCategoryList | undefined; + } = useQuery(ACTION_ITEM_CATEGORY_LIST, { + variables: { + organizationId: orgId, + where: { is_disabled: false }, + }, + }); + + /** + * Query to fetch event volunteers for the event. + */ + const { + data: volunteersData, + }: { + data?: { + getEventVolunteers: InterfaceEventVolunteerInfo[]; + }; + } = useQuery(EVENT_VOLUNTEER_LIST, { + variables: { + where: { + eventId: eventId, + hasAccepted: true, + }, + }, + }); + + /** + * Query to fetch the list of volunteer groups for the event. + */ + const { + data: groupsData, + }: { + data?: { + getEventVolunteerGroups: InterfaceVolunteerGroupInfo[]; + }; + } = useQuery(EVENT_VOLUNTEER_GROUP_LIST, { + variables: { + where: { + eventId: eventId, + }, + }, + }); + + /** + * Query to fetch members of the organization. + */ + const { + data: membersData, + }: { + data: InterfaceMembersList | undefined; + } = useQuery(MEMBERS_LIST, { + variables: { id: orgId }, + }); + + const members = useMemo( + () => membersData?.organizations[0].members || [], + [membersData], + ); + + const volunteers = useMemo( + () => volunteersData?.getEventVolunteers || [], + [volunteersData], + ); + + const groups = useMemo( + () => groupsData?.getEventVolunteerGroups || [], + [groupsData], + ); + + const actionItemCategories = useMemo( + () => actionItemCategoriesData?.actionItemCategoriesByOrganization || [], + [actionItemCategoriesData], + ); + + /** + * Mutation to create & update a new action item. + */ + const [createActionItem] = useMutation(CREATE_ACTION_ITEM_MUTATION); + const [updateActionItem] = useMutation(UPDATE_ACTION_ITEM_MUTATION); + + /** + * Handler function to update the form state. + * + * @param field - The field to be updated. + * @param value - The value to be set. + * @returns void + */ + const handleFormChange = ( + field: keyof InterfaceFormStateType, + value: string | number | boolean | Date | undefined | null, + ): void => { + setFormState((prevState) => ({ ...prevState, [field]: value })); + }; + + /** + * Handler function to create a new action item. + * + * @param e - The form submit event. + * @returns A promise that resolves when the action item is created. + */ + const createActionItemHandler = async ( + e: ChangeEvent, + ): Promise => { + e.preventDefault(); + try { + const dDate = dayjs(dueDate).format('YYYY-MM-DD'); + await createActionItem({ + variables: { + dDate: dDate, + assigneeId: assigneeId, + assigneeType: assigneeType, + actionItemCategoryId: actionItemCategory?._id, + preCompletionNotes: preCompletionNotes, + allottedHours: allottedHours, + ...(eventId && { eventId }), + }, + }); + + // Reset form and date after successful creation + setFormState(initializeFormState(null)); + setActionItemCategory(null); + setAssignee(null); + + actionItemsRefetch(); + hide(); + toast.success(t('successfulCreation')); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + + /** + * Handles the form submission for updating an action item. + * + * @param e - The form submission event. + */ + const updateActionItemHandler = async ( + e: ChangeEvent, + ): Promise => { + e.preventDefault(); + try { + const updatedFields: { + [key: string]: number | string | boolean | Date | undefined | null; + } = {}; + + if (actionItemCategoryId !== actionItem?.actionItemCategory?._id) { + updatedFields.actionItemCategoryId = actionItemCategoryId; + } + + if ( + assigneeId !== actionItem?.assignee?._id && + assigneeType === 'EventVolunteer' + ) { + updatedFields.assigneeId = assigneeId; + } + + if ( + assigneeId !== actionItem?.assigneeGroup?._id && + assigneeType === 'EventVolunteerGroup' + ) { + updatedFields.assigneeId = assigneeId; + } + + if ( + assigneeId !== actionItem?.assigneeUser?._id && + assigneeType === 'User' + ) { + updatedFields.assigneeId = assigneeId; + } + + if (assigneeType !== actionItem?.assigneeType) { + updatedFields.assigneeType = assigneeType; + } + + if (preCompletionNotes !== actionItem?.preCompletionNotes) { + updatedFields.preCompletionNotes = preCompletionNotes; + } + + if (postCompletionNotes !== actionItem?.postCompletionNotes) { + updatedFields.postCompletionNotes = postCompletionNotes; + } + + if (allottedHours !== actionItem?.allottedHours) { + updatedFields.allottedHours = allottedHours; + } + + if (dueDate !== actionItem?.dueDate) { + updatedFields.dueDate = dayjs(dueDate).format('YYYY-MM-DD'); + } + + if (Object.keys(updatedFields).length === 0) { + toast.warning(t('noneUpdated')); + return; + } + + await updateActionItem({ + variables: { + actionItemId: actionItem?._id, + assigneeId: assigneeId, + assigneeType: assigneeType, + ...updatedFields, + }, + }); + + setFormState(initializeFormState(null)); + actionItemsRefetch(); + hide(); + toast.success(t('successfulUpdation')); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + + useEffect(() => { + setFormState(initializeFormState(actionItem)); + setActionItemCategory( + actionItemCategories.find( + (category) => category._id === actionItem?.actionItemCategory?._id, + ) || null, + ); + setAssignee( + volunteers.find( + (volunteer) => volunteer._id === actionItem?.assignee?._id, + ) || null, + ); + setAssigneeGroup( + groups.find((group) => group._id === actionItem?.assigneeGroup?._id) || + null, + ); + setAssigneeUser( + members.find((member) => member._id === actionItem?.assigneeUser?._id) || + null, + ); + }, [actionItem, actionItemCategories, volunteers, groups, members]); + + return ( + + +

    + {editMode ? t('updateActionItem') : t('createActionItem')} +

    + +
    + +
    + + option._id === value._id} + filterSelectedOptions={true} + getOptionLabel={(item: InterfaceActionItemCategoryInfo): string => + item.name + } + onChange={(_, newCategory): void => { + /* istanbul ignore next */ + handleFormChange( + 'actionItemCategoryId', + newCategory?._id ?? '', + ); + setActionItemCategory(newCategory); + }} + renderInput={(params) => ( + + )} + /> + {isCompleted && ( + <> + {/* Input text Component to add allotted Hours for action item */} + + + handleFormChange( + 'allottedHours', + e.target.value === '' || parseInt(e.target.value) < 0 + ? null + : parseInt(e.target.value), + ) + } + /> + + + )} + + {!isCompleted && ( + <> + {eventId && ( + <> + {t('assignTo')} +
    + + handleFormChange('assigneeType', 'EventVolunteer') + } + /> + + + + handleFormChange('assigneeType', 'EventVolunteerGroup') + } + checked={assigneeType === 'EventVolunteerGroup'} + /> + +
    + + )} + + {assigneeType === 'EventVolunteer' ? ( + + + option._id === value._id + } + filterSelectedOptions={true} + getOptionLabel={( + volunteer: InterfaceEventVolunteerInfo, + ): string => + `${volunteer.user.firstName} ${volunteer.user.lastName}` + } + onChange={(_, newAssignee): void => { + /* istanbul ignore next */ + handleFormChange('assigneeId', newAssignee?._id ?? ''); + setAssignee(newAssignee); + }} + renderInput={(params) => ( + + )} + /> + + ) : assigneeType === 'EventVolunteerGroup' ? ( + + + option._id === value._id + } + filterSelectedOptions={true} + getOptionLabel={( + group: InterfaceVolunteerGroupInfo, + ): string => `${group.name}`} + onChange={(_, newAssignee): void => { + /* istanbul ignore next */ + handleFormChange('assigneeId', newAssignee?._id ?? ''); + setAssigneeGroup(newAssignee); + }} + renderInput={(params) => ( + + )} + /> + + ) : ( + + + option._id === value._id + } + filterSelectedOptions={true} + getOptionLabel={(member: InterfaceMemberInfo): string => + `${member.firstName} ${member.lastName}` + } + onChange={(_, newAssignee): void => { + /* istanbul ignore next */ + handleFormChange('assigneeId', newAssignee?._id ?? ''); + setAssigneeUser(newAssignee); + }} + renderInput={(params) => ( + + )} + /> + + )} + + + {/* Date Calendar Component to select due date of an action item */} + { + /* istanbul ignore next */ + if (date) handleFormChange('dueDate', date.toDate()); + }} + /> + + {/* Input text Component to add allotted Hours for action item */} + + + handleFormChange( + 'allottedHours', + e.target.value === '' || parseInt(e.target.value) < 0 + ? null + : parseInt(e.target.value), + ) + } + /> + + + + {/* Input text Component to add notes for action item */} + + + handleFormChange('preCompletionNotes', e.target.value) + } + /> + + + )} + + {isCompleted && ( + + + handleFormChange('postCompletionNotes', e.target.value) + } + /> + + )} + + +
    +
    +
    + ); +}; + +export default ItemModal; diff --git a/src/screens/OrganizationActionItems/ItemUpdateStatusModal.test.tsx b/src/screens/OrganizationActionItems/ItemUpdateStatusModal.test.tsx new file mode 100644 index 0000000000..aa28b14d40 --- /dev/null +++ b/src/screens/OrganizationActionItems/ItemUpdateStatusModal.test.tsx @@ -0,0 +1,259 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18nForTest from '../../utils/i18nForTest'; +import { MOCKS, MOCKS_ERROR } from './OrganizationActionItem.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import ItemUpdateStatusModal, { + type InterfaceItemUpdateStatusModalProps, +} from './ItemUpdateStatusModal'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const t = JSON.parse( + JSON.stringify( + i18nForTest.getDataByLanguage('en')?.translation.organizationActionItems, + ), +); + +const itemProps: InterfaceItemUpdateStatusModalProps[] = [ + { + isOpen: true, + hide: jest.fn(), + actionItemsRefetch: jest.fn(), + actionItem: { + _id: 'actionItemId1', + assignee: null, + assigneeGroup: null, + assigneeType: 'User', + assigneeUser: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: undefined, + }, + actionItemCategory: { + _id: 'actionItemCategoryId1', + name: 'Category 1', + }, + preCompletionNotes: 'Notes 1', + postCompletionNotes: 'Cmp Notes 1', + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-08-30'), + completionDate: new Date('2044-09-03'), + isCompleted: true, + event: null, + allottedHours: 24, + assigner: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: undefined, + }, + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + actionItemsRefetch: jest.fn(), + actionItem: { + _id: 'actionItemId1', + assignee: null, + assigneeGroup: { + _id: 'volunteerGroupId1', + name: 'Group 1', + description: 'Description 1', + event: { + _id: 'eventId1', + }, + createdAt: '2024-08-27', + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: undefined, + }, + leader: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: undefined, + }, + volunteersRequired: 10, + assignments: [], + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: undefined, + }, + }, + ], + }, + assigneeType: 'EventVolunteerGroup', + assigneeUser: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: undefined, + }, + actionItemCategory: { + _id: 'actionItemCategoryId1', + name: 'Category 1', + }, + preCompletionNotes: 'Notes 1', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-08-30'), + completionDate: new Date('2044-09-03'), + isCompleted: false, + event: null, + allottedHours: 24, + assigner: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: undefined, + }, + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + actionItemsRefetch: jest.fn(), + actionItem: { + _id: 'actionItemId1', + assignee: { + _id: 'volunteerId1', + hasAccepted: true, + user: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: undefined, + }, + assignments: [], + groups: [], + hoursVolunteered: 0, + }, + assigneeGroup: null, + assigneeType: 'EventVolunteer', + assigneeUser: null, + actionItemCategory: { + _id: 'actionItemCategoryId1', + name: 'Category 1', + }, + preCompletionNotes: 'Notes 1', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-08-30'), + completionDate: new Date('2044-09-03'), + isCompleted: true, + event: null, + allottedHours: 24, + assigner: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: undefined, + }, + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + }, +]; + +const renderItemUpdateStatusModal = ( + link: ApolloLink, + props: InterfaceItemUpdateStatusModalProps, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing ItemUpdateStatusModal', () => { + it('Update Status of Completed ActionItem', async () => { + renderItemUpdateStatusModal(link1, itemProps[0]); + expect(screen.getByText(t.actionItemStatus)).toBeInTheDocument(); + const yesBtn = await screen.findByTestId('yesBtn'); + fireEvent.click(yesBtn); + + await waitFor(() => { + expect(itemProps[0].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[0].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulUpdation); + }); + }); + + it('Update Status of Pending ActionItem', async () => { + renderItemUpdateStatusModal(link1, itemProps[1]); + expect(screen.getByText(t.actionItemStatus)).toBeInTheDocument(); + + const notes = await screen.findByLabelText(t.postCompletionNotes); + fireEvent.change(notes, { target: { value: 'Cmp Notes 1' } }); + + const createBtn = await screen.findByTestId('createBtn'); + fireEvent.click(createBtn); + + await waitFor(() => { + expect(itemProps[1].actionItemsRefetch).toHaveBeenCalled(); + expect(itemProps[1].hide).toHaveBeenCalled(); + expect(toast.success).toHaveBeenCalledWith(t.successfulUpdation); + }); + }); + + it('should fail to Update status of Action Item', async () => { + renderItemUpdateStatusModal(link2, itemProps[2]); + + expect(screen.getByText(t.actionItemStatus)).toBeInTheDocument(); + const yesBtn = await screen.findByTestId('yesBtn'); + fireEvent.click(yesBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Mock Graphql Error'); + }); + }); +}); diff --git a/src/screens/OrganizationActionItems/ItemUpdateStatusModal.tsx b/src/screens/OrganizationActionItems/ItemUpdateStatusModal.tsx new file mode 100644 index 0000000000..640fdde032 --- /dev/null +++ b/src/screens/OrganizationActionItems/ItemUpdateStatusModal.tsx @@ -0,0 +1,138 @@ +import React, { type FC, type FormEvent, useEffect, useState } from 'react'; +import { Modal, Button, Form } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import { FormControl, TextField } from '@mui/material'; +import styles from '../../style/app.module.css'; +import { useMutation } from '@apollo/client'; +import { UPDATE_ACTION_ITEM_MUTATION } from 'GraphQl/Mutations/ActionItemMutations'; +import { toast } from 'react-toastify'; +import type { InterfaceActionItemInfo } from 'utils/interfaces'; + +export interface InterfaceItemUpdateStatusModalProps { + isOpen: boolean; + hide: () => void; + actionItemsRefetch: () => void; + actionItem: InterfaceActionItemInfo; +} + +const ItemUpdateStatusModal: FC = ({ + hide, + isOpen, + actionItemsRefetch, + actionItem, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'organizationActionItems', + }); + const { t: tCommon } = useTranslation('common'); + + const { + _id, + isCompleted, + assignee, + assigneeGroup, + assigneeUser, + assigneeType, + } = actionItem; + + const [postCompletionNotes, setPostCompletionNotes] = useState( + actionItem.postCompletionNotes ?? '', + ); + + /** + * Mutation to update an action item. + */ + const [updateActionItem] = useMutation(UPDATE_ACTION_ITEM_MUTATION); + + /** + * Handles the form submission for updating an action item. + * + * @param e - The form submission event. + */ + const updateActionItemHandler = async ( + e: FormEvent, + ): Promise => { + e.preventDefault(); + + try { + await updateActionItem({ + variables: { + actionItemId: _id, + assigneeId: + assigneeType === 'EventVolunteer' + ? assignee?._id + : assigneeType === 'EventVolunteerGroup' + ? assigneeGroup?._id + : assigneeUser?._id, + assigneeType, + postCompletionNotes: isCompleted ? '' : postCompletionNotes, + isCompleted: !isCompleted, + }, + }); + + actionItemsRefetch(); + hide(); + toast.success(t('successfulUpdation')); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + + useEffect(() => { + setPostCompletionNotes(actionItem.postCompletionNotes ?? ''); + }, [actionItem]); + + return ( + + +

    {t('actionItemStatus')}

    + +
    + +
    + {!isCompleted ? ( + + setPostCompletionNotes(e.target.value)} + /> + + ) : ( +

    {t('updateStatusMsg')}

    + )} + + {isCompleted ? ( +
    + + +
    + ) : ( + + )} +
    +
    +
    + ); +}; + +export default ItemUpdateStatusModal; diff --git a/src/screens/OrganizationActionItems/ItemViewModal.test.tsx b/src/screens/OrganizationActionItems/ItemViewModal.test.tsx new file mode 100644 index 0000000000..297cfab6a8 --- /dev/null +++ b/src/screens/OrganizationActionItems/ItemViewModal.test.tsx @@ -0,0 +1,287 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, within } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18nForTest from '../../utils/i18nForTest'; +import { MOCKS } from './OrganizationActionItem.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import ItemViewModal, { type InterfaceViewModalProps } from './ItemViewModal'; +import type { + InterfaceEventVolunteerInfo, + InterfaceUserInfo, + InterfaceVolunteerGroupInfo, +} from 'utils/interfaces'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const t = JSON.parse( + JSON.stringify( + i18nForTest.getDataByLanguage('en')?.translation.organizationActionItems, + ), +); + +const createUser = ( + id: string, + firstName: string, + lastName: string, + image?: string, +): InterfaceUserInfo => ({ + _id: id, + firstName, + lastName, + image, +}); + +const createAssignee = ( + user: ReturnType, + hasAccepted = true, +): InterfaceEventVolunteerInfo => ({ + _id: `${user._id}-assignee`, + user, + assignments: [], + groups: [], + hasAccepted, + hoursVolunteered: 0, +}); + +const createAssigneeGroup = ( + id: string, + name: string, + leader: ReturnType, +): InterfaceVolunteerGroupInfo => ({ + _id: id, + name, + description: `${name} description`, + event: { _id: 'eventId1' }, + volunteers: [], + assignments: [], + volunteersRequired: 10, + leader, + creator: leader, + createdAt: '2024-08-27', +}); + +const userWithImage = createUser('userId', 'Wilt', 'Shepherd', 'wilt-image'); +const userWithoutImage = createUser('userId', 'Wilt', 'Shepherd'); +const assigneeWithImage = createUser('userId1', 'John', 'Doe', 'image-url'); +const assigneeWithoutImage = createUser('userId1', 'John', 'Doe'); +const actionItemCategory = { + _id: 'actionItemCategoryId2', + name: 'Category 2', +}; + +const itemProps: InterfaceViewModalProps[] = [ + { + isOpen: true, + hide: jest.fn(), + item: { + _id: 'actionItemId1', + assignee: createAssignee(assigneeWithoutImage), + assigneeGroup: null, + assigneeType: 'EventVolunteer', + assigneeUser: null, + actionItemCategory, + preCompletionNotes: 'Notes 1', + postCompletionNotes: 'Cmp Notes 1', + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-08-30'), + completionDate: new Date('2044-09-03'), + isCompleted: true, + event: null, + allottedHours: 24, + assigner: userWithoutImage, + creator: userWithoutImage, + }, + }, + { + isOpen: true, + hide: jest.fn(), + item: { + _id: 'actionItemId2', + assignee: createAssignee(assigneeWithImage), + assigneeGroup: null, + assigneeType: 'EventVolunteer', + assigneeUser: null, + actionItemCategory, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: null, + allottedHours: null, + assigner: userWithImage, + creator: userWithoutImage, + }, + }, + { + isOpen: true, + hide: jest.fn(), + item: { + _id: 'actionItemId2', + assignee: null, + assigneeGroup: null, + assigneeType: 'User', + assigneeUser: assigneeWithImage, + actionItemCategory, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: null, + allottedHours: null, + assigner: userWithImage, + creator: userWithoutImage, + }, + }, + { + isOpen: true, + hide: jest.fn(), + item: { + _id: 'actionItemId2', + assignee: null, + assigneeGroup: null, + assigneeType: 'User', + assigneeUser: createUser('userId1', 'Jane', 'Doe'), + actionItemCategory, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: null, + allottedHours: null, + assigner: userWithoutImage, + creator: userWithoutImage, + }, + }, + { + isOpen: true, + hide: jest.fn(), + item: { + _id: 'actionItemId2', + assignee: null, + assigneeGroup: createAssigneeGroup( + 'groupId1', + 'Group 1', + assigneeWithoutImage, + ), + assigneeType: 'EventVolunteerGroup', + assigneeUser: null, + actionItemCategory, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: null, + allottedHours: null, + assigner: userWithoutImage, + creator: userWithoutImage, + }, + }, +]; + +const renderItemViewModal = ( + link: ApolloLink, + props: InterfaceViewModalProps, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing ItemViewModal', () => { + it('should render ItemViewModal with pending item & assignee with null image', () => { + renderItemViewModal(link1, itemProps[0]); + expect(screen.getByText(t.actionItemDetails)).toBeInTheDocument(); + + const assigneeInput = screen.getByTestId('assignee_input'); + expect(assigneeInput).toBeInTheDocument(); + + const inputElement = within(assigneeInput).getByRole('textbox'); + expect(inputElement).toHaveValue('John Doe'); + + expect(screen.getByTestId('assignee_avatar')).toBeInTheDocument(); + expect(screen.getByTestId('assigner_avatar')).toBeInTheDocument(); + expect(screen.getByLabelText(t.postCompletionNotes)).toBeInTheDocument(); + expect(screen.getByLabelText(t.allottedHours)).toBeInTheDocument(); + expect(screen.getByLabelText(t.allottedHours)).toHaveValue('24'); + }); + + it('should render ItemViewModal with completed item & assignee with image', () => { + renderItemViewModal(link1, itemProps[1]); + expect(screen.getByText(t.actionItemDetails)).toBeInTheDocument(); + expect(screen.getByTestId('assignee_image')).toBeInTheDocument(); + expect(screen.getByTestId('assigner_image')).toBeInTheDocument(); + expect( + screen.queryByLabelText(t.postCompletionNotes), + ).not.toBeInTheDocument(); + expect(screen.getByLabelText(t.allottedHours)).toBeInTheDocument(); + expect(screen.getByLabelText(t.allottedHours)).toHaveValue('-'); + }); + + it('should render ItemViewModal with assigneeUser with image', () => { + renderItemViewModal(link1, itemProps[2]); + expect(screen.getByText(t.actionItemDetails)).toBeInTheDocument(); + expect(screen.getByTestId('assignee_image')).toBeInTheDocument(); + expect(screen.getByTestId('assigner_image')).toBeInTheDocument(); + const assigneeInput = screen.getByTestId('assignee_input'); + expect(assigneeInput).toBeInTheDocument(); + + const inputElement = within(assigneeInput).getByRole('textbox'); + expect(inputElement).toHaveValue('John Doe'); + }); + + it('should render ItemViewModal with assigneeUser without image', () => { + renderItemViewModal(link1, itemProps[3]); + expect(screen.getByText(t.actionItemDetails)).toBeInTheDocument(); + expect(screen.getByTestId('assignee_avatar')).toBeInTheDocument(); + expect(screen.getByTestId('assigner_avatar')).toBeInTheDocument(); + const assigneeInput = screen.getByTestId('assignee_input'); + expect(assigneeInput).toBeInTheDocument(); + + const inputElement = within(assigneeInput).getByRole('textbox'); + expect(inputElement).toHaveValue('Jane Doe'); + }); + + it('should render ItemViewModal with assigneeGroup', () => { + renderItemViewModal(link1, itemProps[4]); + expect(screen.getByText(t.actionItemDetails)).toBeInTheDocument(); + expect(screen.getByTestId('assigneeGroup_avatar')).toBeInTheDocument(); + expect(screen.getByTestId('assigner_avatar')).toBeInTheDocument(); + const assigneeInput = screen.getByTestId('assignee_input'); + expect(assigneeInput).toBeInTheDocument(); + + const inputElement = within(assigneeInput).getByRole('textbox'); + expect(inputElement).toHaveValue('Group 1'); + }); +}); diff --git a/src/screens/OrganizationActionItems/ItemViewModal.tsx b/src/screens/OrganizationActionItems/ItemViewModal.tsx new file mode 100644 index 0000000000..5a78ac8a91 --- /dev/null +++ b/src/screens/OrganizationActionItems/ItemViewModal.tsx @@ -0,0 +1,253 @@ +import { DatePicker } from '@mui/x-date-pickers'; +import React from 'react'; +import dayjs from 'dayjs'; +import type { FC } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import type { InterfaceActionItemInfo } from 'utils/interfaces'; +import styles from './OrganizationActionItems.module.css'; +import { useTranslation } from 'react-i18next'; +import { FormControl, TextField } from '@mui/material'; +import { TaskAlt, HistoryToggleOff } from '@mui/icons-material'; +import Avatar from 'components/Avatar/Avatar'; + +export interface InterfaceViewModalProps { + isOpen: boolean; + hide: () => void; + item: InterfaceActionItemInfo; +} + +/** + * A modal dialog for viewing action item details. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param item - The action item object to be displayed. + * + * @returns The rendered modal component. + * + * The `ItemViewModal` component displays all the fields of an action item in a modal dialog. + * It includes fields for assignee, assigner, category, pre and post completion notes, assignment date, due date, completion date, and event. + */ + +const ItemViewModal: FC = ({ isOpen, hide, item }) => { + const { t } = useTranslation('translation', { + keyPrefix: 'organizationActionItems', + }); + const { t: tCommon } = useTranslation('common'); + + const { + actionItemCategory, + assignee, + assigneeGroup, + assigneeUser, + assigneeType, + assigner, + completionDate, + dueDate, + isCompleted, + postCompletionNotes, + preCompletionNotes, + allottedHours, + } = item; + + return ( + + +

    {t('actionItemDetails')}

    + +
    + +
    + + + + + + + + + {assignee?.user?.image || assigneeUser?.image ? ( + Assignee + ) : assignee || assigneeUser ? ( + + ) : ( + + )} + + ), + }} + /> + + + + {assigner.image ? ( + Assigner + ) : ( +
    + +
    + )} + + ), + }} + /> +
    +
    + + {/* Status of Action Item */} + + {isCompleted ? ( + + ) : ( + + )} + + ), + style: { + color: isCompleted ? 'green' : '#ed6c02', + }, + }} + inputProps={{ + style: { + WebkitTextFillColor: isCompleted ? 'green' : '#ed6c02', + }, + }} + disabled + /> + + + + + {/* Date Calendar Component to display due date of Action Item */} + + + {/* Date Calendar Component to display completion Date of Action Item */} + {isCompleted && ( + + )} + + + + + + + {isCompleted && ( + + + + )} +
    +
    +
    + ); +}; +export default ItemViewModal; diff --git a/src/screens/OrganizationActionItems/OrganizationActionItem.mocks.ts b/src/screens/OrganizationActionItems/OrganizationActionItem.mocks.ts new file mode 100644 index 0000000000..d7d661fc9d --- /dev/null +++ b/src/screens/OrganizationActionItems/OrganizationActionItem.mocks.ts @@ -0,0 +1,493 @@ +import dayjs from 'dayjs'; +import { + CREATE_ACTION_ITEM_MUTATION, + DELETE_ACTION_ITEM_MUTATION, + UPDATE_ACTION_ITEM_MUTATION, +} from 'GraphQl/Mutations/ActionItemMutations'; +import { ACTION_ITEM_LIST } from 'GraphQl/Queries/Queries'; + +import { + actionItemCategoryListQuery, + groupListQuery, + itemWithGroup, + itemWithUser, + itemWithUserImage, + itemWithVolunteer, + itemWithVolunteerImage, + memberListQuery, + volunteerListQuery, +} from './testObject.mocks'; + +export const MOCKS = [ + { + request: { + query: ACTION_ITEM_LIST, + variables: { + organizationId: 'orgId', + orderBy: null, + where: { + assigneeName: '', + }, + }, + }, + result: { + data: { + actionItemsByOrganization: [ + itemWithVolunteer, + itemWithUser, + itemWithGroup, + itemWithVolunteerImage, + itemWithUserImage, + ], + }, + }, + }, + { + request: { + query: ACTION_ITEM_LIST, + variables: { + organizationId: 'orgId', + orderBy: null, + where: { + categoryName: '', + }, + }, + }, + result: { + data: { + actionItemsByOrganization: [itemWithVolunteer, itemWithUser], + }, + }, + }, + { + request: { + query: ACTION_ITEM_LIST, + variables: { + organizationId: 'orgId', + orderBy: 'dueDate_ASC', + where: { + assigneeName: '', + }, + }, + }, + result: { + data: { + actionItemsByOrganization: [itemWithVolunteer, itemWithUser], + }, + }, + }, + { + request: { + query: ACTION_ITEM_LIST, + variables: { + organizationId: 'orgId', + orderBy: 'dueDate_DESC', + where: { + assigneeName: '', + }, + }, + }, + result: { + data: { + actionItemsByOrganization: [itemWithUser, itemWithVolunteer], + }, + }, + }, + { + request: { + query: ACTION_ITEM_LIST, + variables: { + organizationId: 'orgId', + orderBy: null, + where: { + assigneeName: '', + is_completed: true, + }, + }, + }, + result: { + data: { + actionItemsByOrganization: [itemWithVolunteer], + }, + }, + }, + { + request: { + query: ACTION_ITEM_LIST, + variables: { + organizationId: 'orgId', + orderBy: null, + where: { + assigneeName: '', + is_completed: false, + }, + }, + }, + result: { + data: { + actionItemsByOrganization: [itemWithUser], + }, + }, + }, + { + request: { + query: ACTION_ITEM_LIST, + variables: { + organizationId: 'orgId', + orderBy: null, + where: { + assigneeName: 'John', + }, + }, + }, + result: { + data: { + actionItemsByOrganization: [itemWithVolunteer], + }, + }, + }, + { + request: { + query: ACTION_ITEM_LIST, + variables: { + organizationId: 'orgId', + orderBy: null, + where: { + categoryName: 'Category 1', + }, + }, + }, + result: { + data: { + actionItemsByOrganization: [itemWithVolunteer], + }, + }, + }, + { + request: { + query: DELETE_ACTION_ITEM_MUTATION, + variables: { + actionItemId: 'actionItemId1', + }, + }, + result: { + data: { + removeActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_ACTION_ITEM_MUTATION, + variables: { + actionItemId: 'actionItemId1', + assigneeId: 'userId1', + assigneeType: 'User', + postCompletionNotes: '', + isCompleted: false, + }, + }, + result: { + data: { + updateActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_ACTION_ITEM_MUTATION, + variables: { + actionItemId: 'actionItemId1', + assigneeId: 'userId1', + assigneeType: 'User', + actionItemCategoryId: 'categoryId2', + postCompletionNotes: 'Cmp Notes 2', + allottedHours: 19, + }, + }, + result: { + data: { + updateActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_ACTION_ITEM_MUTATION, + variables: { + actionItemId: 'actionItemId1', + assigneeId: 'volunteerGroupId1', + assigneeType: 'EventVolunteerGroup', + postCompletionNotes: 'Cmp Notes 1', + isCompleted: true, + }, + }, + result: { + data: { + updateActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_ACTION_ITEM_MUTATION, + variables: { + actionItemId: 'actionItemId2', + assigneeId: 'userId1', + assigneeType: 'User', + actionItemCategoryId: 'categoryId1', + preCompletionNotes: 'Notes 3', + allottedHours: 19, + dueDate: '2044-01-02', + }, + }, + result: { + data: { + updateActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_ACTION_ITEM_MUTATION, + variables: { + actionItemId: 'actionItemId1', + assigneeId: 'userId1', + postCompletionNotes: 'Cmp Notes 1', + isCompleted: true, + }, + }, + result: { + data: { + updateActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_ACTION_ITEM_MUTATION, + variables: { + actionItemId: 'actionItemId2', + assigneeId: 'volunteerId2', + assigneeType: 'EventVolunteer', + actionItemCategoryId: 'categoryId1', + allottedHours: 19, + }, + }, + result: { + data: { + updateActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_ACTION_ITEM_MUTATION, + variables: { + actionItemId: 'actionItemId2', + assigneeId: 'groupId2', + assigneeType: 'EventVolunteerGroup', + actionItemCategoryId: 'categoryId1', + allottedHours: 19, + }, + }, + result: { + data: { + updateActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: CREATE_ACTION_ITEM_MUTATION, + variables: { + assigneeId: 'userId1', + assigneeType: 'User', + actionItemCategoryId: 'categoryId1', + preCompletionNotes: 'Notes', + allottedHours: 9, + dDate: '2044-01-02', + }, + }, + result: { + data: { + createActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: CREATE_ACTION_ITEM_MUTATION, + variables: { + assigneeId: 'userId1', + assigneeType: 'User', + actionItemCategoryId: 'categoryId1', + preCompletionNotes: 'Notes', + allottedHours: 9, + dDate: '2044-01-02', + }, + }, + result: { + data: { + createActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: CREATE_ACTION_ITEM_MUTATION, + variables: { + assigneeId: 'volunteerId1', + assigneeType: 'EventVolunteer', + actionItemCategoryId: 'categoryId1', + preCompletionNotes: 'Notes', + allottedHours: 9, + dDate: '2044-01-02', + eventId: 'eventId', + }, + }, + result: { + data: { + createActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + { + request: { + query: CREATE_ACTION_ITEM_MUTATION, + variables: { + assigneeId: 'groupId1', + assigneeType: 'EventVolunteerGroup', + actionItemCategoryId: 'categoryId1', + preCompletionNotes: 'Notes', + allottedHours: 9, + dDate: '2044-01-02', + eventId: 'eventId', + }, + }, + result: { + data: { + createActionItem: { + _id: 'actionItemId1', + }, + }, + }, + }, + memberListQuery, + actionItemCategoryListQuery, + ...volunteerListQuery, + ...groupListQuery, +]; + +export const MOCKS_ERROR = [ + { + request: { + query: ACTION_ITEM_LIST, + variables: { + organizationId: 'orgId', + orderBy: null, + where: { + assigneeName: '', + }, + }, + }, + error: new Error('Mock Graphql Error'), + }, + { + request: { + query: DELETE_ACTION_ITEM_MUTATION, + variables: { + actionItemId: 'actionItemId1', + }, + }, + error: new Error('Mock Graphql Error'), + }, + { + request: { + query: UPDATE_ACTION_ITEM_MUTATION, + variables: { + actionItemId: 'actionItemId1', + assigneeId: 'volunteerId1', + assigneeType: 'EventVolunteer', + postCompletionNotes: '', + isCompleted: false, + }, + }, + error: new Error('Mock Graphql Error'), + }, + { + request: { + query: CREATE_ACTION_ITEM_MUTATION, + variables: { + assigneeId: '', + assigneeType: 'User', + preCompletionNotes: '', + allottedHours: null, + dDate: dayjs().format('YYYY-MM-DD'), + }, + }, + error: new Error('Mock Graphql Error'), + }, + { + request: { + query: UPDATE_ACTION_ITEM_MUTATION, + variables: { + actionItemId: 'actionItemId1', + assigneeId: 'userId1', + assigneeType: 'User', + postCompletionNotes: 'Cmp Notes 2', + }, + }, + error: new Error('Mock Graphql Error'), + }, + memberListQuery, + actionItemCategoryListQuery, + ...volunteerListQuery, + ...groupListQuery, +]; + +export const MOCKS_EMPTY = [ + { + request: { + query: ACTION_ITEM_LIST, + variables: { + organizationId: 'orgId', + orderBy: null, + where: { + assigneeName: '', + }, + }, + }, + result: { + data: { + actionItemsByOrganization: [], + }, + }, + }, + memberListQuery, + actionItemCategoryListQuery, + ...volunteerListQuery, + ...groupListQuery, +]; diff --git a/src/screens/OrganizationActionItems/OrganizationActionItems.module.css b/src/screens/OrganizationActionItems/OrganizationActionItems.module.css new file mode 100644 index 0000000000..ac86e19f3f --- /dev/null +++ b/src/screens/OrganizationActionItems/OrganizationActionItems.module.css @@ -0,0 +1,291 @@ +actionItemsContainer { + height: 90vh; +} + +.actionItemModal { + max-width: 80vw; + margin-top: 2vh; + margin-left: 13vw; +} + +.datediv { + display: flex; + flex-direction: row; +} + +.datebox { + width: 90%; + border-radius: 7px; + outline: none; + box-shadow: none; + padding-top: 2px; + padding-bottom: 2px; + padding-right: 5px; + padding-left: 5px; + margin-right: 5px; + margin-left: 5px; +} + +.dropdownToggle { + margin-bottom: 0; + display: flex; +} + +.dropdownModalToggle { + width: 50%; +} + +.errorIcon { + transform: scale(1.5); + color: var(--bs-danger); + margin-bottom: 1rem; +} + +.greenregbtn { + margin: 1rem 0 0; + margin-top: 15px; + border: 1px solid var(--bs-gray-300); + box-shadow: 0 2px 2px var(--bs-gray-300); + padding: 10px 10px; + border-radius: 5px; + background-color: var(--bs-primary); + width: 100%; + font-size: 16px; + color: var(--bs-white); + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; +} + +hr { + border: none; + height: 1px; + background-color: var(--bs-gray-500); + margin: 1rem; +} + +.iconContainer { + display: flex; + justify-content: flex-end; +} +.icon { + margin: 1px; +} + +.message { + margin-top: 25%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.preview { + display: flex; + flex-direction: row; + font-weight: 900; + font-size: 16px; + color: rgb(80, 80, 80); +} + +.removeFilterIcon { + cursor: pointer; +} + +.searchForm { + display: inline; +} + +.view { + margin-left: 2%; + font-weight: 600; + font-size: 16px; + color: var(--bs-gray-600); +} + +/* header (search, filter, dropdown) */ +.btnsContainer { + display: flex; + margin: 0.5rem 0 1.5rem 0; +} + +.btnsContainer .input { + flex: 1; + min-width: 18rem; + position: relative; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); +} + +.btnsContainer .input button { + width: 52px; +} + +.noOutline input { + outline: none; +} + +.noOutline input:disabled { + -webkit-text-fill-color: black !important; +} + +.noOutline textarea:disabled { + -webkit-text-fill-color: black !important; +} + +.inputField { + margin-top: 10px; + margin-bottom: 10px; + background-color: white; + box-shadow: 0 1px 1px #31bb6b; +} + +.inputField > button { + padding-top: 10px; + padding-bottom: 10px; +} + +.dropdown { + background-color: white; + border: 1px solid #31bb6b; + position: relative; + display: inline-block; + color: #31bb6b; +} + +/* Action Items Data Grid */ +.rowBackground { + background-color: var(--bs-white); + max-height: 120px; +} + +.tableHeader { + background-color: var(--bs-primary); + color: var(--bs-white); + font-size: 1rem; +} + +.chipIcon { + height: 0.9rem !important; +} + +.chip { + height: 1.5rem !important; +} + +.active { + background-color: #31bb6a50 !important; +} + +.pending { + background-color: #ffd76950 !important; + color: #bb952bd0 !important; + border-color: #bb952bd0 !important; +} + +/* Modals */ +.itemModal { + max-width: 80vw; + margin-top: 2vh; + margin-left: 13vw; +} + +.titlemodal { + color: #707070; + font-weight: 600; + font-size: 32px; + width: 65%; + margin-bottom: 0px; +} + +.modalCloseBtn { + width: 40px; + height: 40px; + padding: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.imageContainer { + display: flex; + align-items: center; + justify-content: center; + margin-right: 0.5rem; +} + +.TableImage { + object-fit: cover; + width: 25px !important; + height: 25px !important; + border-radius: 100% !important; +} + +.avatarContainer { + width: 28px; + height: 26px; +} + +/* Toggle Btn */ +.toggleGroup { + width: 50%; + min-width: 20rem; + margin: 0.5rem 0rem; +} + +.toggleBtn { + padding: 0rem; + height: 2rem; + display: flex; + justify-content: center; + align-items: center; +} + +.toggleBtn:hover { + color: #31bb6b !important; +} + +input[type='radio']:checked + label { + background-color: #31bb6a50 !important; +} + +input[type='radio']:checked + label:hover { + color: black !important; +} /* Toggle Btn */ +.toggleGroup { + width: 50%; + min-width: 20rem; + margin: 0.5rem 0rem; +} + +.toggleBtn { + padding: 0rem; + height: 2rem; + display: flex; + justify-content: center; + align-items: center; +} + +.toggleBtn:hover { + color: #31bb6b !important; +} + +input[type='radio']:checked + label { + background-color: #31bb6a50 !important; +} + +input[type='radio']:checked + label:hover { + color: black !important; +} + +.rankings { + aspect-ratio: 1; + border-radius: 50%; + width: 50px; +} diff --git a/src/screens/OrganizationActionItems/OrganizationActionItems.test.tsx b/src/screens/OrganizationActionItems/OrganizationActionItems.test.tsx new file mode 100644 index 0000000000..44e11baa35 --- /dev/null +++ b/src/screens/OrganizationActionItems/OrganizationActionItems.test.tsx @@ -0,0 +1,359 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import OrganizationActionItems from 'screens/OrganizationActionItems/OrganizationActionItems'; +import type { ApolloLink } from '@apollo/client'; +import { + MOCKS, + MOCKS_EMPTY, + MOCKS_ERROR, +} from './OrganizationActionItem.mocks'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('@mui/x-date-pickers/DateTimePicker', () => { + return { + DateTimePicker: jest.requireActual( + '@mui/x-date-pickers/DesktopDateTimePicker', + ).DesktopDateTimePicker, + }; +}); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const link3 = new StaticMockLink(MOCKS_EMPTY); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.organizationActionItems ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderOrganizationActionItems = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } + /> + } + /> + + + + + + , + ); +}; + +describe('Testing Organization Action Items Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId', eventId: 'eventId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } + /> + } + /> + + + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Organization Action Items screen', async () => { + renderOrganizationActionItems(link1); + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getAllByText('John Doe')).toHaveLength(2); + expect(screen.getAllByText('Jane Doe')).toHaveLength(2); + }); + }); + + it('Sort Action Items descending by dueDate', async () => { + renderOrganizationActionItems(link1); + + const sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by dueDate_DESC + fireEvent.click(sortBtn); + await waitFor(() => { + expect(screen.getByTestId('dueDate_DESC')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('dueDate_DESC')); + await waitFor(() => { + expect(screen.getAllByTestId('categoryName')[0]).toHaveTextContent( + 'Category 2', + ); + }); + }); + + it('Sort Action Items ascending by dueDate', async () => { + renderOrganizationActionItems(link1); + + const sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by dueDate_ASC + fireEvent.click(sortBtn); + await waitFor(() => { + expect(screen.getByTestId('dueDate_ASC')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('dueDate_ASC')); + await waitFor(() => { + expect(screen.getAllByTestId('categoryName')[0]).toHaveTextContent( + 'Category 1', + ); + }); + }); + + it('Filter Action Items by status (All/Pending)', async () => { + renderOrganizationActionItems(link1); + + const filterBtn = await screen.findByTestId('filter'); + expect(filterBtn).toBeInTheDocument(); + + // Filter by All + fireEvent.click(filterBtn); + await waitFor(() => { + expect(screen.getByTestId('statusAll')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('statusAll')); + + await waitFor(() => { + expect(screen.getAllByText('Category 1')).toHaveLength(3); + expect(screen.getAllByText('Category 2')).toHaveLength(2); + }); + + // Filter by Pending + fireEvent.click(filterBtn); + await waitFor(() => { + expect(screen.getByTestId('statusPending')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('statusPending')); + await waitFor(() => { + expect(screen.queryByText('Category 1')).toBeNull(); + expect(screen.getByText('Category 2')).toBeInTheDocument(); + }); + }); + + it('Filter Action Items by status (Completed)', async () => { + renderOrganizationActionItems(link1); + + const filterBtn = await screen.findByTestId('filter'); + expect(filterBtn).toBeInTheDocument(); + + fireEvent.click(filterBtn); + await waitFor(() => { + expect(screen.getByTestId('statusCompleted')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('statusCompleted')); + await waitFor(() => { + expect(screen.getByText('Category 1')).toBeInTheDocument(); + expect(screen.queryByText('Category 2')).toBeNull(); + }); + }); + + it('open and close Item modal (create)', async () => { + renderOrganizationActionItems(link1); + + const addItemBtn = await screen.findByTestId('createActionItemBtn'); + expect(addItemBtn).toBeInTheDocument(); + userEvent.click(addItemBtn); + + await waitFor(() => + expect(screen.getAllByText(t.createActionItem)).toHaveLength(2), + ); + userEvent.click(screen.getByTestId('modalCloseBtn')); + await waitFor(() => + expect(screen.queryByTestId('modalCloseBtn')).toBeNull(), + ); + }); + + it('open and close Item modal (view)', async () => { + renderOrganizationActionItems(link1); + + const viewItemBtn = await screen.findByTestId('viewItemBtn1'); + expect(viewItemBtn).toBeInTheDocument(); + userEvent.click(viewItemBtn); + + await waitFor(() => + expect(screen.getByText(t.actionItemDetails)).toBeInTheDocument(), + ); + userEvent.click(screen.getByTestId('modalCloseBtn')); + await waitFor(() => + expect(screen.queryByTestId('modalCloseBtn')).toBeNull(), + ); + }); + + it('open and closes Item modal (edit)', async () => { + renderOrganizationActionItems(link1); + + const editItemBtn = await screen.findByTestId('editItemBtn1'); + await waitFor(() => expect(editItemBtn).toBeInTheDocument()); + userEvent.click(editItemBtn); + + await waitFor(() => + expect(screen.getAllByText(t.updateActionItem)).toHaveLength(2), + ); + userEvent.click(screen.getByTestId('modalCloseBtn')); + await waitFor(() => + expect(screen.queryByTestId('modalCloseBtn')).toBeNull(), + ); + }); + + it('open and closes Item modal (delete)', async () => { + renderOrganizationActionItems(link1); + + const deleteItemBtn = await screen.findByTestId('deleteItemBtn1'); + expect(deleteItemBtn).toBeInTheDocument(); + userEvent.click(deleteItemBtn); + + await waitFor(() => + expect(screen.getByText(t.deleteActionItem)).toBeInTheDocument(), + ); + userEvent.click(screen.getByTestId('modalCloseBtn')); + await waitFor(() => + expect(screen.queryByTestId('modalCloseBtn')).toBeNull(), + ); + }); + + it('open and closes Item modal (update status)', async () => { + renderOrganizationActionItems(link1); + + const statusCheckbox = await screen.findByTestId('statusCheckbox1'); + expect(statusCheckbox).toBeInTheDocument(); + userEvent.click(statusCheckbox); + + await waitFor(() => + expect(screen.getByText(t.actionItemStatus)).toBeInTheDocument(), + ); + userEvent.click(screen.getByTestId('modalCloseBtn')); + await waitFor(() => + expect(screen.queryByTestId('modalCloseBtn')).toBeNull(), + ); + }); + + it('Search action items by assignee', async () => { + renderOrganizationActionItems(link1); + + const searchByToggle = await screen.findByTestId('searchByToggle'); + expect(searchByToggle).toBeInTheDocument(); + + userEvent.click(searchByToggle); + await waitFor(() => { + expect(screen.getByTestId('assignee')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('assignee')); + + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + userEvent.type(searchInput, 'John'); + await debounceWait(); + + await waitFor(() => { + expect(screen.getByText('Category 1')).toBeInTheDocument(); + expect(screen.queryByText('Category 2')).toBeNull(); + }); + }); + + it('Search action items by category', async () => { + renderOrganizationActionItems(link1); + + const searchByToggle = await screen.findByTestId('searchByToggle'); + expect(searchByToggle).toBeInTheDocument(); + + userEvent.click(searchByToggle); + await waitFor(() => { + expect(screen.getByTestId('category')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('category')); + + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + userEvent.type(searchInput, 'Category 1'); + await debounceWait(); + + await waitFor(() => { + expect(screen.getByText('Category 1')).toBeInTheDocument(); + expect(screen.queryByText('Category 2')).toBeNull(); + }); + }); + + it('should render Empty Action Item Categories Screen', async () => { + renderOrganizationActionItems(link3); + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noActionItems)).toBeInTheDocument(); + }); + }); + + it('should render the Action Item Categories Screen with error', async () => { + renderOrganizationActionItems(link2); + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/screens/OrganizationActionItems/OrganizationActionItems.tsx b/src/screens/OrganizationActionItems/OrganizationActionItems.tsx new file mode 100644 index 0000000000..b100d3e116 --- /dev/null +++ b/src/screens/OrganizationActionItems/OrganizationActionItems.tsx @@ -0,0 +1,578 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Navigate, useParams } from 'react-router-dom'; + +import { + Circle, + FilterAltOutlined, + Search, + Sort, + WarningAmberRounded, +} from '@mui/icons-material'; +import dayjs from 'dayjs'; + +import { useQuery } from '@apollo/client'; +import { ACTION_ITEM_LIST } from 'GraphQl/Queries/Queries'; + +import type { + InterfaceActionItemInfo, + InterfaceActionItemList, +} from 'utils/interfaces'; +import styles from '../../style/app.module.css'; +import Loader from 'components/Loader/Loader'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { Chip, debounce, Stack } from '@mui/material'; +import ItemViewModal from './ItemViewModal'; +import ItemModal from './ItemModal'; +import ItemDeleteModal from './ItemDeleteModal'; +import Avatar from 'components/Avatar/Avatar'; +import ItemUpdateStatusModal from './ItemUpdateStatusModal'; + +enum ItemStatus { + Pending = 'pending', + Completed = 'completed', + Late = 'late', +} + +enum ModalState { + SAME = 'same', + DELETE = 'delete', + VIEW = 'view', + STATUS = 'status', +} + +/** + * Component for managing and displaying action items within an organization. + * + * This component allows users to view, filter, sort, and create action items. It also handles fetching and displaying related data such as action item categories and members. + * + * @returns The rendered component. + */ +function organizationActionItems(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'organizationActionItems', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Get the organization ID from URL parameters + const { orgId, eventId } = useParams(); + + if (!orgId) { + return ; + } + + const [actionItem, setActionItem] = useState( + null, + ); + const [modalMode, setModalMode] = useState<'create' | 'edit'>('create'); + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState<'dueDate_ASC' | 'dueDate_DESC' | null>( + null, + ); + const [status, setStatus] = useState(null); + const [searchBy, setSearchBy] = useState<'assignee' | 'category'>('assignee'); + const [modalState, setModalState] = useState<{ + [key in ModalState]: boolean; + }>({ + [ModalState.SAME]: false, + [ModalState.DELETE]: false, + [ModalState.VIEW]: false, + [ModalState.STATUS]: false, + }); + + const openModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: true })); + + const closeModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: false })); + + const handleModalClick = useCallback( + (actionItem: InterfaceActionItemInfo | null, modal: ModalState): void => { + if (modal === ModalState.SAME) { + setModalMode(actionItem ? 'edit' : 'create'); + } + setActionItem(actionItem); + openModal(modal); + }, + [openModal], + ); + + /** + * Query to fetch action items for the organization based on filters and sorting. + */ + const { + data: actionItemsData, + loading: actionItemsLoading, + error: actionItemsError, + refetch: actionItemsRefetch, + }: { + data: InterfaceActionItemList | undefined; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(ACTION_ITEM_LIST, { + variables: { + organizationId: orgId, + eventId: eventId, + orderBy: sortBy, + where: { + assigneeName: searchBy === 'assignee' ? searchTerm : undefined, + categoryName: searchBy === 'category' ? searchTerm : undefined, + is_completed: + status === null ? undefined : status === ItemStatus.Completed, + }, + }, + }); + + const actionItems = useMemo( + () => actionItemsData?.actionItemsByOrganization || [], + [actionItemsData], + ); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + if (actionItemsLoading) { + return ; + } + + if (actionItemsError) { + return ( +
    + +
    + {tErrors('errorLoading', { entity: 'Action Items' })} +
    +
    + ); + } + + const columns: GridColDef[] = [ + { + field: 'assignee', + headerName: 'Assignee', + flex: 1, + align: 'left', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + const { _id, firstName, lastName, image } = + params.row.assigneeUser || params.row.assignee?.user || {}; + + return ( + <> + {params.row.assigneeType !== 'EventVolunteerGroup' ? ( + <> +
    + {image ? ( + Assignee + ) : ( +
    + +
    + )} + {firstName + ' ' + lastName} +
    + + ) : ( + <> +
    +
    + +
    + {params.row.assigneeGroup?.name as string} +
    + + )} + + ); + }, + }, + { + field: 'itemCategory', + headerName: 'Item Category', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
    + {params.row.actionItemCategory?.name} +
    + ); + }, + }, + { + field: 'status', + headerName: 'Status', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + } + label={params.row.isCompleted ? 'Completed' : 'Pending'} + variant="outlined" + color="primary" + className={`${styles.chip} ${params.row.isCompleted ? styles.active : styles.pending}`} + /> + ); + }, + }, + { + field: 'allottedHours', + headerName: 'Allotted Hours', + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + flex: 1, + renderCell: (params: GridCellParams) => { + return ( +
    + {params.row.allottedHours ?? '-'} +
    + ); + }, + }, + { + field: 'dueDate', + headerName: 'Due Date', + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + flex: 1, + renderCell: (params: GridCellParams) => { + return ( +
    + {dayjs(params.row.dueDate).format('DD/MM/YYYY')} +
    + ); + }, + }, + { + field: 'options', + headerName: 'Options', + align: 'center', + flex: 1, + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + <> + + + + + ); + }, + }, + { + field: 'completed', + headerName: 'Completed', + align: 'center', + flex: 1, + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
    + handleModalClick(params.row, ModalState.STATUS)} + /> +
    + ); + }, + }, + ]; + + return ( +
    + {/* Header with search, filter and Create Button */} +
    +
    + { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
    +
    +
    + + + + {tCommon('searchBy', { item: '' })} + + + setSearchBy('assignee')} + data-testid="assignee" + > + {t('assignee')} + + setSearchBy('category')} + data-testid="category" + > + {t('category')} + + + + + + + {tCommon('sort')} + + + setSortBy('dueDate_DESC')} + data-testid="dueDate_DESC" + > + {t('latestDueDate')} + + setSortBy('dueDate_ASC')} + data-testid="dueDate_ASC" + > + {t('earliestDueDate')} + + + + + + + {t('status')} + + + setStatus(null)} + data-testid="statusAll" + > + {tCommon('all')} + + setStatus(ItemStatus.Pending)} + data-testid="statusPending" + > + {tCommon('pending')} + + setStatus(ItemStatus.Completed)} + data-testid="statusCompleted" + > + {tCommon('completed')} + + + +
    +
    + +
    +
    +
    + + {/* Table with Action Items */} + row._id} + slots={{ + noRowsOverlay: () => ( + + {t('noActionItems')} + + ), + }} + sx={{ + borderRadius: '20px', + backgroundColor: 'EAEBEF)', + '& .MuiDataGrid-row': { + backgroundColor: '#eff1f7', + '&:focus-within': { + // outline: '2px solid #000', + outlineOffset: '-2px', + }, + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: '#EAEBEF', + boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.1)', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: '#EAEBEF', + boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.1)', + }, + '& .MuiDataGrid-cell:focus': { + // outline: '2px solid #000', + // outlineOffset: '-2px', + }, + }} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={actionItems.map((actionItem, index) => ({ + id: index + 1, + ...actionItem, + }))} + columns={columns} + isRowSelectable={() => false} + /> + + {/* Item Modal (Create/Edit) */} + closeModal(ModalState.SAME)} + orgId={orgId} + eventId={eventId} + actionItemsRefetch={actionItemsRefetch} + actionItem={actionItem} + editMode={modalMode === 'edit'} + /> + + {/* View Modal */} + {actionItem && ( + <> + closeModal(ModalState.VIEW)} + item={actionItem} + /> + + closeModal(ModalState.STATUS)} + actionItemsRefetch={actionItemsRefetch} + /> + + closeModal(ModalState.DELETE)} + actionItem={actionItem} + actionItemsRefetch={actionItemsRefetch} + /> + + )} +
    + ); +} + +export default organizationActionItems; diff --git a/src/screens/OrganizationActionItems/testObject.mocks.ts b/src/screens/OrganizationActionItems/testObject.mocks.ts new file mode 100644 index 0000000000..d43f574e8c --- /dev/null +++ b/src/screens/OrganizationActionItems/testObject.mocks.ts @@ -0,0 +1,402 @@ +import { + EVENT_VOLUNTEER_GROUP_LIST, + EVENT_VOLUNTEER_LIST, +} from 'GraphQl/Queries/EventVolunteerQueries'; +import { + ACTION_ITEM_CATEGORY_LIST, + MEMBERS_LIST, +} from 'GraphQl/Queries/Queries'; +import type { InterfaceActionItemInfo } from 'utils/interfaces'; + +export const actionItemCategory1 = { + _id: 'actionItemCategoryId1', + name: 'Category 1', +}; + +export const actionItemCategory2 = { + _id: 'actionItemCategoryId2', + name: 'Category 2', +}; + +export const baseActionItem = { + assigner: { + _id: 'userId', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + creator: { + _id: 'userId', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + __typename: 'User', + }, +}; + +export const itemWithVolunteer: InterfaceActionItemInfo = { + _id: 'actionItemId1', + assigneeType: 'EventVolunteer', + assignee: { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 12, + assignments: [], + groups: [], + user: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: null, + }, + }, + assigneeUser: null, + assigneeGroup: null, + preCompletionNotes: 'Notes 1', + postCompletionNotes: 'Cmp Notes 1', + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-08-30'), + completionDate: new Date('2044-09-03'), + isCompleted: true, + event: null, + allottedHours: 24, + actionItemCategory: actionItemCategory1, + ...baseActionItem, +}; + +export const itemWithVolunteerImage: InterfaceActionItemInfo = { + _id: 'actionItemId1b', + assigneeType: 'EventVolunteer', + assignee: { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 12, + assignments: [], + groups: [], + user: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: 'user-image', + }, + }, + assigneeUser: null, + assigneeGroup: null, + preCompletionNotes: 'Notes 1', + postCompletionNotes: 'Cmp Notes 1', + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-08-30'), + completionDate: new Date('2044-09-03'), + isCompleted: true, + event: null, + allottedHours: 24, + actionItemCategory: actionItemCategory1, + ...baseActionItem, +}; + +export const itemWithUser: InterfaceActionItemInfo = { + _id: 'actionItemId2', + assigneeType: 'User', + assigneeUser: { + _id: 'userId1', + firstName: 'Jane', + lastName: 'Doe', + image: null, + }, + assignee: null, + assigneeGroup: null, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: null, + allottedHours: null, + actionItemCategory: actionItemCategory2, + ...baseActionItem, +}; + +export const itemWithUserImage: InterfaceActionItemInfo = { + _id: 'actionItemId2b', + assigneeType: 'User', + assigneeUser: { + _id: 'userId1', + firstName: 'Jane', + lastName: 'Doe', + image: 'user-image', + }, + assignee: null, + assigneeGroup: null, + preCompletionNotes: 'Notes 2', + postCompletionNotes: null, + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-09-30'), + completionDate: new Date('2044-10-03'), + isCompleted: false, + event: null, + allottedHours: null, + actionItemCategory: actionItemCategory2, + ...baseActionItem, +}; + +export const itemWithGroup: InterfaceActionItemInfo = { + _id: 'actionItemId3', + assigneeType: 'EventVolunteerGroup', + assigneeUser: null, + assignee: null, + assigneeGroup: { + _id: 'volunteerGroupId1', + name: 'Group 1', + description: 'Group 1 Description', + volunteersRequired: 10, + event: { + _id: 'eventId1', + }, + assignments: [], + volunteers: [], + createdAt: '2024-08-27', + creator: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: null, + }, + leader: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: null, + }, + }, + preCompletionNotes: 'Notes 1', + postCompletionNotes: 'Cmp Notes 1', + assignmentDate: new Date('2024-08-27'), + dueDate: new Date('2044-08-30'), + completionDate: new Date('2044-09-03'), + isCompleted: true, + event: null, + allottedHours: 24, + actionItemCategory: actionItemCategory1, + ...baseActionItem, +}; + +export const memberListQuery = { + request: { + query: MEMBERS_LIST, + variables: { id: 'orgId' }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + members: [ + { + _id: 'userId1', + firstName: 'Harve', + lastName: 'Lance', + email: 'harve@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + email: 'wilt@example.com', + image: '', + organizationsBlockedBy: [], + createdAt: '2024-02-14', + }, + ], + }, + ], + }, + }, +}; + +export const volunteerListQuery = [ + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { where: { eventId: 'eventId', hasAccepted: true } }, + }, + result: { + data: { + getEventVolunteers: [ + { + _id: 'volunteerId1', + hasAccepted: true, + hoursVolunteered: 0, + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + assignments: [], + groups: [ + { + _id: 'groupId1', + name: 'group1', + volunteers: [ + { + _id: 'volunteerId1', + }, + ], + }, + ], + }, + { + _id: 'volunteerId2', + hasAccepted: true, + hoursVolunteered: 0, + user: { + _id: 'userId3', + firstName: 'Bruce', + lastName: 'Graza', + image: null, + }, + assignments: [], + groups: [], + }, + ], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_LIST, + variables: { where: { hasAccepted: true } }, + }, + result: { + data: { + getEventVolunteers: [], + }, + }, + }, +]; + +export const groupListQuery = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { where: { eventId: 'eventId' } }, + }, + result: { + data: { + getEventVolunteerGroups: [ + { + _id: 'groupId1', + name: 'group1', + description: 'desc', + volunteersRequired: 10, + createdAt: '2024-10-27T15:34:15.889Z', + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, + }, + { + _id: 'groupId2', + name: 'group2', + description: 'desc', + volunteersRequired: 10, + createdAt: '2024-10-27T15:34:15.889Z', + creator: { + _id: 'userId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId1', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + volunteers: [], + assignments: [], + event: { + _id: 'eventId', + }, + }, + ], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { where: { eventId: undefined } }, + }, + result: { + data: { + getEventVolunteerGroups: [], + }, + }, + }, +]; + +export const actionItemCategoryListQuery = { + request: { + query: ACTION_ITEM_CATEGORY_LIST, + variables: { + organizationId: 'orgId', + where: { is_disabled: false }, + }, + }, + result: { + data: { + actionItemCategoriesByOrganization: [ + { + _id: 'categoryId1', + name: 'Category 1', + isDisabled: false, + createdAt: '2024-08-26', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + }, + { + _id: 'categoryId2', + name: 'Category 2', + isDisabled: true, + createdAt: '2024-08-25', + creator: { + _id: 'creatorId2', + firstName: 'John', + lastName: 'Doe', + }, + }, + ], + }, + }, +}; diff --git a/src/screens/OrganizationDashboard/OrganizationDashboard.module.css b/src/screens/OrganizationDashboard/OrganizationDashboard.module.css new file mode 100644 index 0000000000..3ffe274196 --- /dev/null +++ b/src/screens/OrganizationDashboard/OrganizationDashboard.module.css @@ -0,0 +1,35 @@ +.cardHeader { + padding: 1.25rem 1rem 1rem 1rem; + border-bottom: 1px solid var(--bs-gray-200); + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.cardHeader .cardTitle { + font-size: 1.2rem; + font-weight: 600; +} + +.cardBody { + min-height: 180px; + padding-top: 0; + max-height: 570px; + overflow-y: scroll; + width: 100%; + max-width: 400px; +} + +.cardBody .emptyContainer { + display: flex; + height: 180px; + justify-content: center; + align-items: center; +} + +.rankings { + aspect-ratio: 1; + border-radius: 50%; + width: 35px; +} diff --git a/src/screens/OrganizationDashboard/OrganizationDashboard.test.tsx b/src/screens/OrganizationDashboard/OrganizationDashboard.test.tsx new file mode 100644 index 0000000000..88db2aa737 --- /dev/null +++ b/src/screens/OrganizationDashboard/OrganizationDashboard.test.tsx @@ -0,0 +1,277 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import OrganizationDashboard from './OrganizationDashboard'; +import type { ApolloLink } from '@apollo/client'; +import { MOCKS, EMPTY_MOCKS, ERROR_MOCKS } from './OrganizationDashboardMocks'; +import { toast } from 'react-toastify'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(ERROR_MOCKS); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const t = { + ...JSON.parse( + JSON.stringify(i18n.getDataByLanguage('en')?.translation.dashboard ?? {}), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const renderOrganizationDashboard = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + + + + + + , + ); +}; + +describe('Testing Organization Dashboard Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> + } + /> + + + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Organization Dashboard screen', async () => { + renderOrganizationDashboard(link1); + + // Dashboard cards + const membersBtn = await screen.findByText(t.members); + expect(membersBtn).toBeInTheDocument(); + expect(screen.getByText(t.admins)).toBeInTheDocument(); + expect(screen.getByText(t.posts)).toBeInTheDocument(); + expect(screen.getByText(t.events)).toBeInTheDocument(); + expect(screen.getByText(t.blockedUsers)).toBeInTheDocument(); + + // Upcoming events + expect(screen.getByText(t.upcomingEvents)).toBeInTheDocument(); + expect(screen.getByText('Event 1')).toBeInTheDocument(); + + // Latest posts + expect(screen.getByText(t.latestPosts)).toBeInTheDocument(); + expect(screen.getByText('postone')).toBeInTheDocument(); + + // Membership requests + expect(screen.getByText(t.membershipRequests)).toBeInTheDocument(); + expect(screen.getByText('Jane Doe')).toBeInTheDocument(); + + // Volunteer rankings + expect(screen.getByText(t.volunteerRankings)).toBeInTheDocument(); + expect(screen.getByText('Teresa Bradley')).toBeInTheDocument(); + }); + + it('Click People Card', async () => { + renderOrganizationDashboard(link1); + const membersBtn = await screen.findByText(t.members); + expect(membersBtn).toBeInTheDocument(); + + userEvent.click(membersBtn); + await waitFor(() => { + expect(screen.getByTestId('orgpeople')).toBeInTheDocument(); + }); + }); + + it('Click Admin Card', async () => { + renderOrganizationDashboard(link1); + const adminsBtn = await screen.findByText(t.admins); + expect(adminsBtn).toBeInTheDocument(); + }); +}); + +it('Click Post Card', async () => { + renderOrganizationDashboard(link1); + const postsBtn = await screen.findByText(t.posts); + expect(postsBtn).toBeInTheDocument(); + + userEvent.click(postsBtn); + await waitFor(() => { + expect(screen.getByTestId('orgpost')).toBeInTheDocument(); + }); +}); + +it('Click Events Card', async () => { + renderOrganizationDashboard(link1); + const eventsBtn = await screen.findByText(t.events); + expect(eventsBtn).toBeInTheDocument(); + + userEvent.click(eventsBtn); + await waitFor(() => { + expect(screen.getByTestId('orgevents')).toBeInTheDocument(); + }); +}); + +it('Click Blocked Users Card', async () => { + renderOrganizationDashboard(link1); + const blockedUsersBtn = await screen.findByText(t.blockedUsers); + expect(blockedUsersBtn).toBeInTheDocument(); + + userEvent.click(blockedUsersBtn); + await waitFor(() => { + expect(screen.getByTestId('blockuser')).toBeInTheDocument(); + }); +}); + +it('Click Requests Card', async () => { + renderOrganizationDashboard(link1); + const requestsBtn = await screen.findByText(t.requests); + expect(requestsBtn).toBeInTheDocument(); + + userEvent.click(requestsBtn); + await waitFor(() => { + expect(screen.getByTestId('requests')).toBeInTheDocument(); + }); +}); + +it('Click View All Events', async () => { + renderOrganizationDashboard(link1); + const viewAllBtn = await screen.findAllByText(t.viewAll); + expect(viewAllBtn[0]).toBeInTheDocument(); + + userEvent.click(viewAllBtn[0]); + await waitFor(() => { + expect(screen.getByTestId('orgevents')).toBeInTheDocument(); + }); +}); + +it('Click View All Posts', async () => { + renderOrganizationDashboard(link1); + const viewAllBtn = await screen.findAllByText(t.viewAll); + expect(viewAllBtn[1]).toBeInTheDocument(); + + userEvent.click(viewAllBtn[1]); + await waitFor(() => { + expect(screen.getByTestId('orgpost')).toBeInTheDocument(); + }); +}); + +it('Click View All Requests', async () => { + renderOrganizationDashboard(link1); + const viewAllBtn = await screen.findAllByText(t.viewAll); + expect(viewAllBtn[2]).toBeInTheDocument(); + + userEvent.click(viewAllBtn[2]); + await waitFor(() => { + expect(toast.success).toHaveBeenCalled(); + }); +}); + +it('Click View All Leaderboard', async () => { + renderOrganizationDashboard(link1); + const viewAllBtn = await screen.findAllByText(t.viewAll); + expect(viewAllBtn[3]).toBeInTheDocument(); + + userEvent.click(viewAllBtn[3]); + await waitFor(() => { + expect(screen.getByTestId('leaderboard')).toBeInTheDocument(); + }); +}); + +it('should render Organization Dashboard screen with empty data', async () => { + renderOrganizationDashboard(link3); + + await waitFor(() => { + expect(screen.getByText(t.noUpcomingEvents)).toBeInTheDocument(); + expect(screen.getByText(t.noPostsPresent)).toBeInTheDocument(); + expect(screen.getByText(t.noMembershipRequests)).toBeInTheDocument(); + expect(screen.getByText(t.noVolunteers)).toBeInTheDocument(); + }); +}); + +it('should redirectt to / if error occurs', async () => { + renderOrganizationDashboard(link2); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); +}); diff --git a/src/screens/OrganizationDashboard/OrganizationDashboard.tsx b/src/screens/OrganizationDashboard/OrganizationDashboard.tsx new file mode 100644 index 0000000000..ebea874d2e --- /dev/null +++ b/src/screens/OrganizationDashboard/OrganizationDashboard.tsx @@ -0,0 +1,486 @@ +import { useQuery } from '@apollo/client'; +import React, { useEffect, useMemo, useState } from 'react'; +import { Button, Card } from 'react-bootstrap'; +import Col from 'react-bootstrap/Col'; +import Row from 'react-bootstrap/Row'; +import { useTranslation } from 'react-i18next'; + +import type { ApolloError } from '@apollo/client'; +import { + ORGANIZATIONS_LIST, + ORGANIZATION_EVENT_CONNECTION_LIST, + ORGANIZATION_POST_LIST, +} from 'GraphQl/Queries/Queries'; +import AdminsIcon from 'assets/svgs/admin.svg?react'; +import BlockedUsersIcon from 'assets/svgs/blockedUser.svg?react'; +import EventsIcon from 'assets/svgs/events.svg?react'; +import PostsIcon from 'assets/svgs/post.svg?react'; +import UsersIcon from 'assets/svgs/users.svg?react'; +import CardItem from 'components/OrganizationDashCards/CardItem'; +import CardItemLoading from 'components/OrganizationDashCards/CardItemLoading'; +import DashBoardCard from 'components/OrganizationDashCards/DashboardCard'; +import DashboardCardLoading from 'components/OrganizationDashCards/DashboardCardLoading'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import gold from 'assets/images/gold.png'; +import silver from 'assets/images/silver.png'; +import bronze from 'assets/images/bronze.png'; +import { toast } from 'react-toastify'; +import type { + InterfaceQueryOrganizationEventListItem, + InterfaceQueryOrganizationPostListItem, + InterfaceQueryOrganizationsListObject, + InterfaceVolunteerRank, +} from 'utils/interfaces'; +import styles from './OrganizationDashboard.module.css'; +import { VOLUNTEER_RANKING } from 'GraphQl/Queries/EventVolunteerQueries'; + +/** + * Component for displaying the organization dashboard. + * + * This component provides an overview of various statistics and information related to an organization, including members, admins, posts, events, blocked users, and membership requests. It also displays upcoming events and latest posts. + * + * @returns The rendered component. + */ +function organizationDashboard(): JSX.Element { + const { t } = useTranslation('translation', { keyPrefix: 'dashboard' }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + document.title = t('title'); + const { orgId } = useParams(); + + if (!orgId) { + return ; + } + + const leaderboardLink = `/leaderboard/${orgId}`; + const peopleLink = `/orgpeople/${orgId}`; + const postsLink = `/orgpost/${orgId}`; + const eventsLink = `/orgevents/${orgId}`; + const blockUserLink = `/blockuser/${orgId}`; + const requestLink = '/requests'; + + const navigate = useNavigate(); + const [upcomingEvents, setUpcomingEvents] = useState< + InterfaceQueryOrganizationEventListItem[] + >([]); + + /** + * Query to fetch organization data. + */ + const { + data, + loading: loadingOrgData, + error: errorOrg, + }: { + data?: { + organizations: InterfaceQueryOrganizationsListObject[]; + }; + loading: boolean; + error?: ApolloError; + } = useQuery(ORGANIZATIONS_LIST, { + variables: { id: orgId }, + }); + + /** + * Query to fetch vvolunteer rankings. + */ + const { + data: rankingsData, + loading: rankingsLoading, + error: errorRankings, + }: { + data?: { + getVolunteerRanks: InterfaceVolunteerRank[]; + }; + loading: boolean; + error?: ApolloError; + } = useQuery(VOLUNTEER_RANKING, { + variables: { + orgId, + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + limit: 3, + }, + }, + }); + + const rankings = useMemo( + () => rankingsData?.getVolunteerRanks || [], + [rankingsData], + ); + + /** + * Query to fetch posts for the organization. + */ + const { + data: postData, + loading: loadingPost, + error: errorPost, + }: { + data?: { + organizations: InterfaceQueryOrganizationPostListItem[]; + }; + loading: boolean; + error?: ApolloError; + } = useQuery(ORGANIZATION_POST_LIST, { + variables: { id: orgId, first: 10 }, + }); + + /** + * Query to fetch events for the organization. + */ + const { + data: eventData, + loading: loadingEvent, + error: errorEvent, + } = useQuery(ORGANIZATION_EVENT_CONNECTION_LIST, { + variables: { + organization_id: orgId, + }, + }); + + /** + * UseEffect to update the list of upcoming events. + */ + useEffect(() => { + if (eventData && eventData?.eventsByOrganizationConnection.length > 0) { + const tempUpcomingEvents: InterfaceQueryOrganizationEventListItem[] = []; + eventData?.eventsByOrganizationConnection.map( + (event: InterfaceQueryOrganizationEventListItem) => { + const startDate = new Date(event.startDate); + const now = new Date(); + if (startDate > now) { + tempUpcomingEvents.push(event); + } + }, + ); + setUpcomingEvents(tempUpcomingEvents); + } + }, [eventData?.eventsByOrganizationConnection]); + + /** + * UseEffect to handle errors and navigate if necessary. + */ + useEffect(() => { + if (errorOrg || errorPost || errorEvent || errorRankings) { + toast.error(tErrors('errorLoading', { entity: '' })); + navigate('/'); + } + }, [errorOrg, errorPost, errorEvent, errorRankings]); + + return ( + <> + + + {loadingOrgData ? ( + + {[...Array(6)].map((_, index) => { + return ( + + + + ); + })} + + ) : ( + + { + navigate(peopleLink); + }} + > + } + /> + + { + navigate(peopleLink); + } + } + > + } + /> + + { + navigate(postsLink); + }} + > + } + /> + + { + navigate(eventsLink); + }} + > + } + /> + + { + navigate(blockUserLink); + }} + > + } + /> + + { + navigate(requestLink); + }} + > + } + /> + + + )} + + + +
    +
    {t('upcomingEvents')}
    + +
    + + {loadingEvent ? ( + [...Array(4)].map((_, index) => { + return ; + }) + ) : upcomingEvents.length == 0 ? ( +
    +
    {t('noUpcomingEvents')}
    +
    + ) : ( + upcomingEvents.map( + (event: InterfaceQueryOrganizationEventListItem) => { + return ( + + ); + }, + ) + )} +
    +
    + + + +
    +
    {t('latestPosts')}
    + +
    + + {loadingPost ? ( + [...Array(4)].map((_, index) => { + return ; + }) + ) : postData?.organizations[0].posts.totalCount == 0 ? ( + /* eslint-disable */ +
    +
    {t('noPostsPresent')}
    +
    + ) : ( + /* eslint-enable */ + postData?.organizations[0].posts.edges + .slice(0, 5) + .map((edge) => { + const post = edge.node; + return ( + + ); + }) + )} +
    +
    + +
    + + + + +
    +
    + {t('membershipRequests')} +
    + +
    + + {loadingOrgData ? ( + [...Array(4)].map((_, index) => { + return ; + }) + ) : data?.organizations[0].membershipRequests.length == 0 ? ( +
    +
    {t('noMembershipRequests')}
    +
    + ) : ( + data?.organizations[0]?.membershipRequests + .slice(0, 8) + .map((request) => { + return ( + + ); + }) + )} +
    +
    +
    + + +
    +
    {t('volunteerRankings')}
    + +
    + + {rankingsLoading ? ( + [...Array(3)].map((_, index) => { + return ; + }) + ) : rankings.length == 0 ? ( +
    +
    {t('noVolunteers')}
    +
    + ) : ( + rankings.map(({ rank, user, hoursVolunteered }, index) => { + return ( +
    +
    +
    + {rank <= 3 ? ( + gold + ) : ( + rank + )} +
    +
    {`${user.firstName} ${user.lastName}`}
    +
    - {hoursVolunteered} hours
    +
    + {index < 2 &&
    } +
    + ); + }) + )} +
    +
    +
    + +
    + + ); +} + +export default organizationDashboard; diff --git a/src/screens/OrganizationDashboard/OrganizationDashboardMocks.ts b/src/screens/OrganizationDashboard/OrganizationDashboardMocks.ts new file mode 100644 index 0000000000..af1e9a799b --- /dev/null +++ b/src/screens/OrganizationDashboard/OrganizationDashboardMocks.ts @@ -0,0 +1,486 @@ +import { VOLUNTEER_RANKING } from 'GraphQl/Queries/EventVolunteerQueries'; +import { + ORGANIZATIONS_LIST, + ORGANIZATION_EVENT_CONNECTION_LIST, + ORGANIZATION_POST_LIST, +} from 'GraphQl/Queries/Queries'; + +export const MOCKS = [ + { + request: { + query: ORGANIZATIONS_LIST, + variables: { id: 'orgId' }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + image: '', + name: 'Dummy Organization', + description: 'This is a Dummy Organization', + address: { + city: 'Delhi', + countryCode: 'IN', + dependentLocality: 'Some Dependent Locality', + line1: '123 Random Street', + line2: 'Apartment 456', + postalCode: '110001', + sortingCode: 'ABC-123', + state: 'Delhi', + }, + userRegistrationRequired: true, + visibleInSearch: false, + creator: { + firstName: '', + lastName: '', + email: '', + }, + members: [ + { + _id: '123', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@gmail.com', + }, + ], + admins: [ + { + _id: '123', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@gmail.com', + createdAt: '12-03-2024', + }, + ], + membershipRequests: [ + { + _id: 'requestId1', + user: { + firstName: 'Jane', + lastName: 'Doe', + email: 'janedoe@gmail.com', + }, + }, + ], + blockedUsers: [ + { + _id: '789', + firstName: 'Steve', + lastName: 'Smith', + email: 'stevesmith@gmail.com', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_POST_LIST, + variables: { id: 'orgId', first: 10 }, + }, + result: { + data: { + organizations: [ + { + posts: { + edges: [ + { + node: { + _id: 'postId1', + title: 'postone', + text: 'This is the first post', + imageUrl: null, + videoUrl: null, + createdAt: '2023-08-24T09:26:56.524+00:00', + creator: { + _id: '640d98d9eb6a743d75341067', + firstName: 'Aditya', + lastName: 'Shelke', + email: 'adidacreator1@gmail.com', + }, + likeCount: 0, + commentCount: 0, + comments: [], + pinned: true, + likedBy: [], + }, + cursor: 'postId1', + }, + { + node: { + _id: 'postId2', + title: 'posttwo', + text: 'Tis is the post two', + imageUrl: null, + videoUrl: null, + createdAt: '2023-08-24T09:26:56.524+00:00', + creator: { + _id: '640d98d9eb6a743d75341067', + firstName: 'Aditya', + lastName: 'Shelke', + email: 'adidacreator1@gmail.com', + }, + likeCount: 0, + commentCount: 0, + pinned: false, + likedBy: [], + comments: [], + }, + cursor: 'postId2', + }, + { + node: { + _id: 'postId3', + title: 'posttwo', + text: 'Tis is the post two', + imageUrl: null, + videoUrl: null, + createdAt: '2023-08-24T09:26:56.524+00:00', + creator: { + _id: '640d98d9eb6a743d75341067', + firstName: 'Aditya', + lastName: 'Shelke', + email: 'adidacreator1@gmail.com', + }, + likeCount: 0, + commentCount: 0, + pinned: true, + likedBy: [], + comments: [], + }, + cursor: 'postId3', + }, + { + node: { + _id: 'postId4', + title: 'posttwo', + text: 'Tis is the post two', + imageUrl: null, + videoUrl: null, + createdAt: '2023-08-24T09:26:56.524+00:00', + creator: { + _id: '640d98d9eb6a743d75341067', + firstName: 'Aditya', + lastName: 'Shelke', + email: 'adidacreator1@gmail.com', + }, + likeCount: 0, + commentCount: 0, + pinned: false, + likedBy: [], + comments: [], + }, + cursor: 'postId4', + }, + ], + pageInfo: { + startCursor: 'postId1', + endCursor: 'postId4', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 4, + }, + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_EVENT_CONNECTION_LIST, + variables: { + organization_id: 'orgId', + }, + }, + result: { + data: { + eventsByOrganizationConnection: [ + { + _id: 'eventId1', + title: 'Event 1', + description: 'Sample Description', + startDate: '2025-10-29T00:00:00.000Z', + endDate: '2023-10-29T23:59:59.000Z', + location: 'Sample Location', + startTime: '08:00:00', + endTime: '17:00:00', + allDay: false, + recurring: false, + attendees: [ + { + _id: 'userId1', + createdAt: '2023-01-01T00:00:00.000Z', + firstName: 'John', + lastName: 'Doe', + gender: 'Male', + eventsAttended: { + _id: 'eventId1', + endDate: '2023-10-29T23:59:59.000Z', + }, + }, + ], + recurrenceRule: null, + isRecurringEventException: false, + isPublic: true, + isRegisterable: true, + }, + { + _id: 'eventId2', + title: 'Event 2', + description: 'Sample Description', + startDate: '2022-10-29T00:00:00.000Z', + endDate: '2023-10-29T23:59:59.000Z', + location: 'Sample Location', + startTime: '08:00:00', + endTime: '17:00:00', + allDay: false, + attendees: [ + { + _id: 'userId1', + createdAt: '2023-01-01T00:00:00.000Z', + firstName: 'John', + lastName: 'Doe', + gender: 'Male', + eventsAttended: { + _id: 'eventId1', + endDate: '2023-10-29T23:59:59.000Z', + }, + }, + ], + recurring: false, + recurrenceRule: null, + isRecurringEventException: false, + isPublic: true, + isRegisterable: true, + }, + ], + }, + }, + }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: 'orgId', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + limit: 3, + }, + }, + }, + result: { + data: { + getVolunteerRanks: [ + { + rank: 1, + hoursVolunteered: 5, + user: { + _id: 'userId1', + lastName: 'Bradley', + firstName: 'Teresa', + image: null, + email: 'testuser4@example.com', + }, + }, + { + rank: 2, + hoursVolunteered: 4, + user: { + _id: 'userId2', + lastName: 'Garza', + firstName: 'Bruce', + image: null, + email: 'testuser5@example.com', + }, + }, + { + rank: 3, + hoursVolunteered: 3, + user: { + _id: 'userId3', + lastName: 'John', + firstName: 'Doe', + image: null, + email: 'testuser6@example.com', + }, + }, + { + rank: 4, + hoursVolunteered: 2, + user: { + _id: 'userId4', + lastName: 'Jane', + firstName: 'Doe', + image: null, + email: 'testuser7@example.com', + }, + }, + ], + }, + }, + }, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: ORGANIZATIONS_LIST, + variables: { id: 'orgId' }, + }, + result: { + data: { + organizations: [ + { + _id: 123, + image: '', + name: 'Dummy Organization', + description: 'This is a Dummy Organization', + address: { + city: 'Delhi', + countryCode: 'IN', + dependentLocality: 'Some Dependent Locality', + line1: '123 Random Street', + line2: 'Apartment 456', + postalCode: '110001', + sortingCode: 'ABC-123', + state: 'Delhi', + }, + userRegistrationRequired: true, + visibleInSearch: false, + creator: { + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@gmail.com', + }, + members: [ + { + _id: '123', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@gmail.com', + }, + ], + admins: [ + { + _id: '123', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@gmail.com', + createdAt: '12-03-2024', + }, + ], + membershipRequests: [], + blockedUsers: [ + { + _id: '789', + firstName: 'Steve', + lastName: 'Smith', + email: 'stevesmith@gmail.com', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_POST_LIST, + variables: { id: 'orgId', first: 10 }, + }, + result: { + data: { + organizations: [ + { + posts: { + edges: [], + pageInfo: { + startCursor: '', + endCursor: '', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 0, + }, + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_EVENT_CONNECTION_LIST, + variables: { + organization_id: 'orgId', + }, + }, + result: { + data: { + eventsByOrganizationConnection: [], + }, + }, + }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: '123', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + limit: 3, + }, + }, + }, + result: { + data: { + getVolunteerRanks: [], + }, + }, + }, +]; + +export const ERROR_MOCKS = [ + { + request: { + query: ORGANIZATIONS_LIST, + variables: { id: 'orgId' }, + }, + error: new Error('Mock Graphql ORGANIZATIONS_LIST Error'), + }, + { + request: { + query: ORGANIZATION_POST_LIST, + variables: { id: 'orgId', first: 10 }, + }, + error: new Error('Mock Graphql ORGANIZATION_POST_LIST Error'), + }, + { + request: { + query: ORGANIZATION_EVENT_CONNECTION_LIST, + variables: { + organization_id: 'orgId', + }, + }, + error: new Error('Mock Graphql ORGANIZATION_EVENT_LIST Error'), + }, + { + request: { + query: VOLUNTEER_RANKING, + variables: { + orgId: '123', + where: { + orderBy: 'hours_DESC', + timeFrame: 'allTime', + limit: 3, + }, + }, + }, + error: new Error('Mock Graphql VOLUNTEER_RANKING Error'), + }, +]; diff --git a/src/screens/OrganizationEvents/OrganizationEvents.module.css b/src/screens/OrganizationEvents/OrganizationEvents.module.css new file mode 100644 index 0000000000..55e86fcaba --- /dev/null +++ b/src/screens/OrganizationEvents/OrganizationEvents.module.css @@ -0,0 +1,330 @@ +.navbarbg { + height: 60px; + background-color: white; + display: flex; + margin-bottom: 30px; + z-index: 1; + position: relative; + flex-direction: row; + justify-content: space-between; + box-shadow: 0px 0px 8px 2px #c8c8c8; +} + +.logo { + color: #707070; + margin-left: 0; + display: flex; + align-items: center; + text-decoration: none; +} + +.logo img { + margin-top: 0px; + margin-left: 10px; + height: 64px; + width: 70px; +} + +.logo > strong { + line-height: 1.5rem; + margin-left: -5px; + font-family: sans-serif; + font-size: 19px; + color: #707070; +} +.mainpage { + display: flex; + flex-direction: row; +} + +.sidebar:after { + background-color: #f7f7f7; + position: absolute; + width: 2px; + height: 600px; + top: 10px; + left: 94%; + display: block; +} + +.navitem { + padding-left: 27%; + padding-top: 12px; + padding-bottom: 12px; + cursor: pointer; +} + +.logintitle { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 30px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 15%; +} + +.logintitleadmin { + color: #707070; + font-weight: 600; + font-size: 18px; + margin-top: 50px; + margin-bottom: 40px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 30%; +} +.admindetails { + display: flex; + justify-content: space-between; +} +.admindetails > p { + margin-top: -12px; + margin-right: 30px; +} + +.mainpageright > hr { + margin-top: 20px; + width: 100%; + margin-left: -15px; + margin-right: -15px; + margin-bottom: 20px; +} + +.justifysp { + display: block; + justify-content: space-between; + margin-top: 20px; +} + +.addbtn { + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + border-radius: 5px; + font-size: 16px; + height: 60%; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; +} +.flexdir { + display: flex; + flex-direction: row; + justify-content: space-between; + border: none; +} + +.form_wrapper { + margin-top: 27px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + position: absolute; + display: flex; + flex-direction: column; + padding: 40px 30px; + background: #ffffff; + border-color: #e8e5e5; + border-width: 5px; + border-radius: 10px; + max-height: 86vh; + overflow: auto; +} + +.form_wrapper form { + display: flex; + align-items: left; + justify-content: left; + flex-direction: column; +} +.logintitleinvite { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 40%; +} +.titlemodal { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 65%; +} +.cancel > i { + margin-top: 5px; + transform: scale(1.2); + cursor: pointer; + color: #707070; +} +.modalbody { + width: 50px; +} +.greenregbtn { + margin: 1rem 0 0; + margin-top: 15px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 10px; + border-radius: 5px; + background-color: #31bb6b; + width: 100%; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; +} + +.datediv { + display: flex; + flex-direction: row; + margin-bottom: 15px; +} +.datebox { + width: 90%; + border-radius: 7px; + border-color: #e8e5e5; + outline: none; + box-shadow: none; + padding-top: 2px; + padding-bottom: 2px; + padding-right: 5px; + padding-left: 5px; + margin-right: 5px; + margin-left: 5px; +} +.checkboxdiv > label { + margin-right: 50px; +} +.checkboxdiv > label > input { + margin-left: 10px; +} +.loader, +.loader:after { + border-radius: 50%; + width: 10em; + height: 10em; +} +.loader { + margin: 60px auto; + margin-top: 35vh !important; + font-size: 10px; + position: relative; + text-indent: -9999em; + border-top: 1.1em solid rgba(255, 255, 255, 0.2); + border-right: 1.1em solid rgba(255, 255, 255, 0.2); + border-bottom: 1.1em solid rgba(255, 255, 255, 0.2); + border-left: 1.1em solid #febc59; + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); + -webkit-animation: load8 1.1s infinite linear; + animation: load8 1.1s infinite linear; +} +@-webkit-keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +.dispflex { + display: flex; + align-items: center; + justify-content: space-between; +} +.dispflex > input { + border: none; + box-shadow: none; + margin-top: 5px; +} +.checkboxdiv { + display: flex; + margin-bottom: 5px; +} +.checkboxdiv > div { + width: 50%; +} + +.recurrenceRuleNumberInput { + width: 70px; +} + +.recurrenceRuleDateBox { + width: 70%; +} + +.recurrenceDayButton { + width: 33px; + height: 33px; + border: 1px solid var(--bs-gray); + cursor: pointer; + transition: background-color 0.3s; + display: inline-flex; + justify-content: center; + align-items: center; + margin-right: 0.5rem; + border-radius: 50%; +} + +.recurrenceDayButton:hover { + background-color: var(--bs-gray); +} + +.recurrenceDayButton.selected { + background-color: var(--bs-primary); + border-color: var(--bs-primary); + color: var(--bs-white); +} + +.recurrenceDayButton span { + color: var(--bs-gray); + padding: 0.25rem; + text-align: center; +} + +.recurrenceDayButton:hover span { + color: var(--bs-white); +} + +.recurrenceDayButton.selected span { + color: var(--bs-white); +} + +.recurrenceRuleSubmitBtn { + margin-left: 82%; + padding: 7px 15px; +} + +@media only screen and (max-width: 600px) { + .form_wrapper { + width: 90%; + top: 45%; + } +} diff --git a/src/screens/OrganizationEvents/OrganizationEvents.test.tsx b/src/screens/OrganizationEvents/OrganizationEvents.test.tsx new file mode 100644 index 0000000000..09e896e031 --- /dev/null +++ b/src/screens/OrganizationEvents/OrganizationEvents.test.tsx @@ -0,0 +1,464 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { + act, + render, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; + +import OrganizationEvents from './OrganizationEvents'; +import { store } from 'state/store'; +import i18n from 'utils/i18nForTest'; +import userEvent from '@testing-library/user-event'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import { createTheme } from '@mui/material'; +import { ThemeProvider } from 'react-bootstrap'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { MOCKS } from './OrganizationEventsMocks'; + +const theme = createTheme({ + palette: { + primary: { + main: '#31bb6b', + }, + }, +}); + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink([], true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const translations = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.organizationEvents ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +jest.mock('@mui/x-date-pickers/DateTimePicker', () => { + return { + DateTimePicker: jest.requireActual( + '@mui/x-date-pickers/DesktopDateTimePicker', + ).DesktopDateTimePicker, + }; +}); + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }, +})); + +describe('Organisation Events Page', () => { + const formData = { + title: 'Dummy Org', + description: 'This is a dummy organization', + startDate: '03/28/2022', + endDate: '03/30/2022', + location: 'New Delhi', + startTime: '09:00 AM', + endTime: '05:00 PM', + }; + + global.alert = jest.fn(); + + test('It is necessary to query the correct mock data.', async () => { + const dataQuery1 = MOCKS[0]?.result?.data?.eventsByOrganizationConnection; + + expect(dataQuery1).toEqual([ + { + _id: 1, + title: 'Event', + description: 'Event Test', + startDate: '', + endDate: '', + location: 'New Delhi', + startTime: '02:00', + endTime: '06:00', + allDay: false, + recurring: false, + recurrenceRule: null, + isRecurringEventException: false, + isPublic: true, + isRegisterable: true, + }, + ]); + }); + test('It is necessary to query the correct mock data for organization.', async () => { + const dataQuery1 = MOCKS[1]?.result?.data?.eventsByOrganizationConnection; + + expect(dataQuery1).toEqual([ + { + _id: '1', + title: 'Dummy Org', + description: 'This is a dummy organization', + location: 'string', + startDate: '', + endDate: '', + startTime: '02:00', + endTime: '06:00', + allDay: false, + recurring: false, + recurrenceRule: null, + isRecurringEventException: false, + isPublic: true, + isRegisterable: true, + }, + ]); + }); + test('It is necessary to check correct render', async () => { + window.location.assign('/orglist'); + + const { container } = render( + + + + + + + + + + + + + , + ); + + expect(container.textContent).not.toBe('Loading data...'); + await wait(); + expect(container.textContent).toMatch('Month'); + expect(window.location).toBeAt('/orglist'); + }); + + test('No mock data', async () => { + render( + + + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createEventModalBtn')).toBeInTheDocument(); + }); + }); + + test('Testing toggling of Create event modal', async () => { + render( + + + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createEventModalBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + await waitFor(() => { + expect( + screen.getByTestId('createEventModalCloseBtn'), + ).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('createEventModalCloseBtn')); + + await waitFor(() => { + expect( + screen.queryByTestId('createEventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('Testing Create event modal', async () => { + render( + + + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createEventModalBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + await waitFor(() => { + expect(screen.getByPlaceholderText(/Enter Title/i)).toBeInTheDocument(); + }); + + userEvent.type(screen.getByPlaceholderText(/Enter Title/i), formData.title); + + userEvent.type( + screen.getByPlaceholderText(/Enter Description/i), + formData.description, + ); + userEvent.type(screen.getByPlaceholderText(/Location/i), formData.location); + + const endDatePicker = screen.getByLabelText('End Date'); + const startDatePicker = screen.getByLabelText('Start Date'); + + fireEvent.change(endDatePicker, { + target: { value: formData.endDate }, + }); + fireEvent.change(startDatePicker, { + target: { value: formData.startDate }, + }); + + userEvent.click(screen.getByTestId('ispublicCheck')); + userEvent.click(screen.getByTestId('registrableCheck')); + + await wait(); + + expect(screen.getByPlaceholderText(/Enter Title/i)).toHaveValue( + formData.title, + ); + expect(screen.getByPlaceholderText(/Enter Description/i)).toHaveValue( + formData.description, + ); + + expect(endDatePicker).toHaveValue(formData.endDate); + expect(startDatePicker).toHaveValue(formData.startDate); + expect(screen.getByTestId('ispublicCheck')).not.toBeChecked(); + expect(screen.getByTestId('registrableCheck')).toBeChecked(); + + userEvent.click(screen.getByTestId('createEventBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(translations.eventCreated); + }); + + await waitFor(() => { + expect( + screen.queryByTestId('createEventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('Testing Create event with invalid inputs', async () => { + const formData = { + title: ' ', + description: ' ', + location: ' ', + startDate: '03/28/2022', + endDate: '03/30/2022', + startTime: '02:00', + endTime: '06:00', + allDay: false, + recurring: false, + isPublic: true, + isRegisterable: true, + }; + + render( + + + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createEventModalBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + await waitFor(() => { + expect(screen.getByPlaceholderText(/Enter Title/i)).toBeInTheDocument(); + }); + + userEvent.type(screen.getByPlaceholderText(/Enter Title/i), formData.title); + userEvent.type( + screen.getByPlaceholderText(/Enter Description/i), + formData.description, + ); + userEvent.type(screen.getByPlaceholderText(/Location/i), formData.location); + userEvent.type(screen.getByPlaceholderText(/Location/i), formData.location); + + const endDatePicker = screen.getByLabelText('End Date'); + const startDatePicker = screen.getByLabelText('Start Date'); + + fireEvent.change(endDatePicker, { + target: { value: formData.endDate }, + }); + fireEvent.change(startDatePicker, { + target: { value: formData.startDate }, + }); + + userEvent.click(screen.getByTestId('alldayCheck')); + userEvent.click(screen.getByTestId('recurringCheck')); + userEvent.click(screen.getByTestId('ispublicCheck')); + userEvent.click(screen.getByTestId('registrableCheck')); + + await wait(); + + expect(screen.getByPlaceholderText(/Enter Title/i)).toHaveValue(' '); + expect(screen.getByPlaceholderText(/Enter Description/i)).toHaveValue(' '); + + expect(endDatePicker).toHaveValue(formData.endDate); + expect(startDatePicker).toHaveValue(formData.startDate); + expect(screen.getByTestId('alldayCheck')).not.toBeChecked(); + expect(screen.getByTestId('recurringCheck')).toBeChecked(); + expect(screen.getByTestId('ispublicCheck')).not.toBeChecked(); + expect(screen.getByTestId('registrableCheck')).toBeChecked(); + + userEvent.click(screen.getByTestId('createEventBtn')); + expect(toast.warning).toHaveBeenCalledWith('Title can not be blank!'); + expect(toast.warning).toHaveBeenCalledWith('Description can not be blank!'); + expect(toast.warning).toHaveBeenCalledWith('Location can not be blank!'); + + userEvent.click(screen.getByTestId('createEventModalCloseBtn')); + + await waitFor(() => { + expect( + screen.queryByTestId('createEventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); + + test('Testing create event if the event is not for all day', async () => { + render( + + + + + + + + + + + + + , + ); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createEventModalBtn')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + await waitFor(() => { + expect(screen.getByPlaceholderText(/Enter Title/i)).toBeInTheDocument(); + }); + + userEvent.type(screen.getByPlaceholderText(/Enter Title/i), formData.title); + + userEvent.type( + screen.getByPlaceholderText(/Enter Description/i), + formData.description, + ); + + userEvent.type(screen.getByPlaceholderText(/Location/i), formData.location); + + const endDatePicker = screen.getByLabelText('End Date'); + const startDatePicker = screen.getByLabelText('Start Date'); + + fireEvent.change(endDatePicker, { + target: { value: formData.endDate }, + }); + fireEvent.change(startDatePicker, { + target: { value: formData.startDate }, + }); + + userEvent.click(screen.getByTestId('alldayCheck')); + + await waitFor(() => { + expect(screen.getByLabelText(translations.startTime)).toBeInTheDocument(); + }); + + const startTimePicker = screen.getByLabelText(translations.startTime); + const endTimePicker = screen.getByLabelText(translations.endTime); + + fireEvent.change(startTimePicker, { + target: { value: formData.startTime }, + }); + + fireEvent.change(endTimePicker, { + target: { value: formData.endTime }, + }); + + userEvent.click(screen.getByTestId('createEventBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(translations.eventCreated); + }); + + await waitFor(() => { + expect( + screen.queryByTestId('createEventModalCloseBtn'), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/screens/OrganizationEvents/OrganizationEvents.tsx b/src/screens/OrganizationEvents/OrganizationEvents.tsx new file mode 100644 index 0000000000..4c90777c6e --- /dev/null +++ b/src/screens/OrganizationEvents/OrganizationEvents.tsx @@ -0,0 +1,506 @@ +import React, { useState, useEffect } from 'react'; +import Button from 'react-bootstrap/Button'; +import Modal from 'react-bootstrap/Modal'; +import { Form, Popover } from 'react-bootstrap'; +import { useMutation, useQuery } from '@apollo/client'; +import { toast } from 'react-toastify'; +import { useTranslation } from 'react-i18next'; +import EventCalendar from 'components/EventCalendar/EventCalendar'; +import { TimePicker, DatePicker } from '@mui/x-date-pickers'; +import styles from './OrganizationEvents.module.css'; +import { + ORGANIZATION_EVENT_CONNECTION_LIST, + ORGANIZATIONS_LIST, +} from 'GraphQl/Queries/Queries'; +import { CREATE_EVENT_MUTATION } from 'GraphQl/Mutations/mutations'; +import type { Dayjs } from 'dayjs'; +import dayjs from 'dayjs'; +import { errorHandler } from 'utils/errorHandler'; +import Loader from 'components/Loader/Loader'; +import useLocalStorage from 'utils/useLocalstorage'; +import { useParams, useNavigate } from 'react-router-dom'; +import EventHeader from 'components/EventCalendar/EventHeader'; +import { + Frequency, + Days, + getRecurrenceRuleText, + getWeekDayOccurenceInMonth, +} from 'utils/recurrenceUtils'; +import type { InterfaceRecurrenceRuleState } from 'utils/recurrenceUtils'; +import RecurrenceOptions from 'components/RecurrenceOptions/RecurrenceOptions'; + +const timeToDayJs = (time: string): Dayjs => { + const dateTimeString = dayjs().format('YYYY-MM-DD') + ' ' + time; + return dayjs(dateTimeString, { format: 'YYYY-MM-DD HH:mm:ss' }); +}; + +export enum ViewType { + DAY = 'Day', + MONTH = 'Month View', + YEAR = 'Year View', +} + +/** + * Organization Events Page Component to display the events of an organization + * and create new events for the organization by the admin or superadmin user. + * The component uses the EventCalendar component to display the events and EventHeader component + * to display the view type and create event button. + * The component uses the RecurrenceOptions component to display the recurrence options for the event. + * The component uses the CREATE_EVENT_MUTATION mutation to create a new event for the organization. + * The component uses the ORGANIZATION_EVENT_CONNECTION_LIST and ORGANIZATIONS_LIST queries to fetch the events + * and organization details. + * The component uses the useLocalStorage hook to get the user details from the local storage. + * + * @returns JSX.Element to display the Organization Events Page + */ +function organizationEvents(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'organizationEvents', + }); + const { t: tCommon } = useTranslation('common'); + + const { getItem } = useLocalStorage(); + + document.title = t('title'); + const [createEventmodalisOpen, setCreateEventmodalisOpen] = useState(false); + const [startDate, setStartDate] = React.useState(new Date()); + const [endDate, setEndDate] = React.useState(new Date()); + const [viewType, setViewType] = useState(ViewType.MONTH); + const [alldaychecked, setAllDayChecked] = React.useState(true); + const [recurringchecked, setRecurringChecked] = React.useState(false); + + const [publicchecked, setPublicChecked] = React.useState(true); + const [registrablechecked, setRegistrableChecked] = React.useState(false); + + const [recurrenceRuleState, setRecurrenceRuleState] = + useState({ + recurrenceStartDate: startDate, + recurrenceEndDate: null, + frequency: Frequency.WEEKLY, + weekDays: [Days[startDate.getDay()]], + interval: 1, + count: undefined, + weekDayOccurenceInMonth: undefined, + }); + + const [formState, setFormState] = useState({ + title: '', + eventdescrip: '', + date: '', + location: '', + startTime: '08:00:00', + endTime: '18:00:00', + }); + const { orgId: currentUrl } = useParams(); + const navigate = useNavigate(); + + const showInviteModal = (): void => { + setCreateEventmodalisOpen(true); + }; + const hideCreateEventModal = (): void => { + setCreateEventmodalisOpen(false); + }; + const handleChangeView = (item: string | null): void => { + /*istanbul ignore next*/ + if (item) { + setViewType(item as ViewType); + } + }; + + const { + data, + loading, + error: eventDataError, + refetch: refetchEvents, + } = useQuery(ORGANIZATION_EVENT_CONNECTION_LIST, { + variables: { + organization_id: currentUrl, + title_contains: '', + description_contains: '', + location_contains: '', + }, + }); + + const { data: orgData } = useQuery(ORGANIZATIONS_LIST, { + variables: { id: currentUrl }, + }); + + const userId = getItem('id') as string; + const superAdmin = getItem('SuperAdmin'); + const adminFor = getItem('AdminFor'); + const userRole = superAdmin + ? 'SUPERADMIN' + : adminFor?.length > 0 + ? 'ADMIN' + : 'USER'; + + const [create, { loading: loading2 }] = useMutation(CREATE_EVENT_MUTATION); + + const { + recurrenceStartDate, + recurrenceEndDate, + frequency, + weekDays, + interval, + count, + weekDayOccurenceInMonth, + } = recurrenceRuleState; + + const recurrenceRuleText = getRecurrenceRuleText(recurrenceRuleState); + + const createEvent = async ( + e: React.ChangeEvent, + ): Promise => { + e.preventDefault(); + if ( + formState.title.trim().length > 0 && + formState.eventdescrip.trim().length > 0 && + formState.location.trim().length > 0 + ) { + try { + const { data: createEventData } = await create({ + variables: { + title: formState.title, + description: formState.eventdescrip, + isPublic: publicchecked, + recurring: recurringchecked, + isRegisterable: registrablechecked, + organizationId: currentUrl, + startDate: dayjs(startDate).format('YYYY-MM-DD'), + endDate: dayjs(endDate).format('YYYY-MM-DD'), + allDay: alldaychecked, + location: formState.location, + startTime: !alldaychecked ? formState.startTime : undefined, + endTime: !alldaychecked ? formState.endTime : undefined, + recurrenceStartDate: recurringchecked + ? dayjs(recurrenceStartDate).format('YYYY-MM-DD') + : undefined, + recurrenceEndDate: recurringchecked + ? recurrenceEndDate + ? dayjs(recurrenceEndDate).format('YYYY-MM-DD') + : null + : undefined, + frequency: recurringchecked ? frequency : undefined, + weekDays: + recurringchecked && + (frequency === Frequency.WEEKLY || + (frequency === Frequency.MONTHLY && weekDayOccurenceInMonth)) + ? weekDays + : undefined, + interval: recurringchecked ? interval : undefined, + count: recurringchecked ? count : undefined, + weekDayOccurenceInMonth: recurringchecked + ? weekDayOccurenceInMonth + : undefined, + }, + }); + + if (createEventData) { + toast.success(t('eventCreated') as string); + refetchEvents(); + hideCreateEventModal(); + setFormState({ + title: '', + eventdescrip: '', + date: '', + location: '', + startTime: '08:00:00', + endTime: '18:00:00', + }); + setRecurringChecked(false); + setRecurrenceRuleState({ + recurrenceStartDate: new Date(), + recurrenceEndDate: null, + frequency: Frequency.WEEKLY, + weekDays: [Days[new Date().getDay()]], + interval: 1, + count: undefined, + weekDayOccurenceInMonth: undefined, + }); + setStartDate(new Date()); + setEndDate(new Date()); + } + } catch (error: unknown) { + /* istanbul ignore next */ + if (error instanceof Error) { + console.log(error.message); + errorHandler(t, error); + } + } + } + if (formState.title.trim().length === 0) { + toast.warning('Title can not be blank!'); + } + if (formState.eventdescrip.trim().length === 0) { + toast.warning('Description can not be blank!'); + } + if (formState.location.trim().length === 0) { + toast.warning('Location can not be blank!'); + } + }; + + useEffect(() => { + if (eventDataError) { + navigate('/orglist'); + } + }, [eventDataError]); + + if (loading || loading2) { + return ; + } + + const popover = ( + + {recurrenceRuleText} + + ); + + return ( + <> +
    +
    + +
    +
    + + + {/* Create Event Modal */} + + +

    {t('eventDetails')}

    + +
    + +
    + + { + setFormState({ + ...formState, + title: e.target.value, + }); + }} + /> + + { + setFormState({ + ...formState, + eventdescrip: e.target.value, + }); + }} + /> + + { + setFormState({ + ...formState, + location: e.target.value, + }); + }} + /> +
    +
    + { + if (date) { + setStartDate(date?.toDate()); + setEndDate( + endDate < date?.toDate() ? date?.toDate() : endDate, + ); + setRecurrenceRuleState({ + ...recurrenceRuleState, + recurrenceStartDate: date?.toDate(), + weekDays: [Days[date?.toDate().getDay()]], + weekDayOccurenceInMonth: weekDayOccurenceInMonth + ? getWeekDayOccurenceInMonth(date?.toDate()) + : undefined, + }); + } + }} + /> +
    +
    + { + if (date) { + setEndDate(date?.toDate()); + } + }} + minDate={dayjs(startDate)} + /> +
    +
    + {!alldaychecked && ( +
    +
    + { + if (time) { + setFormState({ + ...formState, + startTime: time?.format('HH:mm:ss'), + endTime: + /*istanbul ignore next*/ + timeToDayJs(formState.endTime) < time + ? /* istanbul ignore next */ time?.format( + 'HH:mm:ss', + ) + : formState.endTime, + }); + } + }} + disabled={alldaychecked} + /> +
    +
    + { + if (time) { + setFormState({ + ...formState, + endTime: time?.format('HH:mm:ss'), + }); + } + }} + minTime={timeToDayJs(formState.startTime)} + disabled={alldaychecked} + /> +
    +
    + )} +
    +
    + + setAllDayChecked(!alldaychecked)} + /> +
    +
    + + setPublicChecked(!publicchecked)} + /> +
    +
    +
    +
    + + { + setRecurringChecked(!recurringchecked); + }} + /> +
    +
    + + + setRegistrableChecked(!registrablechecked) + } + /> +
    +
    + + {/* Recurrence Options */} + {recurringchecked && ( + + )} + + + +
    +
    + + ); +} + +export default organizationEvents; diff --git a/src/screens/OrganizationEvents/OrganizationEventsMocks.ts b/src/screens/OrganizationEvents/OrganizationEventsMocks.ts new file mode 100644 index 0000000000..4f844d33fa --- /dev/null +++ b/src/screens/OrganizationEvents/OrganizationEventsMocks.ts @@ -0,0 +1,241 @@ +import { CREATE_EVENT_MUTATION } from 'GraphQl/Mutations/mutations'; +import { ORGANIZATION_EVENT_CONNECTION_LIST } from 'GraphQl/Queries/Queries'; + +export const MOCKS = [ + { + request: { + query: ORGANIZATION_EVENT_CONNECTION_LIST, + variables: { + organization_id: undefined, + title_contains: '', + description_contains: '', + location_contains: '', + }, + }, + result: { + data: { + eventsByOrganizationConnection: [ + { + _id: 1, + title: 'Event', + description: 'Event Test', + startDate: '', + endDate: '', + location: 'New Delhi', + startTime: '02:00', + endTime: '06:00', + allDay: false, + recurring: false, + recurrenceRule: null, + isRecurringEventException: false, + isPublic: true, + isRegisterable: true, + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_EVENT_CONNECTION_LIST, + variables: { + title_contains: '', + description_contains: '', + organization_id: undefined, + location_contains: '', + }, + }, + result: { + data: { + eventsByOrganizationConnection: [ + { + _id: '1', + title: 'Dummy Org', + description: 'This is a dummy organization', + location: 'string', + startDate: '', + endDate: '', + startTime: '02:00', + endTime: '06:00', + allDay: false, + recurring: false, + recurrenceRule: null, + isRecurringEventException: false, + isPublic: true, + isRegisterable: true, + }, + ], + }, + }, + }, + { + request: { + query: CREATE_EVENT_MUTATION, + variables: { + title: 'Dummy Org', + location: 'New Delhi', + description: 'This is a dummy organization', + isPublic: false, + recurring: false, + isRegisterable: true, + organizationId: undefined, + startDate: '2022-03-28', + endDate: '2022-03-30', + allDay: true, + }, + }, + result: { + data: { + createEvent: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: CREATE_EVENT_MUTATION, + variables: { + title: 'Dummy Org', + location: 'New Delhi', + description: 'This is a dummy organization', + isPublic: true, + recurring: false, + isRegisterable: false, + organizationId: undefined, + startDate: '2022-03-28', + endDate: '2022-03-30', + allDay: false, + startTime: '09:00:00', + endTime: '17:00:00', + }, + }, + result: { + data: { + createEvent: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: CREATE_EVENT_MUTATION, + variables: { + title: 'Dummy Org', + location: 'New Delhi', + description: 'This is a dummy organization', + isPublic: true, + recurring: true, + isRegisterable: false, + organizationId: undefined, + startDate: '2022-03-28', + endDate: '2022-03-30', + allDay: false, + startTime: '09:00:00', + endTime: '17:00:00', + recurrenceStartDate: '2022-03-28', + recurrenceEndDate: null, + frequency: 'DAILY', + interval: 1, + }, + }, + result: { + data: { + createEvent: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: CREATE_EVENT_MUTATION, + variables: { + title: 'Dummy Org', + location: 'New Delhi', + description: 'This is a dummy organization', + isPublic: true, + recurring: true, + isRegisterable: false, + organizationId: undefined, + startDate: '2022-03-28', + endDate: '2022-03-30', + allDay: false, + startTime: '09:00:00', + endTime: '17:00:00', + recurrenceStartDate: '2022-03-28', + recurrenceEndDate: null, + frequency: 'WEEKLY', + interval: 1, + weekDays: ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY'], + }, + }, + result: { + data: { + createEvent: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: CREATE_EVENT_MUTATION, + variables: { + title: 'Dummy Org', + description: 'This is a dummy organization', + location: 'New Delhi', + organizationId: undefined, + isPublic: true, + recurring: true, + isRegisterable: false, + startDate: '2022-03-28', + endDate: '2022-03-30', + allDay: true, + recurrenceStartDate: '2022-03-28', + recurrenceEndDate: '2023-04-15', + frequency: 'MONTHLY', + weekDays: ['MONDAY'], + interval: 2, + weekDayOccurenceInMonth: 4, + }, + }, + result: { + data: { + createEvent: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: CREATE_EVENT_MUTATION, + variables: { + title: 'Dummy Org', + location: 'New Delhi', + description: 'This is a dummy organization', + isPublic: true, + recurring: true, + isRegisterable: false, + organizationId: undefined, + startDate: '2022-03-28', + endDate: '2022-03-30', + allDay: true, + recurrenceStartDate: '2022-03-28', + recurrenceEndDate: null, + frequency: 'DAILY', + interval: 1, + count: 100, + }, + }, + result: { + data: { + createEvent: { + _id: '1', + }, + }, + }, + }, +]; diff --git a/src/screens/OrganizationFundCampaign/CampaignModal.test.tsx b/src/screens/OrganizationFundCampaign/CampaignModal.test.tsx new file mode 100644 index 0000000000..2a5a61a22b --- /dev/null +++ b/src/screens/OrganizationFundCampaign/CampaignModal.test.tsx @@ -0,0 +1,268 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18nForTest from '../../utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import { MOCKS, MOCK_ERROR } from './OrganizationFundCampaignMocks'; +import type { InterfaceCampaignModal } from './CampaignModal'; +import CampaignModal from './CampaignModal'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('@mui/x-date-pickers/DateTimePicker', () => { + return { + DateTimePicker: jest.requireActual( + '@mui/x-date-pickers/DesktopDateTimePicker', + ).DesktopDateTimePicker, + }; +}); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCK_ERROR); +const translations = JSON.parse( + JSON.stringify(i18nForTest.getDataByLanguage('en')?.translation.fundCampaign), +); + +const campaignProps: InterfaceCampaignModal[] = [ + { + isOpen: true, + hide: jest.fn(), + fundId: 'fundId', + orgId: 'orgId', + campaign: { + _id: 'campaignId1', + name: 'Campaign 1', + fundingGoal: 100, + startDate: new Date('2021-01-01'), + endDate: new Date('2024-01-01'), + currency: 'USD', + createdAt: '2021-01-01', + }, + refetchCampaign: jest.fn(), + mode: 'create', + }, + { + isOpen: true, + hide: jest.fn(), + fundId: 'fundId', + orgId: 'orgId', + campaign: { + _id: 'campaignId1', + name: 'Campaign 1', + fundingGoal: 100, + startDate: new Date('2021-01-01'), + endDate: new Date('2024-01-01'), + currency: 'USD', + createdAt: '2021-01-01', + }, + refetchCampaign: jest.fn(), + mode: 'edit', + }, +]; +const renderCampaignModal = ( + link: ApolloLink, + props: InterfaceCampaignModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('CampaignModal', () => { + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + it('should populate form fields with correct values in edit mode', async () => { + renderCampaignModal(link1, campaignProps[1]); + await waitFor(() => + expect(screen.getAllByText(translations.updateCampaign)).toHaveLength(2), + ); + + expect(screen.getByLabelText(translations.campaignName)).toHaveValue( + 'Campaign 1', + ); + expect(screen.getByLabelText('Start Date')).toHaveValue('01/01/2021'); + expect(screen.getByLabelText('End Date')).toHaveValue('01/01/2024'); + expect(screen.getByLabelText(translations.currency)).toHaveTextContent( + 'USD ($)', + ); + expect(screen.getByLabelText(translations.fundingGoal)).toHaveValue('100'); + }); + + it('should update fundingGoal when input value changes', async () => { + renderCampaignModal(link1, campaignProps[1]); + const goalInput = screen.getByLabelText(translations.fundingGoal); + expect(goalInput).toHaveValue('100'); + fireEvent.change(goalInput, { target: { value: '200' } }); + expect(goalInput).toHaveValue('200'); + }); + + it('should not update fundingGoal when input value is less than or equal to 0', async () => { + renderCampaignModal(link1, campaignProps[1]); + const goalInput = screen.getByLabelText(translations.fundingGoal); + expect(goalInput).toHaveValue('100'); + fireEvent.change(goalInput, { target: { value: '-10' } }); + expect(goalInput).toHaveValue('100'); + }); + + it('should update Start Date when a new date is selected', async () => { + renderCampaignModal(link1, campaignProps[1]); + const startDateInput = screen.getByLabelText('Start Date'); + fireEvent.change(startDateInput, { target: { value: '02/01/2024' } }); + expect(startDateInput).toHaveValue('02/01/2024'); + }); + + it('Start Date onChange when its null', async () => { + renderCampaignModal(link1, campaignProps[1]); + const startDateInput = screen.getByLabelText('Start Date'); + expect(startDateInput).toHaveValue('01/01/2021'); + fireEvent.change(startDateInput, { target: { value: null } }); + expect(startDateInput).toHaveValue(''); + }); + + it('should update End Date when a new date is selected', async () => { + renderCampaignModal(link1, campaignProps[1]); + const endDateInput = screen.getByLabelText('End Date'); + fireEvent.change(endDateInput, { target: { value: '02/01/2024' } }); + expect(endDateInput).toHaveValue('02/01/2024'); + }); + + it('End Date onChange when its null', async () => { + renderCampaignModal(link1, campaignProps[1]); + const endDateInput = screen.getByLabelText('End Date'); + fireEvent.change(endDateInput, { target: { value: null } }); + expect(endDateInput).toHaveValue(''); + }); + + it('should create campaign', async () => { + renderCampaignModal(link1, campaignProps[0]); + + const campaignName = screen.getByLabelText(translations.campaignName); + fireEvent.change(campaignName, { target: { value: 'Campaign 2' } }); + + const startDate = screen.getByLabelText('Start Date'); + fireEvent.change(startDate, { target: { value: '02/01/2024' } }); + + const endDate = screen.getByLabelText('End Date'); + fireEvent.change(endDate, { target: { value: '02/02/2024' } }); + + const fundingGoal = screen.getByLabelText(translations.fundingGoal); + fireEvent.change(fundingGoal, { target: { value: '200' } }); + + const submitBtn = screen.getByTestId('submitCampaignBtn'); + expect(submitBtn).toBeInTheDocument(); + fireEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(translations.createdCampaign); + expect(campaignProps[0].refetchCampaign).toHaveBeenCalled(); + expect(campaignProps[0].hide).toHaveBeenCalled(); + }); + }); + + it('should update campaign', async () => { + renderCampaignModal(link1, campaignProps[1]); + + const campaignName = screen.getByLabelText(translations.campaignName); + fireEvent.change(campaignName, { target: { value: 'Campaign 4' } }); + + const startDate = screen.getByLabelText('Start Date'); + fireEvent.change(startDate, { target: { value: '02/01/2023' } }); + + const endDate = screen.getByLabelText('End Date'); + fireEvent.change(endDate, { target: { value: '02/02/2023' } }); + + const fundingGoal = screen.getByLabelText(translations.fundingGoal); + fireEvent.change(fundingGoal, { target: { value: '400' } }); + + const submitBtn = screen.getByTestId('submitCampaignBtn'); + expect(submitBtn).toBeInTheDocument(); + + fireEvent.click(submitBtn); + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(translations.updatedCampaign); + expect(campaignProps[1].refetchCampaign).toHaveBeenCalled(); + expect(campaignProps[1].hide).toHaveBeenCalled(); + }); + }); + + it('Error: should create campaign', async () => { + renderCampaignModal(link2, campaignProps[0]); + + const campaignName = screen.getByLabelText(translations.campaignName); + fireEvent.change(campaignName, { target: { value: 'Campaign 2' } }); + + const startDate = screen.getByLabelText('Start Date'); + fireEvent.change(startDate, { target: { value: '02/01/2024' } }); + + const endDate = screen.getByLabelText('End Date'); + fireEvent.change(endDate, { target: { value: '02/02/2024' } }); + + const fundingGoal = screen.getByLabelText(translations.fundingGoal); + fireEvent.change(fundingGoal, { target: { value: '200' } }); + + const submitBtn = screen.getByTestId('submitCampaignBtn'); + expect(submitBtn).toBeInTheDocument(); + fireEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Mock graphql error'); + }); + }); + + it('Error: should update campaign', async () => { + renderCampaignModal(link2, campaignProps[1]); + + const campaignName = screen.getByLabelText(translations.campaignName); + fireEvent.change(campaignName, { target: { value: 'Campaign 4' } }); + + const startDate = screen.getByLabelText('Start Date'); + fireEvent.change(startDate, { target: { value: '02/01/2023' } }); + + const endDate = screen.getByLabelText('End Date'); + fireEvent.change(endDate, { target: { value: '02/02/2023' } }); + + const fundingGoal = screen.getByLabelText(translations.fundingGoal); + fireEvent.change(fundingGoal, { target: { value: '400' } }); + + const submitBtn = screen.getByTestId('submitCampaignBtn'); + expect(submitBtn).toBeInTheDocument(); + + fireEvent.click(submitBtn); + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Mock graphql error'); + }); + }); +}); diff --git a/src/screens/OrganizationFundCampaign/CampaignModal.tsx b/src/screens/OrganizationFundCampaign/CampaignModal.tsx new file mode 100644 index 0000000000..63d1e8de3a --- /dev/null +++ b/src/screens/OrganizationFundCampaign/CampaignModal.tsx @@ -0,0 +1,315 @@ +import { DatePicker } from '@mui/x-date-pickers'; +import type { Dayjs } from 'dayjs'; +import dayjs from 'dayjs'; +import type { ChangeEvent } from 'react'; +import React, { useEffect, useState } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import { currencyOptions, currencySymbols } from 'utils/currency'; +import styles from './OrganizationFundCampaign.module.css'; +import { useTranslation } from 'react-i18next'; +import { useMutation } from '@apollo/client'; +import { + CREATE_CAMPAIGN_MUTATION, + UPDATE_CAMPAIGN_MUTATION, +} from 'GraphQl/Mutations/CampaignMutation'; +import { toast } from 'react-toastify'; +import { + FormControl, + InputLabel, + MenuItem, + Select, + TextField, +} from '@mui/material'; +import type { InterfaceCampaignInfo } from 'utils/interfaces'; + +/** + * Props for the CampaignModal component. + */ +export interface InterfaceCampaignModal { + isOpen: boolean; + hide: () => void; + fundId: string; + orgId: string; + campaign: InterfaceCampaignInfo | null; + refetchCampaign: () => void; + mode: 'create' | 'edit'; +} + +/** + * Modal component for creating or editing a campaign. + * + * @param props - The props for the CampaignModal component. + * @returns JSX.Element + */ +const CampaignModal: React.FC = ({ + isOpen, + hide, + fundId, + orgId, + refetchCampaign, + mode, + campaign, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'fundCampaign', + }); + const { t: tCommon } = useTranslation('common'); + + const [formState, setFormState] = useState({ + campaignName: campaign?.name ?? '', + campaignCurrency: campaign?.currency ?? 'USD', + campaignGoal: campaign?.fundingGoal ?? 0, + campaignStartDate: campaign?.startDate ?? new Date(), + campaignEndDate: campaign?.endDate ?? new Date(), + }); + + useEffect(() => { + setFormState({ + campaignCurrency: campaign?.currency ?? 'USD', + campaignEndDate: campaign?.endDate ?? new Date(), + campaignGoal: campaign?.fundingGoal ?? 0, + campaignName: campaign?.name ?? '', + campaignStartDate: campaign?.startDate ?? new Date(), + }); + }, [campaign]); + + const { + campaignName, + campaignCurrency, + campaignEndDate, + campaignGoal, + campaignStartDate, + } = formState; + + const [createCampaign] = useMutation(CREATE_CAMPAIGN_MUTATION); + const [updateCampaign] = useMutation(UPDATE_CAMPAIGN_MUTATION); + + /** + * Handles form submission to create a new campaign. + * + * @param e - The form event. + * @returns Promise + */ + const createCampaignHandler = async ( + e: ChangeEvent, + ): Promise => { + e.preventDefault(); + try { + await createCampaign({ + variables: { + name: formState.campaignName, + currency: formState.campaignCurrency, + fundingGoal: formState.campaignGoal, + organizationId: orgId, + startDate: dayjs(formState.campaignStartDate).format('YYYY-MM-DD'), + endDate: dayjs(formState.campaignEndDate).format('YYYY-MM-DD'), + fundId, + }, + }); + toast.success(t('createdCampaign') as string); + setFormState({ + campaignName: '', + campaignCurrency: 'USD', + campaignGoal: 0, + campaignStartDate: new Date(), + campaignEndDate: new Date(), + }); + refetchCampaign(); + hide(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + + /** + * Handles form submission to update an existing campaign. + * + * @param e - The form event. + * @returns Promise + */ + /*istanbul ignore next*/ + const updateCampaignHandler = async ( + e: ChangeEvent, + ): Promise => { + e.preventDefault(); + try { + const updatedFields: { [key: string]: string | number | undefined } = {}; + if (campaign?.name !== campaignName) { + updatedFields.name = campaignName; + } + if (campaign?.currency !== campaignCurrency) { + updatedFields.currency = campaignCurrency; + } + if (campaign?.fundingGoal !== campaignGoal) { + updatedFields.fundingGoal = campaignGoal; + } + if (campaign?.startDate !== campaignStartDate) { + updatedFields.startDate = dayjs(campaignStartDate).format('YYYY-MM-DD'); + } + if (campaign?.endDate !== formState.campaignEndDate) { + updatedFields.endDate = dayjs(formState.campaignEndDate).format( + 'YYYY-MM-DD', + ); + } + await updateCampaign({ + variables: { + id: campaign?._id, + ...updatedFields, + }, + }); + setFormState({ + campaignName: '', + campaignCurrency: 'USD', + campaignGoal: 0, + campaignStartDate: new Date(), + campaignEndDate: new Date(), + }); + refetchCampaign(); + hide(); + toast.success(t('updatedCampaign') as string); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + + return ( + <> + + +

    + {t(mode === 'edit' ? 'updateCampaign' : 'createCampaign')} +

    + +
    + +
    + + + + setFormState({ + ...formState, + campaignName: e.target.value, + }) + } + /> + + + + + {/* Date Calendar Component to select start date of campaign*/} + { + if (date) { + setFormState({ + ...formState, + campaignStartDate: date.toDate(), + campaignEndDate: + campaignEndDate && + (campaignEndDate < date?.toDate() + ? date.toDate() + : campaignEndDate), + }); + } + }} + minDate={dayjs(new Date())} + /> + {/* Date Calendar Component to select end Date of campaign */} + { + if (date) { + setFormState({ + ...formState, + campaignEndDate: date.toDate(), + }); + } + }} + minDate={dayjs(campaignStartDate)} + /> + + + + {/* Dropdown to select the currency for funding goal of the campaign*/} + + + {t('currency')} + + + + {/* Input field to enter funding goal for the campaign */} + + { + if (parseInt(e.target.value) > 0) { + setFormState({ + ...formState, + campaignGoal: parseInt(e.target.value), + }); + } + }} + /> + + + {/* Button to create the campaign */} + +
    +
    +
    + + ); +}; +export default CampaignModal; diff --git a/src/screens/OrganizationFundCampaign/OrganizationFundCampagins.tsx b/src/screens/OrganizationFundCampaign/OrganizationFundCampagins.tsx new file mode 100644 index 0000000000..1c6dbf2cc6 --- /dev/null +++ b/src/screens/OrganizationFundCampaign/OrganizationFundCampagins.tsx @@ -0,0 +1,453 @@ +import { useQuery } from '@apollo/client'; +import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import { Stack, Typography, Breadcrumbs, Link } from '@mui/material'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import React, { useCallback, useMemo, useState } from 'react'; +import dayjs from 'dayjs'; +import Loader from 'components/Loader/Loader'; +import CampaignModal from './CampaignModal'; +import { FUND_CAMPAIGN } from 'GraphQl/Queries/fundQueries'; +import styles from './OrganizationFundCampaign.module.css'; +import { currencySymbols } from 'utils/currency'; +import type { + InterfaceCampaignInfo, + InterfaceQueryOrganizationFundCampaigns, +} from 'utils/interfaces'; + +const dataGridStyle = { + '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { + outline: 'none !important', + }, + '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-root': { + borderRadius: '0.5rem', + }, + '& .MuiDataGrid-main': { + borderRadius: '0.5rem', + }, +}; + +/** + * `orgFundCampaign` component displays a list of fundraising campaigns for a specific fund within an organization. + * It allows users to search, sort, view and edit campaigns. + * + * ### Functionality + * - Displays a data grid with campaigns information, including their names, start and end dates, funding goals, and actions. + * - Provides search functionality to filter campaigns by name. + * - Offers sorting options based on funding goal and end date. + * - Opens modals for creating or editing campaigns. + * + * + * ### State + * - `campaign`: The current campaign being edited or deleted. + * - `searchTerm`: The term used for searching campaigns by name. + * - `sortBy`: The current sorting criteria for campaigns. + * - `modalState`: An object indicating the visibility of different modals (`same` for create/edit). + * - `campaignModalMode`: Determines if the modal is in 'edit' or 'create' mode. + * + * ### Methods + * - `handleOpenModal(campaign: InterfaceCampaignInfo | null, mode: 'edit' | 'create')`: Opens the modal for creating or editing a campaign. + * - `handleClick(campaignId: string)`: Navigates to the pledge details page for a specific campaign. + * + * ### GraphQL Queries + * - Uses `FUND_CAMPAIGN` query to fetch the list of campaigns based on the provided fund ID, search term, and sorting criteria. + * + * ### Rendering + * - Renders a `DataGrid` component with campaigns information. + * - Displays modals for creating and editing campaigns. + * - Shows error and loading states using `Loader` and error message components. + * + * @returns The rendered component including breadcrumbs, search and filter controls, data grid, and modals. + */ +const orgFundCampaign = (): JSX.Element => { + const { t } = useTranslation('translation', { + keyPrefix: 'fundCampaign', + }); + const { t: tCommon } = useTranslation('common'); + const navigate = useNavigate(); + + const { fundId, orgId } = useParams(); + + if (!fundId || !orgId) { + return ; + } + + const [campaign, setCampaign] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState(null); + + const [modalState, setModalState] = useState(false); + const [campaignModalMode, setCampaignModalMode] = useState<'edit' | 'create'>( + 'create', + ); + + const handleOpenModal = useCallback( + (campaign: InterfaceCampaignInfo | null, mode: 'edit' | 'create'): void => { + setCampaign(campaign); + setCampaignModalMode(mode); + setModalState(true); + }, + [], + ); + + const { + data: campaignData, + loading: campaignLoading, + error: campaignError, + refetch: refetchCampaign, + }: { + data?: { + getFundById: InterfaceQueryOrganizationFundCampaigns; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(FUND_CAMPAIGN, { + variables: { + id: fundId, + orderBy: sortBy, + where: { + name_contains: searchTerm, + }, + }, + }); + + const handleClick = (campaignId: string): void => { + navigate(`/fundCampaignPledge/${orgId}/${campaignId}`); + }; + + const { campaigns, fundName, isArchived } = useMemo(() => { + const fundName = campaignData?.getFundById?.name || 'Fund'; + const isArchived = campaignData?.getFundById?.isArchived || false; + const campaigns = campaignData?.getFundById?.campaigns || []; + return { fundName, campaigns, isArchived }; + }, [campaignData]); + + if (campaignLoading) { + return ; + } + if (campaignError) { + return ( +
    +
    + +
    + Error occured while loading Campaigns +
    + {campaignError.message} +
    +
    +
    + ); + } + + const columns: GridColDef[] = [ + { + field: 'id', + headerName: 'Sr. No.', + flex: 1, + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return
    {params.row.id}
    ; + }, + }, + { + field: 'campaignName', + headerName: 'Campaign Name', + flex: 2, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return ( +
    handleClick(params.row.campaign._id as string)} + > + {params.row.campaign.name} +
    + ); + }, + }, + { + field: 'startDate', + headerName: 'Start Date', + flex: 1, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return dayjs(params.row.campaign.startDate).format('DD/MM/YYYY'); + }, + }, + { + field: 'endDate', + headerName: 'End Date', + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + flex: 1, + sortable: false, + renderCell: (params: GridCellParams) => { + return ( +
    + {dayjs(params.row.campaign.endDate).format('DD/MM/YYYY')}{' '} +
    + ); + }, + }, + { + field: 'fundingGoal', + headerName: 'Funding Goal', + flex: 1, + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return ( +
    + { + currencySymbols[ + params.row.campaign.currency as keyof typeof currencySymbols + ] + } + {params.row.campaign.fundingGoal} +
    + ); + }, + }, + { + field: 'fundingRaised', + headerName: 'Raised', + flex: 1, + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return ( +
    + { + currencySymbols[ + params.row.campaign.currency as keyof typeof currencySymbols + ] + } + 0 +
    + ); + }, + }, + { + field: 'action', + headerName: 'Action', + flex: 1, + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return ( + <> + + + ); + }, + }, + { + field: 'assocPledge', + headerName: 'Associated Pledges', + flex: 2, + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return ( + + ); + }, + }, + ]; + + return ( +
    + + navigate(`/orgfunds/${orgId}`)} + > + {fundName} + + {t('title')} + + +
    +
    + setSearchTerm(e.target.value)} + data-testid="searchFullName" + /> + +
    +
    +
    + + + + {tCommon('sort')} + + + setSortBy('fundingGoal_ASC')} + data-testid="fundingGoal_ASC" + > + {t('lowestGoal')} + + setSortBy('fundingGoal_DESC')} + data-testid="fundingGoal_DESC" + > + {t('highestGoal')} + + setSortBy('endDate_DESC')} + data-testid="endDate_DESC" + > + {t('latestEndDate')} + + setSortBy('endDate_ASC')} + data-testid="endDate_ASC" + > + {t('earliestEndDate')} + + + +
    +
    + +
    +
    +
    + + row.campaign._id} + slots={{ + noRowsOverlay: () => ( + + {t('noCampaignsFound')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={campaigns.map((campaign, index) => ({ + id: index + 1, + campaign, + }))} + columns={columns} + isRowSelectable={() => false} + /> + + {/* Create Campaign ModalState */} + setModalState(false)} + refetchCampaign={refetchCampaign} + fundId={fundId} + orgId={orgId} + campaign={campaign} + mode={campaignModalMode} + /> +
    + ); +}; +export default orgFundCampaign; diff --git a/src/screens/OrganizationFundCampaign/OrganizationFundCampaign.module.css b/src/screens/OrganizationFundCampaign/OrganizationFundCampaign.module.css new file mode 100644 index 0000000000..55202baef9 --- /dev/null +++ b/src/screens/OrganizationFundCampaign/OrganizationFundCampaign.module.css @@ -0,0 +1,202 @@ +.organizationFundCampaignContainer { + margin: 0.5rem 0; +} +.goalButton { + border: 1px solid rgba(49, 187, 107, 1) !important; + color: rgba(49, 187, 107, 1) !important; + width: 75%; + padding: 10px; + border-radius: 8px; + display: block; + margin: auto; + box-shadow: 5px 5px 4px 0px rgba(49, 187, 107, 0.12); +} +.rowBackground { + background-color: var(--bs-white); + max-height: 120px; +} +.container { + min-height: 100vh; +} +.campaignModal { + max-width: 80vw; + margin-top: 2vh; + margin-left: 13vw; +} +.titlemodal { + color: #707070; + font-weight: 600; + font-size: 32px; + width: 65%; + margin-bottom: 0px; +} +.noOutline input { + outline: none; +} +.modalCloseBtn { + width: 40px; + height: 40px; + padding: 1rem; + display: flex; + justify-content: center; + align-items: center; +} +.greenregbtn { + margin: 1rem 0 0; + margin-top: 15px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 10px; + border-radius: 5px; + background-color: #31bb6b; + width: 100%; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; + flex: 1; +} + +.redregbtn { + margin: 1rem 0 0; + margin-top: 15px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 10px; + border-radius: 5px; + width: 100%; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; + flex: 1; +} +.campaignNameInfo { + font-size: medium; + cursor: pointer; +} +.campaignNameInfo:hover { + color: blue; + transform: translateY(-2px); +} +.message { + margin-top: 25%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.inputField { + background-color: white; + box-shadow: 0 1px 1px #31bb6b; +} + +.dropdown { + background-color: white; + border: 1px solid #31bb6b; + position: relative; + display: inline-block; + color: #31bb6b; +} + +.btnsContainer { + display: flex; + margin: 2rem 0 2rem 0; + gap: 0.8rem; +} + +.btnsContainer .btnsBlock { + display: flex; + gap: 0.8rem; +} + +.btnsContainer .btnsBlock div button { + display: flex; + margin-left: 1rem; + justify-content: center; + align-items: center; +} + +.btnsContainer .input { + flex: 1; + position: relative; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); +} + +.btnsContainer .input button { + width: 52px; +} + +.mainpageright > hr { + margin-top: 20px; + width: 100%; + margin-left: -15px; + margin-right: -15px; + margin-bottom: 20px; +} + +.tableHeader { + background-color: var(--bs-primary); + color: var(--bs-white); + font-size: 1rem; +} + +@media (max-width: 1020px) { + .btnsContainer { + flex-direction: column; + margin: 1.5rem 0; + } + + .btnsContainer .btnsBlock { + margin: 1.5rem 0 0 0; + justify-content: space-between; + } + + .btnsContainer .btnsBlock div button { + margin: 0; + } + + .createFundBtn { + margin-top: 0; + } +} + +@media screen and (max-width: 575.5px) { + .mainpageright { + width: 98%; + } +} + +/* For mobile devices */ + +@media (max-width: 520px) { + .btnsContainer { + margin-bottom: 0; + } + + .btnsContainer .btnsBlock { + display: block; + margin-top: 1rem; + margin-right: 0; + } + + .btnsContainer .btnsBlock div button { + margin-bottom: 1rem; + margin-right: 0; + width: 100%; + } +} diff --git a/src/screens/OrganizationFundCampaign/OrganizationFundCampaign.test.tsx b/src/screens/OrganizationFundCampaign/OrganizationFundCampaign.test.tsx new file mode 100644 index 0000000000..9c169e355a --- /dev/null +++ b/src/screens/OrganizationFundCampaign/OrganizationFundCampaign.test.tsx @@ -0,0 +1,320 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from '../../state/store'; +import { StaticMockLink } from '../../utils/StaticMockLink'; +import i18nForTest from '../../utils/i18nForTest'; +import OrganizaitionFundCampiagn from './OrganizationFundCampagins'; +import { + EMPTY_MOCKS, + MOCKS, + MOCK_ERROR, +} from './OrganizationFundCampaignMocks'; +import type { ApolloLink } from '@apollo/client'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('@mui/x-date-pickers/DateTimePicker', () => { + return { + DateTimePicker: jest.requireActual( + '@mui/x-date-pickers/DesktopDateTimePicker', + ).DesktopDateTimePicker, + }; +}); + +const link1 = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(MOCK_ERROR, true); +const link3 = new StaticMockLink(EMPTY_MOCKS, true); + +const translations = JSON.parse( + JSON.stringify(i18nForTest.getDataByLanguage('en')?.translation.fundCampaign), +); + +const renderFundCampaign = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } + /> + } + /> + } + /> + } + /> + + + + + + , + ); +}; + +describe('FundCampaigns Screen', () => { + beforeEach(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId', fundId: 'fundId' }), + })); + }); + + afterEach(() => { + cleanup(); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should render the Campaign Pledge screen', async () => { + renderFundCampaign(link1); + await waitFor(() => { + expect(screen.getByTestId('searchFullName')).toBeInTheDocument(); + }); + + expect(screen.getByText('Campaign 1')).toBeInTheDocument(); + expect(screen.getByText('Campaign 2')).toBeInTheDocument(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + + } + /> + } + /> + + + + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('open and close Create Campaign modal', async () => { + renderFundCampaign(link1); + + const addCampaignBtn = await screen.findByTestId('addCampaignBtn'); + expect(addCampaignBtn).toBeInTheDocument(); + userEvent.click(addCampaignBtn); + + await waitFor(() => + expect(screen.getAllByText(translations.createCampaign)).toHaveLength(2), + ); + userEvent.click(screen.getByTestId('campaignCloseBtn')); + await waitFor(() => + expect(screen.queryByTestId('campaignCloseBtn')).toBeNull(), + ); + }); + + it('open and close update campaign modal', async () => { + renderFundCampaign(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchFullName')).toBeInTheDocument(); + }); + + const editCampaignBtn = await screen.findAllByTestId('editCampaignBtn'); + await waitFor(() => expect(editCampaignBtn[0]).toBeInTheDocument()); + userEvent.click(editCampaignBtn[0]); + + await waitFor(() => + expect( + screen.getAllByText(translations.updateCampaign)[0], + ).toBeInTheDocument(), + ); + userEvent.click(screen.getByTestId('campaignCloseBtn')); + await waitFor(() => + expect(screen.queryByTestId('campaignCloseBtn')).toBeNull(), + ); + }); + + it('Search the Campaigns list by Name', async () => { + renderFundCampaign(link1); + const searchField = await screen.findByTestId('searchFullName'); + fireEvent.change(searchField, { + target: { value: '2' }, + }); + + await waitFor(() => { + expect(screen.getByText('Campaign 2')).toBeInTheDocument(); + expect(screen.queryByText('Campaign 1')).toBeNull(); + }); + }); + + it('should render the Campaign screen with error', async () => { + renderFundCampaign(link2); + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('renders the empty campaign component', async () => { + renderFundCampaign(link3); + await waitFor(() => + expect( + screen.getByText(translations.noCampaignsFound), + ).toBeInTheDocument(), + ); + }); + + it('Sort the Campaigns list by Latest end Date', async () => { + renderFundCampaign(link1); + + const sortBtn = await screen.findByTestId('filter'); + expect(sortBtn).toBeInTheDocument(); + + fireEvent.click(sortBtn); + fireEvent.click(screen.getByTestId('endDate_DESC')); + + await waitFor(() => { + expect(screen.getByText('Campaign 1')).toBeInTheDocument(); + expect(screen.queryByText('Campaign 2')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('endDateCell')[0]).toHaveTextContent( + '01/01/2024', + ); + }); + }); + + it('Sort the Campaigns list by Earliest end Date', async () => { + renderFundCampaign(link1); + + const sortBtn = await screen.findByTestId('filter'); + expect(sortBtn).toBeInTheDocument(); + + fireEvent.click(sortBtn); + fireEvent.click(screen.getByTestId('endDate_ASC')); + + await waitFor(() => { + expect(screen.getByText('Campaign 1')).toBeInTheDocument(); + expect(screen.queryByText('Campaign 2')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('endDateCell')[0]).toHaveTextContent( + '01/01/2021', + ); + }); + }); + + it('Sort the Campaigns list by lowest goal', async () => { + renderFundCampaign(link1); + + const sortBtn = await screen.findByTestId('filter'); + expect(sortBtn).toBeInTheDocument(); + + fireEvent.click(sortBtn); + fireEvent.click(screen.getByTestId('fundingGoal_ASC')); + + await waitFor(() => { + expect(screen.getByText('Campaign 1')).toBeInTheDocument(); + expect(screen.queryByText('Campaign 2')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('goalCell')[0]).toHaveTextContent('100'); + }); + }); + + it('Sort the Campaigns list by highest goal', async () => { + renderFundCampaign(link1); + + const sortBtn = await screen.findByTestId('filter'); + expect(sortBtn).toBeInTheDocument(); + + fireEvent.click(sortBtn); + fireEvent.click(screen.getByTestId('fundingGoal_DESC')); + + await waitFor(() => { + expect(screen.getByText('Campaign 1')).toBeInTheDocument(); + expect(screen.queryByText('Campaign 2')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('goalCell')[0]).toHaveTextContent('200'); + }); + }); + + it('Click on Campaign Name', async () => { + renderFundCampaign(link1); + + const campaignName = await screen.findAllByTestId('campaignName'); + expect(campaignName[0]).toBeInTheDocument(); + fireEvent.click(campaignName[0]); + + await waitFor(() => { + expect(screen.getByTestId('pledgeScreen')).toBeInTheDocument(); + }); + }); + + it('Click on View Pledge', async () => { + renderFundCampaign(link1); + + const viewBtn = await screen.findAllByTestId('viewBtn'); + expect(viewBtn[0]).toBeInTheDocument(); + fireEvent.click(viewBtn[0]); + + await waitFor(() => { + expect(screen.getByTestId('pledgeScreen')).toBeInTheDocument(); + }); + }); + + it('should render the Fund screen on fund breadcrumb click', async () => { + renderFundCampaign(link1); + + const fundBreadcrumb = await screen.findByTestId('fundsLink'); + expect(fundBreadcrumb).toBeInTheDocument(); + fireEvent.click(fundBreadcrumb); + + await waitFor(() => { + expect(screen.getByTestId('fundScreen')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/screens/OrganizationFundCampaign/OrganizationFundCampaignMocks.ts b/src/screens/OrganizationFundCampaign/OrganizationFundCampaignMocks.ts new file mode 100644 index 0000000000..fa871c3593 --- /dev/null +++ b/src/screens/OrganizationFundCampaign/OrganizationFundCampaignMocks.ts @@ -0,0 +1,320 @@ +import { + CREATE_CAMPAIGN_MUTATION, + UPDATE_CAMPAIGN_MUTATION, +} from 'GraphQl/Mutations/CampaignMutation'; +import { FUND_CAMPAIGN } from 'GraphQl/Queries/fundQueries'; + +export const MOCKS = [ + { + request: { + query: FUND_CAMPAIGN, + variables: { + id: 'fundId', + orderBy: null, + where: { name_contains: '' }, + }, + }, + result: { + data: { + getFundById: { + name: 'Fund 1', + isArchived: false, + campaigns: [ + { + _id: 'campaignId1', + name: 'Campaign 1', + fundingGoal: 100, + startDate: '2024-01-01', + endDate: '2024-01-01', + currency: 'USD', + }, + { + _id: '2', + name: 'Campaign 2', + fundingGoal: 200, + startDate: '2021-01-01', + endDate: '2021-01-01', + currency: 'USD', + }, + ], + }, + }, + }, + }, + { + request: { + query: FUND_CAMPAIGN, + variables: { + id: 'fundId', + orderBy: null, + where: { name_contains: '2' }, + }, + }, + result: { + data: { + getFundById: { + name: 'Fund 1', + isArchived: false, + campaigns: [ + { + _id: '2', + name: 'Campaign 2', + fundingGoal: 200, + startDate: '2021-01-01', + endDate: '2021-01-01', + currency: 'USD', + }, + ], + }, + }, + }, + }, + { + request: { + query: FUND_CAMPAIGN, + variables: { + id: 'fundId', + orderBy: 'endDate_DESC', + where: { name_contains: '' }, + }, + }, + result: { + data: { + getFundById: { + name: 'Fund 1', + isArchived: false, + campaigns: [ + { + _id: '1', + name: 'Campaign 1', + fundingGoal: 100, + startDate: '2024-01-01', + endDate: '2024-01-01', + currency: 'USD', + }, + { + _id: '2', + name: 'Campaign 2', + fundingGoal: 200, + startDate: '2021-01-01', + endDate: '2021-01-01', + currency: 'USD', + }, + ], + }, + }, + }, + }, + { + request: { + query: FUND_CAMPAIGN, + variables: { + id: 'fundId', + orderBy: 'endDate_ASC', + where: { name_contains: '' }, + }, + }, + result: { + data: { + getFundById: { + name: 'Fund 1', + isArchived: false, + campaigns: [ + { + _id: '2', + name: 'Campaign 2', + fundingGoal: 200, + startDate: '2021-01-01', + endDate: '2021-01-01', + currency: 'USD', + }, + { + _id: '1', + name: 'Campaign 1', + fundingGoal: 100, + startDate: '2024-01-01', + endDate: '2024-01-01', + currency: 'USD', + }, + ], + }, + }, + }, + }, + { + request: { + query: FUND_CAMPAIGN, + variables: { + id: 'fundId', + orderBy: 'fundingGoal_DESC', + where: { name_contains: '' }, + }, + }, + result: { + data: { + getFundById: { + name: 'Fund 1', + isArchived: false, + campaigns: [ + { + _id: '2', + name: 'Campaign 2', + fundingGoal: 200, + startDate: '2021-01-01', + endDate: '2021-01-01', + currency: 'USD', + }, + { + _id: '1', + name: 'Campaign 1', + fundingGoal: 100, + startDate: '2024-01-01', + endDate: '2024-01-01', + currency: 'USD', + }, + ], + }, + }, + }, + }, + { + request: { + query: FUND_CAMPAIGN, + variables: { + id: 'fundId', + orderBy: 'fundingGoal_ASC', + where: { name_contains: '' }, + }, + }, + result: { + data: { + getFundById: { + name: 'Fund 1', + isArchived: false, + campaigns: [ + { + _id: '1', + name: 'Campaign 1', + fundingGoal: 100, + startDate: '2024-01-01', + endDate: '2024-01-01', + currency: 'USD', + }, + { + _id: '2', + name: 'Campaign 2', + fundingGoal: 200, + startDate: '2021-01-01', + endDate: '2021-01-01', + currency: 'USD', + }, + ], + }, + }, + }, + }, + { + request: { + query: CREATE_CAMPAIGN_MUTATION, + variables: { + fundId: 'fundId', + organizationId: 'orgId', + name: 'Campaign 2', + fundingGoal: 200, + startDate: '2024-01-02', + endDate: '2024-02-02', + currency: 'USD', + }, + }, + result: { + data: { + createFundraisingCampaign: { + _id: 'fundId', + }, + }, + }, + }, + { + request: { + query: UPDATE_CAMPAIGN_MUTATION, + variables: { + id: 'campaignId1', + name: 'Campaign 4', + fundingGoal: 400, + startDate: '2023-01-02', + endDate: '2023-02-02', + }, + }, + result: { + data: { + updateFundraisingCampaign: { + _id: 'campaignId1', + }, + }, + }, + }, +]; + +export const MOCK_ERROR = [ + { + request: { + query: FUND_CAMPAIGN, + variables: { + id: 'fundId', + orderBy: null, + where: { name_contains: '2' }, + }, + }, + error: new Error('An error occurred'), + }, + { + request: { + query: CREATE_CAMPAIGN_MUTATION, + variables: { + fundId: 'fundId', + organizationId: 'orgId', + name: 'Campaign 2', + fundingGoal: 200, + startDate: '2024-01-02', + endDate: '2024-02-02', + currency: 'USD', + }, + }, + error: new Error('Mock graphql error'), + }, + { + request: { + query: UPDATE_CAMPAIGN_MUTATION, + variables: { + id: 'campaignId1', + name: 'Campaign 4', + fundingGoal: 400, + startDate: '2023-01-02', + endDate: '2023-02-02', + }, + }, + error: new Error('Mock graphql error'), + }, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: FUND_CAMPAIGN, + variables: { + id: 'fundId', + orderBy: null, + where: { name_contains: '' }, + }, + }, + result: { + data: { + getFundById: { + name: 'Fund 1', + isArchived: false, + campaigns: [], + }, + }, + }, + }, +]; diff --git a/src/screens/OrganizationFunds/FundModal.test.tsx b/src/screens/OrganizationFunds/FundModal.test.tsx new file mode 100644 index 0000000000..c74b0434c3 --- /dev/null +++ b/src/screens/OrganizationFunds/FundModal.test.tsx @@ -0,0 +1,260 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18nForTest from '../../utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import { MOCKS, MOCKS_ERROR } from './OrganizationFundsMocks'; +import type { InterfaceFundModal } from './FundModal'; +import FundModal from './FundModal'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(MOCKS_ERROR); +const translations = JSON.parse( + JSON.stringify(i18nForTest.getDataByLanguage('en')?.translation.funds), +); + +const fundProps: InterfaceFundModal[] = [ + { + isOpen: true, + hide: jest.fn(), + fund: { + _id: 'fundId', + name: 'Fund 1', + refrenceNumber: '1111', + taxDeductible: true, + isArchived: false, + isDefault: false, + createdAt: '2024-06-22', + organizationId: 'orgId', + creator: { + _id: 'creatorId1', + firstName: 'John', + lastName: 'Doe', + }, + }, + refetchFunds: jest.fn(), + orgId: 'orgId', + mode: 'create', + }, + { + isOpen: true, + hide: jest.fn(), + fund: { + _id: 'fundId', + name: 'Fund 1', + refrenceNumber: '1111', + taxDeductible: true, + isArchived: false, + isDefault: false, + createdAt: '2024-06-22', + organizationId: 'orgId', + creator: { + _id: 'creatorId1', + firstName: 'John', + lastName: 'Doe', + }, + }, + refetchFunds: jest.fn(), + orgId: 'orgId', + mode: 'edit', + }, +]; + +const renderFundModal = ( + link: ApolloLink, + props: InterfaceFundModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('PledgeModal', () => { + afterEach(() => { + cleanup(); + }); + + it('should populate form fields with correct values in edit mode', async () => { + renderFundModal(link1, fundProps[1]); + await waitFor(() => + expect( + screen.getAllByText(translations.fundUpdate)[0], + ).toBeInTheDocument(), + ); + expect(screen.getByLabelText(translations.fundName)).toHaveValue('Fund 1'); + expect(screen.getByLabelText(translations.fundId)).toHaveValue('1111'); + expect(screen.getByTestId('setTaxDeductibleSwitch')).toBeChecked(); + expect(screen.getByTestId('setDefaultSwitch')).not.toBeChecked(); + expect(screen.getByTestId('archivedSwitch')).not.toBeChecked(); + }); + + it('should update Fund Name when input value changes', async () => { + renderFundModal(link1, fundProps[1]); + const fundNameInput = screen.getByLabelText(translations.fundName); + expect(fundNameInput).toHaveValue('Fund 1'); + fireEvent.change(fundNameInput, { target: { value: 'Fund 2' } }); + expect(fundNameInput).toHaveValue('Fund 2'); + }); + + it('should update Fund Reference ID when input value changes', async () => { + renderFundModal(link1, fundProps[1]); + const fundIdInput = screen.getByLabelText(translations.fundId); + expect(fundIdInput).toHaveValue('1111'); + fireEvent.change(fundIdInput, { target: { value: '2222' } }); + expect(fundIdInput).toHaveValue('2222'); + }); + + it('should update Tax Deductible Switch when input value changes', async () => { + renderFundModal(link1, fundProps[1]); + const taxDeductibleSwitch = screen.getByTestId('setTaxDeductibleSwitch'); + expect(taxDeductibleSwitch).toBeChecked(); + fireEvent.click(taxDeductibleSwitch); + expect(taxDeductibleSwitch).not.toBeChecked(); + }); + + it('should update Tax Default switch when input value changes', async () => { + renderFundModal(link1, fundProps[1]); + const defaultSwitch = screen.getByTestId('setDefaultSwitch'); + expect(defaultSwitch).not.toBeChecked(); + fireEvent.click(defaultSwitch); + expect(defaultSwitch).toBeChecked(); + }); + + it('should update Tax isArchived switch when input value changes', async () => { + renderFundModal(link1, fundProps[1]); + const archivedSwitch = screen.getByTestId('archivedSwitch'); + expect(archivedSwitch).not.toBeChecked(); + fireEvent.click(archivedSwitch); + expect(archivedSwitch).toBeChecked(); + }); + + it('should create fund', async () => { + renderFundModal(link1, fundProps[0]); + + const fundNameInput = screen.getByLabelText(translations.fundName); + fireEvent.change(fundNameInput, { target: { value: 'Fund 2' } }); + + const fundIdInput = screen.getByLabelText(translations.fundId); + fireEvent.change(fundIdInput, { target: { value: '2222' } }); + + const taxDeductibleSwitch = screen.getByTestId('setTaxDeductibleSwitch'); + fireEvent.click(taxDeductibleSwitch); + + const defaultSwitch = screen.getByTestId('setDefaultSwitch'); + fireEvent.click(defaultSwitch); + + fireEvent.click(screen.getByTestId('createFundFormSubmitBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(translations.fundCreated); + expect(fundProps[0].refetchFunds).toHaveBeenCalled(); + expect(fundProps[0].hide).toHaveBeenCalled(); + }); + }); + + it('should update fund', async () => { + renderFundModal(link1, fundProps[1]); + + const fundNameInput = screen.getByLabelText(translations.fundName); + fireEvent.change(fundNameInput, { target: { value: 'Fund 2' } }); + + const fundIdInput = screen.getByLabelText(translations.fundId); + fireEvent.change(fundIdInput, { target: { value: '2222' } }); + + const taxDeductibleSwitch = screen.getByTestId('setTaxDeductibleSwitch'); + fireEvent.click(taxDeductibleSwitch); + + const defaultSwitch = screen.getByTestId('setDefaultSwitch'); + fireEvent.click(defaultSwitch); + + const archivedSwitch = screen.getByTestId('archivedSwitch'); + fireEvent.click(archivedSwitch); + + fireEvent.click(screen.getByTestId('createFundFormSubmitBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(translations.fundUpdated); + expect(fundProps[1].refetchFunds).toHaveBeenCalled(); + expect(fundProps[1].hide).toHaveBeenCalled(); + }); + }); + + it('Error: should create fund', async () => { + renderFundModal(link2, fundProps[0]); + + const fundNameInput = screen.getByLabelText(translations.fundName); + fireEvent.change(fundNameInput, { target: { value: 'Fund 2' } }); + + const fundIdInput = screen.getByLabelText(translations.fundId); + fireEvent.change(fundIdInput, { target: { value: '2222' } }); + + const taxDeductibleSwitch = screen.getByTestId('setTaxDeductibleSwitch'); + fireEvent.click(taxDeductibleSwitch); + + const defaultSwitch = screen.getByTestId('setDefaultSwitch'); + fireEvent.click(defaultSwitch); + + fireEvent.click(screen.getByTestId('createFundFormSubmitBtn')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Mock graphql error'); + }); + }); + + it('Error: should update fund', async () => { + renderFundModal(link2, fundProps[1]); + + const fundNameInput = screen.getByLabelText(translations.fundName); + fireEvent.change(fundNameInput, { target: { value: 'Fund 2' } }); + + const fundIdInput = screen.getByLabelText(translations.fundId); + fireEvent.change(fundIdInput, { target: { value: '2222' } }); + + const taxDeductibleSwitch = screen.getByTestId('setTaxDeductibleSwitch'); + fireEvent.click(taxDeductibleSwitch); + + const defaultSwitch = screen.getByTestId('setDefaultSwitch'); + fireEvent.click(defaultSwitch); + + const archivedSwitch = screen.getByTestId('archivedSwitch'); + fireEvent.click(archivedSwitch); + + fireEvent.click(screen.getByTestId('createFundFormSubmitBtn')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Mock graphql error'); + }); + }); +}); diff --git a/src/screens/OrganizationFunds/FundModal.tsx b/src/screens/OrganizationFunds/FundModal.tsx new file mode 100644 index 0000000000..0f112cba9b --- /dev/null +++ b/src/screens/OrganizationFunds/FundModal.tsx @@ -0,0 +1,286 @@ +import React, { useEffect, useState } from 'react'; +import type { ChangeEvent } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import type { InterfaceCreateFund, InterfaceFundInfo } from 'utils/interfaces'; +import styles from './OrganizationFunds.module.css'; +import { useTranslation } from 'react-i18next'; +import { useMutation } from '@apollo/client'; +import { + CREATE_FUND_MUTATION, + UPDATE_FUND_MUTATION, +} from 'GraphQl/Mutations/FundMutation'; +import { toast } from 'react-toastify'; +import { FormControl, TextField } from '@mui/material'; + +export interface InterfaceFundModal { + isOpen: boolean; + hide: () => void; + refetchFunds: () => void; + fund: InterfaceFundInfo | null; + orgId: string; + mode: 'create' | 'edit'; +} +/** + * `FundModal` component provides a modal dialog for creating or editing a fund. + * It allows users to input fund details and submit them to the server. + * + * This component handles both the creation of new funds and the editing of existing funds, + * based on the `mode` prop. It displays a form with fields for the fund's name, description, + * and other relevant details. Upon submission, it interacts with the GraphQL API to save + * or update the fund details and triggers a refetch of the fund data. + * + * ### Props + * - `isOpen`: A boolean indicating whether the modal is open or closed. + * - `hide`: A function to close the modal. + * - `refetchFunds`: A function to refetch the fund list after a successful operation. + * - `fund`: The current fund object being edited or `null` if creating a new fund. + * - `orgId`: The ID of the organization to which the fund belongs. + * - `mode`: The mode of the modal, either 'edit' or 'create'. + * + * ### State + * - `name`: The name of the fund. + * - `description`: The description of the fund. + * - `amount`: The amount associated with the fund. + * - `status`: The status of the fund (e.g., active, archived). + * + * ### Methods + * - `handleSubmit()`: Handles form submission, creates or updates the fund, and triggers a refetch of the fund list. + * - `handleChange(event: React.ChangeEvent)`: Updates the state based on user input. + * + * @returns The rendered modal dialog. + */ +const FundModal: React.FC = ({ + isOpen, + hide, + refetchFunds, + fund, + orgId, + mode, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'funds', + }); + + const [formState, setFormState] = useState({ + fundName: fund?.name ?? '', + fundRef: fund?.refrenceNumber ?? '', + isDefault: fund?.isDefault ?? false, + taxDeductible: fund?.taxDeductible ?? false, + isArchived: fund?.isArchived ?? false, + }); + + useEffect(() => { + setFormState({ + fundName: fund?.name ?? '', + fundRef: fund?.refrenceNumber ?? '', + isDefault: fund?.isDefault ?? false, + taxDeductible: fund?.taxDeductible ?? false, + isArchived: fund?.isArchived ?? false, + }); + }, [fund]); + + const [createFund] = useMutation(CREATE_FUND_MUTATION); + const [updateFund] = useMutation(UPDATE_FUND_MUTATION); + + const createFundHandler = async ( + e: ChangeEvent, + ): Promise => { + e.preventDefault(); + const { fundName, fundRef, isDefault, taxDeductible, isArchived } = + formState; + try { + await createFund({ + variables: { + name: fundName, + refrenceNumber: fundRef, + organizationId: orgId, + taxDeductible, + isArchived, + isDefault, + }, + }); + + setFormState({ + fundName: '', + fundRef: '', + isDefault: false, + taxDeductible: false, + isArchived: false, + }); + toast.success(t('fundCreated') as string); + refetchFunds(); + hide(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + + /*istanbul ignore next*/ + const updateFundHandler = async ( + e: ChangeEvent, + ): Promise => { + e.preventDefault(); + const { fundName, fundRef, taxDeductible, isArchived, isDefault } = + formState; + try { + const updatedFields: { [key: string]: string | boolean } = {}; + if (fundName != fund?.name) { + updatedFields.name = fundName; + } + if (fundRef != fund?.refrenceNumber) { + updatedFields.refrenceNumber = fundRef; + } + if (taxDeductible != fund?.taxDeductible) { + updatedFields.taxDeductible = taxDeductible; + } + if (isArchived != fund?.isArchived) { + updatedFields.isArchived = isArchived; + } + if (isDefault != fund?.isDefault) { + updatedFields.isDefault = isDefault; + } + + await updateFund({ + variables: { + id: fund?._id, + ...updatedFields, + }, + }); + setFormState({ + fundName: '', + fundRef: '', + isDefault: false, + taxDeductible: false, + isArchived: false, + }); + refetchFunds(); + hide(); + toast.success(t('fundUpdated') as string); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + return ( + <> + + +

    + {t(mode === 'create' ? 'fundCreate' : 'fundUpdate')} +

    + +
    + +
    + + + + setFormState({ + ...formState, + fundName: e.target.value, + }) + } + /> + + + + + + setFormState({ + ...formState, + fundRef: e.target.value, + }) + } + /> + + + +
    + + + + setFormState({ + ...formState, + taxDeductible: !formState.taxDeductible, + }) + } + /> + + + + + setFormState({ + ...formState, + isDefault: !formState.isDefault, + }) + } + /> + + {mode === 'edit' && ( + + + + setFormState({ + ...formState, + isArchived: !formState.isArchived, + }) + } + /> + + )} +
    + +
    +
    +
    + + ); +}; +export default FundModal; diff --git a/src/screens/OrganizationFunds/OrganizationFunds.module.css b/src/screens/OrganizationFunds/OrganizationFunds.module.css new file mode 100644 index 0000000000..aa9d89dfb1 --- /dev/null +++ b/src/screens/OrganizationFunds/OrganizationFunds.module.css @@ -0,0 +1,142 @@ +.list_box { + height: auto; + overflow-y: auto; + width: 100%; +} + +.inputField { + margin-top: 10px; + margin-bottom: 10px; + background-color: white; + box-shadow: 0 1px 1px #31bb6b; +} + +.inputField > button { + padding-top: 10px; + padding-bottom: 10px; +} + +.dropdown { + background-color: white; + border: 1px solid #31bb6b; + position: relative; + display: inline-block; + color: #31bb6b; +} + +.fundName { + font-weight: 600; + cursor: pointer; +} + +.modalHeader { + border: none; + padding-bottom: 0; +} + +.label { + color: var(--bs-emphasis-color); +} + +.fundModal { + max-width: 80vw; + margin-top: 2vh; + margin-left: 13vw; +} + +.titlemodal { + color: #707070; + font-weight: 600; + font-size: 32px; + width: 65%; + margin-bottom: 0px; +} + +.noOutline input { + outline: none; +} + +.modalCloseBtn { + width: 40px; + height: 40px; + padding: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.greenregbtn { + margin: 1rem 0 0; + margin-top: 15px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 10px; + border-radius: 5px; + background-color: #31bb6b; + width: 100%; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; +} + +.manageBtn { + margin: 1rem 0 0; + margin-top: 15px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 10px; + border-radius: 5px; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + width: 45%; + transition: + transform 0.2s, + box-shadow 0.2s; +} + +.btnsContainer { + display: flex; + margin: 2rem 0 2.5rem 0; +} + +.btnsContainer .input { + flex: 1; + min-width: 18rem; + position: relative; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); +} + +.btnsContainer .input button { + width: 52px; +} + +.mainpageright > hr { + margin-top: 20px; + width: 100%; + margin-left: -15px; + margin-right: -15px; + margin-bottom: 20px; +} + +.rowBackground { + background-color: var(--bs-white); + max-height: 120px; +} + +.tableHeader { + background-color: var(--bs-primary); + color: var(--bs-white); + font-size: 1rem; +} diff --git a/src/screens/OrganizationFunds/OrganizationFunds.test.tsx b/src/screens/OrganizationFunds/OrganizationFunds.test.tsx new file mode 100644 index 0000000000..c6983e1d6d --- /dev/null +++ b/src/screens/OrganizationFunds/OrganizationFunds.test.tsx @@ -0,0 +1,244 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/client/testing'; +import type { RenderResult } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18nForTest from 'utils/i18nForTest'; +import OrganizationFunds from './OrganizationFunds'; +import { MOCKS, MOCKS_ERROR, NO_FUNDS } from './OrganizationFundsMocks'; +import type { ApolloLink } from '@apollo/client'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(MOCKS_ERROR, true); +const link3 = new StaticMockLink(NO_FUNDS, true); + +const translations = JSON.parse( + JSON.stringify(i18nForTest.getDataByLanguage('en')?.translation.funds), +); + +const renderOrganizationFunds = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } + /> + } + /> + } + /> + + + + + + , + ); +}; + +describe('OrganizationFunds Screen =>', () => { + beforeEach(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('should render the Campaign Pledge screen', async () => { + renderOrganizationFunds(link1); + await waitFor(() => { + expect(screen.getByTestId('searchByName')).toBeInTheDocument(); + }); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> + } + /> + + + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('open and close Create Fund modal', async () => { + renderOrganizationFunds(link1); + + const createFundBtn = await screen.findByTestId('createFundBtn'); + expect(createFundBtn).toBeInTheDocument(); + userEvent.click(createFundBtn); + + await waitFor(() => + expect(screen.getAllByText(translations.fundCreate)).toHaveLength(3), + ); + userEvent.click(screen.getByTestId('fundModalCloseBtn')); + await waitFor(() => + expect(screen.queryByTestId('fundModalCloseBtn')).toBeNull(), + ); + }); + + it('open and close update fund modal', async () => { + renderOrganizationFunds(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchByName')).toBeInTheDocument(); + }); + + const editFundBtn = await screen.findAllByTestId('editFundBtn'); + await waitFor(() => expect(editFundBtn[0]).toBeInTheDocument()); + userEvent.click(editFundBtn[0]); + + await waitFor(() => + expect( + screen.getAllByText(translations.fundUpdate)[0], + ).toBeInTheDocument(), + ); + userEvent.click(screen.getByTestId('fundModalCloseBtn')); + await waitFor(() => + expect(screen.queryByTestId('fundModalCloseBtn')).toBeNull(), + ); + }); + + it('Search the Funds list by name', async () => { + renderOrganizationFunds(link1); + const searchField = await screen.findByTestId('searchByName'); + fireEvent.change(searchField, { + target: { value: '2' }, + }); + + await waitFor(() => { + expect(screen.getByText('Fund 2')).toBeInTheDocument(); + expect(screen.queryByText('Fund 1')).toBeNull(); + }); + }); + + it('should render the Fund screen with error', async () => { + renderOrganizationFunds(link2); + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('renders the empty fund component', async () => { + renderOrganizationFunds(link3); + await waitFor(() => + expect(screen.getByText(translations.noFundsFound)).toBeInTheDocument(), + ); + }); + + it('Sort the Pledges list by Latest created Date', async () => { + renderOrganizationFunds(link1); + + const sortBtn = await screen.findByTestId('filter'); + expect(sortBtn).toBeInTheDocument(); + + fireEvent.click(sortBtn); + fireEvent.click(screen.getByTestId('createdAt_DESC')); + + await waitFor(() => { + expect(screen.getByText('Fund 1')).toBeInTheDocument(); + expect(screen.queryByText('Fund 2')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('createdOn')[0]).toHaveTextContent( + '22/06/2024', + ); + }); + }); + + it('Sort the Pledges list by Earliest created Date', async () => { + renderOrganizationFunds(link1); + + const sortBtn = await screen.findByTestId('filter'); + expect(sortBtn).toBeInTheDocument(); + + fireEvent.click(sortBtn); + fireEvent.click(screen.getByTestId('createdAt_ASC')); + + await waitFor(() => { + expect(screen.getByText('Fund 1')).toBeInTheDocument(); + expect(screen.queryByText('Fund 2')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('createdOn')[0]).toHaveTextContent( + '21/06/2024', + ); + }); + }); + + it('Click on Fund Name', async () => { + renderOrganizationFunds(link1); + + const fundName = await screen.findAllByTestId('fundName'); + expect(fundName[0]).toBeInTheDocument(); + fireEvent.click(fundName[0]); + + await waitFor(() => { + expect(screen.getByTestId('campaignScreen')).toBeInTheDocument(); + }); + }); + + it('Click on View Campaign', async () => { + renderOrganizationFunds(link1); + + const viewBtn = await screen.findAllByTestId('viewBtn'); + expect(viewBtn[0]).toBeInTheDocument(); + fireEvent.click(viewBtn[0]); + + await waitFor(() => { + expect(screen.getByTestId('campaignScreen')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/screens/OrganizationFunds/OrganizationFunds.tsx b/src/screens/OrganizationFunds/OrganizationFunds.tsx new file mode 100644 index 0000000000..29ffb0d865 --- /dev/null +++ b/src/screens/OrganizationFunds/OrganizationFunds.tsx @@ -0,0 +1,383 @@ +import { useQuery } from '@apollo/client'; +import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import { Stack } from '@mui/material'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import React, { useCallback, useMemo, useState } from 'react'; +import dayjs from 'dayjs'; +import Loader from 'components/Loader/Loader'; +import FundModal from './FundModal'; +import { FUND_LIST } from 'GraphQl/Queries/fundQueries'; +import styles from './OrganizationFunds.module.css'; +import type { InterfaceFundInfo } from 'utils/interfaces'; + +const dataGridStyle = { + '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { + outline: 'none !important', + }, + '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-root': { + borderRadius: '0.5rem', + }, + '& .MuiDataGrid-main': { + borderRadius: '0.5rem', + }, +}; + +/** + * `organizationFunds` component displays a list of funds for a specific organization, + * allowing users to search, sort, view and edit funds. + * + * This component utilizes the `DataGrid` from Material-UI to present the list of funds in a tabular format, + * and includes functionality for filtering and sorting. It also handles the opening and closing of modals + * for creating and editing. + * + * It includes: + * - A search input field to filter funds by name. + * - A dropdown menu to sort funds by creation date. + * - A button to create a new fund. + * - A table to display the list of funds with columns for fund details and actions. + * - Modals for creating and editing funds. + * + * ### GraphQL Queries + * - `FUND_LIST`: Fetches a list of funds for the given organization, filtered and sorted based on the provided parameters. + * + * ### Props + * - `orgId`: The ID of the organization whose funds are being managed. + * + * ### State + * - `fund`: The currently selected fund for editing or deletion. + * - `searchTerm`: The current search term used for filtering funds. + * - `sortBy`: The current sorting order for funds. + * - `modalState`: The state of the modals (edit/create). + * - `fundModalMode`: The mode of the fund modal (edit or create). + * + * ### Methods + * - `handleOpenModal(fund: InterfaceFundInfo | null, mode: 'edit' | 'create')`: Opens the fund modal with the given fund and mode. + * - `handleClick(fundId: string)`: Navigates to the campaign page for the specified fund. + * + * @returns The rendered component. + */ +const organizationFunds = (): JSX.Element => { + const { t } = useTranslation('translation', { + keyPrefix: 'funds', + }); + const { t: tCommon } = useTranslation('common'); + + const { orgId } = useParams(); + const navigate = useNavigate(); + + if (!orgId) { + return ; + } + + const [fund, setFund] = useState(null); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState<'createdAt_ASC' | 'createdAt_DESC'>( + 'createdAt_DESC', + ); + + const [modalState, setModalState] = useState(false); + const [fundModalMode, setFundModalMode] = useState<'edit' | 'create'>( + 'create', + ); + + const handleOpenModal = useCallback( + (fund: InterfaceFundInfo | null, mode: 'edit' | 'create'): void => { + setFund(fund); + setFundModalMode(mode); + setModalState(true); + }, + [], + ); + + const { + data: fundData, + loading: fundLoading, + error: fundError, + refetch: refetchFunds, + }: { + data?: { + fundsByOrganization: InterfaceFundInfo[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(FUND_LIST, { + variables: { + organizationId: orgId, + filter: searchTerm, + orderBy: sortBy, + }, + }); + + const funds = useMemo(() => fundData?.fundsByOrganization ?? [], [fundData]); + + const handleClick = (fundId: string): void => { + navigate(`/orgfundcampaign/${orgId}/${fundId}`); + }; + + if (fundLoading) { + return ; + } + if (fundError) { + return ( +
    +
    + +
    + Error occured while loading Funds +
    + {fundError.message} +
    +
    +
    + ); + } + + const columns: GridColDef[] = [ + { + field: 'id', + headerName: 'Sr. No.', + flex: 1, + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return
    {params.row.id}
    ; + }, + }, + { + field: 'fundName', + headerName: 'Fund Name', + flex: 2, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
    handleClick(params.row._id as string)} + > + {params.row.name} +
    + ); + }, + }, + { + field: 'createdBy', + headerName: 'Created By', + flex: 2, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return params.row.creator.firstName + ' ' + params.row.creator.lastName; + }, + }, + { + field: 'createdOn', + headerName: 'Created On', + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + flex: 2, + renderCell: (params: GridCellParams) => { + return ( +
    + {dayjs(params.row.createdAt).format('DD/MM/YYYY')} +
    + ); + }, + }, + { + field: 'status', + headerName: 'Status', + flex: 2, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return params.row.isArchived ? 'Archived' : 'Active'; + }, + }, + { + field: 'action', + headerName: 'Action', + flex: 2, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + <> + + + ); + }, + }, + { + field: 'assocCampaigns', + headerName: 'Associated Campaigns', + flex: 2, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + + ); + }, + }, + ]; + + return ( +
    +
    +
    + setSearchTerm(e.target.value)} + data-testid="searchByName" + /> + +
    +
    +
    + + + + {tCommon('sort')} + + + setSortBy('createdAt_DESC')} + data-testid="createdAt_DESC" + > + {t('createdLatest')} + + setSortBy('createdAt_ASC')} + data-testid="createdAt_ASC" + > + {t('createdEarliest')} + + + +
    +
    + +
    +
    +
    + + row._id} + slots={{ + noRowsOverlay: () => ( + + {t('noFundsFound')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={funds.map((fund, index) => ({ + id: index + 1, + ...fund, + }))} + columns={columns} + isRowSelectable={() => false} + /> + setModalState(false)} + refetchFunds={refetchFunds} + fund={fund} + orgId={orgId} + mode={fundModalMode} + /> +
    + ); +}; + +export default organizationFunds; diff --git a/src/screens/OrganizationFunds/OrganizationFundsMocks.ts b/src/screens/OrganizationFunds/OrganizationFundsMocks.ts new file mode 100644 index 0000000000..81e5a0fb64 --- /dev/null +++ b/src/screens/OrganizationFunds/OrganizationFundsMocks.ts @@ -0,0 +1,231 @@ +import { + CREATE_FUND_MUTATION, + UPDATE_FUND_MUTATION, +} from 'GraphQl/Mutations/FundMutation'; +import { FUND_LIST } from 'GraphQl/Queries/fundQueries'; + +export const MOCKS = [ + { + request: { + query: FUND_LIST, + variables: { + organizationId: 'orgId', + orderBy: 'createdAt_DESC', + filter: '', + }, + }, + result: { + data: { + fundsByOrganization: [ + { + _id: 'fundId', + name: 'Fund 1', + refrenceNumber: '1111', + taxDeductible: true, + isArchived: false, + isDefault: false, + createdAt: '2024-06-22', + organizationId: 'orgId', + creator: { + _id: 'creatorId1', + firstName: 'John', + lastName: 'Doe', + }, + }, + { + _id: 'fundId2', + name: 'Fund 2', + refrenceNumber: '2222', + taxDeductible: true, + isArchived: true, + isDefault: false, + createdAt: '2024-06-21', + organizationId: 'orgId', + creator: { + _id: 'creatorId1', + firstName: 'John', + lastName: 'Doe', + }, + }, + ], + }, + }, + }, + { + request: { + query: FUND_LIST, + variables: { + organizationId: 'orgId', + orderBy: 'createdAt_ASC', + filter: '', + }, + }, + result: { + data: { + fundsByOrganization: [ + { + _id: 'fundId', + name: 'Fund 2', + refrenceNumber: '2222', + taxDeductible: true, + isArchived: true, + isDefault: false, + createdAt: '2024-06-21', + organizationId: 'orgId', + creator: { + _id: 'creatorId1', + firstName: 'John', + lastName: 'Doe', + }, + }, + { + _id: 'fundId2', + name: 'Fund 1', + refrenceNumber: '1111', + taxDeductible: true, + isArchived: false, + isDefault: false, + createdAt: '2024-06-22', + organizationId: 'orgId', + creator: { + _id: 'creatorId1', + firstName: 'John', + lastName: 'Doe', + }, + }, + ], + }, + }, + }, + { + request: { + query: FUND_LIST, + variables: { + organizationId: 'orgId', + orderBy: 'createdAt_DESC', + filter: '2', + }, + }, + result: { + data: { + fundsByOrganization: [ + { + _id: 'fundId', + name: 'Fund 2', + refrenceNumber: '2222', + taxDeductible: true, + isArchived: true, + isDefault: false, + createdAt: '2024-06-21', + organizationId: 'orgId', + creator: { + _id: 'creatorId1', + firstName: 'John', + lastName: 'Doe', + }, + }, + ], + }, + }, + }, + { + request: { + query: CREATE_FUND_MUTATION, + variables: { + name: 'Fund 2', + refrenceNumber: '2222', + taxDeductible: false, + isArchived: false, + isDefault: true, + organizationId: 'orgId', + }, + }, + result: { + data: { + createFund: { + _id: '2222', + }, + }, + }, + }, + { + request: { + query: UPDATE_FUND_MUTATION, + variables: { + id: 'fundId', + name: 'Fund 2', + refrenceNumber: '2222', + taxDeductible: false, + isArchived: true, + isDefault: true, + }, + }, + result: { + data: { + updateFund: { + _id: 'fundId', + }, + }, + }, + }, +]; + +export const NO_FUNDS = [ + { + request: { + query: FUND_LIST, + variables: { + organizationId: 'orgId', + orderBy: 'createdAt_DESC', + filter: '', + }, + }, + result: { + data: { + fundsByOrganization: [], + }, + }, + }, +]; + +export const MOCKS_ERROR = [ + { + request: { + query: FUND_LIST, + variables: { + organizationId: 'orgId', + orderBy: 'createdAt_DESC', + filter: '', + }, + }, + error: new Error('Mock graphql error'), + }, + { + request: { + query: CREATE_FUND_MUTATION, + variables: { + name: 'Fund 2', + refrenceNumber: '2222', + taxDeductible: false, + isArchived: false, + isDefault: true, + organizationId: 'orgId', + }, + }, + error: new Error('Mock graphql error'), + }, + { + request: { + query: UPDATE_FUND_MUTATION, + variables: { + id: 'fundId', + name: 'Fund 2', + refrenceNumber: '2222', + taxDeductible: false, + isArchived: true, + isDefault: true, + }, + }, + error: new Error('Mock graphql error'), + }, +]; diff --git a/src/screens/OrganizationPeople/AddMember.tsx b/src/screens/OrganizationPeople/AddMember.tsx new file mode 100644 index 0000000000..750d831abf --- /dev/null +++ b/src/screens/OrganizationPeople/AddMember.tsx @@ -0,0 +1,578 @@ +import { useLazyQuery, useMutation, useQuery } from '@apollo/client'; +import { Check, Close, Search } from '@mui/icons-material'; +import EmailOutlinedIcon from '@mui/icons-material/EmailOutlined'; +import Paper from '@mui/material/Paper'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell, { tableCellClasses } from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import { styled } from '@mui/material/styles'; +import { + ADD_MEMBER_MUTATION, + SIGNUP_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import { + ORGANIZATIONS_LIST, + ORGANIZATIONS_MEMBER_CONNECTION_LIST, + USERS_CONNECTION_LIST, +} from 'GraphQl/Queries/Queries'; +import Loader from 'components/Loader/Loader'; +import type { ChangeEvent } from 'react'; +import React, { useEffect, useState } from 'react'; +import { Button, Dropdown, Form, InputGroup, Modal } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import { Link, useParams } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { errorHandler } from 'utils/errorHandler'; +import type { + InterfaceQueryOrganizationsListObject, + InterfaceQueryUserListItem, +} from 'utils/interfaces'; +import styles from '../../style/app.module.css'; +import Avatar from 'components/Avatar/Avatar'; + +const StyledTableCell = styled(TableCell)(() => ({ + [`&.${tableCellClasses.head}`]: { + backgroundColor: 'var(--table-head-bg, blue)', + color: 'var(--table-header-color, black)', + }, + [`&.${tableCellClasses.body}`]: { + fontSize: 14, + }, +})); + +const StyledTableRow = styled(TableRow)(() => ({ + '&:last-child td, &:last-child th': { + border: 0, + }, +})); + +/** + * AddMember component is used to add new members to the organization by selecting from + * the existing users or creating a new user. + * It uses the following queries and mutations: + * ORGANIZATIONS_LIST, + * ORGANIZATIONS_MEMBER_CONNECTION_LIST, + * USERS_CONNECTION_LIST, + * ADD_MEMBER_MUTATION,SIGNUP_MUTATION. + */ +function AddMember(): JSX.Element { + const { t: translateOrgPeople } = useTranslation('translation', { + keyPrefix: 'organizationPeople', + }); + + const { t: translateAddMember } = useTranslation('translation', { + keyPrefix: 'addMember', + }); + + const { t: tCommon } = useTranslation('common'); + + document.title = translateOrgPeople('title'); + + const [addUserModalisOpen, setAddUserModalIsOpen] = useState(false); + + function openAddUserModal(): void { + setAddUserModalIsOpen(true); + } + + const toggleDialogModal = (): void => + setAddUserModalIsOpen(!addUserModalisOpen); + + const [createNewUserModalisOpen, setCreateNewUserModalIsOpen] = + useState(false); + function openCreateNewUserModal(): void { + setCreateNewUserModalIsOpen(true); + } + + function closeCreateNewUserModal(): void { + setCreateNewUserModalIsOpen(false); + } + const toggleCreateNewUserModal = (): void => + setCreateNewUserModalIsOpen(!addUserModalisOpen); + + const [addMember] = useMutation(ADD_MEMBER_MUTATION); + + const createMember = async (userId: string): Promise => { + try { + await addMember({ + variables: { + userid: userId, + orgid: currentUrl, + }, + }); + toast.success(tCommon('addedSuccessfully', { item: 'Member' }) as string); + memberRefetch({ + orgId: currentUrl, + }); + } catch (error: unknown) { + if (error instanceof Error) { + toast.error(error.message); + console.log(error.message); + } + } + }; + + const { orgId: currentUrl } = useParams(); + + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = + useState(false); + + const togglePassword = (): void => setShowPassword(!showPassword); + const toggleConfirmPassword = (): void => + setShowConfirmPassword(!showConfirmPassword); + + const [userName, setUserName] = useState(''); + + const { + data: organizationData, + }: { + data?: { + organizations: InterfaceQueryOrganizationsListObject[]; + }; + } = useQuery(ORGANIZATIONS_LIST, { + variables: { id: currentUrl }, + }); + + const getMembersId = (): string[] => { + if (memberData) { + const ids = memberData?.organizationsMemberConnection.edges.map( + (member: { _id: string }) => member._id, + ); + return ids; + } + return []; + }; + + const { data: memberData, refetch: memberRefetch } = useLazyQuery( + ORGANIZATIONS_MEMBER_CONNECTION_LIST, + { + variables: { + firstName_contains: '', + lastName_contains: '', + orgId: currentUrl, + }, + }, + )[1]; + + const { + data: allUsersData, + loading: allUsersLoading, + refetch: allUsersRefetch, + } = useQuery(USERS_CONNECTION_LIST, { + variables: { + id_not_in: getMembersId(), + firstName_contains: '', + lastName_contains: '', + }, + }); + + useEffect(() => { + memberRefetch({ + orgId: currentUrl, + }); + }); + + const [registerMutation] = useMutation(SIGNUP_MUTATION); + + const [createUserVariables, setCreateUserVariables] = React.useState({ + firstName: '', + lastName: '', + email: '', + password: '', + confirmPassword: '', + }); + + const handleCreateUser = async (): Promise => { + if ( + !( + createUserVariables.email && + createUserVariables.password && + createUserVariables.firstName && + createUserVariables.lastName + ) + ) { + toast.error(translateOrgPeople('invalidDetailsMessage') as string); + } else if ( + createUserVariables.password !== createUserVariables.confirmPassword + ) { + toast.error(translateOrgPeople('passwordNotMatch') as string); + } else { + try { + const registeredUser = await registerMutation({ + variables: { + firstName: createUserVariables.firstName, + lastName: createUserVariables.lastName, + email: createUserVariables.email, + password: createUserVariables.password, + orgId: currentUrl, + }, + }); + const createdUserId = registeredUser?.data.signUp.user._id; + + await createMember(createdUserId); + + closeCreateNewUserModal(); + + setCreateUserVariables({ + firstName: '', + lastName: '', + email: '', + password: '', + confirmPassword: '', + }); + } catch (error: unknown) { + errorHandler(translateOrgPeople, error); + } + } + }; + + const handleFirstName = (e: ChangeEvent): void => { + const firstName = e.target.value; + setCreateUserVariables({ ...createUserVariables, firstName }); + }; + + const handleLastName = (e: ChangeEvent): void => { + const lastName = e.target.value; + setCreateUserVariables({ ...createUserVariables, lastName }); + }; + + const handleEmailChange = (e: ChangeEvent): void => { + const email = e.target.value; + setCreateUserVariables({ ...createUserVariables, email }); + }; + + const handlePasswordChange = (e: ChangeEvent): void => { + const password = e.target.value; + setCreateUserVariables({ ...createUserVariables, password }); + }; + + const handleConfirmPasswordChange = ( + e: ChangeEvent, + ): void => { + const confirmPassword = e.target.value; + setCreateUserVariables({ ...createUserVariables, confirmPassword }); + }; + + const handleUserModalSearchChange = (e: React.FormEvent): void => { + e.preventDefault(); + const [firstName, lastName] = userName.split(' '); + + const newFilterData = { + firstName_contains: firstName || '', + lastName_contains: lastName || '', + }; + + allUsersRefetch({ + ...newFilterData, + }); + }; + + return ( + <> + + + {translateOrgPeople('addMembers')} + + + { + openAddUserModal(); + }} + > + + {translateOrgPeople('existingUser')} + + + { + openCreateNewUserModal(); + }} + > + + + + + + {/* Existing User Modal */} + + + {translateOrgPeople('addMembers')} + + + {allUsersLoading ? ( + + ) : ( + <> +
    +
    + { + const { value } = e.target; + setUserName(value); + }} + /> + + +
    + + + + + # + + {translateAddMember('profile')} + + + {translateAddMember('user')} + + + {translateAddMember('addMember')} + + + + + {allUsersData && + allUsersData.users.length > 0 && + allUsersData.users.map( + ( + userDetails: InterfaceQueryUserListItem, + index: number, + ) => ( + + + {index + 1} + + + {userDetails.user.image ? ( + avatar + ) : ( + + )} + + + + {userDetails.user.firstName + + ' ' + + userDetails.user.lastName} +
    + {userDetails.user.email} + +
    + + + +
    + ), + )} +
    +
    +
    + + )} +
    +
    + + {/* New User Modal */} + + + Create User + + +
    +
    +
    +
    {translateOrgPeople('firstName')}
    + + + +
    +
    +
    {translateOrgPeople('lastName')}
    + + + +
    +
    +
    {translateOrgPeople('emailAddress')}
    + + + + + + +
    {translateOrgPeople('password')}
    + + + + {showPassword ? ( + + ) : ( + + )} + + +
    {translateOrgPeople('confirmPassword')}
    + + + + {showConfirmPassword ? ( + + ) : ( + + )} + + +
    {translateOrgPeople('organization')}
    + + + +
    +
    + + +
    +
    +
    + + ); +} + +export default AddMember; diff --git a/src/screens/OrganizationPeople/MockDataTypes.ts b/src/screens/OrganizationPeople/MockDataTypes.ts new file mode 100644 index 0000000000..c12bb05531 --- /dev/null +++ b/src/screens/OrganizationPeople/MockDataTypes.ts @@ -0,0 +1,77 @@ +import type { DocumentNode } from 'graphql'; +import type { InterfaceQueryOrganizationsListObject } from 'utils/interfaces'; +type User = { + __typename: string; + firstName: string; + lastName: string; + image: string | null; + _id: string; + email: string; + createdAt: string; + joinedOrganizations: { + __typename: string; + _id: string; + name?: string; + creator?: { + _id: string; + firstName: string; + lastName: string; + email: string; + image: null; + createdAt: string; + }; + }[]; +}; +type Edge = { + _id?: string; + firstName?: string; + lastName?: string; + image?: string | null; + email?: string; + createdAt?: string; + user?: Edge; +}; +export type TestMock = { + request: { + query: DocumentNode; + variables: { + id?: string; + orgId?: string; + orgid?: string; + firstNameContains?: string; + lastNameContains?: string; + firstName_contains?: string; + lastName_contains?: string; + id_not_in?: string[]; + userid?: string; + firstName?: string; + lastName?: string; + email?: string; + password?: string; + }; + }; + result: { + __typename?: string; + data: { + __typename?: string; + createMember?: { + __typename: string; + _id: string; + }; + signUp?: { + user?: { + _id: string; + }; + accessToken?: string; + refreshToken?: string; + }; + users?: { user?: User }[]; + organizations?: InterfaceQueryOrganizationsListObject[]; + organizationsMemberConnection?: { + edges?: Edge[]; + user?: Edge[]; + }; + }; + }; + newData?: () => TestMock['result']; +}; diff --git a/src/screens/OrganizationPeople/OrganizationPeople.test.tsx b/src/screens/OrganizationPeople/OrganizationPeople.test.tsx new file mode 100644 index 0000000000..a840f1e1f0 --- /dev/null +++ b/src/screens/OrganizationPeople/OrganizationPeople.test.tsx @@ -0,0 +1,1435 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import OrganizationPeople from './OrganizationPeople'; +import { store } from 'state/store'; +import { + ORGANIZATIONS_LIST, + ORGANIZATIONS_MEMBER_CONNECTION_LIST, + USERS_CONNECTION_LIST, + USER_LIST_FOR_TABLE, +} from 'GraphQl/Queries/Queries'; +import 'jest-location-mock'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { + ADD_MEMBER_MUTATION, + SIGNUP_MUTATION, +} from 'GraphQl/Mutations/mutations'; +import type { TestMock } from './MockDataTypes'; + +const createMemberMock = ( + orgId = '', + firstNameContains = '', + lastNameContains = '', +): TestMock => ({ + request: { + query: ORGANIZATIONS_MEMBER_CONNECTION_LIST, + variables: { + orgId: orgId, + firstNameContains, + lastNameContains, + }, + }, + result: { + data: { + organizationsMemberConnection: { + edges: [ + { + _id: '64001660a711c62d5b4076a2', + firstName: 'Aditya', + lastName: 'Memberguy', + image: null, + email: 'member@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + }, + ], + }, + }, + }, + newData: () => ({ + data: { + organizationsMemberConnection: { + edges: [ + { + user: { + __typename: 'User', + _id: '64001660a711c62d5b4076a2', + firstName: 'Aditya', + lastName: 'Memberguy', + image: null, + email: 'member@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + }, + }, + ], + }, + }, + }), +}); + +const createAdminMock = ( + orgId = '', + firstNameContains = '', + lastNameContains = '', +): TestMock => ({ + request: { + query: ORGANIZATIONS_MEMBER_CONNECTION_LIST, + variables: { + orgId, + firstNameContains, + lastNameContains, + }, + }, + result: { + data: { + organizationsMemberConnection: { + edges: [ + { + user: { + _id: '64001660a711c62d5b4076a2', + firstName: 'Aditya', + lastName: 'Adminguy', + image: null, + email: 'admin@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + }, + }, + ], + }, + }, + }, + newData: () => ({ + data: { + organizationsMemberConnection: { + __typename: 'UserConnection', + edges: [ + { + user: { + __typename: 'User', + _id: '64001660a711c62d5b4076a2', + firstName: 'Aditya', + lastName: 'Adminguy', + image: null, + email: 'admin@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + lol: true, + }, + }, + ], + }, + }, + }), +}); + +const createUserMock = ( + firstNameContains = '', + lastNameContains = '', +): TestMock => ({ + request: { + query: USER_LIST_FOR_TABLE, + variables: { + firstNameContains, + lastNameContains, + }, + }, + result: { + data: { + users: [ + { + user: { + __typename: 'User', + firstName: 'Aditya', + lastName: 'Userguy', + image: 'tempUrl', + _id: '64001660a711c62d5b4076a2', + email: 'adidacreator1@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af1', + }, + ], + }, + }, + { + user: { + __typename: 'User', + firstName: 'Aditya', + lastName: 'Userguytwo', + image: 'tempUrl', + _id: '6402030dce8e8406b8f07b0e', + email: 'adi1@gmail.com', + createdAt: '2023-03-03T14:24:13.084Z', + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + }, + ], + }, + }, + ], + }, + }, +}); + +const MOCKS: TestMock[] = [ + { + request: { + query: ORGANIZATIONS_LIST, + variables: { + id: 'orgid', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgid', + image: '', + creator: { + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + name: 'name', + description: 'description', + userRegistrationRequired: false, + visibleInSearch: false, + address: { + city: 'string', + countryCode: 'string', + dependentLocality: 'string', + line1: 'string', + line2: 'string', + postalCode: 'string', + sortingCode: 'string', + state: 'string', + }, + members: [ + { + _id: 'id', + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + ], + admins: [ + { + _id: 'id', + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + createdAt: '12-03-2024', + }, + ], + membershipRequests: [ + { + _id: 'id', + user: { + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + }, + ], + blockedUsers: [ + { + _id: 'id', + firstName: 'firstName', + lastName: 'lastName', + email: 'email', + }, + ], + }, + ], + }, + }, + }, + + { + //These are mocks for 1st query (member list) + request: { + query: ORGANIZATIONS_MEMBER_CONNECTION_LIST, + variables: { + orgId: 'orgid', + firstName_contains: '', + lastName_contains: '', + }, + }, + result: { + data: { + organizationsMemberConnection: { + edges: [ + { + _id: '64001660a711c62d5b4076a2', + firstName: 'Aditya', + lastName: 'Memberguy', + image: null, + email: 'member@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + }, + ], + }, + }, + }, + newData: () => ({ + //A function if multiple request are sent + data: { + organizationsMemberConnection: { + edges: [ + { + _id: '64001660a711c62d5b4076a2', + firstName: 'Aditya', + lastName: 'Memberguy', + image: null, + email: 'member@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + }, + ], + }, + }, + }), + }, + + { + request: { + query: ORGANIZATIONS_MEMBER_CONNECTION_LIST, + variables: { + orgId: 'orgid', + firstName_contains: '', + lastName_contains: '', + }, + }, + result: { + data: { + organizationsMemberConnection: { + edges: [ + { + _id: '64001660a711c62d5b4076a2', + firstName: 'Aditya', + lastName: 'Adminguy', + image: null, + email: 'admin@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + }, + ], + }, + }, + }, + newData: () => ({ + data: { + organizationsMemberConnection: { + __typename: 'UserConnection', + edges: [ + { + __typename: 'User', + _id: '64001660a711c62d5b4076a2', + firstName: 'Aditya', + lastName: 'Adminguy', + image: null, + email: 'admin@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + lol: true, + }, + ], + }, + }, + }), + }, + + { + //This is mock for user list + request: { + query: USER_LIST_FOR_TABLE, + variables: { + firstName_contains: '', + lastName_contains: '', + }, + }, + result: { + data: { + users: [ + { + user: { + __typename: 'User', + firstName: 'Aditya', + lastName: 'Userguy', + image: 'tempUrl', + _id: '64001660a711c62d5b4076a2', + email: 'adidacreator1@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af1', + }, + ], + }, + }, + { + user: { + __typename: 'User', + firstName: 'Aditya', + lastName: 'Userguytwo', + image: 'tempUrl', + _id: '6402030dce8e8406b8f07b0e', + email: 'adi1@gmail.com', + createdAt: '2023-03-03T14:24:13.084Z', + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + }, + ], + }, + }, + ], + }, + }, + }, + + createMemberMock('orgid', 'Aditya', ''), + createMemberMock('orgid', '', 'Memberguy'), + createMemberMock('orgid', 'Aditya', 'Memberguy'), + + createAdminMock('orgid', 'Aditya', ''), + createAdminMock('orgid', '', 'Adminguy'), + createAdminMock('orgid', 'Aditya', 'Adminguy'), + + createUserMock('Aditya', ''), + createUserMock('', 'Userguytwo'), + createUserMock('Aditya', 'Userguytwo'), + + { + request: { + query: USERS_CONNECTION_LIST, + variables: { + id_not_in: ['64001660a711c62d5b4076a2'], + firstName_contains: '', + lastName_contains: '', + }, + }, + result: { + data: { + users: [ + { + user: { + firstName: 'Vyvyan', + lastName: 'Kerry', + image: 'tempUrl', + _id: '65378abd85008f171cf2990d', + email: 'testadmin1@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + joinedOrganizations: [ + { + _id: '6537904485008f171cf29924', + name: 'Unity Foundation', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + }, + __typename: 'Organization', + }, + ], + __typename: 'User', + }, + }, + { + user: { + firstName: 'Nandika', + lastName: 'Agrawal', + image: null, + _id: '65378abd85008f171cf2990d', + email: 'testadmin1@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + joinedOrganizations: [ + { + _id: '6537904485008f171cf29924', + name: 'Unity Foundation', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + }, + __typename: 'Organization', + }, + ], + __typename: 'User', + }, + }, + ], + }, + }, + }, + { + request: { + query: SIGNUP_MUTATION, + variables: { + firstName: 'Disha', + lastName: 'Talreja', + email: 'test@gmail.com', + password: 'dishatalreja', + orgId: 'orgId', + }, + }, + result: { + data: { + signUp: { + user: { + _id: '', + }, + accessToken: 'accessToken', + refreshToken: 'refreshToken', + }, + }, + }, + }, + { + request: { + query: ADD_MEMBER_MUTATION, + variables: { + userid: '65378abd85008f171cf2990d', + orgid: 'orgid', + }, + }, + result: { + data: { + createMember: { + _id: '6437904485008f171cf29924', + __typename: 'Organization', + }, + }, + }, + }, +]; + +const EMPTYMOCKS: TestMock[] = [ + { + request: { + query: ORGANIZATIONS_LIST, + variables: { + id: 'orgid', + }, + }, + result: { + data: { + organizations: [], + }, + }, + }, + + { + //These are mocks for 1st query (member list) + request: { + query: ORGANIZATIONS_MEMBER_CONNECTION_LIST, + variables: { + orgId: 'orgid', + firstName_contains: '', + lastName_contains: '', + }, + }, + result: { + data: { + organizationsMemberConnection: { + edges: [], + }, + }, + }, + }, + + { + request: { + query: ORGANIZATIONS_MEMBER_CONNECTION_LIST, + variables: { + orgId: 'orgid', + firstName_contains: '', + lastName_contains: '', + }, + }, + result: { + data: { + organizationsMemberConnection: { + edges: [], + }, + }, + }, + }, + + { + //This is mock for user list + request: { + query: USER_LIST_FOR_TABLE, + variables: { + firstName_contains: '', + lastName_contains: '', + }, + }, + result: { + data: { + users: [], + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(EMPTYMOCKS, true); +async function wait(ms = 2): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} +const linkURL = 'orgid'; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: linkURL }), +})); + +// TODO - REMOVE THE NEXT LINE IT IS TO SUPPRESS THE ERROR +// FOR THE FIRST TEST WHICH CAME OUT OF NOWHERE +console.error = jest.fn(); + +describe('Organization People Page', () => { + const searchData = { + fullNameMember: 'Aditya Memberguy', + fullNameAdmin: 'Aditya Adminguy', + fullNameUser: 'Aditya Userguytwo', + location: 'Delhi, India', + event: 'Event', + }; + + test('Correct mock data should be queried', async () => { + window.location.assign('/orgpeople/orgid'); + + const dataQuery1 = + MOCKS[1]?.result?.data?.organizationsMemberConnection?.edges; + expect(dataQuery1).toEqual([ + { + _id: '64001660a711c62d5b4076a2', + firstName: 'Aditya', + lastName: 'Memberguy', + image: null, + email: 'member@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + }, + ]); + + const dataQuery2 = + MOCKS[2]?.result?.data?.organizationsMemberConnection?.edges; + + const dataQuery3 = MOCKS[3]?.result?.data?.users; + + expect(dataQuery3).toEqual([ + { + user: { + __typename: 'User', + firstName: 'Aditya', + lastName: 'Userguy', + image: 'tempUrl', + _id: '64001660a711c62d5b4076a2', + email: 'adidacreator1@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af1', + }, + ], + }, + }, + { + user: { + __typename: 'User', + firstName: 'Aditya', + lastName: 'Userguytwo', + image: 'tempUrl', + _id: '6402030dce8e8406b8f07b0e', + email: 'adi1@gmail.com', + createdAt: '2023-03-03T14:24:13.084Z', + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + }, + ], + }, + }, + ]); + + expect(dataQuery2).toEqual([ + { + _id: '64001660a711c62d5b4076a2', + firstName: 'Aditya', + lastName: 'Adminguy', + image: null, + email: 'admin@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + }, + ]); + + expect(window.location).toBeAt('/orgpeople/orgid'); + }); + + test('It is necessary to query the correct mock data.', async () => { + window.location.assign('/orgpeople/orgid'); + + const { container } = render( + + + + + + + + + , + ); + + expect(container.textContent).not.toBe('Loading data...'); + + await wait(); + + expect(window.location).toBeAt('/orgpeople/orgid'); + }); + + test('Testing MEMBERS list', async () => { + window.location.assign('/orgpeople/orgid'); + render( + + + + + + + + + , + ); + await wait(); + const dropdownToggles = screen.getAllByTestId('role'); + + dropdownToggles.forEach((dropdownToggle) => { + userEvent.click(dropdownToggle); + }); + + const memebersDropdownItem = screen.getByTestId('members'); + userEvent.click(memebersDropdownItem); + await wait(); + + const findtext = screen.getByText(/Aditya Memberguy/i); + await wait(); + expect(findtext).toBeInTheDocument(); + + userEvent.type( + screen.getByPlaceholderText(/Enter Full Name/i), + searchData.fullNameMember, + ); + await wait(); + expect(screen.getByPlaceholderText(/Enter Full Name/i)).toHaveValue( + searchData.fullNameMember, + ); + + await wait(); + expect(window.location).toBeAt('/orgpeople/orgid'); + }); + + test('Testing MEMBERS list with filters', async () => { + window.location.assign('/orgpeople/orgid'); + render( + + + + + + + + + , + ); + await wait(); + + const fullNameInput = screen.getByPlaceholderText(/Enter Full Name/i); + + // Only First Name + userEvent.type(fullNameInput, searchData.fullNameMember); + await wait(); + + let findtext = screen.getByText(/Aditya Memberguy/i); + await wait(); + expect(findtext).toBeInTheDocument(); + + findtext = screen.getByText(/Aditya Memberguy/i); + await wait(); + expect(findtext).toBeInTheDocument(); + await wait(); + expect(window.location).toBeAt('/orgpeople/orgid'); + }); + + test('Testing ADMIN LIST', async () => { + window.location.assign('/orgpeople/orgid'); + + render( + + + + + + + + + , + ); + + await wait(); + + // Get all dropdown toggles by test id + const dropdownToggles = screen.getAllByTestId('role'); + + // Click the dropdown toggle to open the dropdown menu + dropdownToggles.forEach((dropdownToggle) => { + userEvent.click(dropdownToggle); + }); + + // Click the "Admin" dropdown item + const adminDropdownItem = screen.getByTestId('admins'); + userEvent.click(adminDropdownItem); + + // Wait for any asynchronous operations to complete + await wait(); + // remove this comment when table fecthing functionality is fixed + // Assert that the "Aditya Adminguy" text is present + // const findtext = screen.getByText('Aditya Adminguy'); + // expect(findtext).toBeInTheDocument(); + + // Type in the full name input field + userEvent.type( + screen.getByPlaceholderText(/Enter Full Name/i), + searchData.fullNameAdmin, + ); + + // Wait for any asynchronous operations to complete + await wait(); + + // Assert the value of the full name input field + expect(screen.getByPlaceholderText(/Enter Full Name/i)).toHaveValue( + searchData.fullNameAdmin, + ); + await wait(); + + // Wait for any asynchronous operations to complete + await wait(); + expect(window.location).toBeAt('/orgpeople/orgid'); + }); + + test('Testing ADMIN list with filters', async () => { + window.location.assign('/orgpeople/orgid'); + render( + + + + + + + + + , + ); + + // Wait for the component to finish rendering + await wait(); + + // Click on the dropdown toggle to open the menu + userEvent.click(screen.getByTestId('role')); + await wait(); + + // Click on the "Admins" option in the dropdown menu + userEvent.click(screen.getByTestId('admins')); + await wait(); + + // Type the full name into the input field + const fullNameInput = screen.getByPlaceholderText(/Enter Full Name/i); + userEvent.type(fullNameInput, searchData.fullNameAdmin); + + // Wait for the results to update + await wait(); + const btn = screen.getByTestId('searchbtn'); + userEvent.click(btn); + // remove this comment when table fecthing functionality is fixed + // Check if the expected name is present in the results + // let findtext = screen.getByText(/Aditya Adminguy/i); + // expect(findtext).toBeInTheDocument(); + + // Ensure that the name is still present after filtering + await wait(); + expect(window.location).toBeAt('/orgpeople/orgid'); + }); + + test('Testing add existing user modal', async () => { + window.location.assign('/orgpeople/6401ff65ce8e8406b8f07af1'); + render( + + + + + + + + + , + ); + + // Wait for the component to finish rendering + await wait(); + + // Click on the dropdown toggle to open the menu + userEvent.click(screen.getByTestId('addMembers')); + await wait(); + + expect(screen.getByTestId('existingUser')).toBeInTheDocument(); + + // Click on the "Admins" option in the dropdown menu + userEvent.click(screen.getByTestId('existingUser')); + await wait(); + + expect( + screen.getAllByTestId('addExistingUserModal').length, + ).toBeGreaterThan(0); + await wait(); + + const addBtn = screen.getAllByTestId('addBtn'); + userEvent.click(addBtn[0]); + }); + + test('Open and search existing user', async () => { + window.location.assign('/orgpeople/orgid'); + render( + + + + + + + + + , + ); + + // Wait for the component to finish rendering + await wait(); + + // Click on the dropdown toggle to open the menu + userEvent.click(screen.getByTestId('addMembers')); + await wait(); + + // Click on the "Admins" option in the dropdown menu + userEvent.click(screen.getByTestId('existingUser')); + await wait(); + + expect(screen.getByTestId('addExistingUserModal')).toBeInTheDocument(); + + fireEvent.change(screen.getByTestId('searchUser'), { + target: { value: 'Disha' }, + }); + }); + + test('Open and close add new user modal', async () => { + window.location.assign('/orgpeople/orgid'); + render( + + + + + + + + + , + ); + + // Wait for the component to finish rendering + await wait(); + + // Click on the dropdown toggle to open the menu + userEvent.click(screen.getByTestId('addMembers')); + await wait(); + + // Click on the "Admins" option in the dropdown menu + userEvent.click(screen.getByTestId('newUser')); + await wait(); + + expect(screen.getByTestId('addNewUserModal')).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('closeBtn')); + }); + + test('Testing add new user modal', async () => { + window.location.assign('/orgpeople/orgid'); + render( + + + + + + + + + , + ); + + // Wait for the component to finish rendering + await wait(); + + // Click on the dropdown toggle to open the menu + userEvent.click(screen.getByTestId('addMembers')); + await wait(); + + // Click on the "Admins" option in the dropdown menu + userEvent.click(screen.getByTestId('newUser')); + await wait(); + + expect(screen.getByTestId('addNewUserModal')).toBeInTheDocument(); + + fireEvent.change(screen.getByTestId('firstNameInput'), { + target: { value: 'Disha' }, + }); + expect(screen.getByTestId('firstNameInput')).toHaveValue('Disha'); + + fireEvent.change(screen.getByTestId('lastNameInput'), { + target: { value: 'Talreja' }, + }); + expect(screen.getByTestId('lastNameInput')).toHaveValue('Talreja'); + + fireEvent.change(screen.getByTestId('emailInput'), { + target: { value: 'test@gmail.com' }, + }); + expect(screen.getByTestId('emailInput')).toHaveValue('test@gmail.com'); + + fireEvent.change(screen.getByTestId('passwordInput'), { + target: { value: 'dishatalreja' }, + }); + userEvent.click(screen.getByTestId('showPassword')); + expect(screen.getByTestId('passwordInput')).toHaveValue('dishatalreja'); + + fireEvent.change(screen.getByTestId('confirmPasswordInput'), { + target: { value: 'dishatalreja' }, + }); + userEvent.click(screen.getByTestId('showConfirmPassword')); + expect(screen.getByTestId('confirmPasswordInput')).toHaveValue( + 'dishatalreja', + ); + + userEvent.click(screen.getByTestId('createBtn')); + }); + + test('Throw invalid details error in add new user modal', async () => { + window.location.assign('/orgpeople/orgid'); + render( + + + + + + + + + , + ); + + // Wait for the component to finish rendering + await wait(); + + // Click on the dropdown toggle to open the menu + userEvent.click(screen.getByTestId('addMembers')); + await wait(); + + // Click on the "Admins" option in the dropdown menu + userEvent.click(screen.getByTestId('newUser')); + await wait(); + + expect(screen.getByTestId('addNewUserModal')).toBeInTheDocument(); + + fireEvent.change(screen.getByTestId('firstNameInput'), { + target: { value: 'Disha' }, + }); + expect(screen.getByTestId('firstNameInput')).toHaveValue('Disha'); + + fireEvent.change(screen.getByTestId('lastNameInput'), { + target: { value: 'Talreja' }, + }); + expect(screen.getByTestId('lastNameInput')).toHaveValue('Talreja'); + + fireEvent.change(screen.getByTestId('emailInput'), { + target: { value: 'test@gmail.com' }, + }); + expect(screen.getByTestId('emailInput')).toHaveValue('test@gmail.com'); + + fireEvent.change(screen.getByTestId('passwordInput'), { + target: { value: 'dishatalreja' }, + }); + userEvent.click(screen.getByTestId('showPassword')); + expect(screen.getByTestId('passwordInput')).toHaveValue('dishatalreja'); + + fireEvent.change(screen.getByTestId('confirmPasswordInput'), { + target: { value: 'disha' }, + }); + userEvent.click(screen.getByTestId('showConfirmPassword')); + expect(screen.getByTestId('confirmPasswordInput')).toHaveValue('disha'); + + userEvent.click(screen.getByTestId('createBtn')); + }); + + test('Throw passwordNotMatch error in add new user modal', async () => { + window.location.assign('/orgpeople/orgid'); + render( + + + + + + + + + , + ); + + // Wait for the component to finish rendering + await wait(); + + // Click on the dropdown toggle to open the menu + userEvent.click(screen.getByTestId('addMembers')); + await wait(); + + // Click on the "Admins" option in the dropdown menu + userEvent.click(screen.getByTestId('newUser')); + await wait(); + + expect(screen.getByTestId('addNewUserModal')).toBeInTheDocument(); + + fireEvent.change(screen.getByTestId('firstNameInput'), { + target: { value: 'Disha' }, + }); + expect(screen.getByTestId('firstNameInput')).toHaveValue('Disha'); + + fireEvent.change(screen.getByTestId('lastNameInput'), { + target: { value: 'Talreja' }, + }); + expect(screen.getByTestId('lastNameInput')).toHaveValue('Talreja'); + + fireEvent.change(screen.getByTestId('passwordInput'), { + target: { value: 'dishatalreja' }, + }); + userEvent.click(screen.getByTestId('showPassword')); + expect(screen.getByTestId('passwordInput')).toHaveValue('dishatalreja'); + + fireEvent.change(screen.getByTestId('confirmPasswordInput'), { + target: { value: 'dishatalreja' }, + }); + userEvent.click(screen.getByTestId('showConfirmPassword')); + expect(screen.getByTestId('confirmPasswordInput')).toHaveValue( + 'dishatalreja', + ); + + userEvent.click(screen.getByTestId('createBtn')); + }); + + test('Testing USERS list', async () => { + window.location.assign('/orgpeople/6401ff65ce8e8406b8f07af1'); + + render( + + + + + + + + + , + ); + await wait(); + + const orgUsers = MOCKS[3]?.result?.data?.users; + expect(orgUsers?.length).toBe(2); + + const dropdownToggles = screen.getAllByTestId('role'); + + dropdownToggles.forEach((dropdownToggle) => { + userEvent.click(dropdownToggle); + }); + + const usersDropdownItem = screen.getByTestId('users'); + userEvent.click(usersDropdownItem); + await wait(); + const btn = screen.getByTestId('searchbtn'); + userEvent.click(btn); + await wait(); + expect(window.location).toBeAt('/orgpeople/6401ff65ce8e8406b8f07af1'); + }); + + test('Testing USERS list with filters', async () => { + window.location.assign('/orgpeople/6401ff65ce8e8406b8f07af2'); + + render( + + + + + + + + + , + ); + await wait(); + + const fullNameInput = screen.getByPlaceholderText(/Enter Full Name/i); + + // Only Full Name + userEvent.type(fullNameInput, searchData.fullNameUser); + const btn = screen.getByTestId('searchbtn'); + userEvent.click(btn); + await wait(); + expect(window.location).toBeAt('/orgpeople/6401ff65ce8e8406b8f07af2'); + }); + + test('Add Member component renders', async () => { + render( + + + + + + + + + , + ); + await wait(); + userEvent.click(screen.getByTestId('addMembers')); + await wait(); + userEvent.click(screen.getByTestId('existingUser')); + await wait(); + const btn = screen.getByTestId('submitBtn'); + userEvent.click(btn); + }); + + test('Datagrid renders with members data', async () => { + render( + + + + + + + , + ); + + await wait(); + const dataGrid = screen.getByRole('grid'); + expect(dataGrid).toBeInTheDocument(); + const removeButtons = screen.getAllByTestId('removeMemberModalBtn'); + userEvent.click(removeButtons[0]); + }); + + test('Datagrid renders with admin data', async () => { + window.location.assign('/orgpeople/orgid'); + render( + + + + + + + , + ); + + await wait(); + const dropdownToggles = screen.getAllByTestId('role'); + dropdownToggles.forEach((dropdownToggle) => { + userEvent.click(dropdownToggle); + }); + const adminDropdownItem = screen.getByTestId('admins'); + userEvent.click(adminDropdownItem); + await wait(); + const removeButtons = screen.getAllByTestId('removeAdminModalBtn'); + userEvent.click(removeButtons[0]); + }); + + test('No Mock Data test', async () => { + window.location.assign('/orgpeople/orgid'); + + render( + + + + + + + + + , + ); + + await wait(); + expect(window.location).toBeAt('/orgpeople/orgid'); + expect(screen.queryByText(/Nothing Found !!/i)).toBeInTheDocument(); + }); +}); + +test('Open and check if profile image is displayed for existing user', async () => { + window.location.assign('/orgpeople/orgid'); + render( + + + + + + + + + , + ); + + // Wait for the component to finish rendering + await wait(); + + // Click on the dropdown toggle to open the menu + userEvent.click(screen.getByTestId('addMembers')); + await wait(); + + // Click on the "Admins" option in the dropdown menu + userEvent.click(screen.getByTestId('existingUser')); + await wait(); + + expect(screen.getByTestId('addExistingUserModal')).toBeInTheDocument(); + await wait(); + + expect(screen.getAllByTestId('user').length).toBeGreaterThan(0); + await wait(); + + // Check if the image is rendered + expect(screen.getAllByTestId('profileImage').length).toBeGreaterThan(0); + await wait(); + + const images = await screen.findAllByAltText('avatar'); + expect(images.length).toBeGreaterThan(0); + await wait(); + + const avatarImages = await screen.findAllByAltText('Dummy Avatar'); + expect(avatarImages.length).toBeGreaterThan(0); + await wait(); +}); diff --git a/src/screens/OrganizationPeople/OrganizationPeople.tsx b/src/screens/OrganizationPeople/OrganizationPeople.tsx new file mode 100644 index 0000000000..36efdba63c --- /dev/null +++ b/src/screens/OrganizationPeople/OrganizationPeople.tsx @@ -0,0 +1,482 @@ +import { useLazyQuery } from '@apollo/client'; +import { Delete, Search, Sort } from '@mui/icons-material'; +import { + ORGANIZATIONS_LIST, + ORGANIZATIONS_MEMBER_CONNECTION_LIST, + USER_LIST_FOR_TABLE, +} from 'GraphQl/Queries/Queries'; +import Loader from 'components/Loader/Loader'; +import OrgAdminListCard from 'components/OrgAdminListCard/OrgAdminListCard'; +import OrgPeopleListCard from 'components/OrgPeopleListCard/OrgPeopleListCard'; +import dayjs from 'dayjs'; +import React, { useEffect, useState } from 'react'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import Row from 'react-bootstrap/Row'; +import { useTranslation } from 'react-i18next'; +import { Link, useLocation, useParams } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import AddMember from './AddMember'; +import styles from '../../style/app.module.css'; +import { DataGrid } from '@mui/x-data-grid'; +import type { GridColDef, GridCellParams } from '@mui/x-data-grid'; +import { Stack } from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; + +/** + * OrganizationPeople component is used to display the list of members, admins and users of the organization. + * It also provides the functionality to search the members, admins and users by their full name. + * It also provides the functionality to remove the members and admins from the organization. + * @returns JSX.Element which contains the list of members, admins and users of the organization. + */ +function organizationPeople(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'organizationPeople', + }); + const { t: tCommon } = useTranslation('common'); + + document.title = t('title'); + + const location = useLocation(); + const role = location?.state; + + const { orgId: currentUrl } = useParams(); + + const [state, setState] = useState(role?.role || 0); + + const [filterData, setFilterData] = useState({ + firstName_contains: '', + lastName_contains: '', + }); + const [adminFilteredData, setAdminFilteredData] = useState(); + + const [userName, setUserName] = useState(''); + const [showRemoveModal, setShowRemoveModal] = React.useState(false); + const [selectedAdminId, setSelectedAdminId] = React.useState< + string | undefined + >(); + const [selectedMemId, setSelectedMemId] = React.useState< + string | undefined + >(); + const toggleRemoveModal = (): void => { + setShowRemoveModal((prev) => !prev); + }; + const toggleRemoveMemberModal = (id: string): void => { + setSelectedMemId(id); + setSelectedAdminId(undefined); + toggleRemoveModal(); + }; + const toggleRemoveAdminModal = (id: string): void => { + setSelectedAdminId(id); + setSelectedMemId(undefined); + toggleRemoveModal(); + }; + + const { + data: memberData, + loading: memberLoading, + error: memberError, + refetch: memberRefetch, + } = useLazyQuery(ORGANIZATIONS_MEMBER_CONNECTION_LIST, { + variables: { + firstName_contains: '', + lastName_contains: '', + orgId: currentUrl, + }, + })[1]; + + const { + data: adminData, + loading: adminLoading, + error: adminError, + refetch: adminRefetch, + } = useLazyQuery(ORGANIZATIONS_LIST, { + variables: { + id: currentUrl, + }, + })[1]; + + const { + data: usersData, + loading: usersLoading, + error: usersError, + refetch: usersRefetch, + } = useLazyQuery(USER_LIST_FOR_TABLE, { + variables: { + firstName_contains: '', + lastName_contains: '', + }, + })[1]; + + useEffect(() => { + if (state === 0) { + memberRefetch({ + ...filterData, + orgId: currentUrl, + }); + } else if (state === 1) { + adminRefetch({ + id: currentUrl, + }); + setAdminFilteredData(adminData?.organizations[0].admins); + } else { + usersRefetch({ + ...filterData, + }); + } + }, [state, adminData]); + + /* istanbul ignore next */ + if (memberError || usersError || adminError) { + const error = memberError ?? usersError ?? adminError; + toast.error(error?.message); + } + if (memberLoading || usersLoading || adminLoading) { + return ( +
    + +
    + ); + } + + const handleFullNameSearchChange = (e: React.FormEvent): void => { + e.preventDefault(); + /* istanbul ignore next */ + const [firstName, lastName] = userName.split(' '); + const newFilterData = { + firstName_contains: firstName || '', + lastName_contains: lastName || '', + }; + + setFilterData(newFilterData); + + if (state === 0) { + memberRefetch({ + ...newFilterData, + orgId: currentUrl, + }); + } else if (state === 1) { + const filterData = adminData.organizations[0].admins.filter( + (value: { + _id: string; + firstName: string; + lastName: string; + createdAt: string; + }) => { + return (value.firstName + value.lastName) + .toLowerCase() + .includes(userName.toLowerCase()); + }, + ); + setAdminFilteredData(filterData); + } else { + usersRefetch({ + ...newFilterData, + }); + } + }; + + const columns: GridColDef[] = [ + { + field: 'profile', + headerName: tCommon('profile'), + flex: 1, + minWidth: 50, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return params.row?.image ? ( + avatar + ) : ( + + ); + }, + }, + { + field: 'name', + headerName: tCommon('name'), + flex: 2, + minWidth: 150, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return ( + + {params.row?.firstName + ' ' + params.row?.lastName} + + ); + }, + }, + { + field: 'email', + headerName: tCommon('email'), + minWidth: 150, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + flex: 2, + sortable: false, + }, + { + field: 'joined', + headerName: tCommon('joined'), + flex: 2, + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return dayjs(params.row.createdAt).format('DD/MM/YYYY'); + }, + }, + { + field: 'action', + headerName: tCommon('action'), + flex: 1, + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return state === 1 ? ( + + ) : ( + + ); + }, + }, + ]; + return ( + <> + +
    +
    +
    +
    + { + const { value } = e.target; + setUserName(value); + }} + /> + + +
    +
    + + + + {t('sort')} + + + { + setState(2); + }} + > + + {tCommon('users')} + + + { + setState(0); + }} + > + + {tCommon('members')} + + + { + setState(1); + }} + > + + {tCommon('admins')} + + + + +
    +
    + +
    +
    +
    +
    + {((state == 0 && memberData) || + (state == 1 && adminFilteredData) || + (state == 2 && usersData)) && ( +
    + row._id} + slots={{ + noRowsOverlay: () => ( + + Nothing Found !! + + ), + }} + sx={{ + borderRadius: '20px', + backgroundColor: '#EAEBEF', + '& .MuiDataGrid-row': { + backgroundColor: '#eff1f7', + '&:focus-within': { + outline: '2px solid #000', + outlineOffset: '-2px', + }, + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: '#EAEBEF', + boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.1)', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: '#EAEBEF', + boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.1)', + }, + '& .MuiDataGrid-cell:focus': { + outline: '2px solid #000', + outlineOffset: '-2px', + }, + }} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={70} + rows={ + state === 0 + ? memberData.organizationsMemberConnection.edges + : state === 1 + ? adminFilteredData + : convertObject(usersData) + } + columns={columns} + isRowSelectable={() => false} + /> +
    + )} + {showRemoveModal && selectedMemId && ( + + )} + {showRemoveModal && selectedAdminId && ( + + )} + + ); +} + +export default organizationPeople; + +// This code is used to remove 'user' object from the array index of userData and directly use store the properties at array index, this formatting is needed for DataGrid. + +interface InterfaceUser { + _id: string; + firstName: string; + lastName: string; + email: string; + image: string; + createdAt: string; +} +interface InterfaceOriginalObject { + users: { user: InterfaceUser }[]; +} +interface InterfaceConvertedObject { + users: InterfaceUser[]; +} +function convertObject(original: InterfaceOriginalObject): InterfaceUser[] { + const convertedObject: InterfaceConvertedObject = { + users: [], + }; + original.users.forEach((item) => { + convertedObject.users.push({ + firstName: item.user?.firstName, + lastName: item.user?.lastName, + email: item.user?.email, + image: item.user?.image, + createdAt: item.user?.createdAt, + _id: item.user?._id, + }); + }); + return convertedObject.users; +} diff --git a/src/screens/OrganizationTags/OrganizationTags.test.tsx b/src/screens/OrganizationTags/OrganizationTags.test.tsx new file mode 100644 index 0000000000..0d426d20ac --- /dev/null +++ b/src/screens/OrganizationTags/OrganizationTags.test.tsx @@ -0,0 +1,299 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import type { RenderResult } from '@testing-library/react'; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import OrganizationTags from './OrganizationTags'; +import { MOCKS, MOCKS_ERROR } from './OrganizationTagsMocks'; +import type { ApolloLink } from '@apollo/client'; + +const translations = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.organizationTags ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(MOCKS_ERROR, true); + +async function wait(ms = 500): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const renderOrganizationTags = (link: ApolloLink): RenderResult => { + return render( + + + + + + } /> + } + /> + } + /> + + + + + , + ); +}; + +describe('Organisation Tags Page', () => { + beforeEach(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + test('component loads correctly', async () => { + const { getByText } = renderOrganizationTags(link); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.createTag)).toBeInTheDocument(); + }); + }); + + test('render error component on unsuccessful userTags query', async () => { + const { queryByText } = renderOrganizationTags(link2); + + await wait(); + + await waitFor(() => { + expect(queryByText(translations.createTag)).not.toBeInTheDocument(); + }); + }); + + test('opens and closes the create tag modal', async () => { + renderOrganizationTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createTagBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('createTagBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('closeCreateTagModal'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('closeCreateTagModal')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('closeCreateTagModal'), + ); + }); + + test('navigates to sub tags screen after clicking on a tag', async () => { + renderOrganizationTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('tagName')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('tagName')[0]); + + await waitFor(() => { + expect(screen.getByTestId('subTagsScreen')).toBeInTheDocument(); + }); + }); + + test('navigates to manage tag page after clicking manage tag option', async () => { + renderOrganizationTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('manageTagBtn')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('manageTagBtn')[0]); + + await waitFor(() => { + expect(screen.getByTestId('manageTagScreen')).toBeInTheDocument(); + }); + }); + + test('searchs for tags where the name matches the provided search input', async () => { + renderOrganizationTags(link); + + await wait(); + + await waitFor(() => { + expect( + screen.getByPlaceholderText(translations.searchByName), + ).toBeInTheDocument(); + }); + const input = screen.getByPlaceholderText(translations.searchByName); + fireEvent.change(input, { target: { value: 'searchUserTag' } }); + + // should render the two searched tags from the mock data + // where name starts with "searchUserTag" + await waitFor(() => { + const buttons = screen.getAllByTestId('manageTagBtn'); + expect(buttons.length).toEqual(2); + }); + }); + + test('fetches the tags by the sort order, i.e. latest or oldest first', async () => { + renderOrganizationTags(link); + + await wait(); + + await waitFor(() => { + expect( + screen.getByPlaceholderText(translations.searchByName), + ).toBeInTheDocument(); + }); + const input = screen.getByPlaceholderText(translations.searchByName); + fireEvent.change(input, { target: { value: 'searchUserTag' } }); + + // should render the two searched tags from the mock data + // where name starts with "searchUserTag" + await waitFor(() => { + expect(screen.getAllByTestId('tagName')[0]).toHaveTextContent( + 'searchUserTag 1', + ); + }); + + // now change the sorting order + await waitFor(() => { + expect(screen.getByTestId('sortTags')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('sortTags')); + + await waitFor(() => { + expect(screen.getByTestId('oldest')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('oldest')); + + // returns the tags in reverse order + await waitFor(() => { + expect(screen.getAllByTestId('tagName')[0]).toHaveTextContent( + 'searchUserTag 2', + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('sortTags')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('sortTags')); + + await waitFor(() => { + expect(screen.getByTestId('latest')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('latest')); + + // reverse the order again + await waitFor(() => { + expect(screen.getAllByTestId('tagName')[0]).toHaveTextContent( + 'searchUserTag 1', + ); + }); + }); + + test('fetches more tags with infinite scroll', async () => { + const { getByText } = renderOrganizationTags(link); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.createTag)).toBeInTheDocument(); + }); + + const orgUserTagsScrollableDiv = screen.getByTestId( + 'orgUserTagsScrollableDiv', + ); + + // Get the initial number of tags loaded + const initialTagsDataLength = screen.getAllByTestId('manageTagBtn').length; + + // Set scroll position to the bottom + fireEvent.scroll(orgUserTagsScrollableDiv, { + target: { scrollY: orgUserTagsScrollableDiv.scrollHeight }, + }); + + await waitFor(() => { + const finalTagsDataLength = screen.getAllByTestId('manageTagBtn').length; + expect(finalTagsDataLength).toBeGreaterThan(initialTagsDataLength); + + expect(getByText(translations.createTag)).toBeInTheDocument(); + }); + }); + + test('creates a new user tag', async () => { + renderOrganizationTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('createTagBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('createTagBtn')); + + userEvent.click(screen.getByTestId('createTagSubmitBtn')); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith(translations.enterTagName); + }); + + userEvent.type( + screen.getByPlaceholderText(translations.tagNamePlaceholder), + 'userTag 12', + ); + + userEvent.click(screen.getByTestId('createTagSubmitBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + translations.tagCreationSuccess, + ); + }); + }); +}); diff --git a/src/screens/OrganizationTags/OrganizationTags.tsx b/src/screens/OrganizationTags/OrganizationTags.tsx new file mode 100644 index 0000000000..55134b75ac --- /dev/null +++ b/src/screens/OrganizationTags/OrganizationTags.tsx @@ -0,0 +1,501 @@ +import { useMutation, useQuery } from '@apollo/client'; +import { WarningAmberRounded } from '@mui/icons-material'; +import SortIcon from '@mui/icons-material/Sort'; +import Loader from 'components/Loader/Loader'; +import { useNavigate, useParams, Link } from 'react-router-dom'; +import type { ChangeEvent } from 'react'; +import React, { useEffect, useState } from 'react'; +import { Form } from 'react-bootstrap'; +import Button from 'react-bootstrap/Button'; +import Dropdown from 'react-bootstrap/Dropdown'; +import Modal from 'react-bootstrap/Modal'; +import Row from 'react-bootstrap/Row'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import IconComponent from 'components/IconComponent/IconComponent'; +import type { + InterfaceQueryOrganizationUserTags, + InterfaceTagData, +} from 'utils/interfaces'; +import styles from '../../style/app.module.css'; +import { DataGrid } from '@mui/x-data-grid'; +import type { + InterfaceOrganizationTagsQuery, + SortedByType, +} from 'utils/organizationTagsUtils'; +import { TAGS_QUERY_DATA_CHUNK_SIZE } from 'utils/organizationTagsUtils'; +import type { GridCellParams, GridColDef } from '@mui/x-data-grid'; +import { Stack } from '@mui/material'; +import { ORGANIZATION_USER_TAGS_LIST } from 'GraphQl/Queries/OrganizationQueries'; +import { CREATE_USER_TAG } from 'GraphQl/Mutations/TagMutations'; +import InfiniteScroll from 'react-infinite-scroll-component'; +import InfiniteScrollLoader from 'components/InfiniteScrollLoader/InfiniteScrollLoader'; + +/** + * Component that renders the Organization Tags screen when the app navigates to '/orgtags/:orgId'. + * + * This component does not accept any props and is responsible for displaying + * the content associated with the corresponding route. + */ + +function OrganizationTags(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'organizationTags', + }); + const { t: tCommon } = useTranslation('common'); + + const [createTagModalIsOpen, setCreateTagModalIsOpen] = useState(false); + + const [tagSearchName, setTagSearchName] = useState(''); + const [tagSortOrder, setTagSortOrder] = useState('DESCENDING'); + + const { orgId } = useParams(); + const navigate = useNavigate(); + + const [tagName, setTagName] = useState(''); + + const showCreateTagModal = (): void => { + setTagName(''); + setCreateTagModalIsOpen(true); + }; + + const hideCreateTagModal = (): void => { + setCreateTagModalIsOpen(false); + }; + + const { + data: orgUserTagsData, + loading: orgUserTagsLoading, + error: orgUserTagsError, + refetch: orgUserTagsRefetch, + fetchMore: orgUserTagsFetchMore, + }: InterfaceOrganizationTagsQuery = useQuery(ORGANIZATION_USER_TAGS_LIST, { + variables: { + id: orgId, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: tagSearchName } }, + sortedBy: { id: tagSortOrder }, + }, + }); + + const loadMoreUserTags = (): void => { + orgUserTagsFetchMore({ + variables: { + first: TAGS_QUERY_DATA_CHUNK_SIZE, + after: + orgUserTagsData?.organizations?.[0]?.userTags?.pageInfo?.endCursor ?? + /* istanbul ignore next */ + null, + }, + updateQuery: ( + prevResult: { organizations: InterfaceQueryOrganizationUserTags[] }, + { + fetchMoreResult, + }: { + fetchMoreResult?: { + organizations: InterfaceQueryOrganizationUserTags[]; + }; + }, + ) => { + if (!fetchMoreResult) /* istanbul ignore next */ return prevResult; + + return { + organizations: [ + { + ...prevResult.organizations[0], + userTags: { + ...prevResult.organizations[0].userTags, + edges: [ + ...prevResult.organizations[0].userTags.edges, + ...fetchMoreResult.organizations[0].userTags.edges, + ], + pageInfo: fetchMoreResult.organizations[0].userTags.pageInfo, + }, + }, + ], + }; + }, + }); + }; + + useEffect(() => { + orgUserTagsRefetch(); + }, []); + + const [create, { loading: createUserTagLoading }] = + useMutation(CREATE_USER_TAG); + + const createTag = async (e: ChangeEvent): Promise => { + e.preventDefault(); + + if (!tagName.trim()) { + toast.error(t('enterTagName')); + return; + } + + try { + const { data } = await create({ + variables: { + name: tagName, + organizationId: orgId, + }, + }); + + if (data) { + toast.success(t('tagCreationSuccess')); + orgUserTagsRefetch(); + setTagName(''); + setCreateTagModalIsOpen(false); + } + } catch (error: unknown) { + /* istanbul ignore next */ + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + if (orgUserTagsError) { + return ( +
    +
    + +
    + Error occured while loading Organization Tags Data +
    + {orgUserTagsError.message} +
    +
    +
    + ); + } + + const userTagsList = orgUserTagsData?.organizations[0].userTags.edges.map( + (edge) => edge.node, + ); + + const redirectToManageTag = (tagId: string): void => { + navigate(`/orgtags/${orgId}/manageTag/${tagId}`); + }; + + const redirectToSubTags = (tagId: string): void => { + navigate(`/orgtags/${orgId}/subTags/${tagId}`); + }; + + const columns: GridColDef[] = [ + { + field: 'id', + headerName: '#', + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return
    {params.row.id}
    ; + }, + }, + { + field: 'tagName', + headerName: 'Tag Name', + flex: 1, + minWidth: 100, + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
    + {params.row.parentTag && + params.row.ancestorTags?.map((tag) => ( +
    + {tag.name} + +
    + ))} + +
    redirectToSubTags(params.row._id)} + > + {params.row.name} + +
    +
    + ); + }, + }, + { + field: 'totalSubTags', + headerName: 'Total Sub Tags', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + + {params.row.childTags.totalCount} + + ); + }, + }, + { + field: 'totalAssignedUsers', + headerName: 'Total Assigned Users', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + + {params.row.usersAssignedTo.totalCount} + + ); + }, + }, + { + field: 'actions', + headerName: 'Actions', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + + ); + }, + }, + ]; + + return ( + <> + +
    +
    +
    + + setTagSearchName(e.target.value.trim())} + autoComplete="off" + /> +
    +
    + +
    +
    + +
    +
    + + {orgUserTagsLoading || createUserTagLoading ? ( + + ) : ( +
    +
    +
    + +
    + +
    + {'Tags'} +
    +
    + +
    + } + scrollableTarget="orgUserTagsScrollableDiv" + > + row.id} + slots={{ + noRowsOverlay: /* istanbul ignore next */ () => ( + + {t('noTagsFound')} + + ), + }} + sx={{ + borderRadius: '20px', + backgroundColor: '#EAEBEF', + '& .MuiDataGrid-row': { + backgroundColor: '#eff1f7', + '&:focus-within': { + // outline: '2px solid #000', + outlineOffset: '-2px', + }, + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: '#EAEBEF', + boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.1)', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: '#EAEBEF', + boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.1)', + }, + '& .MuiDataGrid-cell:focus': { + // outline: '2px solid #000', + outlineOffset: '-2px', + }, + }} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={userTagsList?.map((userTag, index) => ({ + id: index + 1, + ...userTag, + }))} + columns={columns} + isRowSelectable={() => false} + /> + +
    +
    + )} +
    +
    + + {/* Create Tag Modal */} + + + {t('tagDetails')} + +
    + + {t('tagName')} + { + setTagName(e.target.value); + }} + /> + + + + + + +
    +
    + + ); +} + +export default OrganizationTags; diff --git a/src/screens/OrganizationTags/OrganizationTagsMocks.ts b/src/screens/OrganizationTags/OrganizationTagsMocks.ts new file mode 100644 index 0000000000..0fe48ca97f --- /dev/null +++ b/src/screens/OrganizationTags/OrganizationTagsMocks.ts @@ -0,0 +1,426 @@ +import { CREATE_USER_TAG } from 'GraphQl/Mutations/TagMutations'; +import { ORGANIZATION_USER_TAGS_LIST } from 'GraphQl/Queries/OrganizationQueries'; +import { TAGS_QUERY_DATA_CHUNK_SIZE } from 'utils/organizationTagsUtils'; + +export const MOCKS = [ + { + request: { + query: ORGANIZATION_USER_TAGS_LIST, + variables: { + id: '123', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: '' } }, + sortedBy: { id: 'DESCENDING' }, + }, + }, + result: { + data: { + organizations: [ + { + userTags: { + edges: [ + { + node: { + _id: '1', + name: 'userTag 1', + parentTag: null, + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 11, + }, + ancestorTags: [], + }, + cursor: '1', + }, + { + node: { + _id: '2', + name: 'userTag 2', + parentTag: null, + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [], + }, + cursor: '2', + }, + { + node: { + _id: '3', + name: 'userTag 3', + parentTag: null, + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [], + }, + cursor: '3', + }, + { + node: { + _id: '4', + name: 'userTag 4', + parentTag: null, + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [], + }, + cursor: '4', + }, + { + node: { + _id: '5', + name: 'userTag 5', + parentTag: null, + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [], + }, + cursor: '5', + }, + { + node: { + _id: '6', + name: 'userTag 6', + parentTag: null, + usersAssignedTo: { + totalCount: 6, + }, + childTags: { + totalCount: 6, + }, + ancestorTags: [], + }, + cursor: '6', + }, + { + node: { + _id: '7', + name: 'userTag 7', + parentTag: null, + usersAssignedTo: { + totalCount: 7, + }, + childTags: { + totalCount: 7, + }, + ancestorTags: [], + }, + cursor: '7', + }, + { + node: { + _id: '8', + name: 'userTag 8', + parentTag: null, + usersAssignedTo: { + totalCount: 8, + }, + childTags: { + totalCount: 8, + }, + ancestorTags: [], + }, + cursor: '8', + }, + { + node: { + _id: '9', + name: 'userTag 9', + parentTag: null, + usersAssignedTo: { + totalCount: 9, + }, + childTags: { + totalCount: 9, + }, + ancestorTags: [], + }, + cursor: '9', + }, + { + node: { + _id: '10', + name: 'userTag 10', + parentTag: null, + usersAssignedTo: { + totalCount: 10, + }, + childTags: { + totalCount: 10, + }, + ancestorTags: [], + }, + cursor: '10', + }, + ], + pageInfo: { + startCursor: '1', + endCursor: '10', + hasNextPage: true, + hasPreviousPage: false, + }, + totalCount: 12, + }, + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_USER_TAGS_LIST, + variables: { + id: '123', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + after: '10', + where: { name: { starts_with: '' } }, + sortedBy: { id: 'DESCENDING' }, + }, + }, + result: { + data: { + organizations: [ + { + userTags: { + edges: [ + { + node: { + _id: '11', + name: 'userTag 11', + parentTag: null, + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [], + }, + cursor: '11', + }, + { + node: { + _id: '12', + name: 'userTag 12', + parentTag: null, + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [], + }, + cursor: '12', + }, + ], + pageInfo: { + startCursor: '11', + endCursor: '12', + hasNextPage: false, + hasPreviousPage: true, + }, + totalCount: 12, + }, + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_USER_TAGS_LIST, + variables: { + id: '123', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: 'searchUserTag' } }, + sortedBy: { id: 'DESCENDING' }, + }, + }, + result: { + data: { + organizations: [ + { + userTags: { + edges: [ + { + node: { + _id: 'searchUserTag1', + name: 'searchUserTag 1', + parentTag: { + _id: '1', + }, + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'searchUserTag1', + }, + { + node: { + _id: 'searchUserTag2', + name: 'searchUserTag 2', + parentTag: { + _id: '1', + }, + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'searchUserTag2', + }, + ], + pageInfo: { + startCursor: 'searchUserTag1', + endCursor: 'searchUserTag2', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 2, + }, + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_USER_TAGS_LIST, + variables: { + id: '123', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: 'searchUserTag' } }, + sortedBy: { id: 'ASCENDING' }, + }, + }, + result: { + data: { + organizations: [ + { + userTags: { + edges: [ + { + node: { + _id: 'searchUserTag2', + name: 'searchUserTag 2', + parentTag: { + _id: '1', + }, + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'searchUserTag2', + }, + { + node: { + _id: 'searchUserTag1', + name: 'searchUserTag 1', + parentTag: { + _id: '1', + }, + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'searchUserTag1', + }, + ], + pageInfo: { + startCursor: 'searchUserTag2', + endCursor: 'searchUserTag1', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 2, + }, + }, + ], + }, + }, + }, + { + request: { + query: CREATE_USER_TAG, + variables: { + name: 'userTag 12', + organizationId: '123', + }, + }, + result: { + data: { + createUserTag: { + _id: '12', + }, + }, + }, + }, +]; + +export const MOCKS_ERROR = [ + { + request: { + query: ORGANIZATION_USER_TAGS_LIST, + variables: { + id: '123', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: '' } }, + sortedBy: { id: 'DESCENDING' }, + }, + }, + error: new Error('Mock Graphql Error'), + }, +]; diff --git a/src/screens/OrganizationVenues/OrganizationVenues.module.css b/src/screens/OrganizationVenues/OrganizationVenues.module.css new file mode 100644 index 0000000000..e4ac9d7575 --- /dev/null +++ b/src/screens/OrganizationVenues/OrganizationVenues.module.css @@ -0,0 +1,879 @@ +.navbarbg { + height: 60px; + background-color: white; + display: flex; + margin-bottom: 30px; + z-index: 1; + position: relative; + flex-direction: row; + justify-content: space-between; + box-shadow: 0px 0px 8px 2px #c8c8c8; +} + +.logo { + color: #707070; + margin-left: 0; + display: flex; + align-items: center; + text-decoration: none; +} + +.logo img { + margin-top: 0px; + margin-left: 10px; + height: 64px; + width: 70px; +} + +.logo > strong { + line-height: 1.5rem; + margin-left: -5px; + font-family: sans-serif; + font-size: 19px; + color: #707070; +} +.mainpage { + display: flex; + flex-direction: row; +} + +.sidebar:after { + background-color: #f7f7f7; + position: absolute; + width: 2px; + height: 600px; + top: 10px; + left: 94%; + display: block; +} + +.navitem { + padding-left: 27%; + padding-top: 12px; + padding-bottom: 12px; + cursor: pointer; +} + +.logintitle { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 30px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 15%; +} + +.logintitleadmin { + color: #707070; + font-weight: 600; + font-size: 18px; + margin-top: 50px; + margin-bottom: 40px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 30%; +} +.admindetails { + display: flex; + justify-content: space-between; +} +.admindetails > p { + margin-top: -12px; + margin-right: 30px; +} + +.mainpageright > hr { + margin-top: 20px; + width: 100%; + margin-left: -15px; + margin-right: -15px; + margin-bottom: 20px; +} +.justifysp { + display: flex; + justify-content: space-between; + margin-top: 20px; +} + +.addbtn { + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + border-radius: 5px; + font-size: 16px; + height: 60%; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; +} +.flexdir { + display: flex; + flex-direction: row; + justify-content: space-between; + border: none; +} + +.form_wrapper { + margin-top: 27px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + position: absolute; + display: flex; + flex-direction: column; + padding: 40px 30px; + background: #ffffff; + border-color: #e8e5e5; + border-width: 5px; + border-radius: 10px; + max-height: 86vh; + overflow: auto; +} + +.form_wrapper form { + display: flex; + align-items: left; + justify-content: left; + flex-direction: column; +} +.logintitleinvite { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 40%; +} +.titlemodal { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 65%; +} +.cancel > i { + margin-top: 5px; + transform: scale(1.2); + cursor: pointer; + color: #707070; +} +.modalbody { + width: 50px; +} +.greenregbtn { + margin: 1rem 0 0; + margin-top: 15px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 10px; + border-radius: 5px; + background-color: #31bb6b; + width: 100%; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; +} + +.datediv { + display: flex; + flex-direction: row; + margin-bottom: 15px; +} +.datebox { + width: 90%; + border-radius: 7px; + border-color: #e8e5e5; + outline: none; + box-shadow: none; + padding-top: 2px; + padding-bottom: 2px; + padding-right: 5px; + padding-left: 5px; + margin-right: 5px; + margin-left: 5px; +} +.checkboxdiv > label { + margin-right: 50px; +} +.checkboxdiv > label > input { + margin-left: 10px; +} +.loader, +.loader:after { + border-radius: 50%; + width: 10em; + height: 10em; +} +.loader { + margin: 60px auto; + margin-top: 35vh !important; + font-size: 10px; + position: relative; + text-indent: -9999em; + border-top: 1.1em solid rgba(255, 255, 255, 0.2); + border-right: 1.1em solid rgba(255, 255, 255, 0.2); + border-bottom: 1.1em solid rgba(255, 255, 255, 0.2); + border-left: 1.1em solid #febc59; + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); + -webkit-animation: load8 1.1s infinite linear; + animation: load8 1.1s infinite linear; +} +@-webkit-keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +.dispflex { + display: flex; + align-items: center; +} +.dispflex > input { + border: none; + box-shadow: none; + margin-top: 5px; +} +.checkboxdiv { + display: flex; +} +.checkboxdiv > div { + width: 50%; +} + +@media only screen and (max-width: 600px) { + .form_wrapper { + width: 90%; + top: 45%; + } +} + +.navbarbg { + height: 60px; + background-color: white; + display: flex; + margin-bottom: 30px; + z-index: 1; + position: relative; + flex-direction: row; + justify-content: space-between; + box-shadow: 0px 0px 8px 2px #c8c8c8; +} + +.logo { + color: #707070; + margin-left: 0; + display: flex; + align-items: center; + text-decoration: none; +} + +.logo img { + margin-top: 0px; + margin-left: 10px; + height: 64px; + width: 70px; +} + +.logo > strong { + line-height: 1.5rem; + margin-left: -5px; + font-family: sans-serif; + font-size: 19px; + color: #707070; +} +.mainpage { + display: flex; + flex-direction: row; +} +.sidebar { + display: flex; + width: 100%; + justify-content: space-between; + z-index: 0; + padding-top: 10px; + margin: 0; + height: 100%; +} + +.navitem { + padding-left: 27%; + padding-top: 12px; + padding-bottom: 12px; + cursor: pointer; +} + +.logintitle { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 30px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 15%; +} +.searchtitle { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 60%; +} +.justifysp { + display: flex; + justify-content: space-between; +} +@media screen and (max-width: 575.5px) { + .justifysp { + padding-left: 55px; + display: flex; + justify-content: space-between; + width: 100%; + } + .mainpageright { + width: 98%; + } +} + +.logintitleadmin { + color: #707070; + font-weight: 600; + font-size: 18px; + margin-top: 50px; + margin-bottom: 40px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 40%; +} +.admindetails { + display: flex; + justify-content: space-between; +} +.admindetails > p { + margin-top: -12px; + margin-right: 30px; +} +.mainpageright > hr { + margin-top: 10px; + width: 97%; + margin-left: -15px; + margin-right: -15px; + margin-bottom: 20px; +} +.addbtnmain { + width: 60%; + margin-right: 50px; +} +.addbtn { + float: right; + width: 23%; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + border-radius: 5px; + background-color: #31bb6b; + height: 40px; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + margin-left: 30px; + transition: + transform 0.2s, + box-shadow 0.2s; +} +.flexdir { + display: flex; + flex-direction: row; + justify-content: space-between; + border: none; +} + +.form_wrapper { + margin-top: 27px; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + position: absolute; + display: flex; + flex-direction: column; + padding: 40px 30px; + background: #ffffff; + border-color: #e8e5e5; + border-width: 5px; + border-radius: 10px; +} + +.form_wrapper form { + display: flex; + align-items: left; + justify-content: left; + flex-direction: column; +} +.logintitleinvite { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 40%; +} +.cancel > i { + margin-top: 5px; + transform: scale(1.2); + cursor: pointer; + color: #707070; +} +.modalbody { + width: 50px; +} +.greenregbtn { + margin: 1rem 0 0; + margin-top: 10px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 10px; + border-radius: 5px; + background-color: #31bb6b; + width: 100%; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; +} +.dropdown { + background-color: white; + border: 1px solid #31bb6b; + position: relative; + display: inline-block; + margin-top: 10px; + margin-bottom: 10px; + color: #31bb6b; +} +.input { + flex: 1; + position: relative; +} +/* .btnsContainer { + display: flex; + margin: 2.5rem 0 2.5rem 0; + width: 100%; + flex-direction: row; + justify-content: space-between; + } */ + +.btnsContainer { + display: flex; + margin: 2.5rem 0 2.5rem 0; +} + +.btnsContainer .input { + flex: 1; + position: relative; + min-width: 18rem; + width: 25rem; +} + +.btnsContainer .input button { + width: 52px; +} +.searchBtn { + margin-bottom: 10px; +} + +.inputField { + margin-top: 10px; + margin-bottom: 10px; + background-color: white; + box-shadow: 0 1px 1px #31bb6b; +} +.inputField > button { + padding-top: 10px; + padding-bottom: 10px; +} +.TableImage { + background-color: #31bb6b !important; + width: 50px !important; + height: 50px !important; + border-radius: 100% !important; + margin-right: 10px !important; +} +.tableHead { + background-color: #31bb6b !important; + color: white; + border-radius: 20px !important; + padding: 20px; + margin-top: 20px; +} + +.tableHead :nth-first-child() { + border-top-left-radius: 20px; +} + +.mainpageright > hr { + margin-top: 10px; + width: 100%; + margin-left: -15px; + margin-right: -15px; + margin-bottom: 20px; +} + +.loader, +.loader:after { + border-radius: 50%; + width: 10em; + height: 10em; +} +.loader { + margin: 60px auto; + margin-top: 35vh !important; + font-size: 10px; + position: relative; + text-indent: -9999em; + border-top: 1.1em solid rgba(255, 255, 255, 0.2); + border-right: 1.1em solid rgba(255, 255, 255, 0.2); + border-bottom: 1.1em solid rgba(255, 255, 255, 0.2); + border-left: 1.1em solid #febc59; + -webkit-transform: translateZ(0); + -ms-transform: translateZ(0); + transform: translateZ(0); + -webkit-animation: load8 1.1s infinite linear; + animation: load8 1.1s infinite linear; +} +.radio_buttons { + display: flex; + flex-direction: column; + gap: 0.5rem; + color: #707070; + font-weight: 600; + font-size: 14px; +} +.radio_buttons > input { + transform: scale(1.2); +} +.radio_buttons > label { + margin-top: -4px; + margin-left: 5px; + margin-right: 15px; +} +.preview { + display: flex; + position: relative; + width: 100%; + margin-top: 10px; + justify-content: center; +} +.preview img { + width: 400px; + height: auto; +} +.postimage { + border-radius: 0px; + width: 100%; + height: 12rem; + max-width: 100%; + max-height: 12rem; + object-fit: cover; + position: relative; + color: black; +} +.closeButtonP { + position: absolute; + top: 0px; + right: 0px; + background: transparent; + transform: scale(1.2); + cursor: pointer; + border: none; + color: #707070; + font-weight: 600; + font-size: 16px; + cursor: pointer; +} +.addbtn { + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + border-radius: 5px; + font-size: 16px; + height: 60%; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; +} +@-webkit-keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +@keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} +.list_box { + height: 65vh; + overflow-y: auto; + width: auto; +} + +.cards h2 { + font-size: 20px; +} +.cards > h3 { + font-size: 17px; +} +.card { + width: 100%; + height: 20rem; + margin-bottom: 2rem; +} +.postimage { + border-radius: 0px; + width: 100%; + height: 12rem; + max-width: 100%; + max-height: 12rem; + object-fit: cover; + position: relative; + color: black; +} +.preview { + display: flex; + position: relative; + width: 100%; + margin-top: 10px; + justify-content: center; +} +.preview img { + width: 400px; + height: auto; +} +.preview video { + width: 400px; + height: auto; +} +.novenueimage { + border-radius: 0px; + width: 100%; + height: 12rem; + max-height: 12rem; + object-fit: cover; + position: relative; +} +.cards:hover { + filter: brightness(0.8); +} +.cards:hover::before { + opacity: 0.5; +} +.knowMoreText { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + opacity: 0; + color: white; + padding: 10px; + font-weight: bold; + font-size: 1.5rem; + transition: opacity 0.3s ease-in-out; +} + +.cards:hover .knowMoreText { + opacity: 1; +} +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba( + 0, + 0, + 0, + 0.9 + ); /* Dark grey modal background with transparency */ + z-index: 9999; +} + +.modalContent { + display: flex; + align-items: center; + justify-content: center; + background-color: #fff; + padding: 20px; + max-width: 800px; + max-height: 600px; + overflow: auto; +} + +.modalImage { + flex: 1; + margin-right: 20px; + width: 25rem; + height: 15rem; +} +.nomodalImage { + flex: 1; + margin-right: 20px; + width: 100%; + height: 15rem; +} + +.modalImage img, +.modalImage video { + border-radius: 0px; + width: 100%; + height: 25rem; + max-width: 25rem; + max-height: 15rem; + object-fit: cover; + position: relative; +} +.modalInfo { + flex: 1; +} +.title { + font-size: 16px; + color: #000; + font-weight: 600; +} +.text { + font-size: 13px; + color: #000; + font-weight: 300; +} +.closeButton { + position: relative; + bottom: 5rem; + right: 10px; + padding: 4px; + background-color: red; /* Red close button color */ + color: #fff; + border: none; + cursor: pointer; +} +.closeButtonP { + position: absolute; + top: 0px; + right: 0px; + background: transparent; + transform: scale(1.2); + cursor: pointer; + border: none; + color: #707070; + font-weight: 600; + font-size: 16px; + cursor: pointer; +} +.cards:hover::after { + opacity: 1; + mix-blend-mode: normal; +} +.cards > p { + font-size: 14px; + margin-top: 0px; + margin-bottom: 7px; +} + +.cards:last-child:nth-last-child(odd) { + grid-column: auto / span 2; +} +.cards:first-child:nth-last-child(even), +.cards:first-child:nth-last-child(even) ~ .box { + grid-column: auto / span 1; +} + +.capacityLabel { + background-color: #31bb6b !important; + color: white; + height: 22.19px; + font-size: 12px; + font-weight: bolder; + padding: 0.1rem 0.3rem; + border-radius: 0.5rem; + position: relative; + overflow: hidden; +} + +.capacityLabel svg { + margin-bottom: 3px; +} + +::-webkit-scrollbar { + width: 20px; +} + +::-webkit-scrollbar-track { + background-color: transparent; +} + +::-webkit-scrollbar-thumb { + background-color: #d6dee1; +} + +::-webkit-scrollbar-thumb { + background-color: #d6dee1; + border-radius: 20px; +} + +::-webkit-scrollbar-thumb { + background-color: #d6dee1; + border-radius: 20px; + border: 6px solid transparent; + background-clip: content-box; +} diff --git a/src/screens/OrganizationVenues/OrganizationVenues.test.tsx b/src/screens/OrganizationVenues/OrganizationVenues.test.tsx new file mode 100644 index 0000000000..5b8b9933a1 --- /dev/null +++ b/src/screens/OrganizationVenues/OrganizationVenues.test.tsx @@ -0,0 +1,497 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import type { RenderResult } from '@testing-library/react'; +import { + act, + render, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import OrganizationVenues from './OrganizationVenues'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { VENUE_LIST } from 'GraphQl/Queries/OrganizationQueries'; +import type { ApolloLink } from '@apollo/client'; +import { DELETE_VENUE_MUTATION } from 'GraphQl/Mutations/VenueMutations'; + +const MOCKS = [ + { + request: { + query: VENUE_LIST, + variables: { + orgId: 'orgId', + orderBy: 'capacity_ASC', + where: { + name_starts_with: '', + description_starts_with: undefined, + }, + }, + }, + result: { + data: { + getVenueByOrgId: [ + { + _id: 'venue1', + capacity: 1000, + description: 'Updated description for venue 1', + imageUrl: null, + name: 'Updated Venue 1', + organization: { + __typename: 'Organization', + _id: 'orgId', + }, + __typename: 'Venue', + }, + { + _id: 'venue2', + capacity: 1500, + description: 'Updated description for venue 2', + imageUrl: null, + name: 'Updated Venue 2', + organization: { + __typename: 'Organization', + _id: 'orgId', + }, + __typename: 'Venue', + }, + { + _id: 'venue3', + name: 'Venue with a name longer than 25 characters that should be truncated', + description: + 'Venue description that should be truncated because it is longer than 75 characters', + capacity: 2000, + imageUrl: null, + organization: { + _id: 'orgId', + __typename: 'Organization', + }, + __typename: 'Venue', + }, + ], + }, + }, + }, + { + request: { + query: VENUE_LIST, + variables: { + orgId: 'orgId', + orderBy: 'capacity_DESC', + where: { + name_starts_with: '', + description_starts_with: undefined, + }, + }, + }, + result: { + data: { + getVenueByOrgId: [ + { + _id: 'venue3', + name: 'Venue with a name longer than 25 characters that should be truncated', + description: + 'Venue description that should be truncated because it is longer than 75 characters', + capacity: 2000, + imageUrl: null, + organization: { + _id: 'orgId', + __typename: 'Organization', + }, + __typename: 'Venue', + }, + { + _id: 'venue2', + capacity: 1500, + description: 'Updated description for venue 2', + imageUrl: null, + name: 'Updated Venue 2', + organization: { + __typename: 'Organization', + _id: 'orgId', + }, + __typename: 'Venue', + }, + { + _id: 'venue1', + capacity: 1000, + description: 'Updated description for venue 1', + imageUrl: null, + name: 'Updated Venue 1', + organization: { + __typename: 'Organization', + _id: 'orgId', + }, + __typename: 'Venue', + }, + ], + }, + }, + }, + { + request: { + query: VENUE_LIST, + variables: { + orgId: 'orgId', + orderBy: 'capacity_DESC', + where: { + name_starts_with: 'Updated Venue 1', + description_starts_with: undefined, + }, + }, + }, + result: { + data: { + getVenueByOrgId: [ + { + _id: 'venue1', + capacity: 1000, + description: 'Updated description for venue 1', + imageUrl: null, + name: 'Updated Venue 1', + organization: { + __typename: 'Organization', + _id: 'orgId', + }, + __typename: 'Venue', + }, + ], + }, + }, + }, + { + request: { + query: VENUE_LIST, + variables: { + orgId: 'orgId', + orderBy: 'capacity_DESC', + where: { + name_starts_with: undefined, + description_starts_with: 'Updated description for venue 1', + }, + }, + }, + result: { + data: { + getVenueByOrgId: [ + { + _id: 'venue1', + capacity: 1000, + description: 'Updated description for venue 1', + imageUrl: null, + name: 'Updated Venue 1', + organization: { + __typename: 'Organization', + _id: 'orgId', + }, + __typename: 'Venue', + }, + ], + }, + }, + }, + { + request: { + query: DELETE_VENUE_MUTATION, + variables: { + id: 'venue1', + }, + }, + result: { + data: { + deleteVenue: { + _id: 'venue1', + __typename: 'Venue', + }, + }, + }, + }, + { + request: { + query: DELETE_VENUE_MUTATION, + variables: { + id: 'venue2', + }, + }, + result: { + data: { + deleteVenue: { + _id: 'venue2', + __typename: 'Venue', + }, + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }, +})); + +const renderOrganizationVenue = (link: ApolloLink): RenderResult => { + return render( + + + + + + } + /> + paramsError} + /> + + + + + , + ); +}; + +describe('OrganizationVenue with missing orgId', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: undefined }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + test('Redirect to /orglist when orgId is falsy/undefined', async () => { + render( + + + + + + } /> + } + /> + + + + + , + ); + + await waitFor(() => { + const paramsError = screen.getByTestId('paramsError'); + expect(paramsError).toBeInTheDocument(); + }); + }); +}); + +describe('Organisation Venues', () => { + global.alert = jest.fn(); + + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('searches the venue list correctly by Name', async () => { + renderOrganizationVenue(link); + await wait(); + + fireEvent.click(screen.getByTestId('searchByDrpdwn')); + fireEvent.click(screen.getByTestId('name')); + + const searchInput = screen.getByTestId('searchBy'); + fireEvent.change(searchInput, { + target: { value: 'Updated Venue 1' }, + }); + await waitFor(() => { + expect(screen.getByTestId('venue-item1')).toBeInTheDocument(); + expect(screen.queryByTestId('venue-item2')).not.toBeInTheDocument(); + }); + }); + + test('searches the venue list correctly by Description', async () => { + renderOrganizationVenue(link); + await waitFor(() => + expect(screen.getByTestId('orgvenueslist')).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByTestId('searchByDrpdwn')); + fireEvent.click(screen.getByTestId('desc')); + + const searchInput = screen.getByTestId('searchBy'); + fireEvent.change(searchInput, { + target: { value: 'Updated description for venue 1' }, + }); + + await waitFor(() => { + expect(screen.getByTestId('venue-item1')).toBeInTheDocument(); + expect(screen.queryByTestId('venue-item2')).not.toBeInTheDocument(); + }); + }); + + test('sorts the venue list by lowest capacity correctly', async () => { + renderOrganizationVenue(link); + await waitFor(() => + expect(screen.getByTestId('orgvenueslist')).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByTestId('sortVenues')); + fireEvent.click(screen.getByTestId('lowest')); + await waitFor(() => { + expect(screen.getByTestId('venue-item1')).toHaveTextContent( + /Updated Venue 1/i, + ); + expect(screen.getByTestId('venue-item2')).toHaveTextContent( + /Updated Venue 2/i, + ); + }); + }); + + test('sorts the venue list by highest capacity correctly', async () => { + renderOrganizationVenue(link); + await waitFor(() => + expect(screen.getByTestId('orgvenueslist')).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByTestId('sortVenues')); + fireEvent.click(screen.getByTestId('highest')); + await waitFor(() => { + expect(screen.getByTestId('venue-item1')).toHaveTextContent( + /Venue with a name longer .../i, + ); + expect(screen.getByTestId('venue-item2')).toHaveTextContent( + /Updated Venue 2/i, + ); + }); + }); + + test('renders venue name with ellipsis if name is longer than 25 characters', async () => { + renderOrganizationVenue(link); + await waitFor(() => + expect(screen.getByTestId('orgvenueslist')).toBeInTheDocument(), + ); + + const venue = screen.getByTestId('venue-item1'); + expect(venue).toHaveTextContent(/Venue with a name longer .../i); + }); + + test('renders full venue name if name is less than or equal to 25 characters', async () => { + renderOrganizationVenue(link); + await waitFor(() => + expect(screen.getByTestId('orgvenueslist')).toBeInTheDocument(), + ); + + const venueName = screen.getByTestId('venue-item3'); + expect(venueName).toHaveTextContent('Updated Venue 1'); + }); + + test('renders venue description with ellipsis if description is longer than 75 characters', async () => { + renderOrganizationVenue(link); + await waitFor(() => + expect(screen.getByTestId('orgvenueslist')).toBeInTheDocument(), + ); + + const venue = screen.getByTestId('venue-item1'); + expect(venue).toHaveTextContent( + 'Venue description that should be truncated because it is longer than 75 cha...', + ); + }); + + test('renders full venue description if description is less than or equal to 75 characters', async () => { + renderOrganizationVenue(link); + await waitFor(() => + expect(screen.getByTestId('orgvenueslist')).toBeInTheDocument(), + ); + + const venue = screen.getByTestId('venue-item3'); + expect(venue).toHaveTextContent('Updated description for venue 1'); + }); + + test('Render modal to edit venue', async () => { + renderOrganizationVenue(link); + await waitFor(() => + expect(screen.getByTestId('orgvenueslist')).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByTestId('updateVenueBtn1')); + await waitFor(() => { + expect(screen.getByTestId('venueForm')).toBeInTheDocument(); + }); + }); + + test('Render Modal to add event', async () => { + renderOrganizationVenue(link); + await waitFor(() => + expect(screen.getByTestId('orgvenueslist')).toBeInTheDocument(), + ); + + fireEvent.click(screen.getByTestId('createVenueBtn')); + await waitFor(() => { + expect(screen.getByTestId('venueForm')).toBeInTheDocument(); + }); + }); + + test('calls handleDelete when delete button is clicked', async () => { + renderOrganizationVenue(link); + await waitFor(() => + expect(screen.getByTestId('orgvenueslist')).toBeInTheDocument(), + ); + + const deleteButton = screen.getByTestId('deleteVenueBtn3'); + fireEvent.click(deleteButton); + await wait(); + await waitFor(() => { + const deletedVenue = screen.queryByTestId('venue-item3'); + expect(deletedVenue).not.toHaveTextContent(/Updated Venue 2/i); + }); + }); + + test('displays loader when data is loading', () => { + renderOrganizationVenue(link); + expect(screen.getByTestId('spinner-wrapper')).toBeInTheDocument(); + }); + + test('renders without crashing', async () => { + renderOrganizationVenue(link); + waitFor(() => { + expect(screen.findByTestId('orgvenueslist')).toBeInTheDocument(); + }); + }); + + test('renders the venue list correctly', async () => { + renderOrganizationVenue(link); + waitFor(() => { + expect(screen.getByTestId('venueRow2')).toBeInTheDocument(); + expect(screen.getByTestId('venueRow1')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/screens/OrganizationVenues/OrganizationVenues.tsx b/src/screens/OrganizationVenues/OrganizationVenues.tsx new file mode 100644 index 0000000000..c2c3afe7e4 --- /dev/null +++ b/src/screens/OrganizationVenues/OrganizationVenues.tsx @@ -0,0 +1,286 @@ +import React, { useEffect, useState } from 'react'; +import Button from 'react-bootstrap/Button'; +import { useTranslation } from 'react-i18next'; +import styles from './OrganizationVenues.module.css'; +import { errorHandler } from 'utils/errorHandler'; +import { useMutation, useQuery } from '@apollo/client'; +import Col from 'react-bootstrap/Col'; +import { VENUE_LIST } from 'GraphQl/Queries/OrganizationQueries'; +import Loader from 'components/Loader/Loader'; +import { Navigate, useParams } from 'react-router-dom'; +import VenueModal from 'components/Venues/VenueModal'; +import { Dropdown, Form } from 'react-bootstrap'; +import { Search, Sort } from '@mui/icons-material'; +import { DELETE_VENUE_MUTATION } from 'GraphQl/Mutations/VenueMutations'; +import type { InterfaceQueryVenueListItem } from 'utils/interfaces'; +import VenueCard from 'components/Venues/VenueCard'; + +/** + * Component to manage and display the list of organization venues. + * Handles searching, sorting, and CRUD operations for venues. + */ +function organizationVenues(): JSX.Element { + // Translation hooks for i18n support + const { t } = useTranslation('translation', { + keyPrefix: 'organizationVenues', + }); + const { t: tCommon } = useTranslation('common'); + + // Setting the document title using the translation hook + document.title = t('title'); + + // State hooks for managing component state + const [venueModal, setVenueModal] = useState(false); + const [venueModalMode, setVenueModalMode] = useState<'edit' | 'create'>( + 'create', + ); + const [searchTerm, setSearchTerm] = useState(''); + const [searchBy, setSearchBy] = useState<'name' | 'desc'>('name'); + const [sortOrder, setSortOrder] = useState<'highest' | 'lowest'>('highest'); + const [editVenueData, setEditVenueData] = + useState(null); + const [venues, setVenues] = useState([]); + + // Getting the organization ID from the URL parameters + const { orgId } = useParams(); + if (!orgId) { + return ; + } + + // GraphQL query for fetching venue data + const { + data: venueData, + loading: venueLoading, + error: venueError, + refetch: venueRefetch, + } = useQuery(VENUE_LIST, { + variables: { + orgId: orgId, + orderBy: sortOrder === 'highest' ? 'capacity_DESC' : 'capacity_ASC', + where: { + name_starts_with: searchBy === 'name' ? searchTerm : undefined, + description_starts_with: searchBy === 'desc' ? searchTerm : undefined, + }, + }, + }); + + // GraphQL mutation for deleting a venue + const [deleteVenue] = useMutation(DELETE_VENUE_MUTATION); + + /** + * Handles the deletion of a venue by ID. + * @param venueId - The ID of the venue to delete. + */ + const handleDelete = async (venueId: string): Promise => { + try { + await deleteVenue({ + variables: { id: venueId }, + }); + venueRefetch(); + } catch (error) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }; + + /** + * Updates the search term state when the user types in the search input. + * @param event - The input change event. + */ + const handleSearchChange = ( + event: React.ChangeEvent, + ): void => { + setSearchTerm(event.target.value); + }; + + /** + * Updates the sort order state when the user selects a sort option. + * @param order - The order to sort venues by (highest or lowest capacity). + */ + const handleSortChange = (order: 'highest' | 'lowest'): void => { + setSortOrder(order); + }; + + /** + * Toggles the visibility of the venue modal. + */ + const toggleVenueModal = (): void => { + setVenueModal(!venueModal); + }; + + /** + * Shows the edit venue modal with the selected venue data. + * @param venueItem - The venue data to edit. + */ + const showEditVenueModal = (venueItem: InterfaceQueryVenueListItem): void => { + setVenueModalMode('edit'); + setEditVenueData(venueItem); + toggleVenueModal(); + }; + + /** + * Shows the create venue modal. + */ + const showCreateVenueModal = (): void => { + setVenueModalMode('create'); + setEditVenueData(null); + toggleVenueModal(); + }; + + // Error handling for venue data fetch + /* istanbul ignore next */ + if (venueError) { + errorHandler(t, venueError); + } + + // Updating venues state when venue data changes + useEffect(() => { + if (venueData && venueData.getVenueByOrgId) { + setVenues(venueData.getVenueByOrgId); + } + }, [venueData]); + + return ( + <> +
    +
    + + +
    +
    +
    + + +
    + +
    +
    + + +
    + {venueLoading ? ( + <> + + + ) : ( +
    + {venues.length ? ( + venues.map( + (venueItem: InterfaceQueryVenueListItem, index: number) => ( + + ), + ) + ) : ( +
    {t('noVenues')}
    + )} +
    + )} +
    + + + + ); +} + +export default organizationVenues; diff --git a/src/screens/PageNotFound/PageNotFound.module.css b/src/screens/PageNotFound/PageNotFound.module.css new file mode 100644 index 0000000000..3c1b9a3413 --- /dev/null +++ b/src/screens/PageNotFound/PageNotFound.module.css @@ -0,0 +1,109 @@ +.notfound { + position: relative; + bottom: 20px; +} + +.notfound h3 { + font-family: 'Roboto', sans-serif; + font-weight: normal; + letter-spacing: 1px; +} + +.notfound .brand span { + margin-top: 50px; + font-size: 40px; +} +.notfound .brand h3 { + font-weight: 300; + margin: 10px 0 0 0; +} +.notfound h1.head { + font-size: 250px; + font-weight: 900; + color: #31bb6b; + letter-spacing: 25px; + margin: 10px 0 0 0; +} +.notfound h1.head span { + position: relative; + display: inline-block; +} +.notfound h1.head span:before, +.notfound h1.head span:after { + position: absolute; + top: 50%; + width: 50%; + height: 1px; + background: #fff; + content: ''; +} +.notfound h1.head span:before { + left: -55%; +} +.notfound h1.head span:after { + right: -55%; +} + +@media (max-width: 1024px) { + .notfound h1.head { + font-size: 200px; + letter-spacing: 25px; + } +} + +@media (max-width: 768px) { + .notfound h1.head { + font-size: 150px; + letter-spacing: 25px; + } +} + +@media (max-width: 640px) { + .notfound h1.head { + font-size: 150px; + letter-spacing: 0; + } +} + +@media (max-width: 480px) { + .notfound .brand h3 { + font-size: 20px; + } + .notfound h1.head { + font-size: 130px; + letter-spacing: 0; + } + .notfound h1.head span:before, + .notfound h1.head span:after { + width: 40%; + } + .notfound h1.head span:before { + left: -45%; + } + .notfound h1.head span:after { + right: -45%; + } + .notfound p { + font-size: 18px; + } +} + +@media (max-width: 320px) { + .notfound .brand h3 { + font-size: 16px; + } + .notfound h1.head { + font-size: 100px; + letter-spacing: 0; + } + .notfound h1.head span:before, + .notfound h1.head span:after { + width: 25%; + } + .notfound h1.head span:before { + left: -30%; + } + .notfound h1.head span:after { + right: -30%; + } +} diff --git a/src/screens/PageNotFound/PageNotFound.test.tsx b/src/screens/PageNotFound/PageNotFound.test.tsx new file mode 100644 index 0000000000..501d9f7ef3 --- /dev/null +++ b/src/screens/PageNotFound/PageNotFound.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { I18nextProvider } from 'react-i18next'; + +import { store } from 'state/store'; +import PageNotFound from './PageNotFound'; +import i18nForTest from 'utils/i18nForTest'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem } = useLocalStorage(); + +describe('Testing Page not found component', () => { + test('Component should be rendered properly for User', () => { + //setItem('AdminFor', undefined); + render( + + + + + + + , + ); + + expect(screen.getByText(/Talawa User/i)).toBeTruthy(); + expect(screen.getByText(/404/i)).toBeTruthy(); + expect( + screen.getByText(/Oops! The Page you requested was not found!/i), + ).toBeTruthy(); + expect(screen.getByText(/Back to Home/i)).toBeTruthy(); + }); + + test('Component should be rendered properly for ADMIN or SUPERADMIN', () => { + setItem('AdminFor', [ + { + _id: '6537904485008f171cf29924', + __typename: 'Organization', + }, + ]); + render( + + + + + + + , + ); + + expect(screen.getByText(/Talawa Admin Portal/i)).toBeTruthy(); + expect(screen.getByText(/404/i)).toBeTruthy(); + expect( + screen.getByText(/Oops! The Page you requested was not found!/i), + ).toBeTruthy(); + expect(screen.getByText(/Back to Home/i)).toBeTruthy(); + }); +}); diff --git a/src/screens/PageNotFound/PageNotFound.tsx b/src/screens/PageNotFound/PageNotFound.tsx new file mode 100644 index 0000000000..037aeecbab --- /dev/null +++ b/src/screens/PageNotFound/PageNotFound.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import useLocalStorage from 'utils/useLocalstorage'; + +import styles from './PageNotFound.module.css'; +import Logo from 'assets/images/talawa-logo-600x600.png'; + +/** + * The `PageNotFound` component displays a 404 error page when a user navigates to a non-existent route. + * It shows a message indicating that the page was not found and provides a link to redirect users back + * to the appropriate home page based on their admin status. + * + */ +const PageNotFound = (): JSX.Element => { + // Translation hooks for internationalization + const { t } = useTranslation('translation', { + keyPrefix: 'pageNotFound', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Set the document title to the translated title for the 404 page + document.title = t('title'); + + // Get the admin status from local storage + const { getItem } = useLocalStorage(); + const adminFor = getItem('AdminFor'); + + return ( +
    +
    +
    + Logo + {/* Display a message based on admin status */} + {adminFor != undefined ? ( +

    + {tCommon('talawaAdminPortal')} +

    + ) : ( +

    {t('talawaUser')}

    + )} +
    + {/* Display the 404 error code */} +

    + {t('404')} +

    + {/* Display a not found message */} +

    {tErrors('notFoundMsg')}

    + {/* Provide a link to redirect users based on admin status */} + {adminFor != undefined ? ( + + {t('backToHome')} + + ) : ( + + {t('backToHome')} + + )} +
    +
    + ); +}; + +export default PageNotFound; diff --git a/src/screens/Requests/Requests.module.css b/src/screens/Requests/Requests.module.css new file mode 100644 index 0000000000..b23869c8d0 --- /dev/null +++ b/src/screens/Requests/Requests.module.css @@ -0,0 +1,120 @@ +.btnsContainer { + display: flex; + margin: 2.5rem 0 2.5rem 0; +} + +.btnsContainer .btnsBlock { + display: flex; +} + +.btnsContainer .btnsBlock button { + margin-left: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.btnsContainer .inputContainer { + flex: 1; + position: relative; +} + +.btnsContainer .input { + width: 50%; + position: relative; +} + +.btnsContainer input { + box-sizing: border-box; + background: #fcfcfc; + border: 1px solid #dddddd; + box-shadow: 5px 5px 4px rgba(49, 187, 107, 0.12); + border-radius: 8px; +} + +.btnsContainer .inputContainer button { + width: 55px; + height: 55px; +} + +.listBox { + width: 100%; + flex: 1; +} + +.listTable { + width: 100%; + box-sizing: border-box; + background: #ffffff; + border: 1px solid #0000001f; + border-radius: 24px; +} + +.listBox .customTable { + margin-bottom: 0%; +} + +.requestsTable thead th { + font-size: 20px; + font-weight: 400; + line-height: 24px; + letter-spacing: 0em; + text-align: left; + color: #000000; + border-bottom: 1px solid #dddddd; + padding: 1.5rem; +} + +.notFound { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +@media (max-width: 1020px) { + .btnsContainer { + flex-direction: column; + margin: 1.5rem 0; + } + .btnsContainer .input { + width: 100%; + } + .btnsContainer .btnsBlock { + margin: 1.5rem 0 0 0; + justify-content: space-between; + } + + .btnsContainer .btnsBlock button { + margin: 0; + } + + .btnsContainer .btnsBlock div button { + margin-right: 1.5rem; + } +} + +/* For mobile devices */ + +@media (max-width: 520px) { + .btnsContainer { + margin-bottom: 0; + } + + .btnsContainer .btnsBlock { + display: block; + margin-top: 1rem; + margin-right: 0; + } + + .btnsContainer .btnsBlock div { + flex: 1; + } + + .btnsContainer .btnsBlock button { + margin-bottom: 1rem; + margin-right: 0; + width: 100%; + } +} diff --git a/src/screens/Requests/Requests.test.tsx b/src/screens/Requests/Requests.test.tsx new file mode 100644 index 0000000000..4606fdae08 --- /dev/null +++ b/src/screens/Requests/Requests.test.tsx @@ -0,0 +1,292 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { render, screen } from '@testing-library/react'; +import 'jest-localstorage-mock'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { ToastContainer } from 'react-toastify'; +import userEvent from '@testing-library/user-event'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18nForTest from 'utils/i18nForTest'; +import Requests from './Requests'; +import { + EMPTY_MOCKS, + MOCKS_WITH_ERROR, + MOCKS, + MOCKS2, + EMPTY_REQUEST_MOCKS, + MOCKS3, + MOCKS4, +} from './RequestsMocks'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem, removeItem } = useLocalStorage(); + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(EMPTY_MOCKS, true); +const link3 = new StaticMockLink(EMPTY_REQUEST_MOCKS, true); +const link4 = new StaticMockLink(MOCKS2, true); +const link5 = new StaticMockLink(MOCKS_WITH_ERROR, true); +const link6 = new StaticMockLink(MOCKS3, true); +const link7 = new StaticMockLink(MOCKS4, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +beforeEach(() => { + setItem('id', 'user1'); + setItem('AdminFor', [{ _id: 'org1', __typename: 'Organization' }]); + setItem('SuperAdmin', false); +}); + +afterEach(() => { + localStorage.clear(); +}); + +describe('Testing Requests screen', () => { + test('Component should be rendered properly', async () => { + const loadMoreRequests = jest.fn(); + render( + + + + + + + + + , + ); + + await wait(); + expect(screen.getByTestId('testComp')).toBeInTheDocument(); + expect(screen.getByText('Scott Tony')).toBeInTheDocument(); + }); + + test(`Component should be rendered properly when user is not Admin + and or userId does not exists in localstorage`, async () => { + setItem('id', ''); + removeItem('AdminFor'); + removeItem('SuperAdmin'); + render( + + + + + + + + + , + ); + + await wait(); + }); + + test('Component should be rendered properly when user is Admin', async () => { + render( + + + + + + + + + , + ); + + await wait(); + }); + + test('Redirecting on error', async () => { + setItem('SuperAdmin', true); + render( + + + + + + + + + , + ); + + await wait(); + expect(window.location.href).toEqual('http://localhost/'); + }); + + test('Testing Search requests functionality', async () => { + render( + + + + + + + + + , + ); + + await wait(); + const searchBtn = screen.getByTestId('searchButton'); + const search1 = 'John'; + userEvent.type(screen.getByTestId(/searchByName/i), search1); + userEvent.click(searchBtn); + await wait(); + + const search2 = 'Pete{backspace}{backspace}{backspace}{backspace}'; + userEvent.type(screen.getByTestId(/searchByName/i), search2); + + const search3 = + 'John{backspace}{backspace}{backspace}{backspace}Sam{backspace}{backspace}{backspace}'; + userEvent.type(screen.getByTestId(/searchByName/i), search3); + + const search4 = 'Sam{backspace}{backspace}P{backspace}'; + userEvent.type(screen.getByTestId(/searchByName/i), search4); + + const search5 = 'Xe'; + userEvent.type(screen.getByTestId(/searchByName/i), search5); + userEvent.clear(screen.getByTestId(/searchByName/i)); + userEvent.type(screen.getByTestId(/searchByName/i), ''); + userEvent.click(searchBtn); + await wait(); + }); + + test('Testing search not found', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + const search = 'hello{enter}'; + await act(() => + userEvent.type(screen.getByTestId(/searchByName/i), search), + ); + }); + + test('Testing Request data is not present', async () => { + render( + + + + + + + + + , + ); + + await wait(); + expect(screen.getByText(/No Membership Requests Found/i)).toBeTruthy(); + }); + + test('Should render warning alert when there are no organizations', async () => { + render( + + + + + + + + + + , + ); + + await wait(200); + expect(screen.queryByText('Organizations Not Found')).toBeInTheDocument(); + expect( + screen.queryByText('Please create an organization through dashboard'), + ).toBeInTheDocument(); + }); + + test('Should not render warning alert when there are organizations present', async () => { + const { container } = render( + + + + + + + + + + , + ); + + await wait(); + + expect(container.textContent).not.toMatch( + 'Organizations not found, please create an organization through dashboard', + ); + }); + + test('Should render properly when there are no organizations present in requestsData', async () => { + render( + + + + + + + + + + , + ); + + await wait(); + }); + + test('check for rerendering', async () => { + const { rerender } = render( + + + + + + + + + + , + ); + + await wait(); + rerender( + + + + + + + + + + , + ); + await wait(); + }); +}); diff --git a/src/screens/Requests/Requests.tsx b/src/screens/Requests/Requests.tsx new file mode 100644 index 0000000000..38bc51195e --- /dev/null +++ b/src/screens/Requests/Requests.tsx @@ -0,0 +1,343 @@ +import { useQuery } from '@apollo/client'; +import React, { useEffect, useState } from 'react'; +import { Form, Table } from 'react-bootstrap'; +import Button from 'react-bootstrap/Button'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import { Search } from '@mui/icons-material'; +import { + MEMBERSHIP_REQUEST, + ORGANIZATION_CONNECTION_LIST, +} from 'GraphQl/Queries/Queries'; +import TableLoader from 'components/TableLoader/TableLoader'; +import RequestsTableItem from 'components/RequestsTableItem/RequestsTableItem'; +import InfiniteScroll from 'react-infinite-scroll-component'; +import type { InterfaceQueryMembershipRequestsListItem } from 'utils/interfaces'; +import styles from './Requests.module.css'; +import useLocalStorage from 'utils/useLocalstorage'; +import { useParams } from 'react-router-dom'; + +interface InterfaceRequestsListItem { + _id: string; + user: { + firstName: string; + lastName: string; + email: string; + }; +} + +/** + * The `Requests` component fetches and displays a paginated list of membership requests + * for an organization, with functionality for searching, filtering, and infinite scrolling. + * + */ +const Requests = (): JSX.Element => { + // Translation hooks for internationalization + const { t } = useTranslation('translation', { keyPrefix: 'requests' }); + const { t: tCommon } = useTranslation('common'); + + // Set the document title to the translated title for the requests page + document.title = t('title'); + + // Hook for managing local storage + const { getItem } = useLocalStorage(); + + // Define constants and state variables + const perPageResult = 8; + const [isLoading, setIsLoading] = useState(true); + const [hasMore, setHasMore] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [searchByName, setSearchByName] = useState(''); + const userRole = getItem('SuperAdmin') + ? 'SUPERADMIN' + : getItem('AdminFor') + ? 'ADMIN' + : 'USER'; + const { orgId = '' } = useParams(); + const organizationId = orgId; + + // Query to fetch membership requests + const { data, loading, fetchMore, refetch } = useQuery(MEMBERSHIP_REQUEST, { + variables: { + id: organizationId, + first: perPageResult, + skip: 0, + firstName_contains: '', + }, + notifyOnNetworkStatusChange: true, + }); + + // Query to fetch the list of organizations + const { data: orgsData } = useQuery(ORGANIZATION_CONNECTION_LIST); + const [displayedRequests, setDisplayedRequests] = useState( + data?.organizations[0]?.membershipRequests || [], + ); + + // Manage loading more state + useEffect(() => { + if (!data) { + return; + } + + const membershipRequests = data.organizations[0].membershipRequests; + + if (membershipRequests.length < perPageResult) { + setHasMore(false); + } + + setDisplayedRequests(membershipRequests); + }, [data]); + + // Clear the search field when the component is unmounted + useEffect(() => { + return () => { + setSearchByName(''); + }; + }, []); + + // Show a warning if there are no organizations + useEffect(() => { + if (!orgsData) { + return; + } + + if (orgsData.organizationsConnection.length === 0) { + toast.warning(t('noOrgError') as string); + } + }, [orgsData]); + + // Redirect to orgList page if the user is not an admin + useEffect(() => { + if (userRole != 'ADMIN' && userRole != 'SUPERADMIN') { + window.location.assign('/orglist'); + } + }, []); + + // Manage the loading state + useEffect(() => { + if (loading && isLoadingMore == false) { + setIsLoading(true); + } else { + setIsLoading(false); + } + }, [loading]); + + /** + * Handles the search input change and refetches the data based on the search value. + * + * @param value - The search term entered by the user. + */ + const handleSearch = (value: string): void => { + setSearchByName(value); + if (value === '') { + resetAndRefetch(); + return; + } + refetch({ + id: organizationId, + firstName_contains: value, + // Later on we can add several search and filter options + }); + }; + + /** + * Handles search input when the Enter key is pressed. + * + * @param e - The keyboard event. + */ + const handleSearchByEnter = ( + e: React.KeyboardEvent, + ): void => { + if (e.key === 'Enter') { + const { value } = e.currentTarget; + handleSearch(value); + } + }; + + /** + * Handles the search button click to trigger the search. + */ + const handleSearchByBtnClick = (): void => { + const inputElement = document.getElementById( + 'searchRequests', + ) as HTMLInputElement; + const inputValue = inputElement?.value || ''; + handleSearch(inputValue); + }; + + /** + * Resets search and refetches the data. + */ + const resetAndRefetch = (): void => { + refetch({ + first: perPageResult, + skip: 0, + firstName_contains: '', + }); + setHasMore(true); + }; + + /** + * Loads more requests when scrolling to the bottom of the page. + */ + /* istanbul ignore next */ + const loadMoreRequests = (): void => { + setIsLoadingMore(true); + fetchMore({ + variables: { + id: organizationId, + skip: data?.organizations?.[0]?.membershipRequests?.length || 0, + firstName_contains: searchByName, + }, + updateQuery: ( + prev: InterfaceQueryMembershipRequestsListItem | undefined, + { + fetchMoreResult, + }: { + fetchMoreResult: InterfaceQueryMembershipRequestsListItem | undefined; + }, + ): InterfaceQueryMembershipRequestsListItem | undefined => { + setIsLoadingMore(false); + if (!fetchMoreResult) return prev; + const newMembershipRequests = + fetchMoreResult.organizations[0].membershipRequests || []; + if (newMembershipRequests.length < perPageResult) { + setHasMore(false); + } + return { + organizations: [ + { + _id: organizationId, + membershipRequests: [ + ...(prev?.organizations[0].membershipRequests || []), + ...newMembershipRequests, + ], + }, + ], + }; + }, + }); + }; + + // Header titles for the table + const headerTitles: string[] = [ + t('sl_no'), + tCommon('name'), + tCommon('email'), + t('accept'), + t('reject'), + ]; + + return ( + <> + {/* Buttons Container */} +
    +
    +
    + + +
    +
    +
    + {!isLoading && orgsData?.organizationsConnection.length === 0 ? ( +
    +

    {t('noOrgErrorTitle')}

    +
    {t('noOrgErrorDescription')}
    +
    + ) : !isLoading && + data && + displayedRequests.length === 0 && + searchByName.length > 0 ? ( +
    +

    + {tCommon('noResultsFoundFor')} "{searchByName}" +

    +
    + ) : !isLoading && data && displayedRequests.length === 0 ? ( +
    +

    {t('noRequestsFound')}

    +
    + ) : ( +
    + {isLoading ? ( + + ) : ( + + } + hasMore={hasMore} + className={styles.listTable} + data-testid="requests-list" + endMessage={ +
    +
    {tCommon('endOfResults')}
    +
    + } + > + + + + {headerTitles.map((title: string, index: number) => { + return ( + + ); + })} + + + + {data && + displayedRequests.map( + (request: InterfaceRequestsListItem, index: number) => { + return ( + + ); + }, + )} + +
    + {title} +
    +
    + )} +
    + )} + + ); +}; + +export default Requests; diff --git a/src/screens/Requests/RequestsMocks.ts b/src/screens/Requests/RequestsMocks.ts new file mode 100644 index 0000000000..6dc22dd58e --- /dev/null +++ b/src/screens/Requests/RequestsMocks.ts @@ -0,0 +1,573 @@ +import { + MEMBERSHIP_REQUEST, + ORGANIZATION_CONNECTION_LIST, +} from 'GraphQl/Queries/Queries'; + +export const EMPTY_REQUEST_MOCKS = [ + { + request: { + query: ORGANIZATION_CONNECTION_LIST, + }, + result: { + data: { + organizationsConnection: [ + { + _id: 'org1', + image: null, + creator: { + firstName: 'John', + lastName: 'Doe', + }, + name: 'Palisadoes', + members: [ + { + _id: 'user1', + }, + ], + admins: [ + { + _id: 'user1', + }, + ], + createdAt: '09/11/2001', + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + }, + ], + }, + }, + }, + { + request: { + query: MEMBERSHIP_REQUEST, + variables: { + id: '', + skip: 0, + first: 8, + firstName_contains: '', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'org1', + membershipRequests: [], + }, + ], + }, + }, + }, +]; + +export const MOCKS = [ + { + request: { + query: ORGANIZATION_CONNECTION_LIST, + }, + result: { + data: { + organizationsConnection: [ + { + _id: 'org1', + image: null, + creator: { + firstName: 'John', + lastName: 'Doe', + }, + name: 'Palisadoes', + members: [ + { + _id: 'user1', + }, + ], + admins: [ + { + _id: 'user1', + }, + ], + createdAt: '09/11/2001', + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + }, + ], + }, + }, + }, + { + request: { + query: MEMBERSHIP_REQUEST, + variables: { + id: '', + skip: 0, + first: 8, + firstName_contains: '', + }, + }, + result: { + data: { + organizations: [ + { + _id: '', + membershipRequests: [ + { + _id: '1', + user: { + _id: 'user2', + firstName: 'Scott', + lastName: 'Tony', + email: 'testuser3@example.com', + }, + }, + { + _id: '2', + user: { + _id: 'user3', + firstName: 'Teresa', + lastName: 'Bradley', + email: 'testuser4@example.com', + }, + }, + ], + }, + ], + }, + }, + }, +]; + +export const MOCKS4 = [ + { + request: { + query: ORGANIZATION_CONNECTION_LIST, + }, + result: { + data: { + organizationsConnection: [ + { + _id: 'org1', + image: null, + creator: { + firstName: 'John', + lastName: 'Doe', + }, + name: 'Palisadoes', + members: [ + { + _id: 'user1', + }, + ], + admins: [ + { + _id: 'user1', + }, + ], + createdAt: '09/11/2001', + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + }, + ], + }, + }, + }, + { + request: { + query: MEMBERSHIP_REQUEST, + variables: { + id: '', + skip: 0, + first: 8, + firstName_contains: '', + }, + }, + result: { + data: { + organizations: [ + { + _id: '', + membershipRequests: [ + { + _id: '1', + user: { + _id: 'user2', + firstName: 'Scott', + lastName: 'Tony', + email: 'testuser3@example.com', + }, + }, + { + _id: '2', + user: { + _id: 'user3', + firstName: 'Teresa', + lastName: 'Bradley', + email: 'testuser4@example.com', + }, + }, + { + _id: '3', + user: { + _id: 'user4', + firstName: 'Jesse', + lastName: 'Hart', + email: 'testuser5@example.com', + }, + }, + { + _id: '4', + user: { + _id: 'user5', + firstName: 'Lena', + lastName: 'Mcdonald', + email: 'testuser6@example.com', + }, + }, + { + _id: '5', + user: { + _id: 'user6', + firstName: 'David', + lastName: 'Smith', + email: 'testuser7@example.com', + }, + }, + { + _id: '6', + user: { + _id: 'user7', + firstName: 'Emily', + lastName: 'Johnson', + email: 'testuser8@example.com', + }, + }, + { + _id: '7', + user: { + _id: 'user8', + firstName: 'Michael', + lastName: 'Davis', + email: 'testuser9@example.com', + }, + }, + { + _id: '8', + user: { + _id: 'user9', + firstName: 'Sarah', + lastName: 'Wilson', + email: 'testuser10@example.com', + }, + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: MEMBERSHIP_REQUEST, + variables: { + id: '', + skip: 8, + first: 16, + firstName_contains: '', + }, + }, + result: { + data: { + organizations: [ + { + _id: '', + membershipRequests: [ + { + _id: '9', + user: { + _id: 'user10', + firstName: 'Daniel', + lastName: 'Brown', + email: 'testuser11@example.com', + }, + }, + { + _id: '10', + user: { + _id: 'user11', + firstName: 'Jessica', + lastName: 'Martinez', + email: 'testuser12@example.com', + }, + }, + { + _id: '11', + user: { + _id: 'user12', + firstName: 'Matthew', + lastName: 'Taylor', + email: 'testuser13@example.com', + }, + }, + { + _id: '12', + user: { + _id: 'user13', + firstName: 'Amanda', + lastName: 'Anderson', + email: 'testuser14@example.com', + }, + }, + { + _id: '13', + user: { + _id: 'user14', + firstName: 'Christopher', + lastName: 'Thomas', + email: 'testuser15@example.com', + }, + }, + { + _id: '14', + user: { + _id: 'user15', + firstName: 'Ashley', + lastName: 'Hernandez', + email: 'testuser16@example.com', + }, + }, + { + _id: '15', + user: { + _id: 'user16', + firstName: 'Andrew', + lastName: 'Young', + email: 'testuser17@example.com', + }, + }, + { + _id: '16', + user: { + _id: 'user17', + firstName: 'Nicole', + lastName: 'Garcia', + email: 'testuser18@example.com', + }, + }, + ], + }, + ], + }, + }, + }, +]; + +export const MOCKS2 = [ + { + request: { + query: ORGANIZATION_CONNECTION_LIST, + }, + result: { + data: { + organizationsConnection: [ + { + _id: 'org1', + image: null, + creator: { + firstName: 'John', + lastName: 'Doe', + }, + name: 'Palisadoes', + members: [ + { + _id: 'user1', + }, + ], + admins: [ + { + _id: 'user1', + }, + ], + createdAt: '09/11/2001', + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + }, + ], + }, + }, + }, + { + request: { + query: MEMBERSHIP_REQUEST, + variables: { + id: 'org1', + skip: 0, + first: 8, + firstName_contains: '', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'org1', + membershipRequests: [ + { + _id: '1', + user: { + _id: 'user2', + firstName: 'Scott', + lastName: 'Tony', + email: 'testuser3@example.com', + }, + }, + ], + }, + ], + }, + }, + }, +]; + +export const MOCKS3 = [ + { + request: { + query: ORGANIZATION_CONNECTION_LIST, + }, + result: { + data: { + organizationsConnection: [ + { + _id: 'org1', + image: null, + creator: { + firstName: 'John', + lastName: 'Doe', + }, + name: 'Palisadoes', + members: [ + { + _id: 'user1', + }, + ], + admins: [ + { + _id: 'user1', + }, + ], + createdAt: '09/11/2001', + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + }, + ], + }, + }, + }, + { + request: { + query: MEMBERSHIP_REQUEST, + variables: { + id: 'org1', + skip: 0, + first: 8, + firstName_contains: '', + }, + }, + result: { + data: { + organizations: [], + }, + }, + }, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: MEMBERSHIP_REQUEST, + variables: { + id: 'org1', + skip: 0, + first: 8, + firstName_contains: '', + }, + }, + result: { + data: { + organizations: [ + { + _id: 'org1', + membershipRequests: [], + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_CONNECTION_LIST, + }, + result: { + data: { + organizationsConnection: [], + }, + }, + }, +]; + +export const MOCKS_WITH_ERROR = [ + { + request: { + query: MEMBERSHIP_REQUEST, + variables: { + first: 0, + skip: 0, + id: '1', + firstName_contains: '', + }, + }, + }, + { + request: { + query: ORGANIZATION_CONNECTION_LIST, + }, + }, +]; diff --git a/src/screens/SubTags/SubTags.module.css b/src/screens/SubTags/SubTags.module.css new file mode 100644 index 0000000000..0a210bdfa4 --- /dev/null +++ b/src/screens/SubTags/SubTags.module.css @@ -0,0 +1,145 @@ +.btnsContainer { + display: flex; + margin: 2rem 0; +} + +.btnsContainer .btnsBlock { + display: flex; + width: max-content; +} + +.btnsContainer .btnsBlock button { + margin-left: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.btnsContainer .input { + flex: 1; + position: relative; + max-width: 60%; + justify-content: space-between; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); +} + +.btnsContainer .input button { + width: 52px; +} + +@media (max-width: 1020px) { + .btnsContainer { + flex-direction: column; + margin: 1.5rem 0; + } + + .btnsContainer .btnsBlock { + margin: 1.5rem 0 0 0; + justify-content: space-between; + } + + .btnsContainer .btnsBlock button { + margin: 0; + } + + .btnsContainer .btnsBlock div button { + margin-right: 1.5rem; + } +} + +/* For mobile devices */ + +@media (max-width: 520px) { + .btnsContainer { + margin-bottom: 0; + } + + .btnsContainer .btnsBlock { + display: block; + margin-top: 1rem; + margin-right: 0; + } + + .btnsContainer .btnsBlock div { + flex: 1; + } + + .btnsContainer .btnsBlock div[title='Sort organizations'] { + margin-right: 0.5rem; + } + + .btnsContainer .btnsBlock button { + margin-bottom: 1rem; + margin-right: 0; + width: 100%; + } +} + +.errorContainer { + min-height: 100vh; +} + +.errorMessage { + margin-top: 25%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.errorIcon { + transform: scale(1.5); + color: var(--bs-danger); + margin-bottom: 1rem; +} + +.tableHeader { + background-color: var(--bs-primary); + color: var(--bs-white); + font-size: 1rem; +} +.rowBackground { + background-color: var(--bs-white); + max-height: 120px; +} + +.subTagsLink { + color: var(--bs-blue); + font-weight: 500; + cursor: pointer; +} + +.subTagsLink i { + visibility: hidden; +} + +.subTagsLink:hover { + font-weight: 600; + text-decoration: underline; +} + +.subTagsLink:hover i { + visibility: visible; +} + +.tagsBreadCrumbs { + color: var(--bs-gray); + cursor: pointer; +} + +.tagsBreadCrumbs:hover { + color: var(--bs-blue); + font-weight: 600; + text-decoration: underline; +} + +.subTagsScrollableDiv { + scrollbar-width: auto; + scrollbar-color: var(--bs-gray-400) var(--bs-white); + + max-height: calc(100vh - 18rem); + overflow: auto; +} diff --git a/src/screens/SubTags/SubTags.test.tsx b/src/screens/SubTags/SubTags.test.tsx new file mode 100644 index 0000000000..145d31109d --- /dev/null +++ b/src/screens/SubTags/SubTags.test.tsx @@ -0,0 +1,369 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import type { RenderResult } from '@testing-library/react'; +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + waitForElementToBeRemoved, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import SubTags from './SubTags'; +import { MOCKS, MOCKS_ERROR_SUB_TAGS } from './SubTagsMocks'; +import { InMemoryCache, type ApolloLink } from '@apollo/client'; + +const translations = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.organizationTags ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(MOCKS_ERROR_SUB_TAGS, true); + +async function wait(ms = 500): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const cache = new InMemoryCache({ + typePolicies: { + Query: { + fields: { + getUserTag: { + merge(existing = {}, incoming) { + const merged = { + ...existing, + ...incoming, + childTags: { + ...existing.childTags, + ...incoming.childTags, + edges: [ + ...(existing.childTags?.edges || []), + ...(incoming.childTags?.edges || []), + ], + }, + }; + + return merged; + }, + }, + }, + }, + }, +}); + +const renderSubTags = (link: ApolloLink): RenderResult => { + return render( + + + + + + } + /> + } + /> + } + /> + + + + + , + ); +}; + +describe('Organisation Tags Page', () => { + beforeEach(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + cache.reset(); + }); + + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + + test('Component loads correctly', async () => { + const { getByText } = renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.addChildTag)).toBeInTheDocument(); + }); + }); + + test('render error component on unsuccessful subtags query', async () => { + const { queryByText } = renderSubTags(link2); + + await wait(); + + await waitFor(() => { + expect(queryByText(translations.addChildTag)).not.toBeInTheDocument(); + }); + }); + + test('opens and closes the create tag modal', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('addSubTagBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('addSubTagBtn')); + + await waitFor(() => { + return expect( + screen.findByTestId('addSubTagModalCloseBtn'), + ).resolves.toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('addSubTagModalCloseBtn')); + + await waitForElementToBeRemoved(() => + screen.queryByTestId('addSubTagModalCloseBtn'), + ); + }); + + test('navigates to manage tag screen after clicking manage tag option', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('manageTagBtn')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('manageTagBtn')[0]); + + await waitFor(() => { + expect(screen.getByTestId('manageTagScreen')).toBeInTheDocument(); + }); + }); + + test('navigates to sub tags screen after clicking on a tag', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('tagName')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('tagName')[0]); + + await waitFor(() => { + expect(screen.getByTestId('addSubTagBtn')).toBeInTheDocument(); + }); + }); + + test('navigates to the different sub tag screen screen after clicking a tag in the breadcrumbs', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getAllByTestId('redirectToSubTags')[0]).toBeInTheDocument(); + }); + userEvent.click(screen.getAllByTestId('redirectToSubTags')[0]); + + await waitFor(() => { + expect(screen.getByTestId('addSubTagBtn')).toBeInTheDocument(); + }); + }); + + test('navigates to organization tags screen screen after clicking tha all tags option in the breadcrumbs', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('allTagsBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('allTagsBtn')); + + await waitFor(() => { + expect(screen.getByTestId('orgtagsScreen')).toBeInTheDocument(); + }); + }); + + test('navigates to manage tags screen for the current tag after clicking tha manageCurrentTag button', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('manageCurrentTagBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('manageCurrentTagBtn')); + + await waitFor(() => { + expect(screen.getByTestId('manageTagScreen')).toBeInTheDocument(); + }); + }); + + test('searchs for tags where the name matches the provided search input', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect( + screen.getByPlaceholderText(translations.searchByName), + ).toBeInTheDocument(); + }); + const input = screen.getByPlaceholderText(translations.searchByName); + fireEvent.change(input, { target: { value: 'searchSubTag' } }); + + // should render the two searched tags from the mock data + // where name starts with "searchUserTag" + await waitFor(() => { + const buttons = screen.getAllByTestId('manageTagBtn'); + expect(buttons.length).toEqual(2); + }); + }); + + test('fetches the tags by the sort order, i.e. latest or oldest first', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect( + screen.getByPlaceholderText(translations.searchByName), + ).toBeInTheDocument(); + }); + const input = screen.getByPlaceholderText(translations.searchByName); + fireEvent.change(input, { target: { value: 'searchSubTag' } }); + + // should render the two searched tags from the mock data + // where name starts with "searchUserTag" + await waitFor(() => { + expect(screen.getAllByTestId('tagName')[0]).toHaveTextContent( + 'searchSubTag 1', + ); + }); + + // now change the sorting order + await waitFor(() => { + expect(screen.getByTestId('sortTags')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('sortTags')); + + await waitFor(() => { + expect(screen.getByTestId('oldest')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('oldest')); + + // returns the tags in reverse order + await waitFor(() => { + expect(screen.getAllByTestId('tagName')[0]).toHaveTextContent( + 'searchSubTag 2', + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('sortTags')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('sortTags')); + + await waitFor(() => { + expect(screen.getByTestId('latest')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('latest')); + + // reverse the order again + await waitFor(() => { + expect(screen.getAllByTestId('tagName')[0]).toHaveTextContent( + 'searchSubTag 1', + ); + }); + }); + + test('Fetches more sub tags with infinite scroll', async () => { + const { getByText } = renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(getByText(translations.addChildTag)).toBeInTheDocument(); + }); + + const subTagsScrollableDiv = screen.getByTestId('subTagsScrollableDiv'); + + // Get the initial number of tags loaded + const initialSubTagsDataLength = + screen.getAllByTestId('manageTagBtn').length; + + // Set scroll position to the bottom + fireEvent.scroll(subTagsScrollableDiv, { + target: { scrollY: subTagsScrollableDiv.scrollHeight }, + }); + + await waitFor(() => { + const finalSubTagsDataLength = + screen.getAllByTestId('manageTagBtn').length; + expect(finalSubTagsDataLength).toBeGreaterThan(initialSubTagsDataLength); + + expect(getByText(translations.addChildTag)).toBeInTheDocument(); + }); + }); + + test('adds a new sub tag to the current tag', async () => { + renderSubTags(link); + + await wait(); + + await waitFor(() => { + expect(screen.getByTestId('addSubTagBtn')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('addSubTagBtn')); + + userEvent.type( + screen.getByPlaceholderText(translations.tagNamePlaceholder), + 'subTag 12', + ); + + userEvent.click(screen.getByTestId('addSubTagSubmitBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith( + translations.tagCreationSuccess, + ); + }); + }); +}); diff --git a/src/screens/SubTags/SubTags.tsx b/src/screens/SubTags/SubTags.tsx new file mode 100644 index 0000000000..930232aaca --- /dev/null +++ b/src/screens/SubTags/SubTags.tsx @@ -0,0 +1,494 @@ +import { useMutation, useQuery } from '@apollo/client'; +import { Search, WarningAmberRounded } from '@mui/icons-material'; +import SortIcon from '@mui/icons-material/Sort'; +import Loader from 'components/Loader/Loader'; +import IconComponent from 'components/IconComponent/IconComponent'; +import { useNavigate, useParams, Link } from 'react-router-dom'; +import type { ChangeEvent } from 'react'; +import React, { useState } from 'react'; +import { Form } from 'react-bootstrap'; +import Button from 'react-bootstrap/Button'; +import Dropdown from 'react-bootstrap/Dropdown'; +import Modal from 'react-bootstrap/Modal'; +import Row from 'react-bootstrap/Row'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; +import type { InterfaceQueryUserTagChildTags } from 'utils/interfaces'; +import styles from '../../style/app.module.css'; +import { DataGrid } from '@mui/x-data-grid'; +import type { + InterfaceOrganizationSubTagsQuery, + SortedByType, +} from 'utils/organizationTagsUtils'; +import { + dataGridStyle, + TAGS_QUERY_DATA_CHUNK_SIZE, +} from 'utils/organizationTagsUtils'; +import type { GridCellParams, GridColDef } from '@mui/x-data-grid'; +import { Stack } from '@mui/material'; +import { CREATE_USER_TAG } from 'GraphQl/Mutations/TagMutations'; +import { USER_TAG_SUB_TAGS } from 'GraphQl/Queries/userTagQueries'; +import InfiniteScroll from 'react-infinite-scroll-component'; +import InfiniteScrollLoader from 'components/InfiniteScrollLoader/InfiniteScrollLoader'; + +/** + * Component that renders the SubTags screen when the app navigates to '/orgtags/:orgId/subtags/:tagId'. + * + * This component does not accept any props and is responsible for displaying + * the content associated with the corresponding route. + */ + +function SubTags(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'organizationTags', + }); + const { t: tCommon } = useTranslation('common'); + + const [addSubTagModalIsOpen, setAddSubTagModalIsOpen] = useState(false); + + const { orgId, tagId: parentTagId } = useParams(); + + const navigate = useNavigate(); + + const [tagName, setTagName] = useState(''); + + const [tagSearchName, setTagSearchName] = useState(''); + const [tagSortOrder, setTagSortOrder] = useState('DESCENDING'); + + const showAddSubTagModal = (): void => { + setAddSubTagModalIsOpen(true); + }; + + const hideAddSubTagModal = (): void => { + setAddSubTagModalIsOpen(false); + setTagName(''); + }; + + const { + data: subTagsData, + error: subTagsError, + loading: subTagsLoading, + refetch: subTagsRefetch, + fetchMore: fetchMoreSubTags, + }: InterfaceOrganizationSubTagsQuery = useQuery(USER_TAG_SUB_TAGS, { + variables: { + id: parentTagId, + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: tagSearchName } }, + sortedBy: { id: tagSortOrder }, + }, + }); + + const loadMoreSubTags = (): void => { + fetchMoreSubTags({ + variables: { + first: TAGS_QUERY_DATA_CHUNK_SIZE, + after: subTagsData?.getChildTags.childTags.pageInfo.endCursor, + }, + updateQuery: ( + prevResult: { getChildTags: InterfaceQueryUserTagChildTags }, + { + fetchMoreResult, + }: { + fetchMoreResult?: { getChildTags: InterfaceQueryUserTagChildTags }; + }, + ) => { + if (!fetchMoreResult) /* istanbul ignore next */ return prevResult; + + return { + getChildTags: { + ...fetchMoreResult.getChildTags, + childTags: { + ...fetchMoreResult.getChildTags.childTags, + edges: [ + ...prevResult.getChildTags.childTags.edges, + ...fetchMoreResult.getChildTags.childTags.edges, + ], + }, + }, + }; + }, + }); + }; + + const [create, { loading: createUserTagLoading }] = + useMutation(CREATE_USER_TAG); + + const addSubTag = async (e: ChangeEvent): Promise => { + e.preventDefault(); + + try { + const { data } = await create({ + variables: { + name: tagName, + organizationId: orgId, + parentTagId, + }, + }); + + /* istanbul ignore next */ + if (data) { + toast.success(t('tagCreationSuccess') as string); + subTagsRefetch(); + setTagName(''); + setAddSubTagModalIsOpen(false); + } + } catch (error: unknown) { + /* istanbul ignore next */ + if (error instanceof Error) { + toast.error(error.message); + } + } + }; + + if (subTagsError) { + return ( +
    +
    + +
    + Error occured while loading sub tags +
    +
    +
    + ); + } + + const subTagsList = + subTagsData?.getChildTags.childTags.edges.map((edge) => edge.node) ?? + /* istanbul ignore next */ []; + + const parentTagName = subTagsData?.getChildTags.name; + + // get the ancestorTags array and push the current tag in it + // used for the tag breadcrumbs + const orgUserTagAncestors = [ + ...(subTagsData?.getChildTags.ancestorTags ?? []), + { + _id: parentTagId, + name: parentTagName, + }, + ]; + + const redirectToManageTag = (tagId: string): void => { + navigate(`/orgtags/${orgId}/manageTag/${tagId}`); + }; + + const redirectToSubTags = (tagId: string): void => { + navigate(`/orgtags/${orgId}/subTags/${tagId}`); + }; + + const columns: GridColDef[] = [ + { + field: 'id', + headerName: '#', + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return
    {params.row.id}
    ; + }, + }, + { + field: 'tagName', + headerName: 'Tag Name', + flex: 1, + minWidth: 100, + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
    redirectToSubTags(params.row._id as string)} + > + {params.row.name} + + +
    + ); + }, + }, + { + field: 'totalSubTags', + headerName: 'Total Sub Tags', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + + {params.row.childTags.totalCount} + + ); + }, + }, + { + field: 'totalAssignedUsers', + headerName: 'Total Assigned Users', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + + {params.row.usersAssignedTo.totalCount} + + ); + }, + }, + { + field: 'actions', + headerName: 'Actions', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + + ); + }, + }, + ]; + + return ( + <> + +
    +
    +
    + setTagSearchName(e.target.value.trim())} + data-testid="searchByName" + autoComplete="off" + /> + +
    + + + + + {tagSortOrder === 'DESCENDING' + ? tCommon('Latest') + : tCommon('Oldest')} + + + setTagSortOrder('DESCENDING')} + > + {tCommon('Latest')} + + setTagSortOrder('ASCENDING')} + > + {tCommon('Oldest')} + + + + + + + +
    + + {subTagsLoading || createUserTagLoading ? ( + + ) : ( +
    +
    +
    + +
    + +
    navigate(`/orgtags/${orgId}`)} + className={`fs-6 ms-3 my-1 ${styles.tagsBreadCrumbs}`} + data-testid="allTagsBtn" + > + {'Tags'} + +
    + + {orgUserTagAncestors?.map((tag, index) => ( +
    redirectToSubTags(tag._id as string)} + data-testid="redirectToSubTags" + > + {tag.name} + + {orgUserTagAncestors.length - 1 !== index && ( + + )} +
    + ))} +
    +
    + } + scrollableTarget="subTagsScrollableDiv" + > + row.id} + slots={{ + noRowsOverlay: /* istanbul ignore next */ () => ( + + {t('noTagsFound')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={subTagsList?.map((subTag, index) => ({ + id: index + 1, + ...subTag, + }))} + columns={columns} + isRowSelectable={() => false} + /> + +
    +
    + )} +
    +
    + + {/* Create Tag Modal */} + + + {t('tagDetails')} + +
    + + {t('tagName')} + { + setTagName(e.target.value); + }} + /> + + + + + + +
    +
    + + ); +} + +export default SubTags; diff --git a/src/screens/SubTags/SubTagsMocks.ts b/src/screens/SubTags/SubTagsMocks.ts new file mode 100644 index 0000000000..5165ea3a53 --- /dev/null +++ b/src/screens/SubTags/SubTagsMocks.ts @@ -0,0 +1,502 @@ +import { CREATE_USER_TAG } from 'GraphQl/Mutations/TagMutations'; +import { USER_TAG_SUB_TAGS } from 'GraphQl/Queries/userTagQueries'; +import { TAGS_QUERY_DATA_CHUNK_SIZE } from 'utils/organizationTagsUtils'; + +export const MOCKS = [ + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: '' } }, + sortedBy: { id: 'DESCENDING' }, + }, + }, + result: { + data: { + getChildTags: { + name: 'userTag 1', + childTags: { + edges: [ + { + node: { + _id: 'subTag1', + name: 'subTag 1', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag1', + }, + { + node: { + _id: 'subTag2', + name: 'subTag 2', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag2', + }, + { + node: { + _id: 'subTag3', + name: 'subTag 3', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag3', + }, + { + node: { + _id: 'subTag4', + name: 'subTag 4', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag4', + }, + { + node: { + _id: 'subTag5', + name: 'subTag 5', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag5', + }, + { + node: { + _id: 'subTag6', + name: 'subTag 6', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag6', + }, + { + node: { + _id: 'subTag7', + name: 'subTag 7', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag7', + }, + { + node: { + _id: 'subTag8', + name: 'subTag 8', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag8', + }, + { + node: { + _id: 'subTag9', + name: 'subTag 9', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag9', + }, + { + node: { + _id: 'subTag10', + name: 'subTag 10', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag10', + }, + ], + pageInfo: { + startCursor: '1', + endCursor: '10', + hasNextPage: true, + hasPreviousPage: false, + }, + totalCount: 11, + }, + ancestorTags: [], + }, + }, + }, + }, + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: '1', + after: '10', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: '' } }, + sortedBy: { id: 'DESCENDING' }, + }, + }, + result: { + data: { + getChildTags: { + name: 'userTag 1', + childTags: { + edges: [ + { + node: { + _id: 'subTag11', + name: 'subTag 11', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'subTag11', + }, + ], + pageInfo: { + startCursor: '11', + endCursor: '11', + hasNextPage: false, + hasPreviousPage: true, + }, + totalCount: 11, + }, + ancestorTags: [], + }, + }, + }, + }, + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: 'subTag1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: '' } }, + sortedBy: { id: 'DESCENDING' }, + }, + }, + result: { + data: { + getChildTags: { + name: 'subTag 1', + childTags: { + edges: [ + { + node: { + _id: 'subTag1.1', + name: 'subTag 1.1', + usersAssignedTo: { + totalCount: 5, + }, + childTags: { + totalCount: 5, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + { + _id: 'subTag1', + name: 'subTag 1', + }, + ], + }, + cursor: 'subTag1.1', + }, + ], + pageInfo: { + startCursor: 'subTag1.1', + endCursor: 'subTag1.1', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 1, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + }, + }, + }, + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: 'searchSubTag' } }, + sortedBy: { id: 'DESCENDING' }, + }, + }, + result: { + data: { + getChildTags: { + name: 'userTag 1', + childTags: { + edges: [ + { + node: { + _id: 'searchSubTag1', + name: 'searchSubTag 1', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'searchSubTag1', + }, + { + node: { + _id: 'searchSubTag2', + name: 'searchSubTag 2', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'searchSubTag2', + }, + ], + pageInfo: { + startCursor: 'searchSubTag1', + endCursor: 'searchSubTag2', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 2, + }, + ancestorTags: [], + }, + }, + }, + }, + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: 'searchSubTag' } }, + sortedBy: { id: 'ASCENDING' }, + }, + }, + result: { + data: { + getChildTags: { + name: 'userTag 1', + childTags: { + edges: [ + { + node: { + _id: 'searchSubTag2', + name: 'searchSubTag 2', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'searchSubTag2', + }, + { + node: { + _id: 'searchSubTag1', + name: 'searchSubTag 1', + usersAssignedTo: { + totalCount: 0, + }, + childTags: { + totalCount: 0, + }, + ancestorTags: [ + { + _id: '1', + name: 'userTag 1', + }, + ], + }, + cursor: 'searchSubTag1', + }, + ], + pageInfo: { + startCursor: 'searchSubTag2', + endCursor: 'searchSubTag1', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 2, + }, + ancestorTags: [], + }, + }, + }, + }, + { + request: { + query: CREATE_USER_TAG, + variables: { + name: 'subTag 12', + organizationId: '123', + parentTagId: '1', + }, + }, + result: { + data: { + createUserTag: { + _id: 'subTag12', + }, + }, + }, + }, +]; + +export const MOCKS_ERROR_SUB_TAGS = [ + { + request: { + query: USER_TAG_SUB_TAGS, + variables: { + id: '1', + first: TAGS_QUERY_DATA_CHUNK_SIZE, + where: { name: { starts_with: '' } }, + sortedBy: { id: 'DESCENDING' }, + }, + }, + error: new Error('Mock Graphql Error'), + }, +]; diff --git a/src/screens/UserPortal/Campaigns/Campaigns.module.css b/src/screens/UserPortal/Campaigns/Campaigns.module.css new file mode 100644 index 0000000000..b535b9981c --- /dev/null +++ b/src/screens/UserPortal/Campaigns/Campaigns.module.css @@ -0,0 +1,137 @@ +.btnsContainer { + display: flex; + margin: 1.5rem 0; +} + +.btnsContainer .input { + flex: 1; + min-width: 18rem; + position: relative; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); + background-color: white; +} + +.btnsContainer .input button { + width: 52px; +} + +.accordionSummary { + width: 100% !important; + padding-right: 0.75rem; + display: flex; + justify-content: space-between !important; + align-items: center; +} + +.accordionSummary button { + height: 2.25rem; + padding-top: 0.35rem; +} + +.accordionSummary button:hover { + background-color: #31bb6a50 !important; + color: #31bb6b !important; +} + +.titleContainer { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.titleContainer h3 { + font-size: 1.25rem; + font-weight: 750; + color: #5e5e5e; + margin-top: 0.2rem; +} + +.subContainer span { + font-size: 0.9rem; + margin-left: 0.5rem; + font-weight: lighter; + color: #707070; +} + +.chipIcon { + height: 0.9rem !important; +} + +.chip { + height: 1.5rem !important; + margin: 0.15rem 0 0 1.25rem; +} + +.active { + background-color: #31bb6a50 !important; +} + +.pending { + background-color: #ffd76950 !important; + color: #bb952bd0 !important; + border-color: #bb952bd0 !important; +} + +.progress { + display: flex; + width: 45rem; +} + +.progressBar { + margin: 0rem 0.75rem; + width: 100%; + font-size: 0.9rem; + height: 1.25rem; +} + +/* Pledge Modal */ + +.pledgeModal { + max-width: 80vw; + margin-top: 2vh; + margin-left: 13vw; +} + +.titlemodal { + color: #707070; + font-weight: 600; + font-size: 32px; + width: 65%; + margin-bottom: 0px; +} + +.modalCloseBtn { + width: 40px; + height: 40px; + padding: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.noOutline input { + outline: none; +} + +.greenregbtn { + margin: 1rem 0 0; + margin-top: 15px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 10px; + border-radius: 5px; + background-color: #31bb6b; + width: 100%; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; +} diff --git a/src/screens/UserPortal/Campaigns/Campaigns.test.tsx b/src/screens/UserPortal/Campaigns/Campaigns.test.tsx new file mode 100644 index 0000000000..17b7eec4d5 --- /dev/null +++ b/src/screens/UserPortal/Campaigns/Campaigns.test.tsx @@ -0,0 +1,324 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18nForTest from 'utils/i18nForTest'; +import type { ApolloLink } from '@apollo/client'; +import useLocalStorage from 'utils/useLocalstorage'; +import Campaigns from './Campaigns'; +import { + EMPTY_MOCKS, + MOCKS, + USER_FUND_CAMPAIGNS_ERROR, +} from './CampaignsMocks'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); +jest.mock('@mui/x-date-pickers/DateTimePicker', () => { + return { + DateTimePicker: jest.requireActual( + '@mui/x-date-pickers/DesktopDateTimePicker', + ).DesktopDateTimePicker, + }; +}); +const { setItem } = useLocalStorage(); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(USER_FUND_CAMPAIGNS_ERROR); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const cTranslations = JSON.parse( + JSON.stringify( + i18nForTest.getDataByLanguage('en')?.translation.userCampaigns, + ), +); +const pTranslations = JSON.parse( + JSON.stringify(i18nForTest.getDataByLanguage('en')?.translation.pledges), +); + +const renderCampaigns = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } /> + } + /> + } + /> + + + + + + , + ); +}; + +describe('Testing User Campaigns Screen', () => { + beforeEach(() => { + setItem('userId', 'userId'); + }); + + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('should render the User Campaigns screen', async () => { + renderCampaigns(link1); + await waitFor(() => { + expect(screen.getByTestId('searchCampaigns')).toBeInTheDocument(); + expect(screen.getByText('School Campaign')).toBeInTheDocument(); + expect(screen.getByText('Hospital Campaign')).toBeInTheDocument(); + }); + }); + + it('should redirect to fallback URL if userId is null in LocalStorage', async () => { + setItem('userId', null); + renderCampaigns(link1); + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> + } + /> + + + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render the User Campaign screen with error', async () => { + renderCampaigns(link2); + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('renders the empty campaign component', async () => { + renderCampaigns(link3); + await waitFor(() => + expect(screen.getByText(cTranslations.noCampaigns)).toBeInTheDocument(), + ); + }); + + it('Check if All details are rendered correctly', async () => { + renderCampaigns(link1); + + const detailContainer = await screen.findByTestId('detailContainer1'); + const detailContainer2 = await screen.findByTestId('detailContainer2'); + await waitFor(() => { + expect(detailContainer).toBeInTheDocument(); + expect(detailContainer2).toBeInTheDocument(); + expect(detailContainer).toHaveTextContent('School Campaign'); + expect(detailContainer).toHaveTextContent('$22000'); + expect(detailContainer).toHaveTextContent('2024-07-28'); + expect(detailContainer).toHaveTextContent('2025-08-31'); + expect(detailContainer).toHaveTextContent('Active'); + expect(detailContainer2).toHaveTextContent('Hospital Campaign'); + expect(detailContainer2).toHaveTextContent('$9000'); + expect(detailContainer2).toHaveTextContent('2024-07-28'); + expect(detailContainer2).toHaveTextContent('2022-08-30'); + expect(detailContainer2).toHaveTextContent('Ended'); + }); + }); + + it('Sort the Campaigns list by lowest fundingGoal', async () => { + renderCampaigns(link1); + + const searchCampaigns = await screen.findByTestId('searchCampaigns'); + expect(searchCampaigns).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('filter')); + await waitFor(() => { + expect(screen.getByTestId('fundingGoal_ASC')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('fundingGoal_ASC')); + + await waitFor(() => { + expect(screen.getByText('School Campaign')).toBeInTheDocument(); + expect(screen.getByText('Hospital Campaign')).toBeInTheDocument(); + }); + + await waitFor(() => { + const detailContainer = screen.getByTestId('detailContainer2'); + expect(detailContainer).toHaveTextContent('School Campaign'); + expect(detailContainer).toHaveTextContent('$22000'); + expect(detailContainer).toHaveTextContent('2024-07-28'); + expect(detailContainer).toHaveTextContent('2024-08-31'); + }); + }); + + it('Sort the Campaigns list by highest fundingGoal', async () => { + renderCampaigns(link1); + + const searchCampaigns = await screen.findByTestId('searchCampaigns'); + expect(searchCampaigns).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('filter')); + await waitFor(() => { + expect(screen.getByTestId('fundingGoal_DESC')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('fundingGoal_DESC')); + + await waitFor(() => { + expect(screen.getByText('School Campaign')).toBeInTheDocument(); + expect(screen.getByText('Hospital Campaign')).toBeInTheDocument(); + }); + + await waitFor(() => { + const detailContainer = screen.getByTestId('detailContainer1'); + expect(detailContainer).toHaveTextContent('School Campaign'); + expect(detailContainer).toHaveTextContent('$22000'); + expect(detailContainer).toHaveTextContent('2024-07-28'); + expect(detailContainer).toHaveTextContent('2024-08-31'); + }); + }); + + it('Sort the Campaigns list by earliest endDate', async () => { + renderCampaigns(link1); + + const searchCampaigns = await screen.findByTestId('searchCampaigns'); + expect(searchCampaigns).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('filter')); + await waitFor(() => { + expect(screen.getByTestId('endDate_ASC')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('endDate_ASC')); + + await waitFor(() => { + expect(screen.getByText('School Campaign')).toBeInTheDocument(); + expect(screen.getByText('Hospital Campaign')).toBeInTheDocument(); + }); + + await waitFor(() => { + const detailContainer = screen.getByTestId('detailContainer2'); + expect(detailContainer).toHaveTextContent('School Campaign'); + expect(detailContainer).toHaveTextContent('$22000'); + expect(detailContainer).toHaveTextContent('2024-07-28'); + expect(detailContainer).toHaveTextContent('2024-08-31'); + }); + }); + + it('Sort the Campaigns list by latest endDate', async () => { + renderCampaigns(link1); + + const searchCampaigns = await screen.findByTestId('searchCampaigns'); + expect(searchCampaigns).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('filter')); + await waitFor(() => { + expect(screen.getByTestId('endDate_DESC')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('endDate_DESC')); + + await waitFor(() => { + expect(screen.getByText('School Campaign')).toBeInTheDocument(); + expect(screen.getByText('Hospital Campaign')).toBeInTheDocument(); + }); + + await waitFor(() => { + const detailContainer = screen.getByTestId('detailContainer1'); + expect(detailContainer).toHaveTextContent('School Campaign'); + expect(detailContainer).toHaveTextContent('$22000'); + expect(detailContainer).toHaveTextContent('2024-07-28'); + expect(detailContainer).toHaveTextContent('2025-08-31'); + }); + }); + + it('Search the Campaigns list by name', async () => { + renderCampaigns(link1); + + const searchCampaigns = await screen.findByTestId('searchCampaigns'); + expect(searchCampaigns).toBeInTheDocument(); + + fireEvent.change(searchCampaigns, { + target: { value: 'Hospital' }, + }); + + await waitFor(() => { + expect(screen.queryByText('School Campaign')).toBeNull(); + expect(screen.getByText('Hospital Campaign')).toBeInTheDocument(); + }); + }); + + it('open and closes add pledge modal', async () => { + renderCampaigns(link1); + + const addPledgeBtn = await screen.findAllByTestId('addPledgeBtn'); + await waitFor(() => expect(addPledgeBtn[0]).toBeInTheDocument()); + userEvent.click(addPledgeBtn[0]); + + await waitFor(() => + expect(screen.getAllByText(pTranslations.createPledge)).toHaveLength(2), + ); + userEvent.click(screen.getByTestId('pledgeModalCloseBtn')); + await waitFor(() => + expect(screen.queryByTestId('pledgeModalCloseBtn')).toBeNull(), + ); + }); + + it('Redirect to My Pledges screen', async () => { + renderCampaigns(link1); + + const myPledgesBtn = await screen.findByText(cTranslations.myPledges); + expect(myPledgesBtn).toBeInTheDocument(); + userEvent.click(myPledgesBtn); + + await waitFor(() => { + expect(screen.getByTestId('pledgeScreen')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/screens/UserPortal/Campaigns/Campaigns.tsx b/src/screens/UserPortal/Campaigns/Campaigns.tsx new file mode 100644 index 0000000000..e4483f87fe --- /dev/null +++ b/src/screens/UserPortal/Campaigns/Campaigns.tsx @@ -0,0 +1,305 @@ +import React, { useEffect, useState } from 'react'; +import { Dropdown, Form, Button, ProgressBar } from 'react-bootstrap'; +import styles from './Campaigns.module.css'; +import { useTranslation } from 'react-i18next'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import { Circle, Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Chip, + Stack, +} from '@mui/material'; +import { GridExpandMoreIcon } from '@mui/x-data-grid'; +import useLocalStorage from 'utils/useLocalstorage'; +import PledgeModal from './PledgeModal'; +import { USER_FUND_CAMPAIGNS } from 'GraphQl/Queries/fundQueries'; +import { useQuery } from '@apollo/client'; +import type { InterfaceUserCampaign } from 'utils/interfaces'; +import { currencySymbols } from 'utils/currency'; +import Loader from 'components/Loader/Loader'; + +/** + * The `Campaigns` component displays a list of fundraising campaigns for a specific organization. + * It allows users to search, sort, and view details about each campaign. Users can also add pledges to active campaigns. + * + * @returns The rendered component displaying the campaigns. + */ +const Campaigns = (): JSX.Element => { + // Retrieves translation functions for various namespaces + const { t } = useTranslation('translation', { + keyPrefix: 'userCampaigns', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Retrieves stored user ID from local storage + const { getItem } = useLocalStorage(); + const userId = getItem('userId'); + + // Extracts organization ID from the URL parameters + const { orgId } = useParams(); + if (!orgId || !userId) { + // Redirects to the homepage if orgId or userId is missing + return ; + } + + // Navigation hook to programmatically navigate between routes + const navigate = useNavigate(); + + // State for managing search term, campaigns, selected campaign, modal state, and sorting order + const [searchTerm, setSearchTerm] = useState(''); + const [campaigns, setCampaigns] = useState([]); + const [selectedCampaign, setSelectedCampaign] = + useState(null); + const [modalState, setModalState] = useState(false); + const [sortBy, setSortBy] = useState< + 'fundingGoal_ASC' | 'fundingGoal_DESC' | 'endDate_ASC' | 'endDate_DESC' + >('endDate_DESC'); + + // Fetches campaigns based on the organization ID, search term, and sorting order + const { + data: campaignData, + loading: campaignLoading, + error: campaignError, + refetch: refetchCampaigns, + }: { + data?: { + getFundraisingCampaigns: InterfaceUserCampaign[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(USER_FUND_CAMPAIGNS, { + variables: { + where: { + organizationId: orgId, + name_contains: searchTerm, + }, + campaignOrderBy: sortBy, + }, + }); + + /** + * Opens the modal for adding a pledge to a selected campaign. + * + * @param campaign - The campaign to which the user wants to add a pledge. + */ + const openModal = (campaign: InterfaceUserCampaign): void => { + setSelectedCampaign(campaign); + setModalState(true); + }; + + /** + * Closes the modal and clears the selected campaign. + */ + const closeModal = (): void => { + setModalState(false); + setSelectedCampaign(null); + }; + + // Updates the campaigns state when the fetched campaign data changes + useEffect(() => { + if (campaignData) { + setCampaigns(campaignData.getFundraisingCampaigns); + } + }, [campaignData]); + + // Renders a loader while campaigns are being fetched + if (campaignLoading) return ; + if (campaignError) { + // Displays an error message if there is an issue loading the campaigns + return ( +
    +
    + +
    + {tErrors('errorLoading', { entity: 'Campaigns' })} +
    + {campaignError.message} +
    +
    +
    + ); + } + + // Renders the campaign list and UI elements for searching, sorting, and adding pledges + return ( + <> +
    + {/* Search input field and button */} +
    + setSearchTerm(e.target.value)} + data-testid="searchCampaigns" + /> + +
    +
    +
    + {/* Dropdown menu for sorting campaigns */} + + + + {tCommon('sort')} + + + setSortBy('fundingGoal_ASC')} + data-testid="fundingGoal_ASC" + > + {t('lowestGoal')} + + setSortBy('fundingGoal_DESC')} + data-testid="fundingGoal_DESC" + > + {t('highestGoal')} + + setSortBy('endDate_DESC')} + data-testid="endDate_DESC" + > + {t('latestEndDate')} + + setSortBy('endDate_ASC')} + data-testid="endDate_ASC" + > + {t('earliestEndDate')} + + + +
    +
    + {/* Button to navigate to the user's pledges */} + +
    +
    +
    + {campaigns.length < 1 ? ( + + {/* Displayed if no campaigns are found */} + {t('noCampaigns')} + + ) : ( + campaigns.map((campaign: InterfaceUserCampaign, index: number) => ( + + }> +
    +
    +
    +

    {campaign.name}

    + } + label={ + new Date(campaign.endDate) < new Date() + ? 'Ended' + : 'Active' + } + variant="outlined" + color="primary" + className={`${styles.chip} ${new Date(campaign.endDate) < new Date() ? styles.pending : styles.active}`} + /> +
    + +
    + + Goal:{' '} + { + currencySymbols[ + campaign.currency as keyof typeof currencySymbols + ] + } + {campaign.fundingGoal} + + Raised: $0 + + Start Date: {campaign.startDate as unknown as string} + + + End Date: {campaign.endDate as unknown as string} + +
    +
    +
    + +
    +
    +
    + + Amount Raised: +
    + $0 + + $1000 +
    +
    +
    + )) + )} + + {/* Modal for adding pledges to campaigns */} + + + ); +}; + +export default Campaigns; diff --git a/src/screens/UserPortal/Campaigns/CampaignsMocks.ts b/src/screens/UserPortal/Campaigns/CampaignsMocks.ts new file mode 100644 index 0000000000..f64401bca5 --- /dev/null +++ b/src/screens/UserPortal/Campaigns/CampaignsMocks.ts @@ -0,0 +1,272 @@ +import { USER_DETAILS } from 'GraphQl/Queries/Queries'; +import { USER_FUND_CAMPAIGNS } from 'GraphQl/Queries/fundQueries'; + +const userDetailsQuery = { + request: { + query: USER_DETAILS, + variables: { + id: 'userId', + }, + }, + result: { + data: { + user: { + user: { + _id: 'userId', + joinedOrganizations: [ + { + _id: '6537904485008f171cf29924', + __typename: 'Organization', + }, + ], + firstName: 'Harve', + lastName: 'Lance', + email: 'testuser1@example.com', + image: null, + createdAt: '2023-04-13T04:53:17.742Z', + birthDate: null, + educationGrade: null, + employmentStatus: null, + gender: null, + maritalStatus: null, + phone: null, + address: { + line1: 'Line1', + countryCode: 'CountryCode', + city: 'CityName', + state: 'State', + __typename: 'Address', + }, + registeredEvents: [], + membershipRequests: [], + __typename: 'User', + }, + appUserProfile: { + _id: '67078abd85008f171cf2991d', + adminFor: [], + isSuperAdmin: false, + appLanguageCode: 'en', + pluginCreationAllowed: true, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + __typename: 'AppUserProfile', + }, + __typename: 'UserData', + }, + }, + }, +}; + +export const MOCKS = [ + { + request: { + query: USER_FUND_CAMPAIGNS, + variables: { + where: { + organizationId: 'orgId', + name_contains: '', + }, + campaignOrderBy: 'endDate_DESC', + }, + }, + result: { + data: { + getFundraisingCampaigns: [ + { + _id: 'campaignId1', + startDate: '2024-07-28', + endDate: '2025-08-31', + name: 'School Campaign', + fundingGoal: 22000, + currency: 'USD', + __typename: 'FundraisingCampaign', + }, + { + _id: 'campaignId2', + startDate: '2024-07-28', + endDate: '2022-08-30', + name: 'Hospital Campaign', + fundingGoal: 9000, + currency: 'USD', + __typename: 'FundraisingCampaign', + }, + ], + }, + }, + }, + { + request: { + query: USER_FUND_CAMPAIGNS, + variables: { + where: { + organizationId: 'orgId', + name_contains: '', + }, + campaignOrderBy: 'endDate_ASC', + }, + }, + result: { + data: { + getFundraisingCampaigns: [ + { + _id: 'campaignId2', + startDate: '2024-07-28', + endDate: '2024-08-30', + name: 'Hospital Campaign', + fundingGoal: 9000, + currency: 'USD', + __typename: 'FundraisingCampaign', + }, + { + _id: 'campaignId1', + startDate: '2024-07-28', + endDate: '2024-08-31', + name: 'School Campaign', + fundingGoal: 22000, + currency: 'USD', + __typename: 'FundraisingCampaign', + }, + ], + }, + }, + }, + { + request: { + query: USER_FUND_CAMPAIGNS, + variables: { + where: { + organizationId: 'orgId', + name_contains: '', + }, + campaignOrderBy: 'fundingGoal_ASC', + }, + }, + result: { + data: { + getFundraisingCampaigns: [ + { + _id: 'campaignId2', + startDate: '2024-07-28', + endDate: '2024-08-30', + name: 'Hospital Campaign', + fundingGoal: 9000, + currency: 'USD', + __typename: 'FundraisingCampaign', + }, + { + _id: 'campaignId1', + startDate: '2024-07-28', + endDate: '2024-08-31', + name: 'School Campaign', + fundingGoal: 22000, + currency: 'USD', + __typename: 'FundraisingCampaign', + }, + ], + }, + }, + }, + { + request: { + query: USER_FUND_CAMPAIGNS, + variables: { + where: { + organizationId: 'orgId', + name_contains: '', + }, + campaignOrderBy: 'fundingGoal_DESC', + }, + }, + result: { + data: { + getFundraisingCampaigns: [ + { + _id: 'campaignId1', + startDate: '2024-07-28', + endDate: '2024-08-31', + name: 'School Campaign', + fundingGoal: 22000, + currency: 'USD', + __typename: 'FundraisingCampaign', + }, + { + _id: 'campaignId2', + startDate: '2024-07-28', + endDate: '2024-08-30', + name: 'Hospital Campaign', + fundingGoal: 9000, + currency: 'USD', + __typename: 'FundraisingCampaign', + }, + ], + }, + }, + }, + { + request: { + query: USER_FUND_CAMPAIGNS, + variables: { + where: { + organizationId: 'orgId', + name_contains: 'Hospital', + }, + campaignOrderBy: 'endDate_DESC', + }, + }, + result: { + data: { + getFundraisingCampaigns: [ + { + _id: 'campaignId2', + startDate: '2024-07-28', + endDate: '2024-08-30', + name: 'Hospital Campaign', + fundingGoal: 9000, + currency: 'USD', + __typename: 'FundraisingCampaign', + }, + ], + }, + }, + }, + userDetailsQuery, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: USER_FUND_CAMPAIGNS, + variables: { + where: { + organizationId: 'orgId', + name_contains: '', + }, + campaignOrderBy: 'endDate_DESC', + }, + }, + result: { + data: { + getFundraisingCampaigns: [], + }, + }, + }, + userDetailsQuery, +]; + +export const USER_FUND_CAMPAIGNS_ERROR = [ + { + request: { + query: USER_FUND_CAMPAIGNS, + variables: { + where: { + organizationId: 'orgId', + name_contains: '', + }, + campaignOrderBy: 'endDate_DESC', + }, + }, + error: new Error('Error fetching campaigns'), + }, + userDetailsQuery, +]; diff --git a/src/screens/UserPortal/Campaigns/PledgeModal.test.tsx b/src/screens/UserPortal/Campaigns/PledgeModal.test.tsx new file mode 100644 index 0000000000..b9fd7a7d4d --- /dev/null +++ b/src/screens/UserPortal/Campaigns/PledgeModal.test.tsx @@ -0,0 +1,310 @@ +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { InterfacePledgeModal } from './PledgeModal'; +import PledgeModal from './PledgeModal'; +import React from 'react'; +import { USER_DETAILS } from 'GraphQl/Queries/Queries'; +import { CREATE_PlEDGE, UPDATE_PLEDGE } from 'GraphQl/Mutations/PledgeMutation'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('@mui/x-date-pickers/DateTimePicker', () => { + return { + DateTimePicker: jest.requireActual( + '@mui/x-date-pickers/DesktopDateTimePicker', + ).DesktopDateTimePicker, + }; +}); + +const pledgeProps: InterfacePledgeModal[] = [ + { + isOpen: true, + hide: jest.fn(), + pledge: { + _id: '1', + amount: 100, + currency: 'USD', + startDate: '2024-01-01', + endDate: '2024-01-10', + users: [ + { + _id: '1', + firstName: 'John', + lastName: 'Doe', + image: undefined, + }, + ], + }, + refetchPledge: jest.fn(), + campaignId: 'campaignId', + userId: 'userId', + endDate: new Date(), + mode: 'create', + }, + { + isOpen: true, + hide: jest.fn(), + pledge: { + _id: '1', + amount: 100, + currency: 'USD', + startDate: '2024-01-01', + endDate: '2024-01-10', + users: [ + { + _id: '1', + firstName: 'John', + lastName: 'Doe', + image: undefined, + }, + ], + }, + refetchPledge: jest.fn(), + campaignId: 'campaignId', + userId: 'userId', + endDate: new Date(), + mode: 'edit', + }, +]; + +const PLEDGE_MODAL_MOCKS = [ + { + request: { + query: USER_DETAILS, + variables: { + id: 'userId', + }, + }, + result: { + data: { + user: { + user: { + _id: 'userId', + joinedOrganizations: [ + { + _id: '6537904485008f171cf29924', + __typename: 'Organization', + }, + ], + firstName: 'Harve', + lastName: 'Lance', + email: 'testuser1@example.com', + image: null, + createdAt: '2023-04-13T04:53:17.742Z', + birthDate: null, + educationGrade: null, + employmentStatus: null, + gender: null, + maritalStatus: null, + phone: null, + address: { + line1: 'Line1', + countryCode: 'CountryCode', + city: 'CityName', + state: 'State', + __typename: 'Address', + }, + registeredEvents: [], + membershipRequests: [], + __typename: 'User', + }, + appUserProfile: { + _id: '67078abd85008f171cf2991d', + adminFor: [], + isSuperAdmin: false, + appLanguageCode: 'en', + pluginCreationAllowed: true, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + __typename: 'AppUserProfile', + }, + __typename: 'UserData', + }, + }, + }, + }, + { + request: { + query: UPDATE_PLEDGE, + variables: { + id: '1', + amount: 200, + }, + }, + result: { + data: { + updateFundraisingCampaignPledge: { + _id: '1', + }, + }, + }, + }, + { + request: { + query: CREATE_PlEDGE, + variables: { + campaignId: 'campaignId', + amount: 200, + currency: 'USD', + startDate: '2024-01-02', + endDate: '2024-01-02', + userIds: ['1'], + }, + }, + result: { + data: { + createFundraisingCampaignPledge: { + _id: '3', + }, + }, + }, + }, +]; + +const link1 = new StaticMockLink(PLEDGE_MODAL_MOCKS); +const translations = JSON.parse( + JSON.stringify(i18nForTest.getDataByLanguage('en')?.translation.pledges), +); + +const renderPledgeModal = ( + link: ApolloLink, + props: InterfacePledgeModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('PledgeModal', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId', fundCampaignId: 'fundCampaignId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + it('should populate form fields with correct values in edit mode', async () => { + renderPledgeModal(link1, pledgeProps[1]); + await waitFor(() => + expect(screen.getByText(translations.editPledge)).toBeInTheDocument(), + ); + expect(screen.getByTestId('pledgerSelect')).toHaveTextContent('John Doe'); + expect(screen.getByLabelText('Start Date')).toHaveValue('01/01/2024'); + expect(screen.getByLabelText('End Date')).toHaveValue('10/01/2024'); + expect(screen.getByLabelText('Currency')).toHaveTextContent('USD ($)'); + expect(screen.getByLabelText('Amount')).toHaveValue('100'); + }); + + it('should update pledgeAmount when input value changes', async () => { + renderPledgeModal(link1, pledgeProps[1]); + const amountInput = screen.getByLabelText('Amount'); + expect(amountInput).toHaveValue('100'); + fireEvent.change(amountInput, { target: { value: '200' } }); + expect(amountInput).toHaveValue('200'); + }); + + it('should not update pledgeAmount when input value is less than or equal to 0', async () => { + renderPledgeModal(link1, pledgeProps[1]); + const amountInput = screen.getByLabelText('Amount'); + expect(amountInput).toHaveValue('100'); + fireEvent.change(amountInput, { target: { value: '-10' } }); + expect(amountInput).toHaveValue('100'); + }); + + it('should update pledgeStartDate when a new date is selected', async () => { + renderPledgeModal(link1, pledgeProps[1]); + const startDateInput = screen.getByLabelText('Start Date'); + fireEvent.change(startDateInput, { target: { value: '02/01/2024' } }); + expect(startDateInput).toHaveValue('02/01/2024'); + expect(pledgeProps[1].pledge?.startDate).toEqual('2024-01-01'); + }); + + it('pledgeStartDate onChange when its null', async () => { + renderPledgeModal(link1, pledgeProps[1]); + const startDateInput = screen.getByLabelText('Start Date'); + fireEvent.change(startDateInput, { target: { value: null } }); + expect(pledgeProps[1].pledge?.startDate).toEqual('2024-01-01'); + }); + + it('should update pledgeEndDate when a new date is selected', async () => { + renderPledgeModal(link1, pledgeProps[1]); + const startDateInput = screen.getByLabelText('End Date'); + fireEvent.change(startDateInput, { target: { value: '02/01/2024' } }); + expect(startDateInput).toHaveValue('02/01/2024'); + expect(pledgeProps[1].pledge?.endDate).toEqual('2024-01-10'); + }); + + it('pledgeEndDate onChange when its null', async () => { + renderPledgeModal(link1, pledgeProps[1]); + const endDateInput = screen.getByLabelText('End Date'); + fireEvent.change(endDateInput, { target: { value: null } }); + expect(pledgeProps[1].pledge?.endDate).toEqual('2024-01-10'); + }); + + it('should create pledge', async () => { + renderPledgeModal(link1, pledgeProps[0]); + + fireEvent.change(screen.getByLabelText('Amount'), { + target: { value: '200' }, + }); + fireEvent.change(screen.getByLabelText('Start Date'), { + target: { value: '02/01/2024' }, + }); + fireEvent.change(screen.getByLabelText('End Date'), { + target: { value: '02/01/2024' }, + }); + + expect(screen.getByLabelText('Amount')).toHaveValue('200'); + expect(screen.getByLabelText('Start Date')).toHaveValue('02/01/2024'); + expect(screen.getByLabelText('End Date')).toHaveValue('02/01/2024'); + expect(screen.getByTestId('submitPledgeBtn')).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId('submitPledgeBtn')); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalled(); + expect(pledgeProps[0].refetchPledge).toHaveBeenCalled(); + expect(pledgeProps[0].hide).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/UserPortal/Campaigns/PledgeModal.tsx b/src/screens/UserPortal/Campaigns/PledgeModal.tsx new file mode 100644 index 0000000000..44cc82401b --- /dev/null +++ b/src/screens/UserPortal/Campaigns/PledgeModal.tsx @@ -0,0 +1,373 @@ +import { DatePicker } from '@mui/x-date-pickers'; +import dayjs, { type Dayjs } from 'dayjs'; +import type { ChangeEvent } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import { currencyOptions, currencySymbols } from 'utils/currency'; +import type { + InterfaceCreatePledge, + InterfacePledgeInfo, + InterfaceUserInfo, +} from 'utils/interfaces'; +import styles from './Campaigns.module.css'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery } from '@apollo/client'; +import { CREATE_PlEDGE, UPDATE_PLEDGE } from 'GraphQl/Mutations/PledgeMutation'; +import { toast } from 'react-toastify'; +import { + Autocomplete, + FormControl, + InputLabel, + MenuItem, + Select, + TextField, +} from '@mui/material'; +import { USER_DETAILS } from 'GraphQl/Queries/Queries'; + +/** + * Interface representing the properties for the `PledgeModal` component. + */ +export interface InterfacePledgeModal { + isOpen: boolean; + hide: () => void; + campaignId: string; + userId: string; + pledge: InterfacePledgeInfo | null; + refetchPledge: () => void; + endDate: Date; + mode: 'create' | 'edit'; +} + +/** + * `PledgeModal` is a React component that allows users to create or edit a pledge for a specific campaign. + * It displays a form with inputs for pledge details such as amount, currency, dates, and users involved in the pledge. + * + * @param isOpen - Determines if the modal is visible or hidden. + * @param hide - Function to close the modal. + * @param campaignId - The ID of the campaign for which the pledge is being made. + * @param userId - The ID of the user making or editing the pledge. + * @param pledge - The current pledge information if in edit mode, or null if creating a new pledge. + * @param refetchPledge - Function to refresh the pledge data after a successful operation. + * @param endDate - The maximum date allowed for the pledge's end date, based on the campaign's end date. + * @param mode - Specifies whether the modal is used for creating a new pledge or editing an existing one. + */ +const PledgeModal: React.FC = ({ + isOpen, + hide, + campaignId, + userId, + pledge, + refetchPledge, + endDate, + mode, +}) => { + // Translation functions to support internationalization + const { t } = useTranslation('translation', { + keyPrefix: 'pledges', + }); + const { t: tCommon } = useTranslation('common'); + + // State to manage the form inputs for the pledge + const [formState, setFormState] = useState({ + pledgeUsers: [], + pledgeAmount: pledge?.amount ?? 0, + pledgeCurrency: pledge?.currency ?? 'USD', + pledgeEndDate: new Date(pledge?.endDate ?? new Date()), + pledgeStartDate: new Date(pledge?.startDate ?? new Date()), + }); + + // State to manage the list of pledgers (users who are part of the pledge) + const [pledgers, setPledgers] = useState([]); + + // Mutation to update an existing pledge + const [updatePledge] = useMutation(UPDATE_PLEDGE); + + // Mutation to create a new pledge + const [createPledge] = useMutation(CREATE_PlEDGE); + + // Effect to update the form state when the pledge prop changes (e.g., when editing a pledge) + useEffect(() => { + setFormState({ + pledgeUsers: pledge?.users ?? [], + pledgeAmount: pledge?.amount ?? 0, + pledgeCurrency: pledge?.currency ?? 'USD', + pledgeEndDate: new Date(pledge?.endDate ?? new Date()), + pledgeStartDate: new Date(pledge?.startDate ?? new Date()), + }); + }, [pledge]); + + // Destructuring the form state for easier access + const { + pledgeUsers, + pledgeAmount, + pledgeCurrency, + pledgeStartDate, + pledgeEndDate, + } = formState; + + // Query to get the user details based on the userId prop + const { data: userData } = useQuery(USER_DETAILS, { + variables: { + id: userId, + }, + }); + + // Effect to update the pledgers state when user data is fetched + useEffect(() => { + if (userData) { + setPledgers([ + { + _id: userData.user.user._id, + firstName: userData.user.user.firstName, + lastName: userData.user.user.lastName, + image: userData.user.user.image, + }, + ]); + } + }, [userData]); + + /** + * Handler function to update an existing pledge. + * It compares the current form state with the existing pledge and updates only the changed fields. + * + * @param e - The form submission event. + * @returns A promise that resolves when the pledge is successfully updated. + */ + /*istanbul ignore next*/ + const updatePledgeHandler = useCallback( + async (e: ChangeEvent): Promise => { + e.preventDefault(); + const startDate = dayjs(pledgeStartDate).format('YYYY-MM-DD'); + const endDate = dayjs(pledgeEndDate).format('YYYY-MM-DD'); + + const updatedFields: { + [key: string]: number | string | string[] | undefined; + } = {}; + // checks if there are changes to the pledge and adds them to the updatedFields object + if (pledgeAmount !== pledge?.amount) { + updatedFields.amount = pledgeAmount; + } + if (pledgeCurrency !== pledge?.currency) { + updatedFields.currency = pledgeCurrency; + } + if (startDate !== dayjs(pledge?.startDate).format('YYYY-MM-DD')) { + updatedFields.startDate = startDate; + } + if (endDate !== dayjs(pledge?.endDate).format('YYYY-MM-DD')) { + updatedFields.endDate = endDate; + } + if (pledgeUsers !== pledge?.users) { + updatedFields.users = pledgeUsers.map((user) => user._id); + } + try { + await updatePledge({ + variables: { + id: pledge?._id, + ...updatedFields, + }, + }); + toast.success(t('pledgeUpdated') as string); + refetchPledge(); + hide(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }, + [formState, pledge], + ); + + /** + * Handler function to create a new pledge. + * It collects the form data and sends a request to create a pledge with the specified details. + * + * @param e - The form submission event. + * @returns A promise that resolves when the pledge is successfully created. + */ + const createPledgeHandler = useCallback( + async (e: ChangeEvent): Promise => { + try { + e.preventDefault(); + await createPledge({ + variables: { + campaignId, + amount: pledgeAmount, + currency: pledgeCurrency, + startDate: dayjs(pledgeStartDate).format('YYYY-MM-DD'), + endDate: dayjs(pledgeEndDate).format('YYYY-MM-DD'), + userIds: pledgeUsers.map((user) => user._id), + }, + }); + + toast.success(t('pledgeCreated') as string); + refetchPledge(); + setFormState({ + pledgeUsers: [], + pledgeAmount: 0, + pledgeCurrency: 'USD', + pledgeEndDate: new Date(), + pledgeStartDate: new Date(), + }); + hide(); + } catch (error: unknown) { + /*istanbul ignore next*/ + toast.error((error as Error).message); + } + }, + [formState, campaignId], + ); + + return ( + + +

    + {t(mode === 'edit' ? 'editPledge' : 'createPledge')} +

    + +
    + +
    + {/* A Multi-select dropdown enables user to view participating pledgers */} + + option._id === value._id} + filterSelectedOptions={true} + getOptionLabel={(member: InterfaceUserInfo): string => + `${member.firstName} ${member.lastName}` + } + onChange={ + /*istanbul ignore next*/ + (_, newPledgers): void => { + setFormState({ + ...formState, + pledgeUsers: newPledgers, + }); + } + } + renderInput={(params) => ( + + )} + /> + + + {/* Date Calendar Component to select start date of an event */} + { + if (date) { + setFormState({ + ...formState, + pledgeStartDate: date.toDate(), + pledgeEndDate: + pledgeEndDate && + /*istanbul ignore next*/ + (pledgeEndDate < date?.toDate() + ? date.toDate() + : pledgeEndDate), + }); + } + }} + minDate={dayjs(pledgeStartDate)} + maxDate={dayjs(endDate)} + /> + {/* Date Calendar Component to select end Date of an event */} + { + if (date) { + setFormState({ + ...formState, + pledgeEndDate: date.toDate(), + }); + } + }} + minDate={dayjs(pledgeStartDate)} + maxDate={dayjs(endDate)} + /> + + + {/* Dropdown to select the currency in which amount is to be pledged */} + + + {t('currency')} + + + + {/* Input field to enter amount to be pledged */} + + { + if (parseInt(e.target.value) > 0) { + setFormState({ + ...formState, + pledgeAmount: parseInt(e.target.value), + }); + } + }} + /> + + + {/* Button to submit the pledge form */} + +
    +
    +
    + ); +}; +export default PledgeModal; diff --git a/src/screens/UserPortal/Chat/Chat.module.css b/src/screens/UserPortal/Chat/Chat.module.css new file mode 100644 index 0000000000..5f9a672dea --- /dev/null +++ b/src/screens/UserPortal/Chat/Chat.module.css @@ -0,0 +1,218 @@ +.containerHeight { + padding: 1rem 1.5rem 0 calc(300px); + height: 100vh; +} + +.expand { + padding-left: 4rem; + animation: moveLeft 0.9s ease-in-out; +} + +.contract { + padding-left: calc(300px); + animation: moveRight 0.5s ease-in-out; +} + +.collapseSidebarButton { + position: fixed; + height: 40px; + bottom: 0; + z-index: 9999; + width: 300px; + background-color: rgba(245, 245, 245, 0.7); + color: black; + border: none; + border-radius: 0px; +} + +.collapseSidebarButton:hover, +.opendrawer:hover { + opacity: 1; + color: black !important; +} + +.containerHeight { + height: 100vh; +} + +.mainContainer { + width: 50%; + margin-left: 20px; + flex-grow: 3; + max-height: 100%; + overflow: auto; + display: flex; + flex-direction: row; + border: 1px solid rgb(220, 220, 220); + margin-top: 15px; + border-radius: 24px; + background-color: white; +} + +.chatContainer { + flex-grow: 6; + display: flex; + flex-direction: column; + margin: 20px; + border: 1px solid rgb(220, 220, 220); + border-radius: 24px; + overflow-y: scroll; + margin-left: 0px; +} + +.chatContainer::-webkit-scrollbar { + display: none; +} + +.contactContainer { + flex-grow: 1; + display: flex; + flex-direction: column; + width: 25%; + overflow-y: scroll; +} + +.addChatContainer { + margin: 0 20px; + padding: 20px 0px 10px 0px; + border-bottom: 2px solid black; +} + +.contactCardContainer { + padding: 10px 15px; + display: flex; + flex-direction: column; + gap: 5px; +} + +.chatHeadingContainer { + padding: 10px; +} + +.borderNone { + border: none; +} + +.accordion-button:focus { + box-shadow: none; +} + +.accordion-button:not(.collapsed) { + color: #212529; + background-color: white; +} + +.chatType { + --bs-accordion-btn-bg: white; + --bs-accordion-active-bg: white; + --bs-accordion-active-color: black; + --bs-accordion-btn-focus-box-shadow: none; + --bs-accordion-border-width: 0px; +} + +.chatType > button { + padding-bottom: 0; +} + +.createChat { + background-color: white; + border: none; +} + +.opendrawer { + position: fixed; + display: flex; + align-items: center; + justify-content: center; + top: 0; + left: 0; + width: 40px; + height: 100vh; + z-index: 9999; + background-color: rgba(245, 245, 245); + border: none; + border-radius: 0px; + margin-right: 20px; + color: black; +} + +.opendrawer:hover { + transition: background-color 0.5s ease; + background-color: var(--bs-primary); +} +.collapseSidebarButton:hover { + transition: background-color 0.5s ease; + background-color: var(--bs-primary); +} + +.customToggle { + padding: 0; + background: none; + border: none; + margin-right: 1rem; + --bs-btn-active-bg: none; +} +.customToggle svg { + color: black; +} + +.customToggle::after { + content: none; +} +.customToggle:hover, +.customToggle:focus, +.customToggle:active { + background: none; + border: none; +} +.customToggle svg { + color: black; +} + +@media (max-width: 1120px) { + .collapseSidebarButton { + width: calc(250px); + } +} + +@media (max-height: 650px) { + .collapseSidebarButton { + width: 250px; + height: 20px; + } + .opendrawer { + width: 30px; + } +} + +/* For tablets */ +@media (max-width: 820px) { + .containerHeight { + height: 100vh; + padding: 2rem; + } + + .contract, + .expand { + animation: none; + } + + .opendrawer { + width: 25px; + } + + .collapseSidebarButton { + width: 100%; + left: 0; + right: 0; + } +} + +.accordionBody { + height: calc(100vh / 2 - 2rem - 60px) !important; + overflow-y: scroll; +} + +.accordionBody::-webkit-scrollbar { + display: none; +} diff --git a/src/screens/UserPortal/Chat/Chat.test.tsx b/src/screens/UserPortal/Chat/Chat.test.tsx new file mode 100644 index 0000000000..660eeaa236 --- /dev/null +++ b/src/screens/UserPortal/Chat/Chat.test.tsx @@ -0,0 +1,1695 @@ +import React from 'react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; + +import { + USERS_CONNECTION_LIST, + USER_JOINED_ORGANIZATIONS, +} from 'GraphQl/Queries/Queries'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import Chat from './Chat'; +import useLocalStorage from 'utils/useLocalstorage'; +import { MESSAGE_SENT_TO_CHAT } from 'GraphQl/Mutations/OrganizationMutations'; +import { CHATS_LIST, CHAT_BY_ID } from 'GraphQl/Queries/PlugInQueries'; + +const { setItem } = useLocalStorage(); + +const USER_JOINED_ORG_MOCK = [ + { + request: { + query: USER_JOINED_ORGANIZATIONS, + variables: { + id: '1', + }, + }, + result: { + data: { + users: [ + { + user: { + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + name: 'Any Organization', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }, + { + request: { + query: USER_JOINED_ORGANIZATIONS, + variables: { + id: '1', + }, + }, + result: { + data: { + users: [ + { + user: { + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + name: 'Any Organization', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }, + { + request: { + query: USER_JOINED_ORGANIZATIONS, + variables: { + id: '1', + }, + }, + result: { + data: { + users: [ + { + user: { + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + name: 'Any Organization', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }, + { + request: { + query: USER_JOINED_ORGANIZATIONS, + variables: { + id: null, + }, + }, + result: { + data: { + users: [ + { + user: { + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + name: 'Any Organization', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }, + { + request: { + query: USER_JOINED_ORGANIZATIONS, + variables: { + id: null, + }, + }, + result: { + data: { + users: [ + { + user: { + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + name: 'Any Organization', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }, + { + request: { + query: USER_JOINED_ORGANIZATIONS, + variables: { + id: null, + }, + }, + result: { + data: { + users: [ + { + user: { + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + name: 'Any Organization', + image: '', + description: 'New Desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }, +]; + +const UserConnectionListMock = [ + { + request: { + query: USERS_CONNECTION_LIST, + variables: { + firstName_contains: '', + lastName_contains: '', + }, + }, + result: { + data: { + users: [ + { + user: { + firstName: 'Deanne', + lastName: 'Marks', + image: null, + _id: '6589389d2caa9d8d69087487', + email: 'testuser8@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + organizationsBlockedBy: [], + joinedOrganizations: [ + { + _id: '6537904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Queens', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 Coffee Street', + line2: 'Apartment 501', + postalCode: '11427', + sortingCode: 'ABC-133', + state: 'NYC', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + { + _id: '6637904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Staten Island', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 church Street', + line2: 'Apartment 499', + postalCode: '10301', + sortingCode: 'ABC-122', + state: 'NYC', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + { + _id: '6737904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Brooklyn', + countryCode: 'US', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Main Street', + line2: 'Apt 456', + postalCode: '10004', + sortingCode: 'ABC-789', + state: 'NY', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + { + _id: '6437904485008f171cf29924', + name: 'Unity Foundation', + image: null, + address: { + city: 'Bronx', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 Random Street', + line2: 'Apartment 456', + postalCode: '10451', + sortingCode: 'ABC-123', + state: 'NYC', + __typename: 'Address', + }, + createdAt: '2023-04-13T05:16:52.827Z', + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + __typename: 'Organization', + }, + ], + __typename: 'User', + }, + appUserProfile: { + _id: '64378abd85308f171cf2993d', + adminFor: [], + isSuperAdmin: false, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + __typename: 'AppUserProfile', + }, + __typename: 'UserData', + }, + ], + }, + }, + }, + { + request: { + query: USERS_CONNECTION_LIST, + variables: { + firstName_contains: '', + lastName_contains: '', + }, + }, + result: { + data: { + users: { + user: [ + { + firstName: 'Disha', + lastName: 'Talreja', + image: 'img', + _id: '1', + email: 'disha@email.com', + createdAt: '', + appUserProfile: { + _id: '12', + isSuperAdmin: 'false', + createdOrganizations: { + _id: '345678', + }, + createdEvents: { + _id: '34567890', + }, + }, + organizationsBlockedBy: [], + joinedOrganizations: [], + }, + { + firstName: 'Disha', + lastName: 'Talreja', + image: 'img', + _id: '1', + email: 'disha@email.com', + createdAt: '', + appUserProfile: { + _id: '12', + isSuperAdmin: 'false', + createdOrganizations: { + _id: '345678', + }, + createdEvents: { + _id: '34567890', + }, + }, + organizationsBlockedBy: [], + joinedOrganizations: [], + }, + { + firstName: 'Disha', + lastName: 'Talreja', + image: 'img', + _id: '1', + email: 'disha@email.com', + createdAt: '', + appUserProfile: { + _id: '12', + isSuperAdmin: 'false', + createdOrganizations: { + _id: '345678', + }, + createdEvents: { + _id: '34567890', + }, + }, + organizationsBlockedBy: [], + joinedOrganizations: [], + }, + ], + }, + }, + }, + }, +]; + +const MESSAGE_SENT_TO_CHAT_MOCK = [ + { + request: { + query: MESSAGE_SENT_TO_CHAT, + variables: { + userId: null, + }, + }, + result: { + data: { + messageSentToChat: { + _id: '668ec1f1364e03ac47a151', + createdAt: '2024-07-10T17:16:33.248Z', + messageContent: 'Test ', + type: 'STRING', + replyTo: null, + sender: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + updatedAt: '2024-07-10', + }, + }, + }, + }, + { + request: { + query: MESSAGE_SENT_TO_CHAT, + variables: { + userId: '2', + }, + }, + result: { + data: { + messageSentToChat: { + _id: '668ec1f1df364e03ac47a151', + createdAt: '2024-07-10T17:16:33.248Z', + messageContent: 'Test ', + replyTo: null, + type: 'STRING', + sender: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + updatedAt: '2024-07-10', + }, + }, + }, + }, + { + request: { + query: MESSAGE_SENT_TO_CHAT, + variables: { + userId: '1', + }, + }, + result: { + data: { + messageSentToChat: { + _id: '668ec1f13603ac4697a151', + createdAt: '2024-07-10T17:16:33.248Z', + messageContent: 'Test ', + replyTo: null, + type: 'STRING', + sender: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: '', + }, + updatedAt: '2024-07-10', + }, + }, + }, + }, +]; + +const CHAT_BY_ID_QUERY_MOCK = [ + { + request: { + query: CHAT_BY_ID, + variables: { + id: '1', + }, + }, + result: { + data: { + chatById: { + _id: '1', + createdAt: '2345678903456', + isGroup: false, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: null, + name: '', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + ], + }, + }, + }, + }, + { + request: { + query: CHAT_BY_ID, + variables: { + id: '1', + }, + }, + result: { + data: { + chatById: { + _id: '1', + isGroup: false, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: null, + name: '', + createdAt: '2345678903456', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + appUserProfile: { + _id: '64378abd85308f171cf2993d', + adminFor: [], + isSuperAdmin: false, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + __typename: 'AppUserProfile', + }, + __typename: 'UserData', + }, + ], + }, + }, + }, + }, + { + request: { + query: USERS_CONNECTION_LIST, + variables: { + firstName_contains: '', + lastName_contains: '', + }, + }, + result: { + data: { + users: { + user: [ + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + ], + }, + }, + }, + }, +]; + +const CHATS_LIST_MOCK = [ + { + request: { + query: CHATS_LIST, + variables: { + id: null, + }, + }, + result: { + data: { + chatsByUserId: [ + { + _id: '65844efc814dd40fgh03db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + { + _id: '65844efc814ddgh4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: CHATS_LIST, + variables: { + id: '', + }, + }, + result: { + data: { + chatsByUserId: [ + { + _id: '65844ghjefc814dd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + { + _id: 'ujhgtrdtyuiop', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: CHATS_LIST, + variables: { + id: '1', + }, + }, + result: { + data: { + chatsByUserId: [ + { + _id: '65844efc814dhjmkdftyd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + { + _id: '65844ewsedrffc814dd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + ], + }, + }, + }, +]; + +const GROUP_CHAT_BY_ID_QUERY_MOCK = [ + { + request: { + query: CHAT_BY_ID, + variables: { + id: '', + }, + }, + result: { + data: { + chatById: { + _id: '65844efc814dd4003db811c4', + isGroup: true, + creator: { + _id: '64378abd85008f171cf2990d', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + email: 'testsuperadmin@example.com', + createdAt: '2023-04-13T04:53:17.742Z', + __typename: 'User', + }, + organization: { + _id: 'pw3ertyuiophgfre45678', + name: 'rtyu', + }, + createdAt: '2345678903456', + name: 'Test Group Chat', + messages: [ + { + _id: '345678', + createdAt: '345678908765', + messageContent: 'Hello', + replyTo: null, + type: 'STRING', + sender: { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + }, + ], + users: [ + { + _id: '1', + firstName: 'Disha', + lastName: 'Talreja', + email: 'disha@example.com', + image: '', + }, + { + _id: '2', + firstName: 'Test', + lastName: 'User', + email: 'test@example.com', + image: '', + }, + { + _id: '3', + firstName: 'Test', + lastName: 'User1', + email: 'test1@example.com', + image: '', + }, + { + _id: '4', + firstName: 'Test', + lastName: 'User2', + email: 'test2@example.com', + image: '', + }, + { + _id: '5', + firstName: 'Test', + lastName: 'User4', + email: 'test4@example.com', + image: '', + }, + ], + }, + }, + }, + }, +]; + +const resizeWindow = (width: number): void => { + window.innerWidth = width; + fireEvent(window, new Event('resize')); +}; + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Testing Chat Screen [User Portal]', () => { + window.HTMLElement.prototype.scrollIntoView = jest.fn(); + + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); + + test('Screen should be rendered properly', async () => { + const mock = [ + ...USER_JOINED_ORG_MOCK, + ...GROUP_CHAT_BY_ID_QUERY_MOCK, + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...UserConnectionListMock, + ...CHAT_BY_ID_QUERY_MOCK, + ...CHATS_LIST_MOCK, + ...UserConnectionListMock, + ]; + + render( + + + + + + + + + , + ); + await wait(); + }); + + test('User is able to select a contact', async () => { + const mock = [ + ...USER_JOINED_ORG_MOCK, + ...GROUP_CHAT_BY_ID_QUERY_MOCK, + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...UserConnectionListMock, + ...CHAT_BY_ID_QUERY_MOCK, + ...CHATS_LIST_MOCK, + ...UserConnectionListMock, + ]; + + render( + + + + + + + + + , + ); + + await wait(); + + expect(await screen.findByText('Messages')).toBeInTheDocument(); + + expect( + await screen.findByTestId('contactCardContainer'), + ).toBeInTheDocument(); + }); + + test('create new direct chat', async () => { + const mock = [ + ...USER_JOINED_ORG_MOCK, + ...GROUP_CHAT_BY_ID_QUERY_MOCK, + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...CHAT_BY_ID_QUERY_MOCK, + ...CHATS_LIST_MOCK, + ...UserConnectionListMock, + ]; + render( + + + + + + + + + , + ); + + await wait(); + + const dropdown = await screen.findByTestId('dropdown'); + expect(dropdown).toBeInTheDocument(); + fireEvent.click(dropdown); + const newDirectChatBtn = await screen.findByTestId('newDirectChat'); + expect(newDirectChatBtn).toBeInTheDocument(); + fireEvent.click(newDirectChatBtn); + + const closeButton = screen.getByRole('button', { name: /close/i }); + expect(closeButton).toBeInTheDocument(); + + const submitBtn = await screen.findByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + + fireEvent.click(closeButton); + }); + + test('create new group chat', async () => { + const mock = [ + ...USER_JOINED_ORG_MOCK, + ...GROUP_CHAT_BY_ID_QUERY_MOCK, + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...UserConnectionListMock, + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...CHAT_BY_ID_QUERY_MOCK, + ...CHATS_LIST_MOCK, + ...UserConnectionListMock, + ]; + render( + + + + + + + + + , + ); + + await wait(); + + const dropdown = await screen.findByTestId('dropdown'); + expect(dropdown).toBeInTheDocument(); + fireEvent.click(dropdown); + + const newGroupChatBtn = await screen.findByTestId('newGroupChat'); + expect(newGroupChatBtn).toBeInTheDocument(); + + fireEvent.click(newGroupChatBtn); + const closeButton = screen.getByRole('button', { name: /close/i }); + expect(closeButton).toBeInTheDocument(); + + fireEvent.click(closeButton); + }); + + test('sidebar', async () => { + const mock = [ + ...USER_JOINED_ORG_MOCK, + ...GROUP_CHAT_BY_ID_QUERY_MOCK, + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...UserConnectionListMock, + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...CHAT_BY_ID_QUERY_MOCK, + ...CHATS_LIST_MOCK, + ...UserConnectionListMock, + ]; + setItem('userId', '1'); + + render( + + + + + + + + + , + ); + screen.debug(); + await waitFor(async () => { + const closeMenuBtn = await screen.findByTestId('closeMenu'); + expect(closeMenuBtn).toBeInTheDocument(); + if (closeMenuBtn) { + closeMenuBtn.click(); + } else { + throw new Error('Close menu button not found'); + } + }); + }); + + test('Testing sidebar when the screen size is less than or equal to 820px', async () => { + setItem('userId', '1'); + const mock = [ + ...USER_JOINED_ORG_MOCK, + ...GROUP_CHAT_BY_ID_QUERY_MOCK, + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...UserConnectionListMock, + ...MESSAGE_SENT_TO_CHAT_MOCK, + ...CHAT_BY_ID_QUERY_MOCK, + ...CHATS_LIST_MOCK, + ...UserConnectionListMock, + ]; + resizeWindow(800); + render( + + + + + + + + + , + ); + await wait(); + expect(screen.getByText('My Organizations')).toBeInTheDocument(); + expect(screen.getByText('Talawa User Portal')).toBeInTheDocument(); + + expect(await screen.findByTestId('openMenu')).toBeInTheDocument(); + fireEvent.click(screen.getByTestId('openMenu')); + expect(await screen.findByTestId('closeMenu')).toBeInTheDocument(); + }); +}); diff --git a/src/screens/UserPortal/Chat/Chat.tsx b/src/screens/UserPortal/Chat/Chat.tsx new file mode 100644 index 0000000000..441ce7d4ba --- /dev/null +++ b/src/screens/UserPortal/Chat/Chat.tsx @@ -0,0 +1,249 @@ +import React, { useEffect, useState } from 'react'; +import { useQuery } from '@apollo/client'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown } from 'react-bootstrap'; +import { SearchOutlined, Search } from '@mui/icons-material'; +import HourglassBottomIcon from '@mui/icons-material/HourglassBottom'; +import ContactCard from 'components/UserPortal/ContactCard/ContactCard'; +import ChatRoom from 'components/UserPortal/ChatRoom/ChatRoom'; +import useLocalStorage from 'utils/useLocalstorage'; +import NewChat from 'assets/svgs/newChat.svg?react'; +import styles from './Chat.module.css'; +import UserSidebar from 'components/UserPortal/UserSidebar/UserSidebar'; +import { CHATS_LIST } from 'GraphQl/Queries/PlugInQueries'; +import CreateGroupChat from '../../../components/UserPortal/CreateGroupChat/CreateGroupChat'; +import CreateDirectChat from 'components/UserPortal/CreateDirectChat/CreateDirectChat'; + +interface InterfaceContactCardProps { + id: string; + title: string; + image: string; + selectedContact: string; + setSelectedContact: React.Dispatch>; + isGroup: boolean; +} +/** + * The `chat` component provides a user interface for interacting with contacts and chat rooms within an organization. + * It features a contact list with search functionality and displays the chat room for the selected contact. + * The component uses GraphQL to fetch and manage contact data, and React state to handle user interactions. + * + * ## Features: + * - **Search Contacts:** Allows users to search for contacts by their first name. + * - **Contact List:** Displays a list of contacts with their details and a profile image. + * - **Chat Room:** Shows the chat room for the selected contact. + * + * ## GraphQL Queries: + * - `ORGANIZATIONS_MEMBER_CONNECTION_LIST`: Fetches a list of members within an organization, with optional filtering based on the first name. + * + * ## Event Handlers: + * - `handleSearch`: Updates the filterName state and refetches the contact data based on the search query. + * - `handleSearchByEnter`: Handles search input when the Enter key is pressed. + * - `handleSearchByBtnClick`: Handles search input when the search button is clicked. + * + * ## Rendering: + * - Displays a search input field and a search button for filtering contacts. + * - Shows a list of contacts with their details and profile images. + * - Renders a chat room component for the selected contact. + * - Displays a loading indicator while contact data is being fetched. + * + * @returns The rendered `chat` component. + */ +export default function chat(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'chat', + }); + const { t: tCommon } = useTranslation('common'); + + const [hideDrawer, setHideDrawer] = useState(null); + const [chats, setChats] = useState([]); + const [selectedContact, setSelectedContact] = useState(''); + const { getItem } = useLocalStorage(); + const userId = getItem('userId'); + + const handleResize = (): void => { + if (window.innerWidth <= 820) { + setHideDrawer(!hideDrawer); + } + }; + + useEffect(() => { + handleResize(); + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + const [createDirectChatModalisOpen, setCreateDirectChatModalisOpen] = + useState(false); + + function openCreateDirectChatModal(): void { + setCreateDirectChatModalisOpen(true); + } + + const toggleCreateDirectChatModal = /* istanbul ignore next */ (): void => + setCreateDirectChatModalisOpen(!createDirectChatModalisOpen); + + const [createGroupChatModalisOpen, setCreateGroupChatModalisOpen] = + useState(false); + + function openCreateGroupChatModal(): void { + setCreateGroupChatModalisOpen(true); + } + + const toggleCreateGroupChatModal = /* istanbul ignore next */ (): void => { + setCreateGroupChatModalisOpen(!createGroupChatModalisOpen); + }; + + const { + data: chatsListData, + loading: chatsListLoading, + refetch: chatsListRefetch, + } = useQuery(CHATS_LIST, { + variables: { + id: userId, + }, + }); + + React.useEffect(() => { + if (chatsListData) { + setChats(chatsListData.chatsByUserId); + } + }, [chatsListData]); + + // const handleSearch = (value: string): void => { + // setFilterName(value); + + // contactRefetch(); + // }; + // const handleSearchByEnter = (e: any): void => { + // if (e.key === 'Enter') { + // const { value } = e.target; + // handleSearch(value); + // } + // }; + // const handleSearchByBtnClick = (): void => { + // const value = + // (document.getElementById('searchChats') as HTMLInputElement)?.value || ''; + // handleSearch(value); + // }; + + return ( + <> + {hideDrawer ? ( + + ) : ( + + )} + +
    +
    +
    +
    +

    Messages

    + + + + + + + New Chat + + + New Group Chat + + + Starred Messages + + + +
    +
    + {chatsListLoading ? ( +
    + Loading... +
    + ) : ( +
    + {!!chats.length && + chats.map((chat: any) => { + const cardProps: InterfaceContactCardProps = { + id: chat._id, + title: !chat.isGroup + ? chat.users[0]?._id === userId + ? `${chat.users[1]?.firstName} ${chat.users[1]?.lastName}` + : `${chat.users[0]?.firstName} ${chat.users[0]?.lastName}` + : chat.name, + image: chat.isGroup + ? userId + ? chat.users[1]?.image + : chat.users[0]?.image + : chat.image, + setSelectedContact, + selectedContact, + isGroup: chat.isGroup, + }; + return ( + + ); + })} +
    + )} +
    +
    +
    + +
    +
    +
    + {createGroupChatModalisOpen && ( + + )} + {createDirectChatModalisOpen && ( + + )} + + ); +} diff --git a/src/screens/UserPortal/Donate/Donate.module.css b/src/screens/UserPortal/Donate/Donate.module.css new file mode 100644 index 0000000000..8f19db3ff8 --- /dev/null +++ b/src/screens/UserPortal/Donate/Donate.module.css @@ -0,0 +1,84 @@ +.mainContainer { + width: 50%; + flex-grow: 3; + display: flex; + flex-direction: column; + background-color: #f2f7ff; +} + +.inputContainer { + flex: 1; + position: relative; +} +.input { + position: relative; + box-shadow: 5px 5px 4px 0px #31bb6b1f; +} + +.box { + width: auto; + /* height: 200px; */ + background-color: white; + margin-top: 1rem; + padding: 20px; + border: 1px solid #dddddd; + border-radius: 10px; +} + +.heading { + font-size: 1.1rem; +} + +.donationInputContainer { + display: flex; + flex-direction: row; + margin-top: 20px; +} + +.donateBtn { + padding-inline: 1rem !important; +} + +.dropdown { + min-width: 6rem; +} + +.inputArea { + border: none; + outline: none; + background-color: #f1f3f6; +} + +.maxWidth { + width: 100%; +} + +.donateActions { + margin-top: 1rem; + width: 100%; + display: flex; + flex-direction: row-reverse; +} + +.donationsContainer { + padding-top: 4rem; + flex-grow: 1; + display: flex; + flex-direction: column; +} + +.donationCardsContainer { + display: flex; + flex-wrap: wrap; + gap: 1rem; + --bs-gutter-x: 0; +} + +.colorLight { + background-color: #f5f5f5; +} + +.content { + padding-top: 10px; + flex-grow: 1; +} diff --git a/src/screens/UserPortal/Donate/Donate.test.tsx b/src/screens/UserPortal/Donate/Donate.test.tsx new file mode 100644 index 0000000000..c4d435415e --- /dev/null +++ b/src/screens/UserPortal/Donate/Donate.test.tsx @@ -0,0 +1,390 @@ +import React, { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; + +import { + ORGANIZATION_DONATION_CONNECTION_LIST, + USER_ORGANIZATION_CONNECTION, +} from 'GraphQl/Queries/Queries'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import Donate from './Donate'; +import userEvent from '@testing-library/user-event'; +import useLocalStorage from 'utils/useLocalstorage'; +import { DONATE_TO_ORGANIZATION } from 'GraphQl/Mutations/mutations'; +import { toast } from 'react-toastify'; + +const MOCKS = [ + { + request: { + query: ORGANIZATION_DONATION_CONNECTION_LIST, + variables: { + orgId: '', + }, + }, + result: { + data: { + getDonationByOrgIdConnection: [ + { + _id: '6391a15bcb738c181d238957', + nameOfUser: 'firstName lastName', + amount: 1, + userId: '6391a15bcb738c181d238952', + payPalId: 'payPalId', + updatedAt: '2024-04-03T16:43:01.514Z', + }, + ], + }, + }, + }, + { + request: { + query: USER_ORGANIZATION_CONNECTION, + variables: { + id: '', + }, + }, + result: { + data: { + organizationsConnection: [ + { + _id: '6401ff65ce8e8406b8f07af3', + image: '', + name: 'anyOrganization2', + description: 'desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + userRegistrationRequired: true, + createdAt: '12345678900', + creator: { firstName: 'John', lastName: 'Doe' }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: DONATE_TO_ORGANIZATION, + variables: { + userId: '123', + createDonationOrgId2: '', + payPalId: 'paypalId', + nameOfUser: 'name', + amount: 123, + nameOfOrg: 'anyOrganization2', + }, + }, + result: { + data: { + createDonation: [ + { + _id: '', + amount: 123, + nameOfUser: 'name', + nameOfOrg: 'anyOrganization2', + }, + ], + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: '' }), +})); + +jest.mock('react-toastify', () => ({ + toast: { + error: jest.fn(), + success: jest.fn(), + }, +})); + +describe('Testing Donate Screen [User Portal]', () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('Screen should be rendered properly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + expect(screen.getByPlaceholderText('Search donations')).toBeInTheDocument(); + expect(screen.getByTestId('currency0')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Amount')).toBeInTheDocument(); + }); + + test('Currency is swtiched to USD', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('changeCurrencyBtn')); + + userEvent.click(screen.getByTestId('currency0')); + await wait(); + + expect(screen.getByTestId('currency0')).toBeInTheDocument(); + }); + + test('Currency is swtiched to INR', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('changeCurrencyBtn')); + + userEvent.click(screen.getByTestId('currency1')); + + await wait(); + }); + + test('Currency is swtiched to EUR', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('changeCurrencyBtn')); + + userEvent.click(screen.getByTestId('currency2')); + + await wait(); + }); + + test('Checking the existence of Donation Cards', async () => { + render( + + + + + + + + + , + ); + + await wait(); + expect(screen.getAllByTestId('donationCard')[0]).toBeInTheDocument(); + }); + + test('For Donation functionality', async () => { + const { setItem } = useLocalStorage(); + setItem('userId', '123'); + setItem('name', 'name'); + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.type(screen.getByTestId('donationAmount'), '123'); + userEvent.click(screen.getByTestId('donateBtn')); + await wait(); + }); + + test('displays error toast for donation amount below minimum', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.type(screen.getByTestId('donationAmount'), '0.5'); + userEvent.click(screen.getByTestId('donateBtn')); + + await wait(); + + expect(toast.error).toHaveBeenCalledWith( + 'Donation amount must be between 1 and 10000000.', + ); + }); + + test('displays error toast for donation amount above maximum', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.type(screen.getByTestId('donationAmount'), '10000001'); + userEvent.click(screen.getByTestId('donateBtn')); + + await wait(); + + expect(toast.error).toHaveBeenCalledWith( + 'Donation amount must be between 1 and 10000000.', + ); + }); + + test('displays error toast for empty donation amount', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('donateBtn')); + + await wait(); + + expect(toast.error).toHaveBeenCalledWith( + 'Please enter a numerical value for the donation amount.', + ); + }); + + test('displays error toast for invalid (non-numeric) donation amount', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.type(screen.getByTestId('donationAmount'), 'abc'); + userEvent.click(screen.getByTestId('donateBtn')); + + await wait(); + + expect(toast.error).toHaveBeenCalledWith( + 'Please enter a numerical value for the donation amount.', + ); + }); +}); diff --git a/src/screens/UserPortal/Donate/Donate.tsx b/src/screens/UserPortal/Donate/Donate.tsx new file mode 100644 index 0000000000..c6e6d918d5 --- /dev/null +++ b/src/screens/UserPortal/Donate/Donate.tsx @@ -0,0 +1,319 @@ +import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { Button, Dropdown, Form, InputGroup } from 'react-bootstrap'; +import { toast } from 'react-toastify'; +import { useQuery, useMutation } from '@apollo/client'; +import { Search } from '@mui/icons-material'; +import SendIcon from '@mui/icons-material/Send'; +import HourglassBottomIcon from '@mui/icons-material/HourglassBottom'; +import { useTranslation } from 'react-i18next'; + +import { + ORGANIZATION_DONATION_CONNECTION_LIST, + USER_ORGANIZATION_CONNECTION, +} from 'GraphQl/Queries/Queries'; +import { DONATE_TO_ORGANIZATION } from 'GraphQl/Mutations/mutations'; +import styles from './Donate.module.css'; +import DonationCard from 'components/UserPortal/DonationCard/DonationCard'; +import useLocalStorage from 'utils/useLocalstorage'; +import { errorHandler } from 'utils/errorHandler'; +import OrganizationSidebar from 'components/UserPortal/OrganizationSidebar/OrganizationSidebar'; +import PaginationList from 'components/PaginationList/PaginationList'; + +export interface InterfaceDonationCardProps { + id: string; + name: string; + amount: string; + userId: string; + payPalId: string; + updatedAt: string; +} + +interface InterfaceDonation { + _id: string; + nameOfUser: string; + amount: string; + userId: string; + payPalId: string; + updatedAt: string; +} + +/** + * `donate` component allows users to make donations to an organization and view their previous donations. + * + * This component fetches donation-related data using GraphQL queries and allows users to make donations + * using a mutation. It supports currency selection, donation amount input, and displays a paginated list + * of previous donations. + * + * It includes: + * - An input field for searching donations. + * - A dropdown to select currency. + * - An input field for entering donation amount. + * - A button to submit the donation. + * - A list of previous donations displayed in a paginated format. + * - An organization sidebar for navigation. + * + * ### GraphQL Queries + * - `ORGANIZATION_DONATION_CONNECTION_LIST`: Fetches the list of donations for the organization. + * - `USER_ORGANIZATION_CONNECTION`: Fetches organization details. + * + * ### GraphQL Mutations + * - `DONATE_TO_ORGANIZATION`: Performs the donation action. + * + * @returns The rendered component. + */ +export default function donate(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'donate', + }); + + const { getItem } = useLocalStorage(); + const userId = getItem('userId'); + const userName = getItem('name'); + + const { orgId: organizationId } = useParams(); + const [amount, setAmount] = useState(''); + const [organizationDetails, setOrganizationDetails] = useState<{ + name: string; + }>({ name: '' }); + const [donations, setDonations] = useState([]); + const [selectedCurrency, setSelectedCurrency] = useState(0); + const [page, setPage] = useState(0); + const [rowsPerPage, setRowsPerPage] = useState(5); + + const currencies = ['USD', 'INR', 'EUR']; + + const { + data: donationData, + loading, + refetch, + } = useQuery(ORGANIZATION_DONATION_CONNECTION_LIST, { + variables: { orgId: organizationId }, + }); + + const { data } = useQuery(USER_ORGANIZATION_CONNECTION, { + variables: { id: organizationId }, + }); + + const [donate] = useMutation(DONATE_TO_ORGANIZATION); + + /* istanbul ignore next */ + const handleChangePage = ( + _event: React.MouseEvent | null, + newPage: number, + ): void => { + setPage(newPage); + }; + + /* istanbul ignore next */ + const handleChangeRowsPerPage = ( + event: React.ChangeEvent, + ): void => { + const newRowsPerPage = event.target.value; + + setRowsPerPage(parseInt(newRowsPerPage, 10)); + setPage(0); + }; + + useEffect(() => { + if (data) { + setOrganizationDetails(data.organizationsConnection[0]); + } + }, [data]); + + useEffect(() => { + if (donationData) { + setDonations(donationData.getDonationByOrgIdConnection); + } + }, [donationData]); + + const donateToOrg = (): void => { + // check if the amount is non empty and is a number + if (amount === '' || Number.isNaN(Number(amount))) { + toast.error(t(`invalidAmount`)); + return; + } + + // check if the amount is non negative and within the range + const minDonation = 1; + const maxDonation = 10000000; + if ( + Number(amount) <= 0 || + Number(amount) < minDonation || + Number(amount) > maxDonation + ) { + toast.error( + t(`donationOutOfRange`, { min: minDonation, max: maxDonation }), + ); + return; + } + + const formattedAmount = Number(amount.trim()); + + try { + donate({ + variables: { + userId, + createDonationOrgId2: organizationId, + payPalId: 'paypalId', + nameOfUser: userName, + amount: formattedAmount, + nameOfOrg: organizationDetails.name, + }, + }); + refetch(); + toast.success(t(`success`) as string); + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }; + + return ( + <> +
    +
    +
    +
    + + +
    +
    +
    +
    + {t('donateForThe')} {organizationDetails.name} +
    +
    + + + + + {currencies[selectedCurrency]} + + + + {currencies.map((currency, index) => { + return ( + setSelectedCurrency(index)} + data-testid={`currency${index}`} + > + {currency} + + ); + })} + + + { + setAmount(e.target.value); + }} + /> + +
    + + {t('donationAmountDescription')} + +
    + +
    +
    +
    +
    {t('yourPreviousDonations')}
    +
    +
    + {loading ? ( +
    + Loading... +
    + ) : ( + <> + {donations && donations.length > 0 ? ( + (rowsPerPage > 0 + ? donations.slice( + page * rowsPerPage, + page * rowsPerPage + rowsPerPage, + ) + : /* istanbul ignore next */ + donations + ).map((donation: InterfaceDonation, index) => { + const cardProps: InterfaceDonationCardProps = { + name: donation.nameOfUser, + id: donation._id, + amount: donation.amount, + userId: donation.userId, + payPalId: donation.payPalId, + updatedAt: donation.updatedAt, + }; + return ( +
    + +
    + ); + }) + ) : ( + {t('nothingToShow')} + )} + + )} +
    + + + + + + +
    +
    +
    +
    + +
    + + ); +} diff --git a/src/screens/UserPortal/Events/Events.module.css b/src/screens/UserPortal/Events/Events.module.css new file mode 100644 index 0000000000..eadbf63d0f --- /dev/null +++ b/src/screens/UserPortal/Events/Events.module.css @@ -0,0 +1,156 @@ +.borderNone { + border: none; +} + +.colorWhite { + color: white; +} + +.backgroundWhite { + background-color: white; +} + +.maxWidth { + max-width: 800px; +} + +.mainContainer { + margin-top: 2rem; + width: 50%; + flex-grow: 3; + max-height: 100%; + overflow: auto; +} + +.content { + height: fit-content; + min-height: calc(100% - 40px); +} +.selectType { + border-radius: 10px; +} +.dropdown__item { + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.gap { + gap: 20px; +} + +.paddingY { + padding: 30px 0px; +} + +.colorPrimary { + background: #31bb6b; + color: white; +} + +.eventActionsContainer { + display: flex; + flex-direction: row; + gap: 15px; +} + +.datePicker { + border-radius: 10px; + height: 40px; + text-align: center; + background-color: #f2f2f2; + border: none; + width: 100%; +} + +.modalBody { + display: flex; + flex-direction: column; + gap: 10px; +} + +.switchContainer { + display: flex; + align-items: center; +} + +.switches { + display: flex; + flex-direction: row; + gap: 20px; + flex-wrap: wrap; + margin-top: 20px; +} +.titlemodal { + color: #707070; + font-weight: 600; + font-size: 20px; + margin-bottom: 20px; + padding-bottom: 5px; + border-bottom: 3px solid #31bb6b; + width: 65%; +} + +.datediv { + display: flex; + flex-direction: row; + margin-bottom: 15px; +} + +.datebox { + width: 90%; + border-radius: 7px; + border-color: #e8e5e5; + outline: none; + box-shadow: none; + padding-top: 2px; + padding-bottom: 2px; + padding-right: 5px; + padding-left: 5px; + margin-right: 5px; + margin-left: 5px; +} + +.checkboxdiv > label { + margin-right: 50px; +} +.checkboxdiv > label > input { + margin-left: 10px; +} + +.checkboxdiv { + display: flex; +} +.checkboxdiv > div { + width: 50%; +} + +.dispflex { + display: flex; + align-items: center; +} +.dispflex > input { + border: none; + box-shadow: none; + margin-top: 5px; +} + +.greenregbtn { + margin: 1rem 0 0; + margin-top: 15px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 10px; + border-radius: 5px; + background-color: #31bb6b; + width: 100%; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; +} diff --git a/src/screens/UserPortal/Events/Events.test.tsx b/src/screens/UserPortal/Events/Events.test.tsx new file mode 100644 index 0000000000..8c0b7c6912 --- /dev/null +++ b/src/screens/UserPortal/Events/Events.test.tsx @@ -0,0 +1,521 @@ +import React, { act } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; + +import { ORGANIZATION_EVENTS_CONNECTION } from 'GraphQl/Queries/Queries'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import Events from './Events'; +import userEvent from '@testing-library/user-event'; +import { CREATE_EVENT_MUTATION } from 'GraphQl/Mutations/mutations'; +import { toast } from 'react-toastify'; +import dayjs from 'dayjs'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { ThemeProvider } from 'react-bootstrap'; +import { createTheme } from '@mui/material'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem, getItem } = useLocalStorage(); + +jest.mock('react-toastify', () => ({ + toast: { + error: jest.fn(), + info: jest.fn(), + success: jest.fn(), + }, +})); + +jest.mock('@mui/x-date-pickers/DatePicker', () => { + return { + DatePicker: jest.requireActual('@mui/x-date-pickers/DesktopDatePicker') + .DesktopDatePicker, + }; +}); + +jest.mock('@mui/x-date-pickers/TimePicker', () => { + return { + TimePicker: jest.requireActual('@mui/x-date-pickers/DesktopTimePicker') + .DesktopTimePicker, + }; +}); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: '' }), +})); + +const theme = createTheme({ + palette: { + primary: { + main: '#31bb6b', + }, + }, +}); + +const MOCKS = [ + { + request: { + query: ORGANIZATION_EVENTS_CONNECTION, + variables: { + organization_id: '', + title_contains: '', + }, + }, + result: { + data: { + eventsByOrganizationConnection: [ + { + _id: '6404a267cc270739118e2349', + title: 'NewEvent', + description: 'sdadsasad', + startDate: '2023-03-05', + endDate: '2023-03-05', + location: 'NewLocation', + startTime: null, + endTime: null, + allDay: true, + recurring: false, + isPublic: true, + isRegisterable: false, + creator: { + _id: '63d649417ffe6e4d5174ea32', + firstName: 'Noble', + lastName: 'Mittal', + __typename: 'User', + }, + attendees: [ + { + _id: '63d649417ffe6e4d5174ea32', + __typename: 'User', + }, + { + _id: '63d6064458fce20ee25c3bf7', + __typename: 'User', + }, + ], + __typename: 'Event', + }, + { + _id: '6404e952c651df745358849d', + title: '1parti', + description: 'asddas', + startDate: '2023-03-06', + endDate: '2023-03-06', + location: 'das', + startTime: '00:40:00.000', + endTime: '02:40:00.000', + allDay: false, + recurring: false, + isPublic: true, + isRegisterable: true, + creator: { + _id: '63d649417ffe6e4d5174ea32', + firstName: 'Noble', + lastName: 'Mittal', + __typename: 'User', + }, + attendees: [ + { + _id: '63d649417ffe6e4d5174ea32', + __typename: 'User', + }, + { + _id: '63dd52bbe69f63814b0a5dd4', + __typename: 'User', + }, + { + _id: '63d6064458fce20ee25c3bf7', + __typename: 'User', + }, + ], + __typename: 'Event', + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_EVENTS_CONNECTION, + variables: { + organization_id: '', + title_contains: 'test', + }, + }, + result: { + data: { + eventsByOrganizationConnection: [ + { + _id: '6404a267cc270739118e2349', + title: 'NewEvent', + description: 'sdadsasad', + startDate: '2023-03-05', + endDate: '2023-03-05', + location: 'NewLocation', + startTime: null, + endTime: null, + allDay: true, + recurring: false, + isPublic: true, + isRegisterable: false, + creator: { + _id: '63d649417ffe6e4d5174ea32', + firstName: 'Noble', + lastName: 'Mittal', + __typename: 'User', + }, + attendees: [ + { + _id: '63d649417ffe6e4d5174ea32', + __typename: 'User', + }, + { + _id: '63d6064458fce20ee25c3bf7', + __typename: 'User', + }, + ], + __typename: 'Event', + }, + ], + }, + }, + }, + { + request: { + query: CREATE_EVENT_MUTATION, + variables: { + title: 'testEventTitle', + description: 'testEventDescription', + location: 'testEventLocation', + isPublic: true, + recurring: false, + isRegisterable: true, + organizationId: '', + startDate: dayjs(new Date()).format('YYYY-MM-DD'), + endDate: dayjs(new Date()).format('YYYY-MM-DD'), + allDay: false, + startTime: '08:00:00', + endTime: '10:00:00', + }, + }, + result: { + data: { + createEvent: { + _id: '2', + }, + }, + }, + }, + { + request: { + query: CREATE_EVENT_MUTATION, + variables: { + title: 'testEventTitle', + description: 'testEventDescription', + location: 'testEventLocation', + isPublic: true, + recurring: false, + isRegisterable: true, + organizationId: '', + startDate: dayjs(new Date()).format('YYYY-MM-DD'), + endDate: dayjs(new Date()).format('YYYY-MM-DD'), + allDay: true, + startTime: null, + endTime: null, + }, + }, + result: { + data: { + createEvent: { + _id: '1', + }, + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Testing Events Screen [User Portal]', () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); + + test('Screen should be rendered properly', async () => { + render( + + + + + + + + + , + ); + setItem('SuperAdmin', true); // testing userRole as Superadmin + await wait(); + setItem('SuperAdmin', false); + setItem('AdminFor', ['123']); // testing userRole as Admin + await wait(); + }); + + test('Create event works as expected when event is not an all day event.', async () => { + render( + + + + + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + const randomEventTitle = 'testEventTitle'; + const randomEventDescription = 'testEventDescription'; + const randomEventLocation = 'testEventLocation'; + + userEvent.type(screen.getByTestId('eventTitleInput'), randomEventTitle); + userEvent.type( + screen.getByTestId('eventDescriptionInput'), + randomEventDescription, + ); + userEvent.type( + screen.getByTestId('eventLocationInput'), + randomEventLocation, + ); + + userEvent.click(screen.getByTestId('publicEventCheck')); + userEvent.click(screen.getByTestId('publicEventCheck')); + + userEvent.click(screen.getByTestId('registerableEventCheck')); + userEvent.click(screen.getByTestId('registerableEventCheck')); + + userEvent.click(screen.getByTestId('recurringEventCheck')); + userEvent.click(screen.getByTestId('recurringEventCheck')); + + userEvent.click(screen.getByTestId('recurringEventCheck')); + userEvent.click(screen.getByTestId('recurringEventCheck')); + + userEvent.click(screen.getByTestId('allDayEventCheck')); + + userEvent.click(screen.getByTestId('createEventBtn')); + + await wait(); + + expect(toast.success).toHaveBeenCalledWith( + 'Event created and posted successfully.', + ); + }); + + test('Create event works as expected when event is an all day event.', async () => { + render( + + + + + + + + + + + + + , + ); + await wait(); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + const randomEventTitle = 'testEventTitle'; + const randomEventDescription = 'testEventDescription'; + const randomEventLocation = 'testEventLocation'; + + userEvent.type(screen.getByTestId('eventTitleInput'), randomEventTitle); + userEvent.type( + screen.getByTestId('eventDescriptionInput'), + randomEventDescription, + ); + userEvent.type( + screen.getByTestId('eventLocationInput'), + randomEventLocation, + ); + + userEvent.click(screen.getByTestId('createEventBtn')); + + await wait(); + + expect(toast.success).toHaveBeenCalledWith( + 'Event created and posted successfully.', + ); + }); + + test('Switch to calendar view works as expected.', async () => { + render( + + + + + + + + + + + + + , + ); + + // await wait(); + + // userEvent.click(screen.getByTestId('modeChangeBtn')); + // userEvent.click(screen.getByTestId('modeBtn1')); + + await wait(); + const calenderView = 'Calendar View'; + + expect(screen.queryAllByText(calenderView)).not.toBeNull(); + expect(screen.getByText('Sun')).toBeInTheDocument(); + }); + + test('Testing DatePicker and TimePicker', async () => { + render( + + + + + + + + + + + + + , + ); + + await wait(); + + const startDate = '03/23/2024'; + const endDate = '04/23/2024'; + const startTime = '02:00 PM'; + const endTime = '06:00 PM'; + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + expect(endDate).not.toBeNull(); + const endDateDatePicker = screen.getByLabelText('End Date'); + expect(startDate).not.toBeNull(); + const startDateDatePicker = screen.getByLabelText('Start Date'); + + fireEvent.change(startDateDatePicker, { + target: { value: startDate }, + }); + fireEvent.change(endDateDatePicker, { + target: { value: endDate }, + }); + + await wait(); + + expect(endDateDatePicker).toHaveValue(endDate); + expect(startDateDatePicker).toHaveValue(startDate); + + userEvent.click(screen.getByTestId('allDayEventCheck')); + + expect(endTime).not.toBeNull(); + const endTimePicker = screen.getByLabelText('End Time'); + expect(startTime).not.toBeNull(); + const startTimePicker = screen.getByLabelText('Start Time'); + + fireEvent.change(startTimePicker, { + target: { value: startTime }, + }); + fireEvent.change(endTimePicker, { + target: { value: endTime }, + }); + + await wait(); + expect(endTimePicker).toHaveValue(endTime); + expect(startTimePicker).toHaveValue(startTime); + }); + + test('EndDate null', async () => { + render( + + + + + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('createEventModalBtn')); + + const endDateDatePicker = screen.getByLabelText('End Date'); + const startDateDatePicker = screen.getByLabelText('Start Date'); + + fireEvent.change(startDateDatePicker, { + target: { value: null }, + }); + fireEvent.change(endDateDatePicker, { + target: { value: null }, + }); + + userEvent.click(screen.getByTestId('allDayEventCheck')); + + const endTimePicker = screen.getByLabelText('End Time'); + const startTimePicker = screen.getByLabelText('Start Time'); + + fireEvent.change(startTimePicker, { + target: { value: null }, + }); + fireEvent.change(endTimePicker, { + target: { value: null }, + }); + }); +}); diff --git a/src/screens/UserPortal/Events/Events.tsx b/src/screens/UserPortal/Events/Events.tsx new file mode 100644 index 0000000000..d3fabf9469 --- /dev/null +++ b/src/screens/UserPortal/Events/Events.tsx @@ -0,0 +1,408 @@ +import { useMutation, useQuery } from '@apollo/client'; +import { DatePicker, TimePicker } from '@mui/x-date-pickers'; +import { CREATE_EVENT_MUTATION } from 'GraphQl/Mutations/mutations'; +import { + ORGANIZATIONS_LIST, + ORGANIZATION_EVENTS_CONNECTION, +} from 'GraphQl/Queries/Queries'; +import EventCalendar from 'components/EventCalendar/EventCalendar'; +import EventHeader from 'components/EventCalendar/EventHeader'; +import type { Dayjs } from 'dayjs'; +import dayjs from 'dayjs'; +import type { ChangeEvent } from 'react'; +import React from 'react'; +import { Button, Form } from 'react-bootstrap'; +import Modal from 'react-bootstrap/Modal'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { ViewType } from 'screens/OrganizationEvents/OrganizationEvents'; +import { errorHandler } from 'utils/errorHandler'; +import useLocalStorage from 'utils/useLocalstorage'; +import styles from './Events.module.css'; + +/** + * Converts a time string to a Dayjs object. + * + * @param time - The time string to convert, in HH:mm:ss format. + * @returns A Dayjs object representing the time. + */ +const timeToDayJs = (time: string): Dayjs => { + const dateTimeString = dayjs().format('YYYY-MM-DD') + ' ' + time; + return dayjs(dateTimeString, { format: 'YYYY-MM-DD HH:mm:ss' }); +}; + +/** + * Component to manage and display events for an organization. + * + * This component allows users to view, create, and manage events within an organization. + * It includes a calendar view, a form to create new events, and various filters and settings. + * + * @returns The JSX element for the events management interface. + */ +export default function events(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'userEvents', + }); + const { t: tCommon } = useTranslation('common'); + + const { getItem } = useLocalStorage(); + + // State variables to manage event details and UI + const [events, setEvents] = React.useState([]); + const [eventTitle, setEventTitle] = React.useState(''); + const [eventDescription, setEventDescription] = React.useState(''); + const [eventLocation, setEventLocation] = React.useState(''); + const [startDate, setStartDate] = React.useState(new Date()); + const [endDate, setEndDate] = React.useState(new Date()); + const [isPublic, setIsPublic] = React.useState(true); + const [isRegisterable, setIsRegisterable] = React.useState(true); + const [isRecurring, setIsRecurring] = React.useState(false); + const [isAllDay, setIsAllDay] = React.useState(true); + const [startTime, setStartTime] = React.useState('08:00:00'); + const [endTime, setEndTime] = React.useState('10:00:00'); + const [viewType, setViewType] = React.useState(ViewType.MONTH); + const [createEventModal, setCreateEventmodalisOpen] = React.useState(false); + const { orgId: organizationId } = useParams(); + + // Query to fetch events for the organization + const { data, refetch } = useQuery(ORGANIZATION_EVENTS_CONNECTION, { + variables: { + organization_id: organizationId, + title_contains: '', + }, + }); + + // Query to fetch organization details + const { data: orgData } = useQuery(ORGANIZATIONS_LIST, { + variables: { id: organizationId }, + }); + + // Mutation to create a new event + const [create] = useMutation(CREATE_EVENT_MUTATION); + + // Get user details from local storage + const userId = getItem('id') as string; + + const superAdmin = getItem('SuperAdmin'); + const adminFor = getItem('AdminFor'); + const userRole = superAdmin + ? 'SUPERADMIN' + : adminFor?.length > 0 + ? 'ADMIN' + : 'USER'; + + /** + * Handles the form submission for creating a new event. + * + * @param e - The form submit event. + * @returns A promise that resolves when the event is created. + */ + const createEvent = async ( + e: ChangeEvent, + ): Promise => { + e.preventDefault(); + try { + const { data: createEventData } = await create({ + variables: { + title: eventTitle, + description: eventDescription, + isPublic, + recurring: isRecurring, + isRegisterable: isRegisterable, + organizationId, + startDate: dayjs(startDate).format('YYYY-MM-DD'), + endDate: dayjs(endDate).format('YYYY-MM-DD'), + allDay: isAllDay, + location: eventLocation, + startTime: !isAllDay ? startTime : null, + endTime: !isAllDay ? endTime : null, + }, + }); + + /* istanbul ignore next */ + if (createEventData) { + toast.success(t('eventCreated') as string); + refetch(); + setEventTitle(''); + setEventDescription(''); + setEventLocation(''); + setStartDate(new Date()); + setEndDate(new Date()); + setStartTime('08:00:00'); + setEndTime('10:00:00'); + } + setCreateEventmodalisOpen(false); + } catch (error: unknown) { + /* istanbul ignore next */ + errorHandler(t, error); + } + }; + + /** + * Toggles the visibility of the event creation modal. + * + * @returns Void. + */ + /* istanbul ignore next */ + const toggleCreateEventModal = (): void => + setCreateEventmodalisOpen(!createEventModal); + + /** + * Updates the event title state when the input changes. + * + * @param event - The input change event. + * @returns Void. + */ + const handleEventTitleChange = ( + event: React.ChangeEvent, + ): void => { + setEventTitle(event.target.value); + }; + + /** + * Updates the event location state when the input changes. + * + * @param event - The input change event. + * @returns Void. + */ + const handleEventLocationChange = ( + event: React.ChangeEvent, + ): void => { + setEventLocation(event.target.value); + }; + + /** + * Updates the event description state when the input changes. + * + * @param event - The input change event. + * @returns Void. + */ + const handleEventDescriptionChange = ( + event: React.ChangeEvent, + ): void => { + setEventDescription(event.target.value); + }; + + // Update the list of events when the data from the query changes + /* istanbul ignore next */ + React.useEffect(() => { + if (data) { + setEvents(data.eventsByOrganizationConnection); + } + }, [data]); + + /** + * Shows the modal for creating a new event. + * + * @returns Void. + */ + /* istanbul ignore next */ + const showInviteModal = (): void => { + setCreateEventmodalisOpen(true); + }; + + /** + * Updates the calendar view type. + * + * @param item - The view type to set, or null to reset. + * @returns Void. + */ + /* istanbul ignore next */ + const handleChangeView = (item: string | null): void => { + /*istanbul ignore next*/ + if (item) { + setViewType(item as ViewType); + } + }; + + return ( + <> +
    +
    + +
    + +
    + + +

    {t('eventDetails')}

    + +
    + +
    + + + + + + +
    +
    + { + if (date) { + setStartDate(date?.toDate()); + setEndDate(date?.toDate()); + } + }} + data-testid="eventStartDate" + /> +
    +
    + { + if (date) { + setEndDate(date?.toDate()); + } + }} + minDate={dayjs(startDate)} + data-testid="eventEndDate" + /> +
    +
    +
    +
    + { + if (time) { + setStartTime(time?.format('HH:mm:ss')); + setEndTime(time?.format('HH:mm:ss')); + } + }} + disabled={isAllDay} + /> +
    +
    + { + if (time) { + setEndTime(time?.format('HH:mm:ss')); + } + }} + minTime={timeToDayJs(startTime)} + disabled={isAllDay} + /> +
    +
    +
    +
    + + setIsAllDay(!isAllDay)} + /> +
    +
    + + setIsRecurring(!isRecurring)} + /> +
    +
    +
    +
    + + setIsPublic(!isPublic)} + /> +
    +
    + + setIsRegisterable(!isRegisterable)} + /> +
    +
    + + +
    +
    +
    +
    + + ); +} diff --git a/src/screens/UserPortal/Organizations/Organizations.module.css b/src/screens/UserPortal/Organizations/Organizations.module.css new file mode 100644 index 0000000000..27262932d5 --- /dev/null +++ b/src/screens/UserPortal/Organizations/Organizations.module.css @@ -0,0 +1,145 @@ +.borderNone { + border: none; +} + +.colorWhite { + color: white; +} + +.colorLight { + background-color: #f5f5f5; +} + +.mainContainer { + max-height: 100%; + overflow: auto; +} + +.content { + height: fit-content; + min-height: calc(100% - 40px); +} + +.paddingY { + padding: 30px 0px; +} + +.containerHeight { + height: 100vh; + padding: 1rem 1.5rem 0 calc(300px + 1.5rem); +} + +.expand { + padding-left: 4rem; + animation: moveLeft 0.9s ease-in-out; +} + +.contract { + padding-left: calc(300px + 2rem + 1.5rem); + animation: moveRight 0.5s ease-in-out; +} + +.colorPrimary { + background: #31bb6b; +} + +.backgroundWhite { + background-color: white; +} + +.input { + flex: 1; + position: relative; + margin-right: 10px; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); +} + +.collapseSidebarButton { + position: fixed; + height: 40px; + bottom: 0; + z-index: 9999; + width: calc(300px); + background-color: rgba(245, 245, 245, 0.7); + color: black; + border: none; + border-radius: 0px; +} + +.collapseSidebarButton:hover, +.opendrawer:hover { + opacity: 1; + color: black !important; +} +.opendrawer { + position: fixed; + display: flex; + align-items: center; + justify-content: center; + top: 0; + left: 0; + width: 40px; + height: 100vh; + z-index: 9999; + background-color: rgba(245, 245, 245); + border: none; + border-radius: 0px; + margin-right: 20px; + color: black; +} + +.opendrawer:hover { + transition: background-color 0.5s ease; + background-color: var(--bs-primary); +} +.collapseSidebarButton:hover { + transition: background-color 0.5s ease; + background-color: var(--bs-primary); +} + +@media screen and (max-width: 850px) { + .mainContainer { + width: 100%; + } +} + +@media (max-width: 1120px) { + .collapseSidebarButton { + width: calc(250px + 2rem); + } +} + +@media (max-height: 650px) { + .collapseSidebarButton { + width: 250px; + height: 20px; + } + .opendrawer { + width: 30px; + } +} + +/* For tablets */ +@media (max-width: 820px) { + .containerHeight { + height: 100vh; + padding: 2rem; + } + .opendrawer { + width: 25px; + } + + .contract, + .expand { + animation: none; + } + + .collapseSidebarButton { + width: 100%; + left: 0; + right: 0; + } +} diff --git a/src/screens/UserPortal/Organizations/Organizations.test.tsx b/src/screens/UserPortal/Organizations/Organizations.test.tsx new file mode 100644 index 0000000000..f8a6fc06a5 --- /dev/null +++ b/src/screens/UserPortal/Organizations/Organizations.test.tsx @@ -0,0 +1,562 @@ +import { MockedProvider } from '@apollo/react-testing'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; + +import userEvent from '@testing-library/user-event'; +import { + USER_CREATED_ORGANIZATIONS, + USER_JOINED_ORGANIZATIONS, + USER_ORGANIZATION_CONNECTION, +} from 'GraphQl/Queries/Queries'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18nForTest from 'utils/i18nForTest'; +import useLocalStorage from 'utils/useLocalstorage'; +import Organizations from './Organizations'; +import React, { act } from 'react'; +const { getItem } = useLocalStorage(); + +const MOCKS = [ + { + request: { + query: USER_CREATED_ORGANIZATIONS, + variables: { + id: getItem('userId'), + }, + }, + result: { + data: { + users: [ + { + appUserProfile: { + createdOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + image: '', + name: 'anyOrganization1', + description: 'desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }, + { + request: { + query: USER_ORGANIZATION_CONNECTION, + variables: { + filter: '', + }, + }, + result: { + data: { + organizationsConnection: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + image: '', + name: 'anyOrganization1', + description: 'desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { __typename: 'User', firstName: 'John', lastName: 'Doe' }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af3', + image: '', + name: 'anyOrganization2', + createdAt: '1234567890', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + description: 'desc', + userRegistrationRequired: true, + creator: { __typename: 'User', firstName: 'John', lastName: 'Doe' }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: USER_JOINED_ORGANIZATIONS, + variables: { + id: getItem('userId'), + }, + }, + result: { + data: { + users: [ + { + user: { + joinedOrganizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + image: '', + name: 'anyOrganization1', + description: 'desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + createdAt: '1234567890', + userRegistrationRequired: true, + creator: { + __typename: 'User', + firstName: 'John', + lastName: 'Doe', + }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + ], + }, + }, + }, + { + request: { + query: USER_ORGANIZATION_CONNECTION, + variables: { + filter: '2', + }, + }, + result: { + data: { + organizationsConnection: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af3', + image: '', + name: 'anyOrganization2', + description: 'desc', + address: { + city: 'abc', + countryCode: '123', + postalCode: '456', + state: 'def', + dependentLocality: 'ghi', + line1: 'asdfg', + line2: 'dfghj', + sortingCode: '4567', + }, + userRegistrationRequired: true, + createdAt: '1234567890', + creator: { __typename: 'User', firstName: 'John', lastName: 'Doe' }, + members: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + admins: [ + { + _id: '45gj5678jk45678fvgbhnr4rtgh', + user: { + _id: '4567890fgvhbjn', + }, + }, + ], + membershipRequests: [ + { + _id: '56gheqyr7deyfuiwfewifruy8', + user: { + _id: '45ydeg2yet721rtgdu32ry', + }, + }, + ], + }, + ], + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const resizeWindow = (width: number): void => { + window.innerWidth = width; + fireEvent(window, new Event('resize')); +}; + +describe('Testing Organizations Screen [User Portal]', () => { + test('Screen should be rendered properly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + }); + + test('Search works properly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + const searchBtn = screen.getByTestId('searchBtn'); + userEvent.type(screen.getByTestId('searchInput'), '2{enter}'); + await wait(); + + expect(screen.queryByText('anyOrganization2')).toBeInTheDocument(); + + userEvent.clear(screen.getByTestId('searchInput')); + userEvent.click(searchBtn); + await wait(); + }); + + test('Mode is changed to joined organizations', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('modeChangeBtn')); + await wait(); + userEvent.click(screen.getByTestId('modeBtn1')); + await wait(); + + expect(screen.queryAllByText('joinedOrganization')).not.toBe([]); + }); + + test('Mode is changed to created organizations', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('modeChangeBtn')); + await wait(); + userEvent.click(screen.getByTestId('modeBtn2')); + await wait(); + + expect(screen.queryAllByText('createdOrganization')).not.toBe([]); + }); + + test('Join Now button render correctly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + // Assert "Join Now" button + const joinNowButtons = screen.getAllByTestId('joinBtn'); + expect(joinNowButtons.length).toBeGreaterThan(0); + }); + + test('Mode is changed to created organisations', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('modeChangeBtn')); + await wait(); + userEvent.click(screen.getByTestId('modeBtn2')); + await wait(); + + expect(screen.queryAllByText('createdOrganization')).not.toBe([]); + }); + + test('Testing Sidebar', async () => { + render( + + + + + + + + + , + ); + + await waitFor(() => { + const closeMenuBtn = screen.getByTestId('closeMenu'); + expect(closeMenuBtn).toBeInTheDocument(); + }); + await act(async () => { + const closeMenuBtn = screen.getByTestId('closeMenu'); + closeMenuBtn.click(); + }); + await waitFor(() => { + const openMenuBtn = screen.getByTestId('openMenu'); + expect(openMenuBtn).toBeInTheDocument(); + }); + await act(async () => { + const openMenuBtn = screen.getByTestId('openMenu'); + openMenuBtn.click(); + }); + }); + + test('Testing sidebar when the screen size is less than or equal to 820px', async () => { + resizeWindow(800); + render( + + + + + + + + + , + ); + + await waitFor(() => { + expect(screen.getByText('My Organizations')).toBeInTheDocument(); + expect(screen.getByText('Talawa User Portal')).toBeInTheDocument(); + }); + + await act(async () => { + const settingsBtn = screen.getByTestId('settingsBtn'); + + settingsBtn.click(); + }); + }); + test('Rows per Page values', async () => { + render( + + + + + + + + + , + ); + await wait(); + const dropdown = screen.getByTestId('table-pagination'); + userEvent.click(dropdown); + expect(screen.queryByText('-1')).not.toBeInTheDocument(); + expect(screen.getByText('5')).toBeInTheDocument(); + expect(screen.getByText('10')).toBeInTheDocument(); + expect(screen.getByText('30')).toBeInTheDocument(); + expect(screen.getByText('All')).toBeInTheDocument(); + }); + + test('Search input has correct placeholder text', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + const searchInput = screen.getByPlaceholderText('Search Organization'); + expect(searchInput).toBeInTheDocument(); + }); +}); diff --git a/src/screens/UserPortal/Organizations/Organizations.tsx b/src/screens/UserPortal/Organizations/Organizations.tsx new file mode 100644 index 0000000000..59f5500d02 --- /dev/null +++ b/src/screens/UserPortal/Organizations/Organizations.tsx @@ -0,0 +1,428 @@ +import { useQuery } from '@apollo/client'; +import { SearchOutlined } from '@mui/icons-material'; +import HourglassBottomIcon from '@mui/icons-material/HourglassBottom'; +import { + USER_CREATED_ORGANIZATIONS, + USER_JOINED_ORGANIZATIONS, + USER_ORGANIZATION_CONNECTION, +} from 'GraphQl/Queries/Queries'; +import PaginationList from 'components/PaginationList/PaginationList'; +import OrganizationCard from 'components/UserPortal/OrganizationCard/OrganizationCard'; +import UserSidebar from 'components/UserPortal/UserSidebar/UserSidebar'; +import React, { useEffect, useState } from 'react'; +import { Button, Dropdown, Form, InputGroup } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; +import useLocalStorage from 'utils/useLocalstorage'; +import styles from './Organizations.module.css'; +import ProfileDropdown from 'components/ProfileDropdown/ProfileDropdown'; + +const { getItem } = useLocalStorage(); + +interface InterfaceOrganizationCardProps { + id: string; + name: string; + image: string; + description: string; + admins: []; + members: []; + address: { + city: string; + countryCode: string; + line1: string; + postalCode: string; + state: string; + }; + membershipRequestStatus: string; + userRegistrationRequired: boolean; + membershipRequests: { + _id: string; + user: { + _id: string; + }; + }[]; +} + +/** + * Interface defining the structure of organization properties. + */ +interface InterfaceOrganization { + _id: string; + name: string; + image: string; + description: string; + admins: []; + members: []; + address: { + city: string; + countryCode: string; + line1: string; + postalCode: string; + state: string; + }; + membershipRequestStatus: string; + userRegistrationRequired: boolean; + membershipRequests: { + _id: string; + user: { + _id: string; + }; + }[]; +} + +/** + * Component for displaying and managing user organizations. + * + */ +export default function organizations(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'userOrganizations', + }); + + const [hideDrawer, setHideDrawer] = useState(null); + + /** + * Handles window resize events to toggle drawer visibility. + */ + const handleResize = (): void => { + if (window.innerWidth <= 820) { + setHideDrawer(!hideDrawer); + } + }; + + useEffect(() => { + handleResize(); + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + const [page, setPage] = React.useState(0); + const [rowsPerPage, setRowsPerPage] = React.useState(5); + const [organizations, setOrganizations] = React.useState([]); + const [filterName, setFilterName] = React.useState(''); + const [mode, setMode] = React.useState(0); + + const modes = [ + t('allOrganizations'), + t('joinedOrganizations'), + t('createdOrganizations'), + ]; + + const userId: string | null = getItem('userId'); + + const { + data, + refetch, + loading: loadingOrganizations, + } = useQuery(USER_ORGANIZATION_CONNECTION, { + variables: { filter: filterName }, + }); + + const { data: joinedOrganizationsData } = useQuery( + USER_JOINED_ORGANIZATIONS, + { + variables: { id: userId }, + }, + ); + + const { data: createdOrganizationsData } = useQuery( + USER_CREATED_ORGANIZATIONS, + { + variables: { id: userId }, + }, + ); + + /** + * Handles page change in pagination. + * + * @param _event - The event triggering the page change. + * @param newPage - The new page number. + */ + /* istanbul ignore next */ + const handleChangePage = ( + _event: React.MouseEvent | null, + newPage: number, + ): void => { + setPage(newPage); + }; + + /** + * Handles change in the number of rows per page. + * + * @param event - The event triggering the change. + */ + /* istanbul ignore next */ + const handleChangeRowsPerPage = ( + event: React.ChangeEvent, + ): void => { + const newRowsPerPage = event.target.value; + + setRowsPerPage(parseInt(newRowsPerPage, 10)); + setPage(0); + }; + + /** + * Searches organizations based on the provided filter value. + * + * @param value - The search filter value. + */ + const handleSearch = (value: string): void => { + setFilterName(value); + + refetch({ + filter: value, + }); + }; + + /** + * Handles search input submission by pressing the Enter key. + * + * @param e - The keyboard event. + */ + const handleSearchByEnter = ( + e: React.KeyboardEvent, + ): void => { + if (e.key === 'Enter') { + const { value } = e.target as HTMLInputElement; + handleSearch(value); + } + }; + + /** + * Handles search button click to search organizations. + */ + const handleSearchByBtnClick = (): void => { + const value = + (document.getElementById('searchUserOrgs') as HTMLInputElement)?.value || + ''; + handleSearch(value); + }; + + /** + * Updates the list of organizations based on query results and selected mode. + */ + /* istanbul ignore next */ + useEffect(() => { + if (data) { + const organizations = data.organizationsConnection.map( + (organization: InterfaceOrganization) => { + let membershipRequestStatus = ''; + if ( + organization.members.find( + (member: { _id: string }) => member._id === userId, + ) + ) + membershipRequestStatus = 'accepted'; + else if ( + organization.membershipRequests.find( + (request: { user: { _id: string } }) => + request.user._id === userId, + ) + ) + membershipRequestStatus = 'pending'; + return { ...organization, membershipRequestStatus }; + }, + ); + setOrganizations(organizations); + } + }, [data]); + + /** + * Updates the list of organizations based on the selected mode and query results. + */ + /* istanbul ignore next */ + useEffect(() => { + if (mode === 0) { + if (data) { + const organizations = data.organizationsConnection.map( + (organization: InterfaceOrganization) => { + let membershipRequestStatus = ''; + if ( + organization.members.find( + (member: { _id: string }) => member._id === userId, + ) + ) + membershipRequestStatus = 'accepted'; + else if ( + organization.membershipRequests.find( + (request: { user: { _id: string } }) => + request.user._id === userId, + ) + ) + membershipRequestStatus = 'pending'; + return { ...organization, membershipRequestStatus }; + }, + ); + setOrganizations(organizations); + } + } else if (mode === 1) { + if (joinedOrganizationsData && joinedOrganizationsData.users.length > 0) { + const organizations = + joinedOrganizationsData.users[0]?.user?.joinedOrganizations || []; + setOrganizations(organizations); + } + } else if (mode === 2) { + if ( + createdOrganizationsData && + createdOrganizationsData.users.length > 0 + ) { + const organizations = + createdOrganizationsData.users[0]?.appUserProfile + ?.createdOrganizations || []; + setOrganizations(organizations); + } + } + }, [mode, data, joinedOrganizationsData, createdOrganizationsData, userId]); + + return ( + <> + {hideDrawer ? ( + + ) : ( + + )} + +
    +
    +
    +
    +

    {t('selectOrganization')}

    +
    + +
    + +
    + + + + + + + + + {modes[mode]} + + + {modes.map((value, index) => { + return ( + setMode(index)} + > + {value} + + ); + })} + + +
    + +
    +
    + {loadingOrganizations ? ( +
    + Loading... +
    + ) : ( + <> + {' '} + {organizations && organizations.length > 0 ? ( + (rowsPerPage > 0 + ? organizations.slice( + page * rowsPerPage, + page * rowsPerPage + rowsPerPage, + ) + : /* istanbul ignore next */ + organizations + ).map((organization: InterfaceOrganization, index) => { + const cardProps: InterfaceOrganizationCardProps = { + name: organization.name, + image: organization.image, + id: organization._id, + description: organization.description, + admins: organization.admins, + members: organization.members, + address: organization.address, + membershipRequestStatus: + organization.membershipRequestStatus, + userRegistrationRequired: + organization.userRegistrationRequired, + membershipRequests: organization.membershipRequests, + }; + return ; + }) + ) : ( + {t('nothingToShow')} + )} + + )} +
    + + + + + + +
    +
    +
    +
    + + ); +} diff --git a/src/screens/UserPortal/People/People.module.css b/src/screens/UserPortal/People/People.module.css new file mode 100644 index 0000000000..a87a03f3d8 --- /dev/null +++ b/src/screens/UserPortal/People/People.module.css @@ -0,0 +1,78 @@ +.borderNone { + border: none; +} +.borderBox { + border: 1px solid #dddddd; +} + +.borderRounded5 { + border-radius: 5px; +} + +.borderRounded8 { + border-radius: 8px; +} + +.borderRounded24 { + border-radius: 24px; +} + +.topRadius { + border-top-left-radius: 24px; + border-top-right-radius: 24px; +} + +.bottomRadius { + border-bottom-left-radius: 24px; + border-bottom-right-radius: 24px; +} + +.shadow { + box-shadow: 5px 5px 4px 0px #31bb6b1f; +} + +.colorWhite { + color: white; +} + +.colorGreen { + color: #31bb6b; +} + +.backgroundWhite { + background-color: white; +} + +.maxWidth { + max-width: 600px; +} + +.mainContainer { + width: 50%; + flex-grow: 3; +} + +.content { + height: fit-content; + min-height: calc(100% - 40px); +} + +.gap { + gap: 20px; +} + +.colorPrimary { + background: #31bb6b; +} + +.greenBorder { + border: 1px solid #31bb6b; +} + +.semiBold { + font-weight: 500; +} + +.placeholderColor::placeholder { + color: #737373; +} diff --git a/src/screens/UserPortal/People/People.test.tsx b/src/screens/UserPortal/People/People.test.tsx new file mode 100644 index 0000000000..c978a0a5a3 --- /dev/null +++ b/src/screens/UserPortal/People/People.test.tsx @@ -0,0 +1,225 @@ +import React, { act } from 'react'; +import { render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import { + ORGANIZATIONS_MEMBER_CONNECTION_LIST, + ORGANIZATION_ADMINS_LIST, +} from 'GraphQl/Queries/Queries'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import People from './People'; +import userEvent from '@testing-library/user-event'; + +const MOCKS = [ + { + request: { + query: ORGANIZATIONS_MEMBER_CONNECTION_LIST, + variables: { + orgId: '', + firstName_contains: '', + }, + }, + result: { + data: { + organizationsMemberConnection: { + edges: [ + { + _id: '64001660a711c62d5b4076a2', + firstName: 'Noble', + lastName: 'Mittal', + image: null, + email: 'noble@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + }, + { + _id: '64001660a711c62d5b4076a3', + firstName: 'Noble', + lastName: 'Mittal', + image: 'mockImage', + email: 'noble@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + }, + ], + }, + }, + }, + }, + { + request: { + query: ORGANIZATION_ADMINS_LIST, + variables: { + id: '', + }, + }, + result: { + data: { + organizations: [ + { + __typename: 'Organization', + _id: '6401ff65ce8e8406b8f07af2', + admins: [ + { + _id: '64001660a711c62d5b4076a2', + firstName: 'Noble', + lastName: 'Admin', + image: null, + email: 'noble@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + }, + ], + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATIONS_MEMBER_CONNECTION_LIST, + variables: { + orgId: '', + firstName_contains: 'j', + }, + }, + result: { + data: { + organizationsMemberConnection: { + edges: [ + { + _id: '64001660a711c62d5b4076a2', + firstName: 'John', + lastName: 'Cena', + image: null, + email: 'john@gmail.com', + createdAt: '2023-03-02T03:22:08.101Z', + }, + ], + }, + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: '' }), +})); + +describe('Testing People Screen [User Portal]', () => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); + + test('Screen should be rendered properly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + expect(screen.queryAllByText('Noble Mittal')).not.toBe([]); + }); + + test('Search works properly by pressing enter', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.type(screen.getByTestId('searchInput'), 'j{enter}'); + await wait(); + + expect(screen.queryByText('John Cena')).toBeInTheDocument(); + expect(screen.queryByText('Noble Mittal')).not.toBeInTheDocument(); + }); + + test('Search works properly by clicking search Btn', async () => { + render( + + + + + + + + + , + ); + + await wait(); + const searchBtn = screen.getByTestId('searchBtn'); + userEvent.type(screen.getByTestId('searchInput'), ''); + userEvent.click(searchBtn); + await wait(); + userEvent.type(screen.getByTestId('searchInput'), 'j'); + userEvent.click(searchBtn); + await wait(); + + expect(screen.queryByText('John Cena')).toBeInTheDocument(); + expect(screen.queryByText('Noble Mittal')).not.toBeInTheDocument(); + }); + + test('Mode is changed to Admins', async () => { + render( + + + + + + + + + , + ); + + await wait(); + + userEvent.click(screen.getByTestId('modeChangeBtn')); + await wait(); + userEvent.click(screen.getByTestId('modeBtn1')); + await wait(); + + expect(screen.queryByText('Noble Admin')).toBeInTheDocument(); + expect(screen.queryByText('Noble Mittal')).not.toBeInTheDocument(); + }); +}); diff --git a/src/screens/UserPortal/People/People.tsx b/src/screens/UserPortal/People/People.tsx new file mode 100644 index 0000000000..4904e75b20 --- /dev/null +++ b/src/screens/UserPortal/People/People.tsx @@ -0,0 +1,279 @@ +import React, { useEffect, useState } from 'react'; +import PeopleCard from 'components/UserPortal/PeopleCard/PeopleCard'; +import { Dropdown, Form, InputGroup } from 'react-bootstrap'; +import PaginationList from 'components/PaginationList/PaginationList'; +import { + ORGANIZATIONS_MEMBER_CONNECTION_LIST, + ORGANIZATION_ADMINS_LIST, +} from 'GraphQl/Queries/Queries'; +import { useQuery } from '@apollo/client'; +import { FilterAltOutlined, SearchOutlined } from '@mui/icons-material'; +import styles from './People.module.css'; +import { useTranslation } from 'react-i18next'; +import HourglassBottomIcon from '@mui/icons-material/HourglassBottom'; +import { useParams } from 'react-router-dom'; + +interface InterfaceOrganizationCardProps { + id: string; + name: string; + image: string; + email: string; + role: string; + sno: string; +} + +interface InterfaceMember { + firstName: string; + lastName: string; + image: string; + _id: string; + email: string; + userType: string; +} + +/** + * `People` component displays a list of people associated with an organization. + * It allows users to filter between all members and admins, search for members by their first name, + * and paginate through the list. + */ +export default function people(): JSX.Element { + // i18n translation hook for user organization related translations + const { t } = useTranslation('translation', { + keyPrefix: 'people', + }); + + // i18n translation hook for common translations + const { t: tCommon } = useTranslation('common'); + + // State for managing current page in pagination + const [page, setPage] = useState(0); + + // State for managing the number of rows per page in pagination + const [rowsPerPage, setRowsPerPage] = useState(5); + const [members, setMembers] = useState([]); + const [mode, setMode] = useState(0); + + // Extracting organization ID from URL parameters + const { orgId: organizationId } = useParams(); + + // Filter modes for dropdown selection + const modes = ['All Members', 'Admins']; + + // Query to fetch list of members of the organization + const { data, loading, refetch } = useQuery( + ORGANIZATIONS_MEMBER_CONNECTION_LIST, + { + variables: { + orgId: organizationId, + firstName_contains: '', + }, + }, + ); + + // Query to fetch list of admins of the organization + const { data: data2 } = useQuery(ORGANIZATION_ADMINS_LIST, { + variables: { id: organizationId }, + }); + + /** + * Handles page change in pagination. + * + */ + /* istanbul ignore next */ + const handleChangePage = ( + _event: React.MouseEvent | null, + newPage: number, + ): void => { + setPage(newPage); + }; + + /** + * Handles change in the number of rows per page. + * + */ + /* istanbul ignore next */ + const handleChangeRowsPerPage = ( + event: React.ChangeEvent, + ): void => { + const newRowsPerPage = event.target.value; + + setRowsPerPage(parseInt(newRowsPerPage, 10)); + setPage(0); + }; + + /** + * Searches for members based on the filter value. + * + */ + const handleSearch = (newFilter: string): void => { + refetch({ + firstName_contains: newFilter, + }); + }; + + /** + * Handles search operation triggered by pressing the Enter key. + * + */ + const handleSearchByEnter = ( + e: React.KeyboardEvent, + ): void => { + if (e.key === 'Enter') { + const { value } = e.currentTarget; + handleSearch(value); + } + }; + + /** + * Handles search operation triggered by clicking the search button. + */ + const handleSearchByBtnClick = (): void => { + const inputValue = + (document.getElementById('searchPeople') as HTMLInputElement)?.value || + ''; + handleSearch(inputValue); + }; + + useEffect(() => { + if (data) { + setMembers(data.organizationsMemberConnection.edges); + } + }, [data]); + + /** + * Updates the list of members based on the selected filter mode. + */ + /* istanbul ignore next */ + useEffect(() => { + if (mode == 0) { + if (data) { + setMembers(data.organizationsMemberConnection.edges); + } + } else if (mode == 1) { + if (data2) { + setMembers(data2.organizations[0].admins); + } + } + }, [mode]); + + return ( + <> +
    +
    +
    + + + + + + + + + + {tCommon('filter').toUpperCase()} + + + {modes.map((value, index) => { + return ( + setMode(index)} + > + {value} + + ); + })} + + +
    +
    +
    + + S.No + Avatar + + Name + Email + Role +
    + +
    + {loading ? ( +
    + Loading... +
    + ) : ( + <> + {members && members.length > 0 ? ( + (rowsPerPage > 0 + ? members.slice( + page * rowsPerPage, + page * rowsPerPage + rowsPerPage, + ) + : /* istanbul ignore next */ + members + ).map((member: InterfaceMember, index) => { + const name = `${member.firstName} ${member.lastName}`; + + const cardProps: InterfaceOrganizationCardProps = { + name, + image: member.image, + id: member._id, + email: member.email, + role: member.userType, + sno: (index + 1).toString(), + }; + return ; + }) + ) : ( + {t('nothingToShow')} + )} + + )} +
    + + + + + + +
    +
    +
    + {/* */} +
    + + ); +} diff --git a/src/screens/UserPortal/Pledges/Pledge.test.tsx b/src/screens/UserPortal/Pledges/Pledge.test.tsx new file mode 100644 index 0000000000..3d5eef94c2 --- /dev/null +++ b/src/screens/UserPortal/Pledges/Pledge.test.tsx @@ -0,0 +1,355 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18nForTest from 'utils/i18nForTest'; +import { EMPTY_MOCKS, MOCKS, USER_PLEDGES_ERROR } from './PledgesMocks'; +import type { ApolloLink } from '@apollo/client'; +import Pledges from './Pledges'; +import useLocalStorage from 'utils/useLocalstorage'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); +jest.mock('@mui/x-date-pickers/DateTimePicker', () => { + return { + DateTimePicker: jest.requireActual( + '@mui/x-date-pickers/DesktopDateTimePicker', + ).DesktopDateTimePicker, + }; +}); +const { setItem } = useLocalStorage(); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(USER_PLEDGES_ERROR); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const translations = JSON.parse( + JSON.stringify(i18nForTest.getDataByLanguage('en')?.translation.pledges), +); + +const renderMyPledges = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } /> + } + /> + + + + + + , + ); +}; + +describe('Testing User Pledge Screen', () => { + beforeEach(() => { + setItem('userId', 'userId'); + }); + + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + it('should render the Campaign Pledge screen', async () => { + renderMyPledges(link1); + await waitFor(() => { + expect(screen.getByTestId('searchPledges')).toBeInTheDocument(); + expect(screen.getByText('Harve Lance')).toBeInTheDocument(); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + }); + }); + + it('should redirect to fallback URL if userId is null in LocalStorage', async () => { + setItem('userId', null); + renderMyPledges(link1); + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } /> + } + /> + + + + + , + ); + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('check if user image renders', async () => { + renderMyPledges(link1); + await waitFor(() => { + expect(screen.getByTestId('searchPledges')).toBeInTheDocument(); + }); + + const image = await screen.findByTestId('image1'); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', 'image-url'); + }); + + it('Sort the Pledges list by Lowest Amount', async () => { + renderMyPledges(link1); + + const searchPledger = await screen.findByTestId('searchPledges'); + expect(searchPledger).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('filter')); + await waitFor(() => { + expect(screen.getByTestId('amount_ASC')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('amount_ASC')); + + await waitFor(() => { + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText('Harve Lance')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('amountCell')[0]).toHaveTextContent('100'); + }); + }); + + it('Sort the Pledges list by Highest Amount', async () => { + renderMyPledges(link1); + + const searchPledger = await screen.findByTestId('searchPledges'); + expect(searchPledger).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('filter')); + await waitFor(() => { + expect(screen.getByTestId('amount_DESC')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('amount_DESC')); + + await waitFor(() => { + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText('Harve Lance')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('amountCell')[0]).toHaveTextContent('700'); + }); + }); + + it('Sort the Pledges list by earliest endDate', async () => { + renderMyPledges(link1); + + const searchPledger = await screen.findByTestId('searchPledges'); + expect(searchPledger).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('filter')); + await waitFor(() => { + expect(screen.getByTestId('endDate_ASC')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('endDate_ASC')); + + await waitFor(() => { + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText('Harve Lance')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('amountCell')[0]).toHaveTextContent('700'); + }); + }); + + it('Sort the Pledges list by latest endDate', async () => { + renderMyPledges(link1); + + const searchPledger = await screen.findByTestId('searchPledges'); + expect(searchPledger).toBeInTheDocument(); + + userEvent.click(screen.getByTestId('filter')); + await waitFor(() => { + expect(screen.getByTestId('endDate_DESC')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('endDate_DESC')); + + await waitFor(() => { + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText('Harve Lance')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getAllByTestId('amountCell')[0]).toHaveTextContent('100'); + }); + }); + + it('Search the Pledges list by User name', async () => { + renderMyPledges(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchPledges')).toBeInTheDocument(); + }); + + userEvent.click(screen.getByTestId('searchByDrpdwn')); + + await waitFor(() => { + expect(screen.getByTestId('pledgers')).toBeInTheDocument(); + }); + userEvent.click(screen.getByTestId('pledgers')); + + const searchPledger = screen.getByTestId('searchPledges'); + fireEvent.change(searchPledger, { + target: { value: 'Harve' }, + }); + + await waitFor(() => { + expect(screen.getByText('Harve Lance')).toBeInTheDocument(); + expect(screen.queryByText('John Doe')).toBeNull(); + }); + }); + + it('Search the Pledges list by Campaign name', async () => { + renderMyPledges(link1); + + await waitFor(() => { + expect(screen.getByTestId('searchPledges')).toBeInTheDocument(); + }); + + const searchByToggle = await screen.findByTestId('searchByDrpdwn'); + fireEvent.click(searchByToggle); + + await waitFor(() => { + expect(screen.getByTestId('campaigns')).toBeInTheDocument(); + }); + fireEvent.click(screen.getByTestId('campaigns')); + + const searchPledger = await screen.findByTestId('searchPledges'); + fireEvent.change(searchPledger, { + target: { value: 'School' }, + }); + + await waitFor(() => { + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.queryByText('Harve Lance')).toBeNull(); + }); + }); + + it('should render extraUserDetails in Popup', async () => { + renderMyPledges(link1); + await waitFor(() => { + expect(screen.getByTestId('searchPledges')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(screen.getByText('Harve Lance')).toBeInTheDocument(); + expect(screen.queryByText('Jeramy Gracia')).toBeNull(); + expect(screen.queryByText('Praise Norris')).toBeNull(); + }); + + const moreContainer = await screen.findAllByTestId('moreContainer'); + userEvent.click(moreContainer[0]); + + await waitFor(() => { + expect(screen.getByTestId('extra1')).toBeInTheDocument(); + expect(screen.getByTestId('extra2')).toBeInTheDocument(); + expect(screen.getByTestId('extraAvatar2')).toBeInTheDocument(); + const image = screen.getByTestId('extraImage1'); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', 'image-url3'); + }); + + userEvent.click(moreContainer[0]); + await waitFor(() => { + expect(screen.queryByText('Jeramy Gracia')).toBeNull(); + expect(screen.queryByText('Praise Norris')).toBeNull(); + }); + }); + + it('open and closes delete pledge modal', async () => { + renderMyPledges(link1); + + const deletePledgeBtn = await screen.findAllByTestId('deletePledgeBtn'); + await waitFor(() => expect(deletePledgeBtn[0]).toBeInTheDocument()); + userEvent.click(deletePledgeBtn[0]); + + await waitFor(() => + expect(screen.getByText(translations.deletePledge)).toBeInTheDocument(), + ); + userEvent.click(screen.getByTestId('deletePledgeCloseBtn')); + await waitFor(() => + expect(screen.queryByTestId('deletePledgeCloseBtn')).toBeNull(), + ); + }); + + it('open and closes update pledge modal', async () => { + renderMyPledges(link1); + + const editPledgeBtn = await screen.findAllByTestId('editPledgeBtn'); + await waitFor(() => expect(editPledgeBtn[0]).toBeInTheDocument()); + userEvent.click(editPledgeBtn[0]); + + await waitFor(() => + expect(screen.getByText(translations.editPledge)).toBeInTheDocument(), + ); + userEvent.click(screen.getByTestId('pledgeModalCloseBtn')); + await waitFor(() => + expect(screen.queryByTestId('pledgeModalCloseBtn')).toBeNull(), + ); + }); + + it('should render the Campaign Pledge screen with error', async () => { + renderMyPledges(link2); + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('renders the empty pledge component', async () => { + renderMyPledges(link3); + await waitFor(() => + expect(screen.getByText(translations.noPledges)).toBeInTheDocument(), + ); + }); +}); diff --git a/src/screens/UserPortal/Pledges/Pledges.module.css b/src/screens/UserPortal/Pledges/Pledges.module.css new file mode 100644 index 0000000000..99b1b5f78f --- /dev/null +++ b/src/screens/UserPortal/Pledges/Pledges.module.css @@ -0,0 +1,202 @@ +.btnsContainer { + display: flex; + margin: 1.5rem 0; +} + +.btnsContainer .input { + flex: 1; + min-width: 18rem; + position: relative; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); + background-color: white; +} + +.btnsContainer .input button { + width: 52px; +} + +.titleContainer { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.titleContainer h3 { + font-size: 1.25rem; + font-weight: 750; + color: #5e5e5e; + margin-top: 0.2rem; +} + +.subContainer span { + font-size: 0.9rem; + margin-left: 0.5rem; + font-weight: lighter; + color: #707070; +} + +.progress { + display: flex; + width: 45rem; +} + +.progressBar { + margin: 0rem 0.75rem; + width: 100%; + font-size: 0.9rem; + height: 1.25rem; +} + +/* Pledge Modal */ + +.pledgeModal { + max-width: 80vw; + margin-top: 2vh; + margin-left: 13vw; +} + +.titlemodal { + color: #707070; + font-weight: 600; + font-size: 32px; + width: 65%; + margin-bottom: 0px; +} + +.modalCloseBtn { + width: 40px; + height: 40px; + padding: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.noOutline input { + outline: none; +} + +.greenregbtn { + margin: 1rem 0 0; + margin-top: 15px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 10px; + border-radius: 5px; + background-color: #31bb6b; + width: 100%; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; +} + +/* Error Loading Styles */ +.message { + margin-top: 25%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.container { + min-height: 100vh; +} + +.errorIcon { + transform: scale(1.5); + color: var(--bs-danger); + margin-bottom: 1rem; +} + +/* Data Grid Styles */ +.tableHeader { + background-color: var(--bs-primary); + color: var(--bs-white); + font-size: 1rem; +} + +.rowBackground { + background-color: var(--bs-white); + max-height: 120px; +} + +.TableImage { + object-fit: cover; + width: 25px !important; + height: 25px !important; + border-radius: 100% !important; +} + +.avatarContainer { + width: 28px; + height: 26px; +} + +.imageContainer { + display: flex; + align-items: center; + justify-content: center; +} + +.pledgerContainer { + display: flex; + align-items: center; + justify-content: center; + margin: 0.1rem 0.25rem; + gap: 0.25rem; + padding: 0.25rem 0.45rem; + border-radius: 0.35rem; + background-color: #31bb6b33; + height: 2.2rem; + margin-top: 0.75rem; +} + +.progressBar { + margin: 0rem 0.75rem; + width: 100%; + font-size: 0.7rem; + height: 1.55rem; +} + +/* ExtraPledgers Popup */ +.popup { + z-index: 50; + border-radius: 0.5rem; + font-family: sans-serif; + font-weight: 500; + font-size: 0.875rem; + margin-top: 0.5rem; + padding: 0.75rem; + border: 1px solid #e2e8f0; + background-color: white; + color: #1e293b; + box-shadow: 0 0.5rem 1rem rgb(0 0 0 / 0.15); + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.popupExtra { + max-height: 15rem; + overflow-y: auto; +} + +.moreContainer { + display: flex; + align-items: center; +} + +.moreContainer:hover { + text-decoration: underline; + cursor: pointer; +} diff --git a/src/screens/UserPortal/Pledges/Pledges.tsx b/src/screens/UserPortal/Pledges/Pledges.tsx new file mode 100644 index 0000000000..33e8bf63c2 --- /dev/null +++ b/src/screens/UserPortal/Pledges/Pledges.tsx @@ -0,0 +1,557 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Dropdown, Form, Button, ProgressBar } from 'react-bootstrap'; +import styles from './Pledges.module.css'; +import { useTranslation } from 'react-i18next'; +import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import useLocalStorage from 'utils/useLocalstorage'; +import type { InterfacePledgeInfo, InterfaceUserInfo } from 'utils/interfaces'; +import { Unstable_Popup as BasePopup } from '@mui/base/Unstable_Popup'; +import { type ApolloQueryResult, useQuery } from '@apollo/client'; +import { USER_PLEDGES } from 'GraphQl/Queries/fundQueries'; +import Loader from 'components/Loader/Loader'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { Stack } from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; +import dayjs from 'dayjs'; +import { currencySymbols } from 'utils/currency'; +import PledgeDeleteModal from 'screens/FundCampaignPledge/PledgeDeleteModal'; +import { Navigate, useParams } from 'react-router-dom'; +import PledgeModal from '../Campaigns/PledgeModal'; + +const dataGridStyle = { + '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { + outline: 'none !important', + }, + '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-root': { + borderRadius: '0.5rem', + }, + '& .MuiDataGrid-main': { + borderRadius: '0.5rem', + }, +}; + +enum ModalState { + UPDATE = 'update', + DELETE = 'delete', +} +/** + * The `Pledges` component is responsible for rendering a user's pledges within a campaign. + * It fetches pledges data using Apollo Client's `useQuery` hook and displays the data + * in a DataGrid with various features such as search, sorting, and modal dialogs for updating + * or deleting a pledge. The component also handles various UI interactions including opening + * modals for editing or deleting a pledge, showing additional pledgers in a popup, and + * applying filters for searching pledges by campaign or pledger name. + * + * Key functionalities include: + * - Fetching pledges data from the backend using GraphQL query `USER_PLEDGES`. + * - Displaying pledges in a table with columns for pledgers, associated campaigns, + * end dates, pledged amounts, and actions. + * - Handling search and sorting of pledges. + * - Opening and closing modals for updating and deleting pledges. + * - Displaying additional pledgers in a popup when the list of pledgers exceeds a certain limit. + * + * @returns The rendered Pledges component. + */ + +const Pledges = (): JSX.Element => { + const { t } = useTranslation('translation', { + keyPrefix: 'userCampaigns', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + const { getItem } = useLocalStorage(); + const userId = getItem('userId'); + const { orgId } = useParams(); + if (!orgId || !userId) { + return ; + } + + const [anchor, setAnchor] = useState(null); + const [extraUsers, setExtraUsers] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [pledges, setPledges] = useState([]); + const [pledge, setPledge] = useState(null); + const [searchBy, setSearchBy] = useState<'pledgers' | 'campaigns'>( + 'pledgers', + ); + const [sortBy, setSortBy] = useState< + 'amount_ASC' | 'amount_DESC' | 'endDate_ASC' | 'endDate_DESC' + >('endDate_DESC'); + const [modalState, setModalState] = useState<{ + [key in ModalState]: boolean; + }>({ + [ModalState.UPDATE]: false, + [ModalState.DELETE]: false, + }); + + const open = Boolean(anchor); + const id = open ? 'simple-popup' : undefined; + + const { + data: pledgeData, + loading: pledgeLoading, + error: pledgeError, + refetch: refetchPledge, + }: { + data?: { + getPledgesByUserId: InterfacePledgeInfo[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => Promise< + ApolloQueryResult<{ + getPledgesByUserId: InterfacePledgeInfo[]; + }> + >; + } = useQuery(USER_PLEDGES, { + variables: { + userId: userId, + where: { + firstName_contains: searchBy === 'pledgers' ? searchTerm : undefined, + name_contains: searchBy === 'campaigns' ? searchTerm : undefined, + }, + orderBy: sortBy, + }, + }); + + const openModal = (modal: ModalState): void => { + setModalState((prevState) => ({ ...prevState, [modal]: true })); + }; + + const closeModal = (modal: ModalState): void => { + setModalState((prevState) => ({ ...prevState, [modal]: false })); + }; + + const handleOpenModal = useCallback( + (pledge: InterfacePledgeInfo | null): void => { + setPledge(pledge); + openModal(ModalState.UPDATE); + }, + [openModal], + ); + + const handleDeleteClick = useCallback( + (pledge: InterfacePledgeInfo): void => { + setPledge(pledge); + openModal(ModalState.DELETE); + }, + [openModal], + ); + + const handleClick = ( + event: React.MouseEvent, + users: InterfaceUserInfo[], + ): void => { + setExtraUsers(users); + setAnchor(anchor ? null : event.currentTarget); + }; + + useEffect(() => { + if (pledgeData) { + setPledges(pledgeData.getPledgesByUserId); + } + }, [pledgeData]); + + if (pledgeLoading) return ; + if (pledgeError) { + return ( +
    +
    + +
    + {tErrors('errorLoading', { entity: 'Pledges' })} +
    + {pledgeError.message} +
    +
    +
    + ); + } + + const columns: GridColDef[] = [ + { + field: 'pledgers', + headerName: 'Pledgers', + flex: 4, + minWidth: 50, + align: 'left', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return ( +
    + {params.row.users + .slice(0, 2) + .map((user: InterfaceUserInfo, index: number) => ( +
    + {user.image ? ( + pledge + ) : ( +
    + +
    + )} + + {user.firstName + ' ' + user.lastName} + +
    + ))} + {params.row.users.length > 2 && ( +
    handleClick(e, params.row.users.slice(2))} + > + +{params.row.users.length - 2} more... +
    + )} +
    + ); + }, + }, + { + field: 'associatedCampaign', + headerName: 'Associated Campaign', + flex: 2, + minWidth: 100, + align: 'left', + headerAlign: 'left', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return <>{params.row.campaign?.name}; + }, + }, + { + field: 'endDate', + headerName: 'End Date', + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + flex: 1, + sortable: false, + renderCell: (params: GridCellParams) => { + return dayjs(params.row.endDate).format('DD/MM/YYYY'); + }, + }, + { + field: 'amount', + headerName: 'Pledged', + flex: 1, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return ( +
    + { + currencySymbols[ + params.row.currency as keyof typeof currencySymbols + ] + } + {params.row.amount} +
    + ); + }, + }, + { + field: 'donated', + headerName: 'Donated', + flex: 1, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return ( +
    + { + currencySymbols[ + params.row.currency as keyof typeof currencySymbols + ] + } + 0 +
    + ); + }, + }, + { + field: 'progress', + headerName: 'Progress', + flex: 2, + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: () => { + return ( +
    + +
    + ); + }, + }, + { + field: 'action', + headerName: 'Action', + flex: 1, + minWidth: 100, + align: 'center', + headerAlign: 'center', + headerClassName: `${styles.tableHeader}`, + sortable: false, + renderCell: (params: GridCellParams) => { + return ( + <> + + + + ); + }, + }, + ]; + + return ( +
    +
    +
    + setSearchTerm(e.target.value)} + data-testid="searchPledges" + /> + +
    +
    + + + + + + {tCommon('sort')} + + + setSortBy('amount_ASC')} + data-testid="amount_ASC" + > + {t('lowestAmount')} + + setSortBy('amount_DESC')} + data-testid="amount_DESC" + > + {t('highestAmount')} + + setSortBy('endDate_DESC')} + data-testid="endDate_DESC" + > + {t('latestEndDate')} + + setSortBy('endDate_ASC')} + data-testid="endDate_ASC" + > + {t('earliestEndDate')} + + + +
    +
    + + row._id} + slots={{ + noRowsOverlay: () => ( + + {t('noPledges')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={pledges.map((pledge) => ({ + _id: pledge._id, + users: pledge.users, + startDate: pledge.startDate, + endDate: pledge.endDate, + amount: pledge.amount, + currency: pledge.currency, + campaign: pledge.campaign, + }))} + columns={columns} + isRowSelectable={() => false} + /> + + closeModal(ModalState.UPDATE)} + campaignId={pledge?.campaign ? pledge?.campaign._id : ''} + userId={userId} + pledge={pledge} + refetchPledge={refetchPledge} + endDate={pledge?.campaign ? pledge?.campaign.endDate : new Date()} + mode={'edit'} + /> + + closeModal(ModalState.DELETE)} + pledge={pledge} + refetchPledge={refetchPledge} + /> + + 4 ? styles.popupExtra : ''}`} + > + {extraUsers.map((user: InterfaceUserInfo, index: number) => ( +
    + {user.image ? ( + pledger + ) : ( +
    + +
    + )} + + {user.firstName + ' ' + user.lastName} + +
    + ))} +
    +
    + ); +}; + +export default Pledges; diff --git a/src/screens/UserPortal/Pledges/PledgesMocks.ts b/src/screens/UserPortal/Pledges/PledgesMocks.ts new file mode 100644 index 0000000000..c7666987ff --- /dev/null +++ b/src/screens/UserPortal/Pledges/PledgesMocks.ts @@ -0,0 +1,596 @@ +import { DELETE_PLEDGE } from 'GraphQl/Mutations/PledgeMutation'; +import { USER_DETAILS } from 'GraphQl/Queries/Queries'; +import { USER_PLEDGES } from 'GraphQl/Queries/fundQueries'; + +const userDetailsQuery = { + request: { + query: USER_DETAILS, + variables: { + id: 'userId', + }, + }, + result: { + data: { + user: { + user: { + _id: 'userId', + joinedOrganizations: [ + { + _id: '6537904485008f171cf29924', + __typename: 'Organization', + }, + ], + firstName: 'Harve', + lastName: 'Lance', + email: 'testuser1@example.com', + image: null, + createdAt: '2023-04-13T04:53:17.742Z', + birthDate: null, + educationGrade: null, + employmentStatus: null, + gender: null, + maritalStatus: null, + phone: null, + address: { + line1: 'Line1', + countryCode: 'CountryCode', + city: 'CityName', + state: 'State', + __typename: 'Address', + }, + registeredEvents: [], + membershipRequests: [], + __typename: 'User', + }, + appUserProfile: { + _id: '67078abd85008f171cf2991d', + adminFor: [], + isSuperAdmin: false, + appLanguageCode: 'en', + pluginCreationAllowed: true, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + __typename: 'AppUserProfile', + }, + __typename: 'UserData', + }, + }, + }, +}; + +export const MOCKS = [ + { + request: { + query: USER_PLEDGES, + variables: { + userId: 'userId', + where: { + name_contains: '', + }, + orderBy: 'endDate_DESC', + }, + }, + result: { + data: { + getPledgesByUserId: [ + { + _id: 'pledgeId1', + amount: 700, + startDate: '2024-07-28', + endDate: '2024-08-13', + campaign: { + _id: 'campaignId1', + name: 'Hospital Campaign', + endDate: '2024-08-30', + __typename: 'FundraisingCampaign', + }, + currency: 'USD', + users: [ + { + _id: 'userId', + firstName: 'Harve', + lastName: 'Lance', + image: null, + __typename: 'User', + }, + { + _id: 'userId2', + firstName: 'Deanne', + lastName: 'Marks', + image: null, + __typename: 'User', + }, + { + _id: 'userId3', + firstName: 'Jeramy', + lastName: 'Garcia', + image: null, + __typename: 'User', + }, + { + _id: 'userId4', + firstName: 'Praise', + lastName: 'Norris', + image: null, + __typename: 'User', + }, + ], + __typename: 'FundraisingCampaignPledge', + }, + { + _id: 'pledgeId2', + amount: 100, + startDate: '2024-07-28', + endDate: '2024-08-13', + campaign: { + _id: 'campaignId2', + name: 'School Campaign', + endDate: '2024-08-30', + __typename: 'FundraisingCampaign', + }, + currency: 'USD', + users: [ + { + _id: 'userId5', + firstName: 'John', + lastName: 'Doe', + image: null, + __typename: 'User', + }, + { + _id: 'userId6', + firstName: 'Jane', + lastName: 'Doe', + image: null, + __typename: 'User', + }, + ], + __typename: 'FundraisingCampaignPledge', + }, + ], + }, + }, + }, + { + request: { + query: USER_PLEDGES, + variables: { + userId: 'userId', + where: { + firstName_contains: 'Harve', + }, + orderBy: 'endDate_DESC', + }, + }, + result: { + data: { + getPledgesByUserId: [ + { + _id: 'pledgeId1', + amount: 700, + startDate: '2024-07-28', + endDate: '2024-08-13', + campaign: { + _id: 'campaignId1', + name: 'Hospital Campaign', + endDate: '2024-08-30', + __typename: 'FundraisingCampaign', + }, + currency: 'USD', + users: [ + { + _id: 'userId', + firstName: 'Harve', + lastName: 'Lance', + image: null, + __typename: 'User', + }, + ], + __typename: 'FundraisingCampaignPledge', + }, + ], + }, + }, + }, + { + request: { + query: USER_PLEDGES, + variables: { + userId: 'userId', + where: { + name_contains: 'School', + }, + orderBy: 'endDate_DESC', + }, + }, + result: { + data: { + getPledgesByUserId: [ + { + _id: 'pledgeId2', + amount: 100, + startDate: '2024-07-28', + endDate: '2024-08-13', + campaign: { + _id: 'campaignId2', + name: 'School Campaign', + endDate: '2024-08-30', + __typename: 'FundraisingCampaign', + }, + currency: 'USD', + users: [ + { + _id: 'userId5', + firstName: 'John', + lastName: 'Doe', + image: null, + __typename: 'User', + }, + { + _id: 'userId6', + firstName: 'Jane', + lastName: 'Doe', + image: null, + __typename: 'User', + }, + ], + __typename: 'FundraisingCampaignPledge', + }, + ], + }, + }, + }, + { + request: { + query: USER_PLEDGES, + variables: { + userId: 'userId', + where: { + firstName_contains: '', + }, + orderBy: 'amount_ASC', + }, + }, + result: { + data: { + getPledgesByUserId: [ + { + _id: 'pledgeId2', + amount: 100, + startDate: '2024-07-28', + endDate: '2024-08-13', + campaign: { + _id: 'campaignId2', + name: 'School Campaign', + endDate: '2024-08-30', + __typename: 'FundraisingCampaign', + }, + currency: 'USD', + users: [ + { + _id: 'userId5', + firstName: 'John', + lastName: 'Doe', + image: null, + __typename: 'User', + }, + ], + __typename: 'FundraisingCampaignPledge', + }, + { + _id: 'pledgeId1', + amount: 700, + startDate: '2024-07-28', + endDate: '2024-08-13', + campaign: { + _id: 'campaignId1', + name: 'Hospital Campaign', + endDate: '2024-08-30', + __typename: 'FundraisingCampaign', + }, + currency: 'USD', + users: [ + { + _id: 'userId', + firstName: 'Harve', + lastName: 'Lance', + image: 'image-url', + __typename: 'User', + }, + ], + __typename: 'FundraisingCampaignPledge', + }, + ], + }, + }, + }, + { + request: { + query: USER_PLEDGES, + variables: { + userId: 'userId', + where: { + firstName_contains: '', + }, + orderBy: 'amount_DESC', + }, + }, + result: { + data: { + getPledgesByUserId: [ + { + _id: 'pledgeId1', + amount: 700, + startDate: '2024-07-28', + endDate: '2024-08-13', + campaign: { + _id: 'campaignId1', + name: 'Hospital Campaign', + endDate: '2024-08-30', + __typename: 'FundraisingCampaign', + }, + currency: 'USD', + users: [ + { + _id: 'userId', + firstName: 'Harve', + lastName: 'Lance', + image: 'image-url', + __typename: 'User', + }, + ], + __typename: 'FundraisingCampaignPledge', + }, + { + _id: 'pledgeId2', + amount: 100, + startDate: '2024-07-28', + endDate: '2024-08-13', + campaign: { + _id: 'campaignId2', + name: 'School Campaign', + endDate: '2024-08-30', + __typename: 'FundraisingCampaign', + }, + currency: 'USD', + users: [ + { + _id: 'userId5', + firstName: 'John', + lastName: 'Doe', + image: null, + __typename: 'User', + }, + ], + __typename: 'FundraisingCampaignPledge', + }, + ], + }, + }, + }, + { + request: { + query: USER_PLEDGES, + variables: { + userId: 'userId', + where: { + firstName_contains: '', + }, + orderBy: 'endDate_ASC', + }, + }, + result: { + data: { + getPledgesByUserId: [ + { + _id: 'pledgeId1', + amount: 700, + startDate: '2024-07-28', + endDate: '2024-08-13', + campaign: { + _id: 'campaignId1', + name: 'Hospital Campaign', + endDate: '2024-08-30', + __typename: 'FundraisingCampaign', + }, + currency: 'USD', + users: [ + { + _id: 'userId', + firstName: 'Harve', + lastName: 'Lance', + image: 'image-url', + __typename: 'User', + }, + ], + __typename: 'FundraisingCampaignPledge', + }, + { + _id: 'pledgeId2', + amount: 100, + startDate: '2024-07-28', + endDate: '2024-08-14', + campaign: { + _id: 'campaignId2', + name: 'School Campaign', + endDate: '2024-08-30', + __typename: 'FundraisingCampaign', + }, + currency: 'USD', + users: [ + { + _id: 'userId5', + firstName: 'John', + lastName: 'Doe', + image: null, + __typename: 'User', + }, + ], + __typename: 'FundraisingCampaignPledge', + }, + ], + }, + }, + }, + { + request: { + query: USER_PLEDGES, + variables: { + userId: 'userId', + where: { + firstName_contains: '', + }, + orderBy: 'endDate_DESC', + }, + }, + result: { + data: { + getPledgesByUserId: [ + { + _id: 'pledgeId2', + amount: 100, + startDate: '2024-07-28', + endDate: '2024-08-14', + campaign: { + _id: 'campaignId2', + name: 'School Campaign', + endDate: '2024-08-30', + __typename: 'FundraisingCampaign', + }, + currency: 'USD', + users: [ + { + _id: 'userId5', + firstName: 'John', + lastName: 'Doe', + image: null, + __typename: 'User', + }, + { + _id: 'userId6', + firstName: 'Jane', + lastName: 'Doe', + image: null, + __typename: 'User', + }, + { + _id: 'userId7', + firstName: 'John2', + lastName: 'Doe2', + image: 'image-url3', + __typename: 'User', + }, + { + _id: 'userId8', + firstName: 'Jane2', + lastName: 'Doe2', + image: null, + __typename: 'User', + }, + { + _id: 'userId9', + firstName: 'John3', + lastName: 'Doe3', + image: null, + __typename: 'User', + }, + { + _id: 'userId10', + firstName: 'Jane3', + lastName: 'Doe3', + image: null, + __typename: 'User', + }, + { + _id: 'userId11', + firstName: 'John4', + lastName: 'Doe4', + image: null, + __typename: 'User', + }, + ], + __typename: 'FundraisingCampaignPledge', + }, + { + _id: 'pledgeId1', + amount: 700, + startDate: '2024-07-28', + endDate: '2024-08-13', + campaign: { + _id: 'campaignId1', + name: 'Hospital Campaign', + endDate: '2024-08-30', + __typename: 'FundraisingCampaign', + }, + currency: 'USD', + users: [ + { + _id: 'userId', + firstName: 'Harve', + lastName: 'Lance', + image: 'image-url', + __typename: 'User', + }, + ], + __typename: 'FundraisingCampaignPledge', + }, + ], + }, + }, + }, + { + request: { + query: DELETE_PLEDGE, + variables: { + id: '1', + }, + }, + result: { + data: { + removeFundraisingCampaignPledge: { + _id: '1', + }, + }, + }, + }, + userDetailsQuery, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: USER_PLEDGES, + variables: { + userId: 'userId', + where: { + firstName_contains: '', + }, + orderBy: 'endDate_DESC', + }, + }, + result: { + data: { + getPledgesByUserId: [], + }, + }, + }, + userDetailsQuery, +]; + +export const USER_PLEDGES_ERROR = [ + { + request: { + query: USER_PLEDGES, + variables: { + userId: 'userId', + where: { + firstName_contains: '', + }, + orderBy: 'endDate_DESC', + }, + }, + error: new Error('Error fetching pledges'), + }, + userDetailsQuery, +]; diff --git a/src/screens/UserPortal/Posts/Posts.module.css b/src/screens/UserPortal/Posts/Posts.module.css new file mode 100644 index 0000000000..fc0263833e --- /dev/null +++ b/src/screens/UserPortal/Posts/Posts.module.css @@ -0,0 +1,191 @@ +.borderNone { + border: none; +} + +.colorWhite { + color: white; +} + +.colorLight { + background-color: #f5f5f5; +} + +.mainContainer { + width: 50%; + flex-grow: 3; + padding: 1rem; + max-height: 100%; + overflow-y: auto; + overflow-x: hidden; + background-color: #f2f7ff; +} + +.containerHeight { + height: 100vh; +} + +.link { + text-decoration: none !important; + color: black; +} + +.postInputContainer { + margin-top: 0.5rem; + margin-bottom: 1rem; +} + +.maxWidth { + width: 100%; + /* min-width: 3rem; */ + /* padding: 0; */ +} + +.inputArea { + border: none; + outline: none; + background-color: #f1f3f6; +} + +.postActionContainer { + display: flex; + flex-direction: row; + justify-content: space-between; + gap: 10px; +} + +.postActionBtn { + background-color: white; + border: none; + color: black; +} + +.postActionBtn:hover { + background-color: ghostwhite; + border: none; + color: black; +} + +.postInput { + resize: none; + border: none; + outline: none; + box-shadow: none; + background-color: white; + margin-bottom: 10px; +} + +.postInput:focus { + box-shadow: none; +} + +.postContainer { + width: auto; + background-color: white; + margin-top: 1rem; + padding: 1rem; + border: 1px solid #dddddd; + border-radius: 10px; +} + +.heading { + font-size: 1.1rem; +} + +.pinnedPostsCardsContainer { + overflow-x: scroll; + display: flex; + gap: 1rem; + --bs-gutter-x: 0; +} + +.postsCardsContainer { + /* display: flex; + flex-wrap: wrap; + gap: 1rem; + --bs-gutter-x: 0; */ +} + +.userImage { + display: flex; + width: 50px; + height: 50px; + margin-left: 1rem; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 50%; + position: relative; + border: 2px solid #31bb6b; +} + +.userImage img { + position: absolute; + top: 0; + left: 0; + width: 100%; + scale: 1.5; +} + +.startPostBtn { + width: 100%; + border-radius: 25px; + background-color: transparent; + /* border: 1px solid #acacac; */ + box-shadow: rgba(0, 0, 0, 0.16) 0px 1px 4px; + outline: none; + border: none; + color: #000; + font-weight: 900; +} + +.startPostBtn:hover { + background-color: #00000010; + border: 0; + color: #000 !important; +} + +.icons { + width: 25px; +} + +.icons svg { + stroke: #000; +} + +.icons.dark { + cursor: pointer; + border: none; + outline: none; + background-color: transparent; +} + +.icons.dark svg { + stroke: #000; +} + +.iconLabel { + margin: 0; + color: #000; + font-weight: 900; +} + +.uploadLink { + text-align: center; + width: min-content; + padding: 8px 4px; + border-radius: 4px; + cursor: pointer; +} + +.uploadLink:hover { + background-color: #00000010; +} + +.modal { + width: 100dvw; + margin: 0 auto; +} + +.imageInput { + display: none; +} diff --git a/src/screens/UserPortal/Posts/Posts.test.tsx b/src/screens/UserPortal/Posts/Posts.test.tsx new file mode 100644 index 0000000000..aa5f03fdcf --- /dev/null +++ b/src/screens/UserPortal/Posts/Posts.test.tsx @@ -0,0 +1,399 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import userEvent from '@testing-library/user-event'; +import { + ORGANIZATION_ADVERTISEMENT_LIST, + ORGANIZATION_POST_LIST, +} from 'GraphQl/Queries/Queries'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18nForTest from 'utils/i18nForTest'; +import Home from './Posts'; +import useLocalStorage from 'utils/useLocalstorage'; +import { DELETE_POST_MUTATION } from 'GraphQl/Mutations/mutations'; + +const { setItem } = useLocalStorage(); + +jest.mock('react-toastify', () => ({ + toast: { + error: jest.fn(), + info: jest.fn(), + success: jest.fn(), + }, +})); + +const MOCKS = [ + { + request: { + query: ORGANIZATION_POST_LIST, + variables: { + id: 'orgId', + first: 10, + }, + }, + result: { + data: { + organizations: [ + { + posts: { + edges: [ + { + node: { + _id: '6411e53835d7ba2344a78e21', + title: 'post one', + text: 'This is the first post', + imageUrl: null, + videoUrl: null, + createdAt: '2024-03-03T09:26:56.524+00:00', + creator: { + _id: '640d98d9eb6a743d75341067', + firstName: 'Glen', + lastName: 'Dsza', + email: 'glendsza@gmail.com', + }, + likeCount: 0, + commentCount: 0, + comments: [], + pinned: true, + likedBy: [], + }, + cursor: '6411e53835d7ba2344a78e21', + }, + { + node: { + _id: '6411e54835d7ba2344a78e29', + title: 'post two', + text: 'This is the post two', + imageUrl: null, + videoUrl: null, + createdAt: '2024-03-03T09:26:56.524+00:00', + creator: { + _id: '640d98d9eb6a743d75341067', + firstName: 'Glen', + lastName: 'Dsza', + email: 'glendsza@gmail.com', + }, + likeCount: 2, + commentCount: 1, + pinned: false, + likedBy: [ + { + _id: '640d98d9eb6a743d75341067', + firstName: 'Glen', + lastName: 'Dsza', + }, + { + _id: '640d98d9eb6a743d75341068', + firstName: 'Glen2', + lastName: 'Dsza2', + }, + ], + comments: [ + { + _id: '6411e54835d7ba2344a78e29', + creator: { + _id: '640d98d9eb6a743d75341067', + firstName: 'Glen', + lastName: 'Dsza', + email: 'glendsza@gmail.com', + }, + likeCount: 2, + likedBy: [ + { + _id: '640d98d9eb6a743d75341067', + firstName: 'Glen', + lastName: 'Dsza', + }, + { + _id: '640d98d9eb6a743d75341068', + firstName: 'Glen2', + lastName: 'Dsza2', + }, + ], + text: 'This is the post two', + createdAt: '2024-03-03T09:26:56.524+00:00', + }, + ], + }, + cursor: '6411e54835d7ba2344a78e29', + }, + ], + pageInfo: { + startCursor: '6411e53835d7ba2344a78e21', + endCursor: '6411e54835d7ba2344a78e31', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 2, + }, + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_ADVERTISEMENT_LIST, + variables: { id: 'orgId', first: 6 }, + }, + result: { + data: { + organizations: [ + { + _id: 'orgId', + advertisements: { + edges: [ + { + node: { + _id: '1234', + name: 'Ad 1', + type: 'Type 1', + organization: { + _id: 'orgId', + }, + mediaUrl: 'Link 1', + endDate: '2024-12-31', + startDate: '2022-01-01', + }, + cursor: '1234', + }, + { + node: { + _id: '2345', + name: 'Ad 2', + type: 'Type 1', + organization: { + _id: 'orgId', + }, + mediaUrl: 'Link 2', + endDate: '2024-09-31', + startDate: '2023-04-01', + }, + cursor: '1234', + }, + { + node: { + _id: '3456', + name: 'name3', + type: 'Type 2', + organization: { + _id: 'orgId', + }, + mediaUrl: 'link3', + startDate: '2023-01-30', + endDate: '2023-12-31', + }, + cursor: '1234', + }, + { + node: { + _id: '4567', + name: 'name4', + type: 'Type 2', + organization: { + _id: 'orgId1', + }, + mediaUrl: 'link4', + startDate: '2023-01-30', + endDate: '2023-12-01', + }, + cursor: '1234', + }, + ], + pageInfo: { + startCursor: '6411e53835d7ba2344a78e21', + endCursor: '6411e54835d7ba2344a78e31', + hasNextPage: false, + hasPreviousPage: false, + }, + totalCount: 2, + }, + }, + ], + }, + }, + }, + { + request: { + query: DELETE_POST_MUTATION, + variables: { id: '6411e54835d7ba2344a78e29' }, + }, + result: { + data: { + removePost: { _id: '6411e54835d7ba2344a78e29' }, + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); + +afterEach(() => { + localStorage.clear(); +}); + +async function wait(ms = 100): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +const renderHomeScreen = (): RenderResult => + render( + + + + + + } /> + + + + + , + ); + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}); + +describe('Testing Home Screen: User Portal', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + test('Check if HomeScreen renders properly', async () => { + renderHomeScreen(); + + await wait(); + const startPostBtn = await screen.findByTestId('postBtn'); + expect(startPostBtn).toBeInTheDocument(); + }); + + test('StartPostModal should render on click of StartPost btn', async () => { + renderHomeScreen(); + + await wait(); + const startPostBtn = await screen.findByTestId('postBtn'); + expect(startPostBtn).toBeInTheDocument(); + + userEvent.click(startPostBtn); + const startPostModal = screen.getByTestId('startPostModal'); + expect(startPostModal).toBeInTheDocument(); + }); + + test('StartPostModal should close on clicking the close button', async () => { + renderHomeScreen(); + + await wait(); + userEvent.upload( + screen.getByTestId('postImageInput'), + new File(['image content'], 'image.png', { type: 'image/png' }), + ); + await wait(); + + const startPostBtn = await screen.findByTestId('postBtn'); + expect(startPostBtn).toBeInTheDocument(); + + userEvent.click(startPostBtn); + const startPostModal = screen.getByTestId('startPostModal'); + expect(startPostModal).toBeInTheDocument(); + + userEvent.type(screen.getByTestId('postInput'), 'some content'); + + // Check that the content and image have been added + expect(screen.getByTestId('postInput')).toHaveValue('some content'); + await screen.findByAltText('Post Image Preview'); + expect(screen.getByAltText('Post Image Preview')).toBeInTheDocument(); + + const closeButton = within(startPostModal).getByRole('button', { + name: /close/i, + }); + userEvent.click(closeButton); + + const closedModalText = screen.queryByText(/somethingOnYourMind/i); + expect(closedModalText).not.toBeInTheDocument(); + + expect(screen.getByTestId('postInput')).toHaveValue(''); + expect(screen.getByTestId('postImageInput')).toHaveValue(''); + }); + + test('Check whether Posts render in PostCard', async () => { + setItem('userId', '640d98d9eb6a743d75341067'); + renderHomeScreen(); + await wait(); + + const postCardContainers = screen.findAllByTestId('postCardContainer'); + expect(postCardContainers).not.toBeNull(); + + expect(screen.queryAllByText('post one')[0]).toBeInTheDocument(); + expect( + screen.queryAllByText('This is the first post')[0], + ).toBeInTheDocument(); + + expect(screen.queryByText('post two')).toBeInTheDocument(); + expect(screen.queryByText('This is the post two')).toBeInTheDocument(); + }); + + test('Checking if refetch works after deleting this post', async () => { + setItem('userId', '640d98d9eb6a743d75341067'); + renderHomeScreen(); + expect(screen.queryAllByTestId('dropdown')).not.toBeNull(); + const dropdowns = await screen.findAllByTestId('dropdown'); + userEvent.click(dropdowns[1]); + const deleteButton = await screen.findByTestId('deletePost'); + userEvent.click(deleteButton); + }); +}); + +describe('HomeScreen with invalid orgId', () => { + test('Redirect to /user when organizationId is falsy', async () => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: undefined }), + })); + render( + + + + + + } /> + } + /> + + + + + , + ); + const homeEl = await screen.findByTestId('homeEl'); + expect(homeEl).toBeInTheDocument(); + }); +}); diff --git a/src/screens/UserPortal/Posts/Posts.tsx b/src/screens/UserPortal/Posts/Posts.tsx new file mode 100644 index 0000000000..ceb10c9b49 --- /dev/null +++ b/src/screens/UserPortal/Posts/Posts.tsx @@ -0,0 +1,379 @@ +import { useQuery } from '@apollo/client'; +import HourglassBottomIcon from '@mui/icons-material/HourglassBottom'; +import SendIcon from '@mui/icons-material/Send'; +import { + ORGANIZATION_ADVERTISEMENT_LIST, + ORGANIZATION_POST_LIST, + USER_DETAILS, +} from 'GraphQl/Queries/Queries'; +import PostCard from 'components/UserPortal/PostCard/PostCard'; +import type { + InterfacePostCard, + InterfaceQueryOrganizationAdvertisementListItem, + InterfaceQueryUserListItem, +} from 'utils/interfaces'; +import PromotedPost from 'components/UserPortal/PromotedPost/PromotedPost'; +import StartPostModal from 'components/UserPortal/StartPostModal/StartPostModal'; +import React, { useEffect, useState } from 'react'; +import { Button, Col, Form, Row } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import { Navigate, useParams } from 'react-router-dom'; +import useLocalStorage from 'utils/useLocalstorage'; +import styles from './Posts.module.css'; +import convertToBase64 from 'utils/convertToBase64'; +import Carousel from 'react-multi-carousel'; +import 'react-multi-carousel/lib/styles.css'; + +const responsive = { + superLargeDesktop: { + breakpoint: { max: 4000, min: 3000 }, + items: 5, + }, + desktop: { + breakpoint: { max: 3000, min: 1024 }, + items: 3, + }, + tablet: { + breakpoint: { max: 1024, min: 600 }, + items: 2, + }, + mobile: { + breakpoint: { max: 600, min: 0 }, + items: 1, + }, +}; + +type Ad = { + _id: string; + name: string; + type: 'BANNER' | 'MENU' | 'POPUP'; + mediaUrl: string; + endDate: string; // Assuming it's a string in the format 'yyyy-MM-dd' + startDate: string; // Assuming it's a string in the format 'yyyy-MM-dd' +}; + +type InterfacePostComments = { + id: string; + creator: { + id: string; + firstName: string; + lastName: string; + email: string; + }; + + likeCount: number; + likedBy: { + id: string; + }[]; + text: string; +}[]; + +type InterfacePostLikes = { + firstName: string; + lastName: string; + id: string; +}[]; + +type InterfacePostNode = { + commentCount: number; + createdAt: string; + creator: { + email: string; + firstName: string; + lastName: string; + _id: string; + }; + imageUrl: string | null; + likeCount: number; + likedBy: { + _id: string; + firstName: string; + lastName: string; + }[]; + pinned: boolean; + text: string; + title: string; + videoUrl: string | null; + _id: string; + + comments: InterfacePostComments; + likes: InterfacePostLikes; +}; + +/** + * `home` component displays the main feed for a user, including posts, promoted content, and options to create a new post. + * + * It utilizes Apollo Client for fetching and managing data through GraphQL queries. The component fetches and displays posts from an organization, promoted advertisements, and handles user interactions for creating new posts. It also manages state for displaying modal dialogs and handling file uploads for new posts. + * + * @returns JSX.Element - The rendered `home` component. + */ +export default function home(): JSX.Element { + // Translation hook for localized text + const { t } = useTranslation('translation', { keyPrefix: 'home' }); + const { t: tCommon } = useTranslation('common'); + + // Custom hook for accessing local storage + const { getItem } = useLocalStorage(); + const [posts, setPosts] = useState([]); + const [pinnedPosts, setPinnedPosts] = useState([]); + + const [showModal, setShowModal] = useState(false); + const [postImg, setPostImg] = useState(''); + + // Fetching the organization ID from URL parameters + const { orgId } = useParams(); + + // Redirect to user page if organization ID is not available + if (!orgId) { + return ; + } + + // Query hooks for fetching posts, advertisements, and user details + const { + data: promotedPostsData, + }: { + data?: { + organizations: InterfaceQueryOrganizationAdvertisementListItem[]; + }; + refetch: () => void; + } = useQuery(ORGANIZATION_ADVERTISEMENT_LIST, { + variables: { + id: orgId, + first: 6, + }, + }); + + const { + data, + refetch, + loading: loadingPosts, + } = useQuery(ORGANIZATION_POST_LIST, { + variables: { id: orgId, first: 10 }, + }); + + const [adContent, setAdContent] = useState([]); + const userId: string | null = getItem('userId'); + + const { data: userData } = useQuery(USER_DETAILS, { + variables: { id: userId }, + }); + + const user: InterfaceQueryUserListItem | undefined = userData?.user; + + // Effect hook to update posts state when data changes + useEffect(() => { + if (data) { + setPosts(data.organizations[0].posts.edges); + } + }, [data]); + + // Effect hook to update advertisements state when data changes + useEffect(() => { + if (promotedPostsData && promotedPostsData.organizations) { + const ads: Ad[] = + promotedPostsData.organizations[0].advertisements?.edges.map( + (edge) => edge.node, + ) || []; + + setAdContent(ads); + } + }, [promotedPostsData]); + + useEffect(() => { + setPinnedPosts( + posts.filter(({ node }: { node: InterfacePostNode }) => { + return node.pinned; + }), + ); + }, [posts]); + + /** + * Converts a post node into props for the `PostCard` component. + * + * @param node - The post node to convert. + * @returns The props for the `PostCard` component. + */ + const getCardProps = (node: InterfacePostNode): InterfacePostCard => { + const { + creator, + _id, + imageUrl, + videoUrl, + title, + text, + likeCount, + commentCount, + likedBy, + comments, + } = node; + + const allLikes: InterfacePostLikes = likedBy.map((value) => ({ + firstName: value.firstName, + lastName: value.lastName, + id: value._id, + })); + + const postComments: InterfacePostComments = comments?.map((value) => ({ + id: value.id, + creator: { + firstName: value.creator?.firstName ?? '', + lastName: value.creator?.lastName ?? '', + id: value.creator?.id ?? '', + email: value.creator?.email ?? '', + }, + likeCount: value.likeCount, + likedBy: value.likedBy?.map((like) => ({ id: like?.id ?? '' })) ?? [], + text: value.text, + })); + + const date = new Date(node.createdAt); + const formattedDate = new Intl.DateTimeFormat('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }).format(date); + + const cardProps: InterfacePostCard = { + id: _id, + creator: { + id: creator._id, + firstName: creator.firstName, + lastName: creator.lastName, + email: creator.email, + }, + postedAt: formattedDate, + image: imageUrl, + video: videoUrl, + title, + text, + likeCount, + commentCount, + comments: postComments, + likedBy: allLikes, + fetchPosts: () => refetch(), + }; + + return cardProps; + }; + + /** + * Opens the post creation modal. + */ + const handlePostButtonClick = (): void => { + setShowModal(true); + }; + + /** + * Closes the post creation modal. + */ + const handleModalClose = (): void => { + setShowModal(false); + }; + + return ( + <> +
    +
    +
    +
    {t('startPost')}
    +
    + + + , + ): Promise => { + setPostImg(''); + const target = e.target as HTMLInputElement; + const file = target.files && target.files[0]; + const base64file = file && (await convertToBase64(file)); + setPostImg(base64file); + }} + /> + + +
    +
    + +
    +
    +
    +

    {t('feed')}

    + {pinnedPosts.length > 0 && ( + + {pinnedPosts.map(({ node }: { node: InterfacePostNode }) => { + const cardProps = getCardProps(node); + return ; + })} + + )} +
    + + {adContent.length > 0 && ( +
    + {adContent.map((post: Ad) => ( + + ))} +
    + )} +

    {t(`yourFeed`)}

    +
    + {loadingPosts ? ( +
    + {tCommon('loading')} +
    + ) : ( + <> + {posts.length > 0 ? ( + + {posts.map(({ node }: { node: InterfacePostNode }) => { + const cardProps = getCardProps(node); + return ; + })} + + ) : ( +

    + {t(`nothingToShowHere`)} +

    + )} + + )} + +
    +
    + + ); +} diff --git a/src/screens/UserPortal/Settings/Settings.module.css b/src/screens/UserPortal/Settings/Settings.module.css new file mode 100644 index 0000000000..2558ea9f63 --- /dev/null +++ b/src/screens/UserPortal/Settings/Settings.module.css @@ -0,0 +1,188 @@ +.mainContainer { + flex-grow: 3; + max-height: 100%; + overflow: auto; +} + +.containerHeight { + padding: 1rem 1.5rem 0 calc(300px + 1.5rem); + height: 100vh; +} + +.expand { + padding-left: 4rem; + animation: moveLeft 0.9s ease-in-out; +} + +.contract { + padding-left: calc(300px + 2rem + 1.5rem); + animation: moveRight 0.5s ease-in-out; +} + +.cardHeader .cardTitle { + font-size: 1.2rem; + font-weight: 600; +} +.scrollableCardBody { + max-height: min(220px, 50vh); + overflow-y: auto; + scroll-behavior: smooth; +} +.cardHeader { + padding: 1.25rem 1rem 1rem 1rem; + border-bottom: 1px solid var(--bs-gray-200); + display: flex; + justify-content: space-between; + align-items: center; +} + +.cardBody { + padding: 1.25rem 1rem 1.5rem 1rem; + display: flex; + flex-direction: column; + overflow-y: scroll; +} + +.cardLabel { + font-weight: bold; + padding-bottom: 1px; + font-size: 14px; + color: #707070; + margin-bottom: 10px; +} + +.cardControl { + margin-bottom: 20px; +} + +.cardButton { + width: fit-content; +} + +.imgContianer { + margin: 0 2rem 0 0; +} + +.imgContianer img { + height: 120px; + width: 120px; + border-radius: 50%; +} + +.profileDetails { + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-evenly; +} + +.collapseSidebarButton { + position: fixed; + height: 40px; + bottom: 0; + z-index: 9999; + width: calc(300px + 2rem); + background-color: rgba(245, 245, 245, 0.7); + color: black; + border: none; + border-radius: 0px; +} + +.collapseSidebarButton:hover, +.opendrawer:hover { + opacity: 1; + color: black !important; +} +.opendrawer { + position: fixed; + display: flex; + align-items: center; + justify-content: center; + top: 0; + left: 0; + width: 40px; + height: 100vh; + z-index: 9999; + background-color: rgba(245, 245, 245); + border: none; + border-radius: 0px; + margin-right: 20px; + color: black; +} + +.opendrawer:hover { + transition: background-color 0.5s ease; + background-color: var(--bs-primary); +} +.collapseSidebarButton:hover { + transition: background-color 0.5s ease; + background-color: var(--bs-primary); +} + +@media (max-width: 1120px) { + .collapseSidebarButton { + width: calc(250px); + } +} + +@media (max-height: 650px) { + .collapseSidebarButton { + width: 250px; + height: 20px; + } + .opendrawer { + width: 30px; + } +} + +/* For tablets */ +@media (max-width: 820px) { + .containerHeight { + height: 100vh; + padding: 2rem; + } + + .scrollableCardBody { + max-height: 40vh; + } + + .contract, + .expand { + animation: none; + } + + .opendrawer { + width: 25px; + } + + .collapseSidebarButton { + width: 100%; + left: 0; + right: 0; + } +} + +@media screen and (max-width: 1280px) and (min-width: 992px) { + .imgContianer { + margin: 1rem auto; + } + .profileContainer { + flex-direction: column; + } +} + +@media screen and (max-width: 992px) { + .profileContainer { + align-items: center; + justify-content: center; + } +} + +@media screen and (max-width: 420px) { + .imgContianer { + margin: 1rem auto; + } + .profileContainer { + flex-direction: column; + } +} diff --git a/src/screens/UserPortal/Settings/Settings.test.tsx b/src/screens/UserPortal/Settings/Settings.test.tsx new file mode 100644 index 0000000000..fd9e1ed350 --- /dev/null +++ b/src/screens/UserPortal/Settings/Settings.test.tsx @@ -0,0 +1,416 @@ +import React, { act } from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import { UPDATE_USER_MUTATION } from 'GraphQl/Mutations/mutations'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import Settings from './Settings'; +import userEvent from '@testing-library/user-event'; +import { CHECK_AUTH } from 'GraphQl/Queries/Queries'; +const MOCKS = [ + { + request: { + query: UPDATE_USER_MUTATION, + variables: { + firstName: 'Noble', + lastName: 'Mittal', + createdAt: '2021-03-01', + gender: 'MALE', + phoneNumber: '+174567890', + birthDate: '2024-03-01', + grade: 'GRADE_1', + empStatus: 'UNEMPLOYED', + maritalStatus: 'SINGLE', + address: 'random', + state: 'random', + country: 'IN', + }, + result: { + data: { + updateUserProfile: { + _id: '453', + }, + }, + }, + }, + }, +]; + +const Mocks1 = [ + { + request: { + query: CHECK_AUTH, + }, + result: { + data: { + checkAuth: { + email: 'johndoe@gmail.com', + firstName: 'John', + lastName: 'Doe', + createdAt: '2021-03-01T00:00:00.000Z', + gender: 'MALE', + maritalStatus: 'SINGLE', + educationGrade: 'GRADUATE', + employmentStatus: 'PART_TIME', + birthDate: '2024-03-01', + address: { + state: 'random', + countryCode: 'IN', + line1: 'random', + }, + eventsAttended: [{ _id: 'event1' }, { _id: 'event2' }], + phone: { + mobile: '+174567890', + }, + image: 'https://api.dicebear.com/5.x/initials/svg?seed=John%20Doe', + _id: '65ba1621b7b00c20e5f1d8d2', + }, + }, + }, + }, +]; + +const Mocks2 = [ + { + request: { + query: CHECK_AUTH, + }, + result: { + data: { + checkAuth: { + email: 'johndoe@gmail.com', + firstName: '', + lastName: '', + createdAt: '', + gender: '', + maritalStatus: '', + educationGrade: '', + employmentStatus: '', + eventsAttended: [], + birthDate: '', + address: { + state: '', + countryCode: '', + line1: '', + }, + phone: { + mobile: '', + }, + image: '', + _id: '65ba1621b7b00c20e5f1d8d2', + }, + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); +const link1 = new StaticMockLink(Mocks1, true); +const link2 = new StaticMockLink(Mocks2, true); + +const resizeWindow = (width: number): void => { + window.innerWidth = width; + fireEvent(window, new Event('resize')); +}; + +async function wait(ms = 100): Promise { + await act(async () => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} + +describe('Testing Settings Screen [User Portal]', () => { + // Mock implementation of matchMedia + beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // Deprecated + removeListener: jest.fn(), // Deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), + }); + }); + + test('Screen should be rendered properly', async () => { + await act(async () => { + render( + + + + + + + + + , + ); + }); + + await wait(); + + expect(screen.queryAllByText('Settings')).not.toBe([]); + }); + + test('input works properly', async () => { + await act(async () => { + render( + + + + + + + + + , + ); + }); + + await wait(); + + userEvent.type(screen.getByTestId('inputFirstName'), 'Noble'); + await wait(); + userEvent.type(screen.getByTestId('inputLastName'), 'Mittal'); + await wait(); + userEvent.selectOptions(screen.getByTestId('inputGender'), 'Male'); + await wait(); + userEvent.type(screen.getByTestId('inputPhoneNumber'), '1234567890'); + await wait(); + userEvent.selectOptions(screen.getByTestId('inputGrade'), 'Grade-1'); + await wait(); + userEvent.selectOptions(screen.getByTestId('inputEmpStatus'), 'Unemployed'); + await wait(); + userEvent.selectOptions(screen.getByTestId('inputMaritalStatus'), 'Single'); + await wait(); + userEvent.type(screen.getByTestId('inputAddress'), 'random'); + await wait(); + userEvent.type(screen.getByTestId('inputState'), 'random'); + await wait(); + userEvent.selectOptions(screen.getByTestId('inputCountry'), 'IN'); + await wait(); + expect(screen.getByTestId('resetChangesBtn')).toBeInTheDocument(); + await wait(); + fireEvent.change(screen.getByLabelText('Birth Date'), { + target: { value: '2024-03-01' }, + }); + expect(screen.getByLabelText('Birth Date')).toHaveValue('2024-03-01'); + await wait(); + const fileInp = screen.getByTestId('fileInput'); + fileInp.style.display = 'block'; + userEvent.click(screen.getByTestId('uploadImageBtn')); + await wait(); + const imageFile = new File(['(⌐□_□)'], 'profile-image.jpg', { + type: 'image/jpeg', + }); + const files = [imageFile]; + userEvent.upload(fileInp, files); + await wait(); + expect(screen.getByTestId('profile-picture')).toBeInTheDocument(); + }); + + test('resetChangesBtn works properly', async () => { + await act(async () => { + render( + + + + + + + + + , + ); + }); + + await wait(); + userEvent.type(screen.getByTestId('inputAddress'), 'random'); + await wait(); + userEvent.click(screen.getByTestId('resetChangesBtn')); + await wait(); + expect(screen.getByTestId('inputFirstName')).toHaveValue('John'); + expect(screen.getByTestId('inputLastName')).toHaveValue('Doe'); + expect(screen.getByTestId('inputGender')).toHaveValue('MALE'); + expect(screen.getByTestId('inputPhoneNumber')).toHaveValue('+174567890'); + expect(screen.getByTestId('inputGrade')).toHaveValue('GRADUATE'); + expect(screen.getByTestId('inputEmpStatus')).toHaveValue('PART_TIME'); + expect(screen.getByTestId('inputMaritalStatus')).toHaveValue('SINGLE'); + expect(screen.getByTestId('inputAddress')).toHaveValue('random'); + expect(screen.getByTestId('inputState')).toHaveValue('random'); + expect(screen.getByTestId('inputCountry')).toHaveValue('IN'); + expect(screen.getByLabelText('Birth Date')).toHaveValue('2024-03-01'); + }); + + test('resetChangesBtn works properly when the details are empty', async () => { + await act(async () => { + render( + + + + + + + + + , + ); + }); + + await wait(); + userEvent.type(screen.getByTestId('inputAddress'), 'random'); + await wait(); + userEvent.click(screen.getByTestId('resetChangesBtn')); + await wait(); + expect(screen.getByTestId('inputFirstName')).toHaveValue(''); + expect(screen.getByTestId('inputLastName')).toHaveValue(''); + expect(screen.getByTestId('inputGender')).toHaveValue(''); + expect(screen.getByTestId('inputPhoneNumber')).toHaveValue(''); + expect(screen.getByTestId('inputGrade')).toHaveValue(''); + expect(screen.getByTestId('inputEmpStatus')).toHaveValue(''); + expect(screen.getByTestId('inputMaritalStatus')).toHaveValue(''); + expect(screen.getByTestId('inputAddress')).toHaveValue(''); + expect(screen.getByTestId('inputState')).toHaveValue(''); + expect(screen.getByTestId('inputCountry')).toHaveValue(''); + expect(screen.getByLabelText('Birth Date')).toHaveValue(''); + }); + + test('sidebar', async () => { + await act(async () => { + render( + + + + + + + + + , + ); + }); + + await wait(); + + const closeMenubtn = screen.getByTestId('closeMenu'); + expect(closeMenubtn).toBeInTheDocument(); + act(() => closeMenubtn.click()); + const openMenuBtn = screen.getByTestId('openMenu'); + expect(openMenuBtn).toBeInTheDocument(); + act(() => openMenuBtn.click()); + }); + + test('Testing sidebar when the screen size is less than or equal to 820px', async () => { + resizeWindow(800); + await act(async () => { + render( + + + + + + + + + , + ); + }); + + await wait(); + + screen.debug(); + + const openMenuBtn = screen.queryByTestId('openMenu'); + console.log('Open Menu Button:', openMenuBtn); + expect(openMenuBtn).toBeInTheDocument(); + + if (openMenuBtn) { + act(() => openMenuBtn.click()); + } + + const closeMenuBtn = screen.queryByTestId('closeMenu'); + console.log('Close Menu Button:', closeMenuBtn); + expect(closeMenuBtn).toBeInTheDocument(); + + if (closeMenuBtn) { + act(() => closeMenuBtn.click()); + } + }); + + test('renders events attended card correctly', async () => { + render( + + + + + + + + + , + ); + await wait(); + // Check if the card title is rendered + expect(screen.getByText('Events Attended')).toBeInTheDocument(); + await wait(1000); + // Check for empty state immediately + expect(screen.getByText('No Events Attended')).toBeInTheDocument(); + }); + + test('renders events attended card correctly with events', async () => { + const mockEventsAttended = [ + { _id: '1', title: 'Event 1' }, + { _id: '2', title: 'Event 2' }, + ]; + + const MocksWithEvents = [ + { + ...Mocks1[0], + result: { + data: { + checkAuth: { + ...Mocks1[0].result.data.checkAuth, + eventsAttended: mockEventsAttended, + }, + }, + }, + }, + ]; + + const linkWithEvents = new StaticMockLink(MocksWithEvents, true); + + render( + + + + + + + + + , + ); + + await wait(1000); + + expect(screen.getByText('Events Attended')).toBeInTheDocument(); + const eventsCards = screen.getAllByTestId('usereventsCard'); + expect(eventsCards.length).toBe(2); + + eventsCards.forEach((card) => { + expect(card).toBeInTheDocument(); + expect(card.children.length).toBe(1); + }); + }); +}); diff --git a/src/screens/UserPortal/Settings/Settings.tsx b/src/screens/UserPortal/Settings/Settings.tsx new file mode 100644 index 0000000000..6038879b7f --- /dev/null +++ b/src/screens/UserPortal/Settings/Settings.tsx @@ -0,0 +1,593 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import styles from './Settings.module.css'; +import { Button, Card, Col, Form, Row } from 'react-bootstrap'; +import convertToBase64 from 'utils/convertToBase64'; +import { UPDATE_USER_MUTATION } from 'GraphQl/Mutations/mutations'; +import { useMutation, useQuery } from '@apollo/client'; +import { errorHandler } from 'utils/errorHandler'; +import { toast } from 'react-toastify'; +import { CHECK_AUTH } from 'GraphQl/Queries/Queries'; +import useLocalStorage from 'utils/useLocalstorage'; +import { + educationGradeEnum, + employmentStatusEnum, + genderEnum, + maritalStatusEnum, +} from 'utils/formEnumFields'; +import DeleteUser from 'components/UserProfileSettings/DeleteUser'; +import OtherSettings from 'components/UserProfileSettings/OtherSettings'; +import UserSidebar from 'components/UserPortal/UserSidebar/UserSidebar'; +import ProfileDropdown from 'components/ProfileDropdown/ProfileDropdown'; +import Avatar from 'components/Avatar/Avatar'; +import type { InterfaceEvent } from 'components/EventManagement/EventAttendance/InterfaceEvents'; +import { EventsAttendedByUser } from 'components/UserPortal/UserProfile/EventsAttendedByUser'; +import UserAddressFields from 'components/UserPortal/UserProfile/UserAddressFields'; + +/** + * The Settings component allows users to view and update their profile settings. + * It includes functionality to handle image uploads, reset changes, and save updated user details. + * + * @returns The Settings component. + */ +export default function settings(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'settings', + }); + const { t: tCommon } = useTranslation('common'); + const [isUpdated, setisUpdated] = useState(false); + const [hideDrawer, setHideDrawer] = useState(null); + + /** + * Handler to adjust sidebar visibility based on window width. + * This function is invoked on window resize and when the component mounts. + */ + const handleResize = (): void => { + if (window.innerWidth <= 820) { + setHideDrawer(!hideDrawer); + } + }; + + useEffect(() => { + handleResize(); + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + const { setItem } = useLocalStorage(); + const { data } = useQuery(CHECK_AUTH, { fetchPolicy: 'network-only' }); + const [updateUserDetails] = useMutation(UPDATE_USER_MUTATION); + const [userDetails, setUserDetails] = React.useState({ + firstName: '', + lastName: '', + createdAt: '', + gender: '', + email: '', + phoneNumber: '', + birthDate: '', + grade: '', + empStatus: '', + maritalStatus: '', + address: '', + state: '', + country: '', + image: '', + eventsAttended: [] as InterfaceEvent[], + }); + + /** + * Ref to store the original image URL for comparison during updates. + */ + const originalImageState = React.useRef(''); + /** + * Ref to access the file input element for image uploads. + */ + const fileInputRef = React.useRef(null); + + /** + * Handles the update of user details. + * This function sends a mutation request to update the user details + * and reloads the page on success. + */ + /*istanbul ignore next*/ + const handleUpdateUserDetails = async (): Promise => { + try { + let updatedUserDetails = { ...userDetails }; + if (updatedUserDetails.image === originalImageState.current) { + updatedUserDetails = { ...updatedUserDetails, image: '' }; + } + const { data } = await updateUserDetails({ + variables: updatedUserDetails, + }); + /* istanbul ignore next */ + if (data) { + toast.success( + tCommon('updatedSuccessfully', { item: 'Profile' }) as string, + ); + setTimeout(() => { + window.location.reload(); + }, 500); + const userFullName = `${userDetails.firstName} ${userDetails.lastName}`; + setItem('name', userFullName); + } + } catch (error: unknown) { + /*istanbul ignore next*/ + errorHandler(t, error); + } + }; + + /** + * Handles the change of a specific field in the user details state. + * + * @param fieldName - The name of the field to be updated. + * @param value - The new value for the field. + */ + const handleFieldChange = (fieldName: string, value: string): void => { + setisUpdated(true); + setUserDetails((prevState) => ({ + ...prevState, + [fieldName]: value, + })); + }; + + /** + * Triggers the file input click event to open the file picker dialog. + */ + const handleImageUpload = (): void => { + setisUpdated(true); + if (fileInputRef.current) { + (fileInputRef.current as HTMLInputElement).click(); + } + }; + + /** + * Resets the user details to the values fetched from the server. + */ + const handleResetChanges = (): void => { + setisUpdated(false); + /* istanbul ignore next */ + if (data) { + const { + firstName, + lastName, + createdAt, + gender, + phone, + birthDate, + educationGrade, + employmentStatus, + maritalStatus, + address, + } = data.checkAuth; + + setUserDetails({ + ...userDetails, + firstName: firstName || '', + lastName: lastName || '', + createdAt: createdAt || '', + gender: gender || '', + phoneNumber: phone?.mobile || '', + birthDate: birthDate || '', + grade: educationGrade || '', + empStatus: employmentStatus || '', + maritalStatus: maritalStatus || '', + address: address?.line1 || '', + state: address?.state || '', + country: address?.countryCode || '', + }); + } + }; + + useEffect(() => { + /* istanbul ignore next */ + if (data) { + const { + firstName, + lastName, + createdAt, + gender, + email, + phone, + birthDate, + educationGrade, + employmentStatus, + maritalStatus, + address, + image, + eventsAttended, + } = data.checkAuth; + + setUserDetails({ + firstName, + lastName, + createdAt, + gender, + email, + phoneNumber: phone?.mobile || '', + birthDate, + grade: educationGrade || '', + empStatus: employmentStatus || '', + maritalStatus: maritalStatus || '', + address: address?.line1 || '', + state: address?.state || '', + country: address?.countryCode || '', + eventsAttended, + image, + }); + originalImageState.current = image; + } + }, [data]); + return ( + <> + {hideDrawer ? ( + + ) : ( + + )} + +
    +
    +
    +
    +

    {tCommon('settings')}

    +
    + +
    +

    {tCommon('settings')}

    + + + +
    +
    + {t('profileSettings')} +
    +
    + + + +
    +
    + {userDetails?.image ? ( + User + ) : ( + + )} + e.key === 'Enter' && handleImageUpload() + } + /> +
    +
    + , + ): Promise => { + const file = e.target?.files?.[0]; + if (file) { + const image = await convertToBase64(file); + setUserDetails({ ...userDetails, image }); + } + } + } + style={{ display: 'none' }} + /> + + + + {tCommon('firstName')} + + + handleFieldChange('firstName', e.target.value) + } + className={`${styles.cardControl}`} + data-testid="inputFirstName" + /> + + + + {tCommon('lastName')} + + + handleFieldChange('lastName', e.target.value) + } + className={`${styles.cardControl}`} + data-testid="inputLastName" + /> + + + + {t('gender')} + + + handleFieldChange('gender', e.target.value) + } + className={`${styles.cardControl}`} + data-testid="inputGender" + > + + {genderEnum.map((g) => ( + + ))} + + +
    + + + + {tCommon('emailAddress')} + + + + + + {t('phoneNumber')} + + + handleFieldChange('phoneNumber', e.target.value) + } + className={`${styles.cardControl}`} + data-testid="inputPhoneNumber" + /> + + + + {t('birthDate')} + + + handleFieldChange('birthDate', e.target.value) + } + className={`${styles.cardControl}`} + /> + + + + + + {t('grade')} + + + handleFieldChange('grade', e.target.value) + } + className={`${styles.cardControl}`} + data-testid="inputGrade" + > + + {educationGradeEnum.map((grade) => ( + + ))} + + + + + {t('empStatus')} + + + handleFieldChange('empStatus', e.target.value) + } + className={`${styles.cardControl}`} + data-testid="inputEmpStatus" + > + + {employmentStatusEnum.map((status) => ( + + ))} + + + + + {t('maritalStatus')} + + + handleFieldChange('maritalStatus', e.target.value) + } + className={`${styles.cardControl}`} + data-testid="inputMaritalStatus" + > + + {maritalStatusEnum.map((status) => ( + + ))} + + + + + {isUpdated && ( +
    + + +
    + )} +
    +
    + + + + + + + + + +
    + +
    +
    + + ); +} diff --git a/src/screens/UserPortal/UserScreen/UserScreen.module.css b/src/screens/UserPortal/UserScreen/UserScreen.module.css new file mode 100644 index 0000000000..f8ad1c5bfd --- /dev/null +++ b/src/screens/UserPortal/UserScreen/UserScreen.module.css @@ -0,0 +1,173 @@ +.pageContainer { + display: flex; + flex-direction: column; + min-height: 100vh; + padding: 1rem 1.5rem 0 calc(300px + 2rem + 1.5rem); +} + +.expand { + padding-left: 4rem; + animation: moveLeft 0.9s ease-in-out; +} +.avatarStyle { + border-radius: 100%; +} +.profileContainer { + border: none; + padding: 2.1rem 0.5rem; + height: 52px; + border-radius: 8px 0px 0px 8px; + display: flex; + align-items: center; + background-color: white !important; + box-shadow: + 0 4px 4px 0 rgba(177, 177, 177, 0.2), + 0 6px 20px 0 rgba(151, 151, 151, 0.19); +} +.profileContainer:focus { + outline: none; + background-color: var(--bs-gray-100); +} +.imageContainer { + width: 56px; +} +.profileContainer .profileText { + flex: 1; + text-align: start; + overflow: hidden; + margin-right: 4px; +} +.angleDown { + margin-left: 4px; +} +.profileContainer .profileText .primaryText { + font-size: 1rem; + font-weight: 600; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; /* number of lines to show */ + -webkit-box-orient: vertical; + word-wrap: break-word; + white-space: normal; +} +.profileContainer .profileText .secondaryText { + font-size: 0.8rem; + font-weight: 400; + color: var(--bs-secondary); + display: block; + text-transform: capitalize; +} + +.contract { + padding-left: calc(300px + 2rem + 1.5rem); + animation: moveRight 0.5s ease-in-out; +} + +.collapseSidebarButton { + position: fixed; + height: 40px; + bottom: 0; + z-index: 9999; + width: calc(300px + 2rem); + background-color: rgba(245, 245, 245, 0.7); + color: black; + border: none; + border-radius: 0px; +} + +.collapseSidebarButton:hover, +.opendrawer:hover { + opacity: 1; + color: black !important; +} +.opendrawer { + position: fixed; + display: flex; + align-items: center; + justify-content: center; + top: 0; + left: 0; + width: 40px; + height: 100vh; + z-index: 9999; + background-color: rgba(245, 245, 245); + border: none; + border-radius: 0px; + margin-right: 20px; + color: black; +} +.profileDropdown { + background-color: transparent !important; +} +.profileDropdown .dropdown-toggle .btn .btn-normal { + display: none !important; + background-color: transparent !important; +} +.dropdownToggle { + background-image: url(/public/images/svg/angleDown.svg); + background-repeat: no-repeat; + background-position: center; + background-color: azure; +} + +.dropdownToggle::after { + border-top: none !important; + border-bottom: none !important; +} + +.opendrawer:hover { + transition: background-color 0.5s ease; + background-color: var(--bs-primary); +} +.collapseSidebarButton:hover { + transition: background-color 0.5s ease; + background-color: var(--bs-primary); +} + +@media (max-width: 1120px) { + .contract { + padding-left: calc(276px + 2rem + 1.5rem); + } + .collapseSidebarButton { + width: calc(250px + 2rem); + } +} + +@media (max-height: 900px) { + .collapseSidebarButton { + width: calc(300px + 1rem); + } +} +@media (max-height: 650px) { + .pageContainer { + padding: 1rem 1.5rem 0 calc(270px); + } + .collapseSidebarButton { + width: 250px; + } + .opendrawer { + width: 30px; + } +} + +/* For tablets */ +@media (max-width: 820px) { + .pageContainer { + padding-left: 2.5rem; + } + + .opendrawer { + width: 25px; + } + + .contract, + .expand { + animation: none; + } + + .collapseSidebarButton { + width: 100%; + left: 0; + right: 0; + } +} diff --git a/src/screens/UserPortal/UserScreen/UserScreen.test.tsx b/src/screens/UserPortal/UserScreen/UserScreen.test.tsx new file mode 100644 index 0000000000..642b231a66 --- /dev/null +++ b/src/screens/UserPortal/UserScreen/UserScreen.test.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import 'jest-location-mock'; +import { Provider } from 'react-redux'; +import { BrowserRouter, useNavigate } from 'react-router-dom'; +import { store } from 'state/store'; +import i18nForTest from 'utils/i18nForTest'; +import UserScreen from './UserScreen'; +import { ORGANIZATIONS_LIST } from 'GraphQl/Queries/Queries'; +import { StaticMockLink } from 'utils/StaticMockLink'; + +let mockID: string | undefined = '123'; +let mockLocation: string | undefined = '/user/organization/123'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: mockID }), + useLocation: () => ({ pathname: mockLocation }), +})); + +const MOCKS = [ + { + request: { + query: ORGANIZATIONS_LIST, + variables: { id: '123' }, + }, + result: { + data: { + organizations: [ + { + _id: '123', + image: null, + creator: { + firstName: 'John', + lastName: 'Doe', + email: 'JohnDoe@example.com', + }, + name: 'Test Organization', + description: 'Testing this organization', + address: { + city: 'Mountain View', + countryCode: 'US', + dependentLocality: 'Some Dependent Locality', + line1: '123 Main Street', + line2: 'Apt 456', + postalCode: '94040', + sortingCode: 'XYZ-789', + state: 'CA', + }, + userRegistrationRequired: true, + visibleInSearch: true, + members: [], + admins: [], + membershipRequests: [], + blockedUsers: [], + }, + ], + }, + }, + }, +]; +const link = new StaticMockLink(MOCKS, true); + +const resizeWindow = (width: number): void => { + window.innerWidth = width; + fireEvent(window, new Event('resize')); +}; + +const clickToggleMenuBtn = (toggleButton: HTMLElement): void => { + fireEvent.click(toggleButton); +}; + +describe('Testing LeftDrawer in OrganizationScreen', () => { + test('renders the correct title based on the titleKey for posts', () => { + render( + + + + + + + + + , + ); + + const titleElement = screen.getByRole('heading', { level: 1 }); + expect(titleElement).toHaveTextContent('Posts'); + }); + + test('renders the correct title based on the titleKey', () => { + mockLocation = '/user/people/123'; + + render( + + + + + + + + + , + ); + + const titleElement = screen.getByRole('heading', { level: 1 }); + expect(titleElement).toHaveTextContent('People'); + }); + + test('LeftDrawer should toggle correctly based on window size and user interaction', async () => { + render( + + + + + + + + + , + ); + const toggleButton = screen.getByTestId('closeMenu') as HTMLElement; + const icon = toggleButton.querySelector('i'); + + // Resize window to a smaller width + resizeWindow(800); + clickToggleMenuBtn(toggleButton); + expect(icon).toHaveClass('fa fa-angle-double-left'); + + // Resize window back to a larger width + resizeWindow(1000); + clickToggleMenuBtn(toggleButton); + expect(icon).toHaveClass('fa fa-angle-double-right'); + + clickToggleMenuBtn(toggleButton); + expect(icon).toHaveClass('fa fa-angle-double-left'); + }); + + test('should be redirected to root when orgId is undefined', async () => { + mockID = undefined; + const navigate = jest.fn(); + jest.spyOn({ useNavigate }, 'useNavigate').mockReturnValue(navigate); + render( + + + + + + + + + , + ); + expect(window.location.pathname).toEqual('/'); + }); +}); diff --git a/src/screens/UserPortal/UserScreen/UserScreen.tsx b/src/screens/UserPortal/UserScreen/UserScreen.tsx new file mode 100644 index 0000000000..bcb1d867f3 --- /dev/null +++ b/src/screens/UserPortal/UserScreen/UserScreen.tsx @@ -0,0 +1,144 @@ +import React, { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { Navigate, Outlet, useLocation, useParams } from 'react-router-dom'; +import { updateTargets } from 'state/action-creators'; +import { useAppDispatch } from 'state/hooks'; +import type { RootState } from 'state/reducers'; +import type { TargetsType } from 'state/reducers/routesReducer'; +import styles from './UserScreen.module.css'; +import { Button } from 'react-bootstrap'; +import UserSidebarOrg from 'components/UserPortal/UserSidebarOrg/UserSidebarOrg'; +import ProfileDropdown from 'components/ProfileDropdown/ProfileDropdown'; +import type { InterfaceMapType } from 'utils/interfaces'; +import { useTranslation } from 'react-i18next'; + +const map: InterfaceMapType = { + organization: 'home', + people: 'people', + events: 'userEvents', + donate: 'donate', + campaigns: 'userCampaigns', + pledges: 'userPledges', + volunteer: 'userVolunteer', +}; + +/** + * The UserScreen component serves as a container for user-specific pages + * within an organization context. It provides layout and sidebar navigation + * functionality based on the current organization ID and user roles. + * + * @returns The UserScreen component. + */ +const UserScreen = (): JSX.Element => { + // Get the current location path for debugging or conditional rendering + const location = useLocation(); + + /** + * State to manage the visibility of the sidebar (drawer). + */ + + const { orgId } = useParams(); + + // Redirect to home if orgId is not present + if (!orgId) { + return ; + } + + const titleKey: string | undefined = map[location.pathname.split('/')[2]]; + const { t } = useTranslation('translation', { keyPrefix: titleKey }); + + const userRoutes: { + targets: TargetsType[]; + } = useSelector((state: RootState) => state.userRoutes); + + const { targets } = userRoutes; + const [hideDrawer, setHideDrawer] = useState(null); + + /** + * Retrieves the organization ID from the URL parameters. + */ + + // Initialize Redux dispatch + const dispatch = useAppDispatch(); + + /** + * Effect hook to update targets based on the organization ID. + * This hook is triggered when the orgId changes. + */ + useEffect(() => { + dispatch(updateTargets(orgId)); + }, [orgId]); + + /** + * Handles window resize events to toggle the sidebar visibility + * based on the screen width. + */ + const handleResize = (): void => { + if (window.innerWidth <= 820) { + setHideDrawer(!hideDrawer); + } + }; + + // Set up event listener for window resize and clean up on unmount + useEffect(() => { + handleResize(); + window.addEventListener('resize', handleResize); + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + return ( + <> + {hideDrawer ? ( + + ) : ( + + )} +
    + +
    +
    +
    +
    +

    {t('title')}

    +
    + +
    + +
    + + ); +}; + +export default UserScreen; diff --git a/src/screens/UserPortal/Volunteer/Actions/Actions.mocks.ts b/src/screens/UserPortal/Volunteer/Actions/Actions.mocks.ts new file mode 100644 index 0000000000..5162134c65 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Actions/Actions.mocks.ts @@ -0,0 +1,268 @@ +import { ACTION_ITEMS_BY_USER } from 'GraphQl/Queries/ActionItemQueries'; + +const action1 = { + _id: 'actionId1', + assignee: { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + assigneeGroup: null, + assigneeType: 'EventVolunteer', + assigner: { + _id: 'userId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + actionItemCategory: { + _id: 'categoryId1', + name: 'Category 1', + }, + preCompletionNotes: '', + postCompletionNotes: '', + assignmentDate: '2024-10-25', + dueDate: '2025-10-25', + completionDate: '2024-11-01', + isCompleted: false, + event: { + _id: 'eventId1', + title: 'Event 1', + }, + creator: { + _id: 'userId1', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + allottedHours: 8, +}; + +const action2 = { + _id: 'actionId2', + assignee: null, + assigneeGroup: { + _id: 'groupId1', + name: 'Group 1', + }, + assigneeType: 'EventVolunteerGroup', + assigner: { + _id: 'userId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + actionItemCategory: { + _id: 'categoryId2', + name: 'Category 2', + }, + preCompletionNotes: '', + postCompletionNotes: '', + assignmentDate: '2024-10-25', + dueDate: '2025-10-26', + completionDate: '2024-11-01', + isCompleted: false, + event: { + _id: 'eventId1', + title: 'Event 1', + }, + creator: { + _id: 'userId1', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + allottedHours: 8, +}; + +const action3 = { + _id: 'actionId3', + assignee: { + _id: 'volunteerId3', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + }, + assigneeGroup: null, + assigneeType: 'EventVolunteer', + assigner: { + _id: 'userId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + actionItemCategory: { + _id: 'categoryId3', + name: 'Category 3', + }, + preCompletionNotes: '', + postCompletionNotes: '', + assignmentDate: '2024-10-25', + dueDate: '2024-10-27', + completionDate: '2024-11-01', + isCompleted: true, + event: { + _id: 'eventId2', + title: 'Event 2', + }, + creator: { + _id: 'userId1', + firstName: 'Wilt', + lastName: 'Shepherd', + }, + allottedHours: null, +}; + +export const MOCKS = [ + { + request: { + query: ACTION_ITEMS_BY_USER, + variables: { + userId: 'userId', + orderBy: null, + where: { + orgId: 'orgId', + assigneeName: '', + }, + }, + }, + result: { + data: { + actionItemsByUser: [action1, action2, action3], + }, + }, + }, + { + request: { + query: ACTION_ITEMS_BY_USER, + variables: { + userId: 'userId', + orderBy: 'dueDate_DESC', + where: { + orgId: 'orgId', + assigneeName: '', + }, + }, + }, + result: { + data: { + actionItemsByUser: [action2, action1], + }, + }, + }, + { + request: { + query: ACTION_ITEMS_BY_USER, + variables: { + userId: 'userId', + orderBy: 'dueDate_ASC', + where: { + orgId: 'orgId', + assigneeName: '', + }, + }, + }, + result: { + data: { + actionItemsByUser: [action1, action2], + }, + }, + }, + { + request: { + query: ACTION_ITEMS_BY_USER, + variables: { + userId: 'userId', + orderBy: null, + where: { + orgId: 'orgId', + assigneeName: '1', + }, + }, + }, + result: { + data: { + actionItemsByUser: [action2], + }, + }, + }, + { + request: { + query: ACTION_ITEMS_BY_USER, + variables: { + userId: 'userId', + orderBy: null, + where: { + orgId: 'orgId', + categoryName: '', + }, + }, + }, + result: { + data: { + actionItemsByUser: [action1, action2], + }, + }, + }, + { + request: { + query: ACTION_ITEMS_BY_USER, + variables: { + userId: 'userId', + orderBy: null, + where: { + orgId: 'orgId', + categoryName: '1', + }, + }, + }, + result: { + data: { + actionItemsByUser: [action1], + }, + }, + }, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: ACTION_ITEMS_BY_USER, + variables: { + userId: 'userId', + orderBy: null, + where: { + orgId: 'orgId', + assigneeName: '', + }, + }, + }, + result: { + data: { + actionItemsByUser: [], + }, + }, + }, +]; + +export const ERROR_MOCKS = [ + { + request: { + query: ACTION_ITEMS_BY_USER, + variables: { + userId: 'userId', + orderBy: null, + where: { + orgId: 'orgId', + assigneeName: '', + }, + }, + }, + error: new Error('Mock Graphql ACTION_ITEMS_BY_USER Error'), + }, +]; diff --git a/src/screens/UserPortal/Volunteer/Actions/Actions.test.tsx b/src/screens/UserPortal/Volunteer/Actions/Actions.test.tsx new file mode 100644 index 0000000000..ce64d98adf --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Actions/Actions.test.tsx @@ -0,0 +1,221 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import Actions from './Actions'; +import type { ApolloLink } from '@apollo/client'; +import { MOCKS, EMPTY_MOCKS, ERROR_MOCKS } from './Actions.mocks'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem } = useLocalStorage(); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(ERROR_MOCKS); +const link3 = new StaticMockLink(EMPTY_MOCKS); + +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.organizationActionItems ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderActions = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } /> + } + /> + + + + + + , + ); +}; + +describe('Testing Actions Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + beforeEach(() => { + setItem('userId', 'userId'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + setItem('userId', null); + render( + + + + + + } /> + } + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Actions screen', async () => { + renderActions(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const assigneeName = await screen.findAllByTestId('assigneeName'); + expect(assigneeName[0]).toHaveTextContent('Teresa Bradley'); + }); + + it('Check Sorting Functionality', async () => { + renderActions(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + let sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by dueDate_DESC + fireEvent.click(sortBtn); + const dueDateDESC = await screen.findByTestId('dueDate_DESC'); + expect(dueDateDESC).toBeInTheDocument(); + fireEvent.click(dueDateDESC); + + let assigneeName = await screen.findAllByTestId('assigneeName'); + expect(assigneeName[0]).toHaveTextContent('Group 1'); + + // Sort by dueDate_ASC + sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const dueDateASC = await screen.findByTestId('dueDate_ASC'); + expect(dueDateASC).toBeInTheDocument(); + fireEvent.click(dueDateASC); + + assigneeName = await screen.findAllByTestId('assigneeName'); + expect(assigneeName[0]).toHaveTextContent('Teresa Bradley'); + }); + + it('Search by Assignee name', async () => { + renderActions(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByAssignee = await screen.findByTestId('assignee'); + expect(searchByAssignee).toBeInTheDocument(); + userEvent.click(searchByAssignee); + + userEvent.type(searchInput, '1'); + await debounceWait(); + + const assigneeName = await screen.findAllByTestId('assigneeName'); + expect(assigneeName[0]).toHaveTextContent('Group 1'); + }); + + it('Search by Category name', async () => { + renderActions(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByCategory = await screen.findByTestId('category'); + expect(searchByCategory).toBeInTheDocument(); + userEvent.click(searchByCategory); + + // Search by name on press of ENTER + userEvent.type(searchInput, '1'); + await debounceWait(); + + const assigneeName = await screen.findAllByTestId('assigneeName'); + expect(assigneeName[0]).toHaveTextContent('Teresa Bradley'); + }); + + it('should render screen with No Actions', async () => { + renderActions(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noActionItems)).toBeInTheDocument(); + }); + }); + + it('Error while fetching Actions data', async () => { + renderActions(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Open and close ItemUpdateStatusModal', async () => { + renderActions(link1); + + const checkbox = await screen.findAllByTestId('statusCheckbox'); + userEvent.click(checkbox[0]); + + expect(await screen.findByText(t.actionItemStatus)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); + + it('Open and close ItemViewModal', async () => { + renderActions(link1); + + const viewItemBtn = await screen.findAllByTestId('viewItemBtn'); + userEvent.click(viewItemBtn[0]); + + expect(await screen.findByText(t.actionItemDetails)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); +}); diff --git a/src/screens/UserPortal/Volunteer/Actions/Actions.tsx b/src/screens/UserPortal/Volunteer/Actions/Actions.tsx new file mode 100644 index 0000000000..36b1f29b83 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Actions/Actions.tsx @@ -0,0 +1,471 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Navigate, useParams } from 'react-router-dom'; + +import { Circle, Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import dayjs from 'dayjs'; + +import { useQuery } from '@apollo/client'; + +import type { InterfaceActionItemInfo } from 'utils/interfaces'; +import styles from 'screens/OrganizationActionItems/OrganizationActionItems.module.css'; +import Loader from 'components/Loader/Loader'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { Chip, debounce, Stack } from '@mui/material'; +import ItemViewModal from 'screens/OrganizationActionItems/ItemViewModal'; +import Avatar from 'components/Avatar/Avatar'; +import ItemUpdateStatusModal from 'screens/OrganizationActionItems/ItemUpdateStatusModal'; +import { ACTION_ITEMS_BY_USER } from 'GraphQl/Queries/ActionItemQueries'; +import useLocalStorage from 'utils/useLocalstorage'; + +enum ModalState { + VIEW = 'view', + STATUS = 'status', +} + +const dataGridStyle = { + '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { + outline: 'none !important', + }, + '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-root': { + borderRadius: '0.5rem', + }, + '& .MuiDataGrid-main': { + borderRadius: '0.5rem', + }, +}; + +/** + * Component for managing and displaying action items within an organization. + * + * This component allows users to view, filter, sort, and create action items. It also handles fetching and displaying related data such as action item categories and members. + * + * @returns The rendered component. + */ +function actions(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'organizationActionItems', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Get the organization ID from URL parameters + const { orgId } = useParams(); + const { getItem } = useLocalStorage(); + const userId = getItem('userId'); + + if (!orgId || !userId) { + return ; + } + + const [actionItem, setActionItem] = useState( + null, + ); + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState<'dueDate_ASC' | 'dueDate_DESC' | null>( + null, + ); + const [searchBy, setSearchBy] = useState<'assignee' | 'category'>('assignee'); + const [modalState, setModalState] = useState<{ + [key in ModalState]: boolean; + }>({ + [ModalState.VIEW]: false, + [ModalState.STATUS]: false, + }); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const openModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: true })); + + const closeModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: false })); + + const handleModalClick = useCallback( + (actionItem: InterfaceActionItemInfo | null, modal: ModalState): void => { + setActionItem(actionItem); + openModal(modal); + }, + [openModal], + ); + + /** + * Query to fetch action items for the organization based on filters and sorting. + */ + const { + data: actionItemsData, + loading: actionItemsLoading, + error: actionItemsError, + refetch: actionItemsRefetch, + }: { + data?: { + actionItemsByUser: InterfaceActionItemInfo[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(ACTION_ITEMS_BY_USER, { + variables: { + userId, + orderBy: sortBy, + where: { + orgId, + assigneeName: searchBy === 'assignee' ? searchTerm : undefined, + categoryName: searchBy === 'category' ? searchTerm : undefined, + }, + }, + }); + + const actionItems = useMemo( + () => actionItemsData?.actionItemsByUser || [], + [actionItemsData], + ); + + if (actionItemsLoading) { + return ; + } + + if (actionItemsError) { + return ( +
    + +
    + {tErrors('errorLoading', { entity: 'Action Items' })} +
    +
    + ); + } + + const columns: GridColDef[] = [ + { + field: 'assignee', + headerName: 'Assignee', + flex: 1, + align: 'left', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + const { _id, firstName, lastName, image } = + params.row.assignee?.user || {}; + + return ( + <> + {params.row.assigneeType === 'EventVolunteer' ? ( + <> +
    + {image ? ( + Assignee + ) : ( +
    + +
    + )} + {firstName + ' ' + lastName} +
    + + ) : ( + <> +
    +
    + +
    + {params.row.assigneeGroup?.name as string} +
    + + )} + + ); + }, + }, + { + field: 'itemCategory', + headerName: 'Item Category', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
    + {params.row.actionItemCategory?.name} +
    + ); + }, + }, + { + field: 'status', + headerName: 'Status', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + } + label={params.row.isCompleted ? 'Completed' : 'Pending'} + variant="outlined" + color="primary" + className={`${styles.chip} ${params.row.isCompleted ? styles.active : styles.pending}`} + /> + ); + }, + }, + { + field: 'allottedHours', + headerName: 'Allotted Hours', + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + flex: 1, + renderCell: (params: GridCellParams) => { + return ( +
    + {params.row.allottedHours ?? '-'} +
    + ); + }, + }, + { + field: 'dueDate', + headerName: 'Due Date', + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + flex: 1, + renderCell: (params: GridCellParams) => { + return ( +
    + {dayjs(params.row.dueDate).format('DD/MM/YYYY')} +
    + ); + }, + }, + { + field: 'options', + headerName: 'Options', + align: 'center', + flex: 1, + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + <> + + + ); + }, + }, + { + field: 'completed', + headerName: 'Completed', + align: 'center', + flex: 1, + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
    + handleModalClick(params.row, ModalState.STATUS)} + /> +
    + ); + }, + }, + ]; + + return ( +
    + {/* Header with search, filter and Create Button */} +
    +
    + { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
    +
    +
    + + + + {tCommon('searchBy', { item: '' })} + + + setSearchBy('assignee')} + data-testid="assignee" + > + {t('assignee')} + + setSearchBy('category')} + data-testid="category" + > + {t('category')} + + + + + + + {tCommon('sort')} + + + setSortBy('dueDate_DESC')} + data-testid="dueDate_DESC" + > + {t('latestDueDate')} + + setSortBy('dueDate_ASC')} + data-testid="dueDate_ASC" + > + {t('earliestDueDate')} + + + +
    +
    +
    + + {/* Table with Action Items */} + row._id} + slots={{ + noRowsOverlay: () => ( + + {t('noActionItems')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={actionItems} + columns={columns} + isRowSelectable={() => false} + /> + + {/* View Modal */} + {actionItem && ( + <> + closeModal(ModalState.VIEW)} + item={actionItem} + /> + + closeModal(ModalState.STATUS)} + actionItemsRefetch={actionItemsRefetch} + /> + + )} +
    + ); +} + +export default actions; diff --git a/src/screens/UserPortal/Volunteer/Groups/GroupModal.test.tsx b/src/screens/UserPortal/Volunteer/Groups/GroupModal.test.tsx new file mode 100644 index 0000000000..1d83d9a872 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Groups/GroupModal.test.tsx @@ -0,0 +1,308 @@ +import React from 'react'; +import type { ApolloLink } from '@apollo/client'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { store } from 'state/store'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import i18n from 'utils/i18nForTest'; +import { MOCKS, UPDATE_ERROR_MOCKS } from './Groups.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import { toast } from 'react-toastify'; +import type { InterfaceGroupModal } from './GroupModal'; +import GroupModal from './GroupModal'; +import userEvent from '@testing-library/user-event'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(UPDATE_ERROR_MOCKS); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const itemProps: InterfaceGroupModal[] = [ + { + isOpen: true, + hide: jest.fn(), + eventId: 'eventId', + refetchGroups: jest.fn(), + group: { + _id: 'groupId', + name: 'Group 1', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, + { + isOpen: true, + hide: jest.fn(), + eventId: 'eventId', + refetchGroups: jest.fn(), + group: { + _id: 'groupId', + name: 'Group 1', + description: null, + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [], + assignments: [], + event: { + _id: 'eventId', + }, + }, + }, +]; + +const renderGroupModal = ( + link: ApolloLink, + props: InterfaceGroupModal, +): RenderResult => { + return render( + + + + + + + + + + + , + ); +}; + +describe('Testing GroupModal', () => { + it('GroupModal -> Requests -> Accept', async () => { + renderGroupModal(link1, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const requestsBtn = screen.getByText(t.requests); + expect(requestsBtn).toBeInTheDocument(); + userEvent.click(requestsBtn); + + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(2); + expect(userName[0]).toHaveTextContent('John Doe'); + expect(userName[1]).toHaveTextContent('Teresa Bradley'); + + const acceptBtn = screen.getAllByTestId('acceptBtn'); + expect(acceptBtn).toHaveLength(2); + userEvent.click(acceptBtn[0]); + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.requestAccepted); + }); + }); + + it('GroupModal -> Requests -> Reject', async () => { + renderGroupModal(link1, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const requestsBtn = screen.getByText(t.requests); + expect(requestsBtn).toBeInTheDocument(); + userEvent.click(requestsBtn); + + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(2); + expect(userName[0]).toHaveTextContent('John Doe'); + expect(userName[1]).toHaveTextContent('Teresa Bradley'); + + const rejectBtn = screen.getAllByTestId('rejectBtn'); + expect(rejectBtn).toHaveLength(2); + userEvent.click(rejectBtn[0]); + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.requestRejected); + }); + }); + + it('GroupModal -> Click Requests -> Click Details', async () => { + renderGroupModal(link1, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const requestsBtn = screen.getByText(t.requests); + expect(requestsBtn).toBeInTheDocument(); + userEvent.click(requestsBtn); + + const detailsBtn = await screen.findByText(t.details); + expect(detailsBtn).toBeInTheDocument(); + userEvent.click(detailsBtn); + }); + + it('GroupModal -> Details -> Update', async () => { + renderGroupModal(link1, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const nameInput = screen.getByLabelText(`${t.name} *`); + expect(nameInput).toBeInTheDocument(); + fireEvent.change(nameInput, { target: { value: 'Group 2' } }); + expect(nameInput).toHaveValue('Group 2'); + + const descInput = screen.getByLabelText(t.description); + expect(descInput).toBeInTheDocument(); + fireEvent.change(descInput, { target: { value: 'desc new' } }); + expect(descInput).toHaveValue('desc new'); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '10' } }); + expect(vrInput).toHaveValue('10'); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.volunteerGroupUpdated); + expect(itemProps[0].refetchGroups).toHaveBeenCalled(); + expect(itemProps[0].hide).toHaveBeenCalled(); + }); + }); + + it('GroupModal -> Details -> Update -> Error', async () => { + renderGroupModal(link2, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const nameInput = screen.getByLabelText(`${t.name} *`); + expect(nameInput).toBeInTheDocument(); + fireEvent.change(nameInput, { target: { value: 'Group 2' } }); + expect(nameInput).toHaveValue('Group 2'); + + const descInput = screen.getByLabelText(t.description); + expect(descInput).toBeInTheDocument(); + fireEvent.change(descInput, { target: { value: 'desc new' } }); + expect(descInput).toHaveValue('desc new'); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '10' } }); + expect(vrInput).toHaveValue('10'); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + it('GroupModal -> Requests -> Accept -> Error', async () => { + renderGroupModal(link2, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const requestsBtn = screen.getByText(t.requests); + expect(requestsBtn).toBeInTheDocument(); + userEvent.click(requestsBtn); + + const userName = await screen.findAllByTestId('userName'); + expect(userName).toHaveLength(2); + expect(userName[0]).toHaveTextContent('John Doe'); + expect(userName[1]).toHaveTextContent('Teresa Bradley'); + + const acceptBtn = screen.getAllByTestId('acceptBtn'); + expect(acceptBtn).toHaveLength(2); + userEvent.click(acceptBtn[0]); + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); + + it('Try adding different values for volunteersRequired', async () => { + renderGroupModal(link1, itemProps[1]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const vrInput = screen.getByLabelText(t.volunteersRequired); + expect(vrInput).toBeInTheDocument(); + fireEvent.change(vrInput, { target: { value: '-1' } }); + + await waitFor(() => { + expect(vrInput).toHaveValue(''); + }); + + userEvent.clear(vrInput); + userEvent.type(vrInput, '1{backspace}'); + + await waitFor(() => { + expect(vrInput).toHaveValue(''); + }); + + fireEvent.change(vrInput, { target: { value: '0' } }); + await waitFor(() => { + expect(vrInput).toHaveValue(''); + }); + + fireEvent.change(vrInput, { target: { value: '19' } }); + await waitFor(() => { + expect(vrInput).toHaveValue('19'); + }); + }); + + it('GroupModal -> Details -> No values updated', async () => { + renderGroupModal(link1, itemProps[0]); + expect(screen.getByText(t.manageGroup)).toBeInTheDocument(); + + const submitBtn = screen.getByTestId('submitBtn'); + expect(submitBtn).toBeInTheDocument(); + userEvent.click(submitBtn); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/UserPortal/Volunteer/Groups/GroupModal.tsx b/src/screens/UserPortal/Volunteer/Groups/GroupModal.tsx new file mode 100644 index 0000000000..4ae162cd70 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Groups/GroupModal.tsx @@ -0,0 +1,415 @@ +import type { ChangeEvent } from 'react'; +import { Button, Form, Modal } from 'react-bootstrap'; +import type { + InterfaceCreateVolunteerGroup, + InterfaceVolunteerGroupInfo, + InterfaceVolunteerMembership, +} from 'utils/interfaces'; +import styles from 'screens/EventVolunteers/EventVolunteers.module.css'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMutation, useQuery } from '@apollo/client'; +import { toast } from 'react-toastify'; +import { + FormControl, + Paper, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField, +} from '@mui/material'; +import { + UPDATE_VOLUNTEER_GROUP, + UPDATE_VOLUNTEER_MEMBERSHIP, +} from 'GraphQl/Mutations/EventVolunteerMutation'; +import { PiUserListBold } from 'react-icons/pi'; +import { TbListDetails } from 'react-icons/tb'; +import { USER_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Queries/EventVolunteerQueries'; +import Avatar from 'components/Avatar/Avatar'; +import { FaXmark } from 'react-icons/fa6'; + +export interface InterfaceGroupModal { + isOpen: boolean; + hide: () => void; + eventId: string; + group: InterfaceVolunteerGroupInfo; + refetchGroups: () => void; +} + +/** + * A modal dialog for creating or editing a volunteer group. + * + * @param isOpen - Indicates whether the modal is open. + * @param hide - Function to close the modal. + * @param eventId - The ID of the event associated with volunteer group. + * @param orgId - The ID of the organization associated with volunteer group. + * @param group - The volunteer group object to be edited. + * @param refetchGroups - Function to refetch the volunteer groups after creation or update. + * @returns The rendered modal component. + * + * The `VolunteerGroupModal` component displays a form within a modal dialog for creating or editing a Volunteer Group. + * It includes fields for entering the group name, description, volunteersRequired, and selecting volunteers/leaders. + * + * The modal includes: + * - A header with a title indicating the current mode (create or edit) and a close button. + * - A form with: + * - An input field for entering the group name. + * - A textarea for entering the group description. + * - An input field for entering the number of volunteers required. + * - A submit button to create or update the pledge. + * + * On form submission, the component either: + * - Calls `updateVoluneerGroup` mutation to update an existing group, or + * + * Success or error messages are displayed using toast notifications based on the result of the mutation. + */ + +const GroupModal: React.FC = ({ + isOpen, + hide, + eventId, + group, + refetchGroups, +}) => { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + + const [modalType, setModalType] = useState<'details' | 'requests'>('details'); + const [formState, setFormState] = useState({ + name: group.name, + description: group.description ?? '', + leader: group.leader, + volunteerUsers: group.volunteers.map((volunteer) => volunteer.user), + volunteersRequired: group.volunteersRequired ?? null, + }); + + const [updateVolunteerGroup] = useMutation(UPDATE_VOLUNTEER_GROUP); + const [updateMembership] = useMutation(UPDATE_VOLUNTEER_MEMBERSHIP); + + const updateMembershipStatus = async ( + id: string, + status: 'accepted' | 'rejected', + ): Promise => { + try { + await updateMembership({ + variables: { + id: id, + status: status, + }, + }); + toast.success( + t( + status === 'accepted' ? 'requestAccepted' : 'requestRejected', + ) as string, + ); + refetchRequests(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + + /** + * Query to fetch volunteer Membership requests for the event. + */ + const { + data: requestsData, + refetch: refetchRequests, + }: { + data?: { + getVolunteerMembership: InterfaceVolunteerMembership[]; + }; + refetch: () => void; + } = useQuery(USER_VOLUNTEER_MEMBERSHIP, { + variables: { + where: { + eventId, + groupId: group._id, + status: 'requested', + }, + }, + }); + + const requests = useMemo(() => { + if (!requestsData) return []; + return requestsData.getVolunteerMembership; + }, [requestsData]); + + useEffect(() => { + setFormState({ + name: group.name, + description: group.description ?? '', + leader: group.leader, + volunteerUsers: group.volunteers.map((volunteer) => volunteer.user), + volunteersRequired: group.volunteersRequired ?? null, + }); + }, [group]); + + const { name, description, volunteersRequired } = formState; + + const updateGroupHandler = useCallback( + async (e: ChangeEvent): Promise => { + e.preventDefault(); + + const updatedFields: { + [key: string]: number | string | undefined | null; + } = {}; + + if (name !== group?.name) { + updatedFields.name = name; + } + if (description !== group?.description) { + updatedFields.description = description; + } + if (volunteersRequired !== group?.volunteersRequired) { + updatedFields.volunteersRequired = volunteersRequired; + } + try { + await updateVolunteerGroup({ + variables: { + id: group?._id, + data: { ...updatedFields, eventId }, + }, + }); + toast.success(t('volunteerGroupUpdated')); + refetchGroups(); + hide(); + } catch (error: unknown) { + console.log(error); + toast.error((error as Error).message); + } + }, + [formState, group], + ); + + return ( + + +

    {t('manageGroup')}

    + +
    + +
    + setModalType('details')} + /> + + + setModalType('requests')} + checked={modalType === 'requests'} + /> + +
    + + {modalType === 'details' ? ( +
    + {/* Input field to enter the group name */} + + + + setFormState({ ...formState, name: e.target.value }) + } + /> + + + {/* Input field to enter the group description */} + + + + setFormState({ ...formState, description: e.target.value }) + } + /> + + + + + + { + if (parseInt(e.target.value) > 0) { + setFormState({ + ...formState, + volunteersRequired: parseInt(e.target.value), + }); + } else if (e.target.value === '') { + setFormState({ + ...formState, + volunteersRequired: null, + }); + } + }} + /> + + + + {/* Button to submit the pledge form */} + +
    + ) : ( +
    + {requests.length === 0 ? ( + + {t('noRequests')} + + ) : ( + + + + + Name + Actions + + + + {requests.map((request, index) => { + const { _id, firstName, lastName, image } = + request.volunteer.user; + return ( + + + {image ? ( + volunteer + ) : ( +
    + +
    + )} + {firstName + ' ' + lastName} +
    + +
    + + +
    +
    +
    + ); + })} +
    +
    +
    + )} +
    + )} +
    +
    + ); +}; +export default GroupModal; diff --git a/src/screens/UserPortal/Volunteer/Groups/Groups.mocks.ts b/src/screens/UserPortal/Volunteer/Groups/Groups.mocks.ts new file mode 100644 index 0000000000..204326ee8d --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Groups/Groups.mocks.ts @@ -0,0 +1,468 @@ +import { + UPDATE_VOLUNTEER_GROUP, + UPDATE_VOLUNTEER_MEMBERSHIP, +} from 'GraphQl/Mutations/EventVolunteerMutation'; +import { + EVENT_VOLUNTEER_GROUP_LIST, + USER_VOLUNTEER_MEMBERSHIP, +} from 'GraphQl/Queries/EventVolunteerQueries'; + +const group1 = { + _id: 'groupId1', + name: 'Group 1', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-25T16:16:32.978Z', + creator: { + _id: 'creatorId1', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: 'img-url', + }, + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId1', + }, +}; + +const group2 = { + _id: 'groupId2', + name: 'Group 2', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-27T15:25:13.044Z', + creator: { + _id: 'creatorId2', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId2', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId2', + }, +}; + +const group3 = { + _id: 'groupId3', + name: 'Group 3', + description: 'desc', + volunteersRequired: null, + createdAt: '2024-10-27T15:34:15.889Z', + creator: { + _id: 'creatorId3', + firstName: 'Wilt', + lastName: 'Shepherd', + image: null, + }, + leader: { + _id: 'userId1', + firstName: 'Bruce', + lastName: 'Garza', + image: null, + }, + volunteers: [ + { + _id: 'volunteerId3', + user: { + _id: 'userId', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + ], + assignments: [], + event: { + _id: 'eventId3', + }, +}; + +const membership1 = { + _id: 'membershipId1', + status: 'requested', + createdAt: '2024-10-29T10:18:05.851Z', + event: { + _id: 'eventId', + title: 'Event 1', + startDate: '2044-10-31', + }, + volunteer: { + _id: 'volunteerId1', + user: { + _id: 'userId1', + firstName: 'John', + lastName: 'Doe', + image: 'img-url', + }, + }, + group: { + _id: 'groupId', + name: 'Group 1', + }, +}; + +const membership2 = { + _id: 'membershipId2', + status: 'requested', + createdAt: '2024-10-29T10:18:05.851Z', + event: { + _id: 'eventId', + title: 'Event 1', + startDate: '2044-10-31', + }, + volunteer: { + _id: 'volunteerId2', + user: { + _id: 'userId2', + firstName: 'Teresa', + lastName: 'Bradley', + image: null, + }, + }, + group: { + _id: 'groupId', + name: 'Group 2', + }, +}; + +export const MOCKS = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: null, + name_contains: '', + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1, group2, group3], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: null, + name_contains: '', + }, + orderBy: 'volunteers_DESC', + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1, group2], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: null, + name_contains: '', + }, + orderBy: 'volunteers_ASC', + }, + }, + result: { + data: { + getEventVolunteerGroups: [group2, group1], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: null, + name_contains: '1', + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: '', + name_contains: null, + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group1, group2, group3], + }, + }, + }, + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: 'Bruce', + name_contains: null, + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [group3], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + eventId: 'eventId', + groupId: 'groupId', + status: 'requested', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1, membership2], + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'accepted', + }, + }, + result: { + data: { + updateVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'rejected', + }, + }, + result: { + data: { + updateVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + data: { + eventId: 'eventId', + name: 'Group 2', + description: 'desc new', + volunteersRequired: 10, + }, + }, + }, + result: { + data: { + updateEventVolunteerGroup: { + _id: 'groupId', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + data: { + eventId: 'eventId', + }, + }, + }, + result: { + data: { + updateEventVolunteerGroup: { + _id: 'groupId', + }, + }, + }, + }, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: null, + name_contains: '', + }, + orderBy: null, + }, + }, + result: { + data: { + getEventVolunteerGroups: [], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + eventId: 'eventId', + group: 'groupId', + status: 'requested', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [], + }, + }, + }, +]; + +export const ERROR_MOCKS = [ + { + request: { + query: EVENT_VOLUNTEER_GROUP_LIST, + variables: { + where: { + userId: 'userId', + orgId: 'orgId', + leaderName: null, + name_contains: '', + }, + orderBy: null, + }, + }, + error: new Error('Mock Graphql EVENT_VOLUNTEER_GROUP_LIST Error'), + }, +]; + +export const UPDATE_ERROR_MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + eventId: 'eventId', + groupId: 'groupId', + status: 'requested', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1, membership2], + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'accepted', + }, + }, + error: new Error('Mock Graphql UPDATE_VOLUNTEER_MEMBERSHIP Error'), + }, + { + request: { + query: UPDATE_VOLUNTEER_GROUP, + variables: { + id: 'groupId', + data: { + eventId: 'eventId', + name: 'Group 2', + description: 'desc new', + volunteersRequired: 10, + }, + }, + }, + error: new Error('Mock Graphql UPDATE_VOLUNTEER_GROUP Error'), + }, +]; diff --git a/src/screens/UserPortal/Volunteer/Groups/Groups.test.tsx b/src/screens/UserPortal/Volunteer/Groups/Groups.test.tsx new file mode 100644 index 0000000000..bc0a4993b9 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Groups/Groups.test.tsx @@ -0,0 +1,217 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import Groups from './Groups'; +import type { ApolloLink } from '@apollo/client'; +import { MOCKS, EMPTY_MOCKS, ERROR_MOCKS } from './Groups.mocks'; +import useLocalStorage from 'utils/useLocalstorage'; + +const { setItem } = useLocalStorage(); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(ERROR_MOCKS); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.eventVolunteers ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderGroups = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } /> + } + /> + + + + + + , + ); +}; + +describe('Testing Groups Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + beforeEach(() => { + setItem('userId', 'userId'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + setItem('userId', null); + render( + + + + + + } /> + } + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Groups screen', async () => { + renderGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + }); + + it('Check Sorting Functionality', async () => { + renderGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + let sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by members_DESC + fireEvent.click(sortBtn); + const volunteersDESC = await screen.findByTestId('volunteers_DESC'); + expect(volunteersDESC).toBeInTheDocument(); + fireEvent.click(volunteersDESC); + + let groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 1'); + + // Sort by members_ASC + sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const volunteersASC = await screen.findByTestId('volunteers_ASC'); + expect(volunteersASC).toBeInTheDocument(); + fireEvent.click(volunteersASC); + + groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 2'); + }); + + it('Search by Groups', async () => { + renderGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByGroup = await screen.findByTestId('group'); + expect(searchByGroup).toBeInTheDocument(); + userEvent.click(searchByGroup); + + userEvent.type(searchInput, '1'); + await debounceWait(); + + const groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 1'); + }); + + it('Search by Leader', async () => { + renderGroups(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByLeader = await screen.findByTestId('leader'); + expect(searchByLeader).toBeInTheDocument(); + userEvent.click(searchByLeader); + + // Search by name on press of ENTER + userEvent.type(searchInput, 'Bruce'); + await debounceWait(); + + const groupName = await screen.findAllByTestId('groupName'); + expect(groupName[0]).toHaveTextContent('Group 1'); + }); + + it('should render screen with No Groups', async () => { + renderGroups(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noVolunteerGroups)).toBeInTheDocument(); + }); + }); + + it('Error while fetching groups data', async () => { + renderGroups(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Open and close ViewModal', async () => { + renderGroups(link1); + + const viewGroupBtn = await screen.findAllByTestId('viewGroupBtn'); + userEvent.click(viewGroupBtn[0]); + + expect(await screen.findByText(t.groupDetails)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('volunteerViewModalCloseBtn')); + }); + + it('Open and close GroupModal', async () => { + renderGroups(link1); + + const editGroupBtn = await screen.findAllByTestId('editGroupBtn'); + userEvent.click(editGroupBtn[0]); + + expect(await screen.findByText(t.manageGroup)).toBeInTheDocument(); + userEvent.click(await screen.findByTestId('modalCloseBtn')); + }); +}); diff --git a/src/screens/UserPortal/Volunteer/Groups/Groups.tsx b/src/screens/UserPortal/Volunteer/Groups/Groups.tsx new file mode 100644 index 0000000000..3941f461d5 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Groups/Groups.tsx @@ -0,0 +1,415 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Navigate, useParams } from 'react-router-dom'; + +import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; + +import { useQuery } from '@apollo/client'; + +import type { InterfaceVolunteerGroupInfo } from 'utils/interfaces'; +import Loader from 'components/Loader/Loader'; +import { + DataGrid, + type GridCellParams, + type GridColDef, +} from '@mui/x-data-grid'; +import { debounce, Stack } from '@mui/material'; +import Avatar from 'components/Avatar/Avatar'; +import styles from 'screens/EventVolunteers/EventVolunteers.module.css'; +import { EVENT_VOLUNTEER_GROUP_LIST } from 'GraphQl/Queries/EventVolunteerQueries'; +import VolunteerGroupViewModal from 'screens/EventVolunteers/VolunteerGroups/VolunteerGroupViewModal'; +import useLocalStorage from 'utils/useLocalstorage'; +import GroupModal from './GroupModal'; + +enum ModalState { + EDIT = 'edit', + VIEW = 'view', +} + +const dataGridStyle = { + '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { + outline: 'none !important', + }, + '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-root': { + borderRadius: '0.5rem', + }, + '& .MuiDataGrid-main': { + borderRadius: '0.5rem', + }, +}; + +/** + * Component for managing volunteer groups for an event. + * This component allows users to view, filter, sort, and create action items. It also provides a modal for creating and editing action items. + * @returns The rendered component. + */ +function groups(): JSX.Element { + const { t } = useTranslation('translation', { + keyPrefix: 'eventVolunteers', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + const { getItem } = useLocalStorage(); + const userId = getItem('userId'); + + // Get the organization ID from URL parameters + const { orgId } = useParams(); + + if (!orgId || !userId) { + return ; + } + + const [group, setGroup] = useState(null); + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState< + 'volunteers_ASC' | 'volunteers_DESC' | null + >(null); + const [searchBy, setSearchBy] = useState<'leader' | 'group'>('group'); + const [modalState, setModalState] = useState<{ + [key in ModalState]: boolean; + }>({ + [ModalState.EDIT]: false, + [ModalState.VIEW]: false, + }); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + /** + * Query to fetch the list of volunteer groups for the event. + */ + const { + data: groupsData, + loading: groupsLoading, + error: groupsError, + refetch: refetchGroups, + }: { + data?: { + getEventVolunteerGroups: InterfaceVolunteerGroupInfo[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(EVENT_VOLUNTEER_GROUP_LIST, { + variables: { + where: { + eventId: undefined, + userId, + orgId, + leaderName: searchBy === 'leader' ? searchTerm : null, + name_contains: searchBy === 'group' ? searchTerm : null, + }, + orderBy: sortBy, + }, + }); + + const openModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: true })); + + const closeModal = (modal: ModalState): void => + setModalState((prevState) => ({ ...prevState, [modal]: false })); + + const handleModalClick = useCallback( + (group: InterfaceVolunteerGroupInfo | null, modal: ModalState): void => { + setGroup(group); + openModal(modal); + }, + [openModal], + ); + + const groups = useMemo( + () => groupsData?.getEventVolunteerGroups || [], + [groupsData], + ); + + if (groupsLoading) { + return ; + } + + if (groupsError) { + return ( +
    + +
    + {tErrors('errorLoading', { entity: 'Volunteer Groups' })} +
    +
    + ); + } + + const columns: GridColDef[] = [ + { + field: 'group', + headerName: 'Group', + flex: 1, + align: 'left', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
    + {params.row.name} +
    + ); + }, + }, + { + field: 'leader', + headerName: 'Leader', + flex: 1, + align: 'center', + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + const { _id, firstName, lastName, image } = params.row.leader; + return ( +
    + {image ? ( + Assignee + ) : ( +
    + +
    + )} + {firstName + ' ' + lastName} +
    + ); + }, + }, + { + field: 'actions', + headerName: 'Actions Completed', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
    + {params.row.assignments.length} +
    + ); + }, + }, + { + field: 'volunteers', + headerName: 'No. of Volunteers', + flex: 1, + align: 'center', + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( +
    + {params.row.volunteers.length} +
    + ); + }, + }, + { + field: 'options', + headerName: 'Options', + align: 'center', + flex: 1, + minWidth: 100, + headerAlign: 'center', + sortable: false, + headerClassName: `${styles.tableHeader}`, + renderCell: (params: GridCellParams) => { + return ( + <> + + {params.row.leader._id === userId && ( + + )} + + ); + }, + }, + ]; + + return ( +
    + {/* Header with search, filter and Create Button */} +
    +
    + { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
    +
    +
    + + + + {tCommon('searchBy', { item: '' })} + + + setSearchBy('leader')} + data-testid="leader" + > + {t('leader')} + + setSearchBy('group')} + data-testid="group" + > + {t('group')} + + + + + + + {tCommon('sort')} + + + setSortBy('volunteers_DESC')} + data-testid="volunteers_DESC" + > + {t('mostVolunteers')} + + setSortBy('volunteers_ASC')} + data-testid="volunteers_ASC" + > + {t('leastVolunteers')} + + + +
    +
    +
    + + {/* Table with Volunteer Groups */} + row._id} + slots={{ + noRowsOverlay: () => ( + + {t('noVolunteerGroups')} + + ), + }} + sx={dataGridStyle} + getRowClassName={() => `${styles.rowBackground}`} + autoHeight + rowHeight={65} + rows={groups.map((group, index) => ({ + id: index + 1, + ...group, + }))} + columns={columns} + isRowSelectable={() => false} + /> + + {group && ( + <> + closeModal(ModalState.EDIT)} + refetchGroups={refetchGroups} + group={group} + eventId={group.event._id} + /> + closeModal(ModalState.VIEW)} + group={group} + /> + + )} +
    + ); +} + +export default groups; diff --git a/src/screens/UserPortal/Volunteer/Invitations/Invitations.mocks.ts b/src/screens/UserPortal/Volunteer/Invitations/Invitations.mocks.ts new file mode 100644 index 0000000000..c400d96939 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Invitations/Invitations.mocks.ts @@ -0,0 +1,263 @@ +import { UPDATE_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Mutations/EventVolunteerMutation'; +import { USER_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Queries/EventVolunteerQueries'; + +const membership1 = { + _id: 'membershipId1', + status: 'invited', + createdAt: '2024-10-29T10:18:05.851Z', + event: { + _id: 'eventId', + title: 'Event 1', + startDate: '2044-10-31', + }, + volunteer: { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'John', + lastName: 'Doe', + image: 'img-url', + }, + }, + group: null, +}; + +const membership2 = { + _id: 'membershipId2', + status: 'invited', + createdAt: '2024-10-30T10:18:05.851Z', + event: { + _id: 'eventId', + title: 'Event 2', + startDate: '2044-11-31', + }, + volunteer: { + _id: 'volunteerId1', + user: { + _id: 'userId', + firstName: 'John', + lastName: 'Doe', + image: null, + }, + }, + group: { + _id: 'groupId1', + name: 'Group 1', + }, +}; + +export const MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1, membership2], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + }, + orderBy: 'createdAt_DESC', + }, + }, + result: { + data: { + getVolunteerMembership: [membership2, membership1], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + }, + orderBy: 'createdAt_ASC', + }, + }, + result: { + data: { + getVolunteerMembership: [membership1, membership2], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: 'group', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership2], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: 'individual', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1], + }, + }, + }, + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + eventTitle: '1', + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1], + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'accepted', + }, + }, + result: { + data: { + updateVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'rejected', + }, + }, + result: { + data: { + updateVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + }, + }, + }, + result: { + data: { + getVolunteerMembership: [], + }, + }, + }, +]; + +export const ERROR_MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + }, + }, + }, + error: new Error('Mock Graphql USER_VOLUNTEER_MEMBERSHIP Error'), + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'accepted', + }, + }, + error: new Error('Mock Graphql UPDATE_VOLUNTEER_MEMBERSHIP Error'), + }, +]; + +export const UPDATE_ERROR_MOCKS = [ + { + request: { + query: USER_VOLUNTEER_MEMBERSHIP, + variables: { + where: { + userId: 'userId', + status: 'invited', + filter: null, + }, + }, + }, + result: { + data: { + getVolunteerMembership: [membership1, membership2], + }, + }, + }, + { + request: { + query: UPDATE_VOLUNTEER_MEMBERSHIP, + variables: { + id: 'membershipId1', + status: 'accepted', + }, + }, + error: new Error('Mock Graphql UPDATE_VOLUNTEER_MEMBERSHIP Error'), + }, +]; diff --git a/src/screens/UserPortal/Volunteer/Invitations/Invitations.test.tsx b/src/screens/UserPortal/Volunteer/Invitations/Invitations.test.tsx new file mode 100644 index 0000000000..2c0cafc6a9 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Invitations/Invitations.test.tsx @@ -0,0 +1,303 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import Invitations from './Invitations'; +import type { ApolloLink } from '@apollo/client'; +import { + MOCKS, + EMPTY_MOCKS, + ERROR_MOCKS, + UPDATE_ERROR_MOCKS, +} from './Invitations.mocks'; +import { toast } from 'react-toastify'; +import useLocalStorage from 'utils/useLocalstorage'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const { setItem } = useLocalStorage(); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(ERROR_MOCKS); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const link4 = new StaticMockLink(UPDATE_ERROR_MOCKS); +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.userVolunteer ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderInvitations = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } + /> + } + /> + + + + + + , + ); +}; + +describe('Testing Invvitations Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + beforeEach(() => { + setItem('userId', 'userId'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + setItem('userId', null); + render( + + + + + + } /> + } + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Invitations screen', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + }); + + it('Check Sorting Functionality', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + let sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + + // Sort by createdAt_DESC + fireEvent.click(sortBtn); + const createdAtDESC = await screen.findByTestId('createdAt_DESC'); + expect(createdAtDESC).toBeInTheDocument(); + fireEvent.click(createdAtDESC); + + let inviteSubject = await screen.findAllByTestId('inviteSubject'); + expect(inviteSubject[0]).toHaveTextContent( + 'Invitation to join volunteer group', + ); + + // Sort by createdAt_ASC + sortBtn = await screen.findByTestId('sort'); + expect(sortBtn).toBeInTheDocument(); + fireEvent.click(sortBtn); + const createdAtASC = await screen.findByTestId('createdAt_ASC'); + expect(createdAtASC).toBeInTheDocument(); + fireEvent.click(createdAtASC); + + inviteSubject = await screen.findAllByTestId('inviteSubject'); + expect(inviteSubject[0]).toHaveTextContent( + 'Invitation to volunteer for event', + ); + }); + + it('Filter Invitations (all)', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + // Filter by All + const filter = await screen.findByTestId('filter'); + expect(filter).toBeInTheDocument(); + + fireEvent.click(filter); + const filterAll = await screen.findByTestId('filterAll'); + expect(filterAll).toBeInTheDocument(); + + fireEvent.click(filterAll); + const inviteSubject = await screen.findAllByTestId('inviteSubject'); + expect(inviteSubject).toHaveLength(2); + }); + + it('Filter Invitations (group)', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + // Filter by All + const filter = await screen.findByTestId('filter'); + expect(filter).toBeInTheDocument(); + + fireEvent.click(filter); + const filterGroup = await screen.findByTestId('filterGroup'); + expect(filterGroup).toBeInTheDocument(); + + fireEvent.click(filterGroup); + const inviteSubject = await screen.findAllByTestId('inviteSubject'); + expect(inviteSubject).toHaveLength(1); + expect(inviteSubject[0]).toHaveTextContent( + 'Invitation to join volunteer group', + ); + }); + + it('Filter Invitations (individual)', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + // Filter by All + const filter = await screen.findByTestId('filter'); + expect(filter).toBeInTheDocument(); + + fireEvent.click(filter); + const filterIndividual = await screen.findByTestId('filterIndividual'); + expect(filterIndividual).toBeInTheDocument(); + + fireEvent.click(filterIndividual); + const inviteSubject = await screen.findAllByTestId('inviteSubject'); + expect(inviteSubject).toHaveLength(1); + expect(inviteSubject[0]).toHaveTextContent( + 'Invitation to volunteer for event', + ); + }); + + it('Search Invitations', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + // Search by name on press of ENTER + userEvent.type(searchInput, '1'); + await debounceWait(); + + await waitFor(() => { + const inviteSubject = screen.getAllByTestId('inviteSubject'); + expect(inviteSubject).toHaveLength(1); + expect(inviteSubject[0]).toHaveTextContent( + 'Invitation to volunteer for event', + ); + }); + }); + + it('should render screen with No Invitations', async () => { + renderInvitations(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noInvitations)).toBeInTheDocument(); + }); + }); + + it('Error while fetching invitations data', async () => { + renderInvitations(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Accept Invite', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const acceptBtn = await screen.findAllByTestId('acceptBtn'); + expect(acceptBtn).toHaveLength(2); + + // Accept Request + userEvent.click(acceptBtn[0]); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.invitationAccepted); + }); + }); + + it('Reject Invite', async () => { + renderInvitations(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const rejectBtn = await screen.findAllByTestId('rejectBtn'); + expect(rejectBtn).toHaveLength(2); + + // Reject Request + userEvent.click(rejectBtn[0]); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.invitationRejected); + }); + }); + + it('Error in Update Invite Mutation', async () => { + renderInvitations(link4); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const acceptBtn = await screen.findAllByTestId('acceptBtn'); + expect(acceptBtn).toHaveLength(2); + + // Accept Request + userEvent.click(acceptBtn[0]); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/UserPortal/Volunteer/Invitations/Invitations.tsx b/src/screens/UserPortal/Volunteer/Invitations/Invitations.tsx new file mode 100644 index 0000000000..a79b64251d --- /dev/null +++ b/src/screens/UserPortal/Volunteer/Invitations/Invitations.tsx @@ -0,0 +1,297 @@ +import React, { useMemo, useState } from 'react'; +import { Dropdown, Form, Button } from 'react-bootstrap'; +import styles from '../VolunteerManagement.module.css'; +import { useTranslation } from 'react-i18next'; +import { Navigate, useParams } from 'react-router-dom'; +import { + FilterAltOutlined, + Search, + Sort, + WarningAmberRounded, +} from '@mui/icons-material'; +import { TbCalendarEvent } from 'react-icons/tb'; +import { FaUserGroup } from 'react-icons/fa6'; +import { debounce, Stack } from '@mui/material'; + +import useLocalStorage from 'utils/useLocalstorage'; +import { useMutation, useQuery } from '@apollo/client'; +import type { InterfaceVolunteerMembership } from 'utils/interfaces'; +import { FaRegClock } from 'react-icons/fa'; +import Loader from 'components/Loader/Loader'; +import { USER_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Queries/EventVolunteerQueries'; +import { UPDATE_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Mutations/EventVolunteerMutation'; +import { toast } from 'react-toastify'; + +enum ItemFilter { + Group = 'group', + Individual = 'individual', +} + +/** + * The `Invitations` component displays list of invites for the user to volunteer. + * It allows the user to search, sort, and accept/reject invites. + * + * @returns The rendered component displaying the upcoming events. + */ +const Invitations = (): JSX.Element => { + // Retrieves translation functions for various namespaces + const { t } = useTranslation('translation', { + keyPrefix: 'userVolunteer', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Retrieves stored user ID from local storage + const { getItem } = useLocalStorage(); + const userId = getItem('userId'); + + // Extracts organization ID from the URL parameters + const { orgId } = useParams(); + if (!orgId || !userId) { + // Redirects to the homepage if orgId or userId is missing + return ; + } + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const [searchTerm, setSearchTerm] = useState(''); + const [searchValue, setSearchValue] = useState(''); + const [filter, setFilter] = useState(null); + const [sortBy, setSortBy] = useState< + 'createdAt_ASC' | 'createdAt_DESC' | null + >(null); + + const [updateMembership] = useMutation(UPDATE_VOLUNTEER_MEMBERSHIP); + + const updateMembershipStatus = async ( + id: string, + status: 'accepted' | 'rejected', + ): Promise => { + try { + await updateMembership({ + variables: { + id: id, + status: status, + }, + }); + toast.success( + t( + status === 'accepted' ? 'invitationAccepted' : 'invitationRejected', + ) as string, + ); + refetchInvitations(); + } catch (error: unknown) { + toast.error((error as Error).message); + } + }; + + const { + data: invitationData, + loading: invitationLoading, + error: invitationError, + refetch: refetchInvitations, + }: { + data?: { + getVolunteerMembership: InterfaceVolunteerMembership[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(USER_VOLUNTEER_MEMBERSHIP, { + variables: { + where: { + userId: userId, + status: 'invited', + filter: filter, + eventTitle: searchTerm ? searchTerm : undefined, + }, + orderBy: sortBy ? sortBy : undefined, + }, + }); + + const invitations = useMemo(() => { + if (!invitationData) return []; + return invitationData.getVolunteerMembership; + }, [invitationData]); + + // loads the invitations when the component mounts + if (invitationLoading) return ; + if (invitationError) { + // Displays an error message if there is an issue loading the invvitations + return ( +
    +
    + +
    + {tErrors('errorLoading', { entity: 'Volunteership Invitations' })} +
    +
    +
    + ); + } + + // Renders the invitations list and UI elements for searching, sorting, and accepting/rejecting invites + return ( + <> +
    + {/* Search input field and button */} +
    + { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
    +
    +
    + {/* Dropdown menu for sorting invitations */} + + + + {tCommon('sort')} + + + setSortBy('createdAt_DESC')} + data-testid="createdAt_DESC" + > + {t('receivedLatest')} + + setSortBy('createdAt_ASC')} + data-testid="createdAt_ASC" + > + {t('receivedEarliest')} + + + + + + + + {t('filter')} + + + setFilter(null)} + data-testid="filterAll" + > + {tCommon('all')} + + setFilter(ItemFilter.Group)} + data-testid="filterGroup" + > + {t('groupInvite')} + + setFilter(ItemFilter.Individual)} + data-testid="filterIndividual" + > + {t('individualInvite')} + + + +
    +
    +
    + {invitations.length < 1 ? ( + + {/* Displayed if no invitations are found */} + {t('noInvitations')} + + ) : ( + invitations.map((invite: InterfaceVolunteerMembership) => ( +
    +
    +
    + {invite.group ? ( + <>{t('groupInvitationSubject')} + ) : ( + <>{t('eventInvitationSubject')} + )} +
    +
    + {invite.group && ( + <> +
    + + Group:{' '} + {invite.group.name} +
    + | + + )} +
    + + Event:{' '} + {invite.event.title} +
    + | +
    + + Received:{' '} + {new Date(invite.createdAt).toLocaleString()} +
    +
    +
    +
    + + +
    +
    + )) + )} + + ); +}; + +export default Invitations; diff --git a/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.mocks.ts b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.mocks.ts new file mode 100644 index 0000000000..ae00d52dbe --- /dev/null +++ b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.mocks.ts @@ -0,0 +1,281 @@ +import { CREATE_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Mutations/EventVolunteerMutation'; +import { USER_EVENTS_VOLUNTEER } from 'GraphQl/Queries/PlugInQueries'; + +const event1 = { + _id: 'eventId1', + title: 'Event 1', + startDate: '2044-10-30', + endDate: '2044-10-30', + location: 'Mumbai', + startTime: null, + endTime: null, + allDay: true, + recurring: true, + volunteerGroups: [ + { + _id: 'groupId1', + name: 'Group 1', + volunteersRequired: null, + description: 'desc', + volunteers: [ + { + _id: 'volunteerId1', + }, + { + _id: 'volunteerId2', + }, + ], + }, + ], + volunteers: [ + { + _id: 'volunteerId1', + user: { + _id: 'userId1', + }, + }, + { + _id: 'volunteerId2', + user: { + _id: 'userId2', + }, + }, + ], +}; + +const event2 = { + _id: 'eventId2', + title: 'Event 2', + startDate: '2044-10-31', + endDate: '2044-10-31', + location: 'Pune', + startTime: null, + endTime: null, + allDay: true, + recurring: false, + volunteerGroups: [ + { + _id: 'groupId2', + name: 'Group 2', + volunteersRequired: null, + description: 'desc', + volunteers: [ + { + _id: 'volunteerId3', + }, + ], + }, + ], + volunteers: [ + { + _id: 'volunteerId3', + user: { + _id: 'userId3', + }, + }, + ], +}; + +const event3 = { + _id: 'eventId3', + title: 'Event 3', + startDate: '2044-10-31', + endDate: '2022-10-31', + location: 'Delhi', + startTime: null, + endTime: null, + description: 'desc', + allDay: true, + recurring: true, + volunteerGroups: [ + { + _id: 'groupId3', + name: 'Group 3', + volunteersRequired: null, + description: 'desc', + volunteers: [ + { + _id: 'userId', + }, + ], + }, + ], + volunteers: [ + { + _id: 'volunteerId', + user: { + _id: 'userId', + }, + }, + ], +}; + +export const MOCKS = [ + { + request: { + query: USER_EVENTS_VOLUNTEER, + variables: { + organization_id: 'orgId', + title_contains: '', + location_contains: '', + upcomingOnly: true, + first: null, + skip: null, + }, + }, + result: { + data: { + eventsByOrganizationConnection: [event1, event2, event3], + }, + }, + }, + { + request: { + query: USER_EVENTS_VOLUNTEER, + variables: { + organization_id: 'orgId', + title_contains: '1', + location_contains: '', + upcomingOnly: true, + first: null, + skip: null, + }, + }, + result: { + data: { + eventsByOrganizationConnection: [event1], + }, + }, + }, + { + request: { + query: USER_EVENTS_VOLUNTEER, + variables: { + organization_id: 'orgId', + title_contains: '', + location_contains: 'M', + upcomingOnly: true, + first: null, + skip: null, + }, + }, + result: { + data: { + eventsByOrganizationConnection: [event1], + }, + }, + }, + { + request: { + query: CREATE_VOLUNTEER_MEMBERSHIP, + variables: { + data: { + event: 'eventId1', + group: null, + status: 'requested', + userId: 'userId', + }, + }, + }, + result: { + data: { + createVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, + { + request: { + query: CREATE_VOLUNTEER_MEMBERSHIP, + variables: { + data: { + event: 'eventId1', + group: 'groupId1', + status: 'requested', + userId: 'userId', + }, + }, + }, + result: { + data: { + createVolunteerMembership: { + _id: 'membershipId1', + }, + }, + }, + }, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: USER_EVENTS_VOLUNTEER, + variables: { + organization_id: 'orgId', + title_contains: '', + location_contains: '', + upcomingOnly: true, + first: null, + skip: null, + }, + }, + result: { + data: { + eventsByOrganizationConnection: [], + }, + }, + }, +]; + +export const ERROR_MOCKS = [ + { + request: { + query: USER_EVENTS_VOLUNTEER, + variables: { + organization_id: 'orgId', + title_contains: '', + location_contains: '', + upcomingOnly: true, + first: null, + skip: null, + }, + }, + error: new Error('Mock Graphql USER_EVENTS_VOLUNTEER Error'), + }, +]; + +export const CREATE_ERROR_MOCKS = [ + { + request: { + query: USER_EVENTS_VOLUNTEER, + variables: { + organization_id: 'orgId', + title_contains: '', + location_contains: '', + upcomingOnly: true, + first: null, + skip: null, + }, + }, + result: { + data: { + eventsByOrganizationConnection: [event1, event2], + }, + }, + }, + { + request: { + query: CREATE_VOLUNTEER_MEMBERSHIP, + variables: { + data: { + event: 'eventId1', + group: null, + status: 'requested', + userId: 'userId', + }, + }, + }, + error: new Error('Mock Graphql CREATE_VOLUNTEER_MEMBERSHIP Error'), + }, +]; diff --git a/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.test.tsx b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.test.tsx new file mode 100644 index 0000000000..43e0b15cdb --- /dev/null +++ b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.test.tsx @@ -0,0 +1,224 @@ +import React, { act } from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { LocalizationProvider } from '@mui/x-date-pickers'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18n from 'utils/i18nForTest'; +import UpcomingEvents from './UpcomingEvents'; +import type { ApolloLink } from '@apollo/client'; +import { + MOCKS, + EMPTY_MOCKS, + ERROR_MOCKS, + CREATE_ERROR_MOCKS, +} from './UpcomingEvents.mocks'; +import { toast } from 'react-toastify'; +import useLocalStorage from 'utils/useLocalstorage'; + +jest.mock('react-toastify', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + }, +})); + +const { setItem } = useLocalStorage(); + +const link1 = new StaticMockLink(MOCKS); +const link2 = new StaticMockLink(ERROR_MOCKS); +const link3 = new StaticMockLink(EMPTY_MOCKS); +const link4 = new StaticMockLink(CREATE_ERROR_MOCKS); + +const t = { + ...JSON.parse( + JSON.stringify( + i18n.getDataByLanguage('en')?.translation.userVolunteer ?? {}, + ), + ), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.common ?? {})), + ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), +}; + +const debounceWait = async (ms = 300): Promise => { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +}; + +const renderUpcomingEvents = (link: ApolloLink): RenderResult => { + return render( + + + + + + + } + /> + } + /> + + + + + + , + ); +}; + +describe('Testing Upcoming Events Screen', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + beforeEach(() => { + setItem('userId', 'userId'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + setItem('userId', null); + render( + + + + + + } /> + } + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + it('should render Upcoming Events screen', async () => { + renderUpcomingEvents(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + }); + + it('Search by event title', async () => { + renderUpcomingEvents(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByTitle = await screen.findByTestId('title'); + expect(searchByTitle).toBeInTheDocument(); + userEvent.click(searchByTitle); + + userEvent.type(searchInput, '1'); + await debounceWait(); + + const eventTitle = await screen.findAllByTestId('eventTitle'); + expect(eventTitle[0]).toHaveTextContent('Event 1'); + }); + + it('Search by event location on click of search button', async () => { + renderUpcomingEvents(link1); + const searchInput = await screen.findByTestId('searchBy'); + expect(searchInput).toBeInTheDocument(); + + const searchToggle = await screen.findByTestId('searchByToggle'); + expect(searchToggle).toBeInTheDocument(); + userEvent.click(searchToggle); + + const searchByLocation = await screen.findByTestId('location'); + expect(searchByLocation).toBeInTheDocument(); + userEvent.click(searchByLocation); + + // Search by name on press of ENTER + userEvent.type(searchInput, 'M'); + await debounceWait(); + + const eventTitle = await screen.findAllByTestId('eventTitle'); + expect(eventTitle[0]).toHaveTextContent('Event 1'); + }); + + it('should render screen with No Events', async () => { + renderUpcomingEvents(link3); + + await waitFor(() => { + expect(screen.getByTestId('searchBy')).toBeInTheDocument(); + expect(screen.getByText(t.noEvents)).toBeInTheDocument(); + }); + }); + + it('Error while fetching Events data', async () => { + renderUpcomingEvents(link2); + + await waitFor(() => { + expect(screen.getByTestId('errorMsg')).toBeInTheDocument(); + }); + }); + + it('Click on Individual volunteer button', async () => { + renderUpcomingEvents(link1); + + const volunteerBtn = await screen.findAllByTestId('volunteerBtn'); + userEvent.click(volunteerBtn[0]); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.volunteerSuccess); + }); + }); + + it('Join Volunteer Group', async () => { + renderUpcomingEvents(link1); + + const eventTitle = await screen.findAllByTestId('eventTitle'); + expect(eventTitle[0]).toHaveTextContent('Event 1'); + userEvent.click(eventTitle[0]); + + const joinGroupBtn = await screen.findAllByTestId('joinBtn'); + expect(joinGroupBtn).toHaveLength(3); + userEvent.click(joinGroupBtn[0]); + + await waitFor(() => { + expect(toast.success).toHaveBeenCalledWith(t.volunteerSuccess); + }); + }); + + it('Error on Create Volunteer Membership', async () => { + renderUpcomingEvents(link4); + + const volunteerBtn = await screen.findAllByTestId('volunteerBtn'); + userEvent.click(volunteerBtn[0]); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.tsx b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.tsx new file mode 100644 index 0000000000..bd61ca97e0 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.tsx @@ -0,0 +1,377 @@ +import React, { useMemo, useState } from 'react'; +import { Dropdown, Form, Button } from 'react-bootstrap'; +import styles from '../VolunteerManagement.module.css'; +import { useTranslation } from 'react-i18next'; +import { Navigate, useParams } from 'react-router-dom'; +import { IoLocationOutline } from 'react-icons/io5'; +import { + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Accordion, + AccordionDetails, + AccordionSummary, + Chip, + Stack, + debounce, +} from '@mui/material'; +import { Circle, Search, Sort, WarningAmberRounded } from '@mui/icons-material'; + +import { GridExpandMoreIcon } from '@mui/x-data-grid'; +import useLocalStorage from 'utils/useLocalstorage'; +import { useMutation, useQuery } from '@apollo/client'; +import type { InterfaceUserEvents } from 'utils/interfaces'; +import { IoIosHand } from 'react-icons/io'; +import Loader from 'components/Loader/Loader'; +import { USER_EVENTS_VOLUNTEER } from 'GraphQl/Queries/PlugInQueries'; +import { CREATE_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Mutations/EventVolunteerMutation'; +import { toast } from 'react-toastify'; +import { FaCheck } from 'react-icons/fa'; + +/** + * The `UpcomingEvents` component displays list of upcoming events for the user to volunteer. + * It allows the user to search, sort, and volunteer for events/volunteer groups. + * + * @returns The rendered component displaying the upcoming events. + */ +const UpcomingEvents = (): JSX.Element => { + // Retrieves translation functions for various namespaces + const { t } = useTranslation('translation', { + keyPrefix: 'userVolunteer', + }); + const { t: tCommon } = useTranslation('common'); + const { t: tErrors } = useTranslation('errors'); + + // Retrieves stored user ID from local storage + const { getItem } = useLocalStorage(); + const userId = getItem('userId'); + + // Extracts organization ID from the URL parameters + const { orgId } = useParams(); + if (!orgId || !userId) { + // Redirects to the homepage if orgId or userId is missing + return ; + } + const [searchValue, setSearchValue] = useState(''); + const [searchTerm, setSearchTerm] = useState(''); + const [searchBy, setSearchBy] = useState<'title' | 'location'>('title'); + + const [createVolunteerMembership] = useMutation(CREATE_VOLUNTEER_MEMBERSHIP); + + const debouncedSearch = useMemo( + () => debounce((value: string) => setSearchTerm(value), 300), + [], + ); + + const handleVolunteer = async ( + eventId: string, + group: string | null, + status: string, + ): Promise => { + try { + await createVolunteerMembership({ + variables: { + data: { + event: eventId, + group, + status, + userId, + }, + }, + }); + toast.success(t('volunteerSuccess')); + refetchEvents(); + } catch (error) { + toast.error((error as Error).message); + } + }; + + // Fetches upcomin events based on the organization ID, search term, and sorting order + const { + data: eventsData, + loading: eventsLoading, + error: eventsError, + refetch: refetchEvents, + }: { + data?: { + eventsByOrganizationConnection: InterfaceUserEvents[]; + }; + loading: boolean; + error?: Error | undefined; + refetch: () => void; + } = useQuery(USER_EVENTS_VOLUNTEER, { + variables: { + organization_id: orgId, + title_contains: searchBy === 'title' ? searchTerm : '', + location_contains: searchBy === 'location' ? searchTerm : '', + upcomingOnly: true, + first: null, + skip: null, + }, + }); + + // Extracts the list of upcoming events from the fetched data + const events = useMemo(() => { + if (eventsData) { + return eventsData.eventsByOrganizationConnection; + } + return []; + }, [eventsData]); + + // Renders a loader while events are being fetched + if (eventsLoading) return ; + if (eventsError) { + // Displays an error message if there is an issue loading the events + return ( +
    +
    + +
    + {tErrors('errorLoading', { entity: 'Events' })} +
    +
    +
    + ); + } + + // Renders the upcoming events list and UI elements for searching, sorting, and adding pledges + return ( + <> +
    + {/* Search input field and button */} +
    + { + setSearchValue(e.target.value); + debouncedSearch(e.target.value); + }} + data-testid="searchBy" + /> + +
    +
    +
    + + + + {tCommon('searchBy', { item: '' })} + + + setSearchBy('title')} + data-testid="title" + > + {t('name')} + + setSearchBy('location')} + data-testid="location" + > + {tCommon('location')} + + + +
    +
    +
    + {events.length < 1 ? ( + + {/* Displayed if no events are found */} + {t('noEvents')} + + ) : ( + events.map((event: InterfaceUserEvents, index: number) => { + const { + title, + description, + startDate, + endDate, + location, + volunteerGroups, + recurring, + _id, + volunteers, + } = event; + const isVolunteered = volunteers.some( + (volunteer) => volunteer.user._id === userId, + ); + return ( + + }> +
    +
    +
    +

    {title}

    + {recurring && ( + } + label={t('recurring')} + variant="outlined" + color="primary" + className={`${styles.chip} ${styles.active}`} + /> + )} +
    + +
    + + {' '} + + location: {location} + + Start Date: {startDate as unknown as string} + End Date: {endDate as unknown as string} +
    +
    +
    + +
    +
    +
    + + { + /*istanbul ignore next*/ + description && ( +
    + Description: + {description} +
    + ) + } + {volunteerGroups && volunteerGroups.length > 0 && ( + + + Volunteer Groups: + + + + + + + Sr. No. + + Group Name + + + No. of Members + + + Options + + + + + {volunteerGroups.map((group, index) => { + const { _id: gId, name, volunteers } = group; + const hasJoined = volunteers.some( + (volunteer) => volunteer._id === userId, + ); + return ( + + + {index + 1} + + + {name} + + + {volunteers.length} + + + + + + ); + })} + +
    +
    +
    + )} +
    +
    + ); + }) + )} + + ); +}; + +export default UpcomingEvents; diff --git a/src/screens/UserPortal/Volunteer/VolunteerManagement.module.css b/src/screens/UserPortal/Volunteer/VolunteerManagement.module.css new file mode 100644 index 0000000000..d3b2bbaa54 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/VolunteerManagement.module.css @@ -0,0 +1,138 @@ +/* Upcoming Events Styles */ +.btnsContainer { + display: flex; + margin: 1.5rem 0; +} + +.btnsContainer .input { + flex: 1; + min-width: 18rem; + position: relative; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); + background-color: white; +} + +.btnsContainer .input button { + width: 52px; +} + +.accordionSummary { + width: 100% !important; + padding-right: 0.75rem; + display: flex; + justify-content: space-between !important; + align-items: center; +} + +.accordionSummary button { + height: 2.25rem; + padding-top: 0.35rem; +} + +.accordionSummary button:hover { + background-color: #31bb6a50 !important; + color: #31bb6b !important; +} + +.titleContainer { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.titleContainer h3 { + font-size: 1.25rem; + font-weight: 750; + color: #5e5e5e; + margin-top: 0.2rem; +} + +.subContainer span { + font-size: 0.9rem; + margin-left: 0.5rem; + font-weight: lighter; + color: #707070; +} + +.chipIcon { + height: 0.9rem !important; +} + +.chip { + height: 1.5rem !important; + margin: 0.15rem 0 0 1.25rem; +} + +.active { + background-color: #31bb6a50 !important; +} + +.pending { + background-color: #ffd76950 !important; + color: #bb952bd0 !important; + border-color: #bb952bd0 !important; +} + +.progress { + display: flex; + width: 45rem; +} + +.progressBar { + margin: 0rem 0.75rem; + width: 100%; + font-size: 0.9rem; + height: 1.25rem; +} + +/* Pledge Modal */ + +.pledgeModal { + max-width: 80vw; + margin-top: 2vh; + margin-left: 13vw; +} + +.titlemodal { + color: #707070; + font-weight: 600; + font-size: 32px; + width: 65%; + margin-bottom: 0px; +} + +.modalCloseBtn { + width: 40px; + height: 40px; + padding: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.noOutline input { + outline: none; +} + +.greenregbtn { + margin: 1rem 0 0; + margin-top: 15px; + border: 1px solid #e8e5e5; + box-shadow: 0 2px 2px #e8e5e5; + padding: 10px 10px; + border-radius: 5px; + background-color: #31bb6b; + width: 100%; + font-size: 16px; + color: white; + outline: none; + font-weight: 600; + cursor: pointer; + transition: + transform 0.2s, + box-shadow 0.2s; + width: 100%; +} diff --git a/src/screens/UserPortal/Volunteer/VolunteerManagement.test.tsx b/src/screens/UserPortal/Volunteer/VolunteerManagement.test.tsx new file mode 100644 index 0000000000..65d2d082a7 --- /dev/null +++ b/src/screens/UserPortal/Volunteer/VolunteerManagement.test.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import type { RenderResult } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/react-testing'; +import { I18nextProvider } from 'react-i18next'; +import i18n from 'utils/i18nForTest'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { Provider } from 'react-redux'; +import { store } from 'state/store'; +import VolunteerManagement from './VolunteerManagement'; +import userEvent from '@testing-library/user-event'; +import { MOCKS } from './UpcomingEvents/UpcomingEvents.mocks'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import useLocalStorage from 'utils/useLocalstorage'; +const { setItem } = useLocalStorage(); + +const link1 = new StaticMockLink(MOCKS); + +const renderVolunteerManagement = (): RenderResult => { + return render( + + + + + + } + /> + } /> + } + /> + + + + + , + ); +}; + +describe('Volunteer Management', () => { + beforeAll(() => { + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ orgId: 'orgId' }), + })); + }); + + beforeEach(() => { + setItem('userId', 'userId'); + }); + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('should redirect to fallback URL if URL params are undefined', async () => { + render( + + + + + + } + /> + } + /> + + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('paramsError')).toBeInTheDocument(); + }); + }); + + test('Render Volunteer Management Screen', async () => { + renderVolunteerManagement(); + + const upcomingEventsTab = await screen.findByTestId('upcomingEventsTab'); + expect(upcomingEventsTab).toBeInTheDocument(); + expect(screen.getByTestId('invitationsBtn')).toBeInTheDocument(); + expect(screen.getByTestId('actionsBtn')).toBeInTheDocument(); + expect(screen.getByTestId('groupsBtn')).toBeInTheDocument(); + }); + + test('Testing back button navigation', async () => { + renderVolunteerManagement(); + + const backButton = await screen.findByTestId('backBtn'); + userEvent.click(backButton); + await waitFor(() => { + const orgHome = screen.getByTestId('orgHome'); + expect(orgHome).toBeInTheDocument(); + }); + }); + + test('Testing volunteer management tab switching', async () => { + renderVolunteerManagement(); + + const invitationsBtn = screen.getByTestId('invitationsBtn'); + userEvent.click(invitationsBtn); + + const invitationsTab = screen.getByTestId('invitationsTab'); + expect(invitationsTab).toBeInTheDocument(); + + const actionsBtn = screen.getByTestId('actionsBtn'); + userEvent.click(actionsBtn); + + const actionsTab = screen.getByTestId('actionsTab'); + expect(actionsTab).toBeInTheDocument(); + + const groupsBtn = screen.getByTestId('groupsBtn'); + userEvent.click(groupsBtn); + + const groupsTab = screen.getByTestId('groupsTab'); + expect(groupsTab).toBeInTheDocument(); + }); +}); diff --git a/src/screens/UserPortal/Volunteer/VolunteerManagement.tsx b/src/screens/UserPortal/Volunteer/VolunteerManagement.tsx new file mode 100644 index 0000000000..87be9d7adc --- /dev/null +++ b/src/screens/UserPortal/Volunteer/VolunteerManagement.tsx @@ -0,0 +1,211 @@ +import React, { useState } from 'react'; +import Row from 'react-bootstrap/Row'; +import Col from 'react-bootstrap/Col'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import { FaChevronLeft, FaTasks } from 'react-icons/fa'; +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown } from 'react-bootstrap'; +import { TbCalendarEvent } from 'react-icons/tb'; +import { FaRegEnvelopeOpen, FaUserGroup } from 'react-icons/fa6'; +import UpcomingEvents from './UpcomingEvents/UpcomingEvents'; +import Invitations from './Invitations/Invitations'; +import Actions from './Actions/Actions'; +import Groups from './Groups/Groups'; + +/** + * List of tabs for the volunteer dashboard. + * + * Each tab is associated with an icon and value. + */ +const volunteerDashboardTabs: { + value: TabOptions; + icon: JSX.Element; +}[] = [ + { + value: 'upcomingEvents', + icon: , + }, + { + value: 'invitations', + icon: , + }, + { + value: 'actions', + icon: , + }, + { + value: 'groups', + icon: , + }, +]; + +/** + * Tab options for the volunteer management component. + */ +type TabOptions = 'upcomingEvents' | 'invitations' | 'actions' | 'groups'; + +/** + * `VolunteerManagement` component handles the display and navigation of different event management sections. + * + * It provides a tabbed interface for: + * - Viewing upcoming events to volunteer + * - Managing volunteer requests + * - Managing volunteer invitations + * - Managing volunteer groups + * + * @returns JSX.Element - The `VolunteerManagement` component. + */ +const VolunteerManagement = (): JSX.Element => { + // Translation hook for internationalization + const { t } = useTranslation('translation', { + keyPrefix: 'userVolunteer', + }); + + // Extract organization ID from URL parameters + const { orgId } = useParams(); + + if (!orgId) { + return ; + } + + // Hook for navigation + const navigate = useNavigate(); + + // State hook for managing the currently selected tab + const [tab, setTab] = useState('upcomingEvents'); + + /** + * Renders a button for each tab with the appropriate icon and label. + * + * @param value - The tab value + * @param icon - The icon to display for the tab + * @returns JSX.Element - The rendered button component + */ + const renderButton = ({ + value, + icon, + }: { + value: TabOptions; + icon: React.ReactNode; + }): JSX.Element => { + const selected = tab === value; + const variant = selected ? 'success' : 'light'; + const translatedText = t(value); + + const className = selected + ? 'px-4 d-flex align-items-center rounded-3 shadow-sm' + : 'text-secondary bg-white px-4 d-flex align-items-center rounded-3 shadow-sm'; + const props = { + variant, + className, + style: { height: '2.5rem' }, + onClick: () => setTab(value), + 'data-testid': `${value}Btn`, + }; + + return ( + + ); + }; + + const handleBack = (): void => { + navigate(`/user/organization/${orgId}`); + }; + + return ( +
    + + +
    + + {volunteerDashboardTabs.map(renderButton)} +
    + + + + {t(tab)} + + + {/* Render dropdown items for each settings category */} + {volunteerDashboardTabs.map(({ value, icon }, index) => ( + setTab(value) + } + className={`d-flex gap-2 ${tab === value && 'text-secondary'}`} + > + {icon} {t(value)} + + ))} + + + + + +
    +
    +
    + + {/* Render content based on the selected settings category */} + {(() => { + switch (tab) { + case 'upcomingEvents': + return ( +
    + +
    + ); + case 'invitations': + return ( +
    + +
    + ); + case 'actions': + return ( +
    + +
    + ); + case 'groups': + return ( +
    + +
    + ); + } + })()} +
    + ); +}; + +export default VolunteerManagement; diff --git a/src/screens/Users/Users.module.css b/src/screens/Users/Users.module.css new file mode 100644 index 0000000000..0750dba108 --- /dev/null +++ b/src/screens/Users/Users.module.css @@ -0,0 +1,95 @@ +.btnsContainer { + display: flex; + margin: 2.5rem 0 2.5rem 0; +} + +.btnsContainer .btnsBlock { + display: flex; +} + +.btnsContainer .btnsBlock button { + margin-left: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.btnsContainer .inputContainer { + flex: 1; + position: relative; +} +.btnsContainer .input { + width: 70%; + position: relative; +} + +.btnsContainer input { + outline: 1px solid var(--bs-gray-400); +} + +.btnsContainer .inputContainer button { + width: 52px; +} + +.listBox { + width: 100%; + flex: 1; +} + +.notFound { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +@media (max-width: 1020px) { + .btnsContainer { + flex-direction: column; + margin: 1.5rem 0; + } + .btnsContainer .input { + width: 100%; + } + .btnsContainer .btnsBlock { + margin: 1.5rem 0 0 0; + justify-content: space-between; + } + + .btnsContainer .btnsBlock button { + margin: 0; + } + + .btnsContainer .btnsBlock div button { + margin-right: 1.5rem; + } +} + +/* For mobile devices */ + +@media (max-width: 520px) { + .btnsContainer { + margin-bottom: 0; + } + + .btnsContainer .btnsBlock { + display: block; + margin-top: 1rem; + margin-right: 0; + } + + .btnsContainer .btnsBlock div { + flex: 1; + } + + .btnsContainer .btnsBlock div[title='Sort organizations'] { + margin-right: 0.5rem; + } + + .btnsContainer .btnsBlock button { + margin-bottom: 1rem; + margin-right: 0; + width: 100%; + } +} diff --git a/src/screens/Users/Users.test.tsx b/src/screens/Users/Users.test.tsx new file mode 100644 index 0000000000..65558e6ea7 --- /dev/null +++ b/src/screens/Users/Users.test.tsx @@ -0,0 +1,778 @@ +import React from 'react'; +import { MockedProvider } from '@apollo/react-testing'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import 'jest-localstorage-mock'; +import 'jest-location-mock'; +import { I18nextProvider } from 'react-i18next'; +import { Provider } from 'react-redux'; +import { BrowserRouter } from 'react-router-dom'; +import { ToastContainer } from 'react-toastify'; +import userEvent from '@testing-library/user-event'; +import { store } from 'state/store'; +import { StaticMockLink } from 'utils/StaticMockLink'; +import i18nForTest from 'utils/i18nForTest'; +import Users from './Users'; +import { EMPTY_MOCKS, MOCKS, MOCKS2 } from './UsersMocks'; +import useLocalStorage from 'utils/useLocalstorage'; + +import { + USER_LIST, + ORGANIZATION_CONNECTION_LIST, +} from 'GraphQl/Queries/Queries'; + +const { setItem, removeItem } = useLocalStorage(); + +const MOCK_USERS = [ + { + user: { + _id: 'user1', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '20/06/2022', + registeredEvents: [], + membershipRequests: [], + organizationsBlockedBy: [ + { + _id: 'xyz', + name: 'ABC', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '20/06/2022', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '20/06/2022', + }, + }, + ], + joinedOrganizations: [ + { + _id: 'abc', + name: 'Joined Organization 1', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '20/06/2022', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '20/06/2022', + }, + }, + ], + }, + appUserProfile: { + _id: 'user1', + adminFor: [ + { + _id: '123', + }, + ], + isSuperAdmin: true, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + }, + }, + { + user: { + _id: 'user2', + firstName: 'Jane', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '21/06/2022', + registeredEvents: [], + membershipRequests: [], + organizationsBlockedBy: [ + { + _id: '456', + name: 'ABC', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '21/06/2022', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '21/06/2022', + }, + }, + ], + joinedOrganizations: [ + { + _id: '123', + name: 'Palisadoes', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '21/06/2022', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '21/06/2022', + }, + }, + ], + }, + appUserProfile: { + _id: 'user2', + adminFor: [ + { + _id: '123', + }, + ], + isSuperAdmin: false, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + }, + }, + { + user: { + _id: 'user3', + firstName: 'Jack', + lastName: 'Smith', + image: null, + email: 'jack@example.com', + createdAt: '19/06/2022', + registeredEvents: [], + membershipRequests: [], + organizationsBlockedBy: [ + { + _id: 'xyz', + name: 'ABC', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '19/06/2022', + creator: { + _id: '123', + firstName: 'Jack', + lastName: 'Smith', + image: null, + email: 'jack@example.com', + createdAt: '19/06/2022', + }, + }, + ], + joinedOrganizations: [ + { + _id: 'abc', + name: 'Joined Organization 1', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '19/06/2022', + creator: { + _id: '123', + firstName: 'Jack', + lastName: 'Smith', + image: null, + email: 'jack@example.com', + createdAt: '19/06/2022', + }, + }, + ], + }, + appUserProfile: { + _id: 'user3', + adminFor: [], + isSuperAdmin: false, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + }, + }, +]; + +const MOCKS_NEW = [ + { + request: { + query: USER_LIST, + variables: { + first: 12, + skip: 0, + firstName_contains: '', + lastName_contains: '', + order: 'createdAt_DESC', + }, + }, + result: { + data: { + users: MOCK_USERS, + }, + }, + }, + { + request: { + query: ORGANIZATION_CONNECTION_LIST, + }, + result: { + data: { + organizationsConnection: [], + }, + }, + }, +]; + +const link = new StaticMockLink(MOCKS, true); +const link2 = new StaticMockLink(EMPTY_MOCKS, true); +const link3 = new StaticMockLink(MOCKS2, true); +const link5 = new StaticMockLink(MOCKS_NEW, true); + +async function wait(ms = 1000): Promise { + await act(() => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }); +} +beforeEach(() => { + setItem('id', '123'); + setItem('SuperAdmin', true); + setItem('FirstName', 'John'); + setItem('AdminFor', [{ name: 'adi', _id: '1234', image: '' }]); + setItem('LastName', 'Doe'); +}); + +afterEach(() => { + localStorage.clear(); +}); + +describe('Testing Users screen', () => { + test('Component should be rendered properly', async () => { + render( + + + + + + + + + , + ); + + await wait(); + expect(screen.getByTestId('testcomp')).toBeInTheDocument(); + }); + + test(`Component should be rendered properly when user is not superAdmin + and or userId does not exists in localstorage`, async () => { + setItem('AdminFor', ['123']); + removeItem('SuperAdmin'); + await wait(); + setItem('id', ''); + render( + + + + + + + + + , + ); + await wait(); + }); + + test(`Component should be rendered properly when userId does not exists in localstorage`, async () => { + removeItem('AdminFor'); + removeItem('SuperAdmin'); + await wait(); + removeItem('id'); + render( + + + + + + + + + , + ); + await wait(); + }); + + test('Component should be rendered properly when user is superAdmin', async () => { + render( + + + + + + + + + , + ); + + await wait(); + }); + + test('Testing seach by name functionality', async () => { + render( + + + + + + + + + , + ); + + await wait(); + const searchBtn = screen.getByTestId('searchButton'); + const search1 = 'John'; + userEvent.type(screen.getByTestId(/searchByName/i), search1); + userEvent.click(searchBtn); + await wait(); + expect(screen.queryByText(/not found/i)).not.toBeInTheDocument(); + + const search2 = 'Pete{backspace}{backspace}{backspace}{backspace}'; + userEvent.type(screen.getByTestId(/searchByName/i), search2); + + const search3 = + 'John{backspace}{backspace}{backspace}{backspace}Sam{backspace}{backspace}{backspace}'; + userEvent.type(screen.getByTestId(/searchByName/i), search3); + + const search4 = 'Sam{backspace}{backspace}P{backspace}'; + userEvent.type(screen.getByTestId(/searchByName/i), search4); + + const search5 = 'Xe'; + userEvent.type(screen.getByTestId(/searchByName/i), search5); + userEvent.clear(screen.getByTestId(/searchByName/i)); + userEvent.type(screen.getByTestId(/searchByName/i), ''); + userEvent.click(searchBtn); + await wait(); + }); + + test('testing search not found', async () => { + await act(async () => { + render( + + + + + + + + + , + ); + }); + await wait(); + + const searchBtn = screen.getByTestId('searchButton'); + const searchInput = screen.getByTestId(/searchByName/i); + + await act(async () => { + // Clear the search input + userEvent.clear(searchInput); + // Search for a name that doesn't exist + userEvent.type(screen.getByTestId(/searchByName/i), 'NonexistentName'); + userEvent.click(searchBtn); + }); + + expect(screen.queryByText(/No User Found/i)).toBeInTheDocument(); + }); + + test('Testing User data is not present', async () => { + render( + + + + + + + + + , + ); + + await wait(); + expect(screen.getByText(/No User Found/i)).toBeTruthy(); + }); + + test('Should render warning alert when there are no organizations', async () => { + const { container } = render( + + + + + + + + + + , + ); + + await wait(200); + expect(container.textContent).toMatch( + 'Organizations not found, please create an organization through dashboard', + ); + }); + + test('Should not render warning alert when there are organizations present', async () => { + const { container } = render( + + + + + + + + + + , + ); + + await wait(); + + expect(container.textContent).not.toMatch( + 'Organizations not found, please create an organization through dashboard', + ); + }); + + test('Testing filter functionality', async () => { + await act(async () => { + render( + + + + + + + + + + , + ); + }); + await wait(); + + const searchInput = screen.getByTestId('filter'); + expect(searchInput).toBeInTheDocument(); + + const inputText = screen.getByTestId('filterUsers'); + + await act(async () => { + fireEvent.click(inputText); + }); + + const toggleText = screen.getByTestId('admin'); + + await act(async () => { + fireEvent.click(toggleText); + }); + + expect(searchInput).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(inputText); + }); + + let toggleTite = screen.getByTestId('superAdmin'); + + await act(async () => { + fireEvent.click(toggleTite); + }); + + expect(searchInput).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(inputText); + }); + + toggleTite = screen.getByTestId('user'); + + await act(async () => { + fireEvent.click(toggleTite); + }); + + expect(searchInput).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(inputText); + }); + + toggleTite = screen.getByTestId('cancel'); + + await act(async () => { + fireEvent.click(toggleTite); + }); + + await wait(); + + expect(searchInput).toBeInTheDocument(); + }); + + test('check for rerendering', async () => { + const { rerender } = render( + + + + + + + + + + , + ); + + await wait(); + rerender( + + + + + + + + + + , + ); + await wait(); + }); + + test('should set hasMore to false if users length is less than perPageResult', async () => { + const link = new StaticMockLink(EMPTY_MOCKS, true); + + render( + + + + + + + + + + , + ); + + await wait(200); + + // Check if "No User Found" is displayed + expect(screen.getByText(/No User Found/i)).toBeInTheDocument(); + }); + + test('should filter users correctly', async () => { + await act(async () => { + render( + + + + + + + + + + , + ); + }); + await wait(); + + const filterButton = screen.getByTestId('filterUsers'); + + await act(async () => { + fireEvent.click(filterButton); + }); + + const filterAdmin = screen.getByTestId('admin'); + + await act(async () => { + fireEvent.click(filterAdmin); + }); + + await wait(); + expect(screen.getByText('Jane Doe')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(filterButton); + }); + + const filterSuperAdmin = screen.getByTestId('superAdmin'); + + await act(async () => { + fireEvent.click(filterSuperAdmin); + }); + + await wait(); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(filterButton); + }); + + const filterUser = screen.getByTestId('user'); + await act(async () => { + fireEvent.click(filterUser); + }); + + await wait(); + expect(screen.getByText('Jack Smith')).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(filterButton); + }); + + const filterCancel = screen.getByTestId('cancel'); + + await act(async () => { + fireEvent.click(filterCancel); + }); + + await wait(); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('Jane Doe')).toBeInTheDocument(); + expect(screen.getByText('Jack Smith')).toBeInTheDocument(); + }); + + test('Users should be sorted and filtered correctly', async () => { + await act(async () => { + render( + + + + + + + + + + , + ); + }); + await wait(); + + // Check if the sorting and filtering logic was applied correctly + const rows = screen.getAllByRole('row'); + + const firstRow = rows[1]; + const secondRow = rows[2]; + + expect(firstRow).toHaveTextContent('John Doe'); + expect(secondRow).toHaveTextContent('Jane Doe'); + + await wait(); + + const inputText = screen.getByTestId('sortUsers'); + + await act(async () => { + fireEvent.click(inputText); + }); + + const toggleText = screen.getByTestId('oldest'); + + await act(async () => { + fireEvent.click(toggleText); + fireEvent.click(inputText); + }); + + const toggleTite = screen.getByTestId('newest'); + + await act(async () => { + fireEvent.click(toggleTite); + }); + + // Verify the users are sorted by oldest + await wait(); + + const displayedUsers = screen.getAllByRole('row'); + expect(displayedUsers[1]).toHaveTextContent('John Doe'); // assuming User1 is the oldest + expect(displayedUsers[displayedUsers.length - 1]).toHaveTextContent( + 'Jack Smith', + ); // assuming UserN is the newest + + await wait(); + + await act(async () => { + fireEvent.click(inputText); + }); + + const toggleOld = screen.getByTestId('oldest'); + + await act(async () => { + fireEvent.click(toggleOld); + fireEvent.click(inputText); + }); + + const toggleNewest = screen.getByTestId('newest'); + await act(async () => { + fireEvent.click(toggleNewest); + }); + }); +}); diff --git a/src/screens/Users/Users.tsx b/src/screens/Users/Users.tsx new file mode 100644 index 0000000000..72acba5b5c --- /dev/null +++ b/src/screens/Users/Users.tsx @@ -0,0 +1,480 @@ +import { useQuery } from '@apollo/client'; +import React, { useEffect, useState } from 'react'; +import { Dropdown, Form, Table } from 'react-bootstrap'; +import Button from 'react-bootstrap/Button'; +import { useTranslation } from 'react-i18next'; +import { toast } from 'react-toastify'; + +import { Search } from '@mui/icons-material'; +import FilterListIcon from '@mui/icons-material/FilterList'; +import SortIcon from '@mui/icons-material/Sort'; +import { + ORGANIZATION_CONNECTION_LIST, + USER_LIST, +} from 'GraphQl/Queries/Queries'; +import TableLoader from 'components/TableLoader/TableLoader'; +import UsersTableItem from 'components/UsersTableItem/UsersTableItem'; +import InfiniteScroll from 'react-infinite-scroll-component'; +import type { InterfaceQueryUserListItem } from 'utils/interfaces'; +import styles from './Users.module.css'; +import useLocalStorage from 'utils/useLocalstorage'; +import type { ApolloError } from '@apollo/client'; +/** + * The `Users` component is responsible for displaying a list of users in a paginated and sortable format. + * It supports search functionality, filtering, and sorting of users. The component integrates with GraphQL + * for fetching and managing user data and displays results with infinite scrolling. + * + * ## Features: + * - **Search:** Allows users to search for users by their first name. + * - **Sorting:** Provides options to sort users by creation date (newest or oldest). + * - **Filtering:** Enables filtering users based on their roles (admin, superadmin, user, etc.). + * - **Pagination:** Utilizes infinite scrolling to load more users as the user scrolls down. + * + * ## GraphQL Queries: + * - `USER_LIST`: Fetches a list of users with specified search, sorting, and pagination parameters. + * - `ORGANIZATION_CONNECTION_LIST`: Fetches a list of organizations to verify organization existence. + * + * + * ## Component State: + * - `isLoading`: Indicates whether the component is currently loading data. + * - `hasMore`: Indicates if there are more users to load. + * - `isLoadingMore`: Indicates if more users are currently being loaded. + * - `searchByName`: The current search query for user names. + * - `sortingOption`: The current sorting option (newest or oldest). + * - `filteringOption`: The current filtering option (admin, superadmin, user, cancel). + * - `displayedUsers`: The list of users currently displayed, filtered and sorted. + * + * ## Event Handlers: + * - `handleSearch`: Handles searching users by name and refetches the user list. + * - `handleSearchByEnter`: Handles search input when the Enter key is pressed. + * - `handleSearchByBtnClick`: Handles search input when the search button is clicked. + * - `resetAndRefetch`: Resets search and refetches the user list with default parameters. + * - `loadMoreUsers`: Loads more users when scrolling reaches the end of the list. + * - `handleSorting`: Updates sorting option and refetches the user list. + * - `handleFiltering`: Updates filtering option and refetches the user list. + * + * ## Rendering: + * - Displays a search input and button for searching users. + * - Provides dropdowns for sorting and filtering users. + * - Renders a table of users with infinite scrolling support. + * - Shows appropriate messages when no users are found or when search yields no results. + * + * @returns The rendered `Users` component. + */ +const Users = (): JSX.Element => { + const { t } = useTranslation('translation', { keyPrefix: 'users' }); + const { t: tCommon } = useTranslation('common'); + + document.title = t('title'); + + const { getItem } = useLocalStorage(); + + const perPageResult = 12; + const [isLoading, setIsLoading] = useState(true); + const [hasMore, setHasMore] = useState(true); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [searchByName, setSearchByName] = useState(''); + const [sortingOption, setSortingOption] = useState('newest'); + const [filteringOption, setFilteringOption] = useState('cancel'); + const userType = getItem('SuperAdmin') + ? 'SUPERADMIN' + : getItem('AdminFor') + ? 'ADMIN' + : 'USER'; + const loggedInUserId = getItem('id'); + + const { + data: usersData, + loading: loading, + fetchMore, + refetch: refetchUsers, + }: { + data?: { users: InterfaceQueryUserListItem[] }; + loading: boolean; + fetchMore: any; + refetch: any; + error?: ApolloError; + } = useQuery(USER_LIST, { + variables: { + first: perPageResult, + skip: 0, + firstName_contains: '', + lastName_contains: '', + order: sortingOption === 'newest' ? 'createdAt_DESC' : 'createdAt_ASC', + }, + notifyOnNetworkStatusChange: true, + }); + + const { data: dataOrgs } = useQuery(ORGANIZATION_CONNECTION_LIST); + const [displayedUsers, setDisplayedUsers] = useState(usersData?.users || []); + + // Manage loading more state + useEffect(() => { + if (!usersData) { + return; + } + if (usersData.users.length < perPageResult) { + setHasMore(false); + } + if (usersData && usersData.users) { + let newDisplayedUsers = sortUsers(usersData.users, sortingOption); + newDisplayedUsers = filterUsers(newDisplayedUsers, filteringOption); + setDisplayedUsers(newDisplayedUsers); + } + }, [usersData, sortingOption, filteringOption]); + + // To clear the search when the component is unmounted + useEffect(() => { + return () => { + setSearchByName(''); + }; + }, []); + + // Warn if there is no organization + useEffect(() => { + if (!dataOrgs) { + return; + } + + if (dataOrgs.organizationsConnection.length === 0) { + toast.warning(t('noOrgError') as string); + } + }, [dataOrgs]); + + // Send to orgList page if user is not superadmin + useEffect(() => { + if (userType != 'SUPERADMIN') { + window.location.assign('/orglist'); + } + }, []); + + // Manage the loading state + useEffect(() => { + if (loading && isLoadingMore == false) { + setIsLoading(true); + } else { + setIsLoading(false); + } + }, [loading]); + + const handleSearch = (value: string): void => { + setSearchByName(value); + if (value === '') { + resetAndRefetch(); + return; + } + refetchUsers({ + firstName_contains: value, + lastName_contains: '', + // Later on we can add several search and filter options + }); + setHasMore(true); + }; + + const handleSearchByEnter = (e: any): void => { + if (e.key === 'Enter') { + const { value } = e.target; + handleSearch(value); + } + }; + + const handleSearchByBtnClick = (): void => { + const inputElement = document.getElementById( + 'searchUsers', + ) as HTMLInputElement; + const inputValue = inputElement?.value || ''; + handleSearch(inputValue); + }; + /* istanbul ignore next */ + const resetAndRefetch = (): void => { + refetchUsers({ + first: perPageResult, + skip: 0, + firstName_contains: '', + lastName_contains: '', + order: sortingOption === 'newest' ? 'createdAt_DESC' : 'createdAt_ASC', + }); + setHasMore(true); + }; + /* istanbul ignore next */ + const loadMoreUsers = (): void => { + setIsLoadingMore(true); + fetchMore({ + variables: { + skip: usersData?.users.length || 0, + userType: 'ADMIN', + filter: searchByName, + order: sortingOption === 'newest' ? 'createdAt_DESC' : 'createdAt_ASC', + }, + updateQuery: ( + prev: { users: InterfaceQueryUserListItem[] } | undefined, + { + fetchMoreResult, + }: { + fetchMoreResult: { users: InterfaceQueryUserListItem[] } | undefined; + }, + ): { users: InterfaceQueryUserListItem[] } | undefined => { + setIsLoadingMore(false); + if (!fetchMoreResult) return prev; + if (fetchMoreResult.users.length < perPageResult) { + setHasMore(false); + } + return { + users: [...(prev?.users || []), ...(fetchMoreResult.users || [])], + }; + }, + }); + }; + + const handleSorting = (option: string): void => { + setDisplayedUsers([]); + setHasMore(true); + setSortingOption(option); + }; + + const sortUsers = ( + allUsers: InterfaceQueryUserListItem[], + sortingOption: string, + ): InterfaceQueryUserListItem[] => { + const sortedUsers = [...allUsers]; + + if (sortingOption === 'newest') { + sortedUsers.sort( + (a, b) => + new Date(b.user.createdAt).getTime() - + new Date(a.user.createdAt).getTime(), + ); + return sortedUsers; + } else { + sortedUsers.sort( + (a, b) => + new Date(a.user.createdAt).getTime() - + new Date(b.user.createdAt).getTime(), + ); + return sortedUsers; + } + }; + + const handleFiltering = (option: string): void => { + setDisplayedUsers([]); + setFilteringOption(option); + }; + + const filterUsers = ( + allUsers: InterfaceQueryUserListItem[], + filteringOption: string, + ): InterfaceQueryUserListItem[] => { + const filteredUsers = [...allUsers]; + + if (filteringOption === 'cancel') { + return filteredUsers; + } else if (filteringOption === 'user') { + const output = filteredUsers.filter((user) => { + return user.appUserProfile.adminFor.length === 0; + }); + return output; + } else if (filteringOption === 'admin') { + const output = filteredUsers.filter((user) => { + return ( + user.appUserProfile.isSuperAdmin === false && + user.appUserProfile.adminFor.length !== 0 + ); + }); + return output; + } else { + const output = filteredUsers.filter((user) => { + return user.appUserProfile.isSuperAdmin === true; + }); + return output; + } + }; + + const headerTitles: string[] = [ + '#', + tCommon('name'), + tCommon('email'), + t('joined_organizations'), + t('blocked_organizations'), + ]; + + return ( + <> + {/* Buttons Container */} +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    + {isLoading == false && + usersData && + displayedUsers.length === 0 && + searchByName.length > 0 ? ( +
    +

    + {tCommon('noResultsFoundFor')} "{searchByName}" +

    +
    + ) : isLoading == false && + usersData === undefined && + displayedUsers.length === 0 ? ( +
    +

    {t('noUserFound')}

    +
    + ) : ( +
    + {isLoading ? ( + + ) : ( + + } + hasMore={hasMore} + className={styles.listBox} + data-testid="users-list" + endMessage={ +
    +
    {tCommon('endOfResults')}
    +
    + } + > + + + + {headerTitles.map((title: string, index: number) => { + return ( + + ); + })} + + + + {usersData && + displayedUsers.map( + (user: InterfaceQueryUserListItem, index: number) => { + return ( + + ); + }, + )} + +
    + {title} +
    +
    + )} +
    + )} + + ); +}; + +export default Users; diff --git a/src/screens/Users/UsersMocks.ts b/src/screens/Users/UsersMocks.ts new file mode 100644 index 0000000000..ff346a1c97 --- /dev/null +++ b/src/screens/Users/UsersMocks.ts @@ -0,0 +1,505 @@ +import { + ORGANIZATION_CONNECTION_LIST, + USER_LIST, + USER_ORGANIZATION_LIST, +} from 'GraphQl/Queries/Queries'; + +export const MOCKS = [ + { + request: { + query: USER_ORGANIZATION_LIST, + variables: { id: 'user1' }, + }, + result: { + data: { + user: { + firstName: 'John', + lastName: 'Doe', + image: '', + email: 'John_Does_Palasidoes@gmail.com', + }, + }, + }, + }, + { + request: { + query: USER_LIST, + variables: { + first: 12, + skip: 0, + firstName_contains: '', + lastName_contains: '', + }, + }, + result: { + data: { + users: [ + { + user: { + _id: 'user1', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '20/06/2022', + registeredEvents: [], + membershipRequests: [], + organizationsBlockedBy: [ + { + _id: 'xyz', + name: 'ABC', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '20/06/2022', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '20/06/2022', + }, + }, + ], + joinedOrganizations: [ + { + _id: 'abc', + name: 'Joined Organization 1', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '20/06/2022', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '20/06/2022', + }, + }, + ], + }, + appUserProfile: { + _id: 'user1', + adminFor: [ + { + _id: '123', + }, + ], + isSuperAdmin: true, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + }, + }, + { + user: { + _id: 'user2', + firstName: 'Jane', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '20/06/2022', + registeredEvents: [], + membershipRequests: [], + organizationsBlockedBy: [ + { + _id: '456', + name: 'ABC', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '20/06/2022', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '20/06/2022', + }, + }, + ], + joinedOrganizations: [ + { + _id: '123', + name: 'Palisadoes', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '20/06/2022', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '20/06/2022', + }, + }, + ], + }, + appUserProfile: { + _id: 'user2', + adminFor: [ + { + _id: '123', + }, + ], + isSuperAdmin: false, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + }, + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_CONNECTION_LIST, + }, + result: { + data: { + organizationsConnection: [ + { + _id: 123, + image: null, + creator: { + firstName: 'John', + lastName: 'Doe', + }, + name: 'Palisadoes', + members: [ + { + _id: 'user1', + }, + { + _id: 'user2', + }, + ], + admins: [ + { + _id: 'user1', + }, + { + _id: 'user2', + }, + ], + createdAt: '09/11/2001', + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + }, + ], + }, + }, + }, +]; + +export const MOCKS2 = [ + { + request: { + query: USER_ORGANIZATION_LIST, + variables: { id: 'user1' }, + }, + result: { + data: { + user: { + firstName: 'John', + lastName: 'Doe', + image: '', + email: 'John_Does_Palasidoes@gmail.com', + }, + }, + }, + }, + { + request: { + query: USER_LIST, + variables: { + first: 12, + skip: 0, + firstName_contains: '', + lastName_contains: '', + }, + }, + result: { + data: { + users: [ + { + user: { + _id: 'user1', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '20/06/2022', + registeredEvents: [], + membershipRequests: [], + organizationsBlockedBy: [ + { + _id: 'xyz', + name: 'ABC', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '20/06/2022', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '20/06/2022', + }, + }, + ], + joinedOrganizations: [ + { + _id: 'abc', + name: 'Joined Organization 1', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '20/06/2022', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '20/06/2022', + }, + }, + ], + }, + appUserProfile: { + _id: 'user1', + adminFor: [ + { + _id: '123', + }, + ], + isSuperAdmin: true, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + }, + }, + { + user: { + _id: 'user2', + firstName: 'Jane', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '20/06/2022', + registeredEvents: [], + membershipRequests: [], + organizationsBlockedBy: [ + { + _id: '456', + name: 'ABC', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '20/06/2022', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '20/06/2022', + }, + }, + ], + joinedOrganizations: [ + { + _id: '123', + name: 'Palisadoes', + image: null, + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + createdAt: '20/06/2022', + creator: { + _id: '123', + firstName: 'John', + lastName: 'Doe', + image: null, + email: 'john@example.com', + createdAt: '20/06/2022', + }, + }, + ], + }, + appUserProfile: { + _id: 'user2', + adminFor: [ + { + _id: '123', + }, + ], + isSuperAdmin: false, + createdOrganizations: [], + createdEvents: [], + eventAdmin: [], + }, + }, + ], + }, + }, + }, + { + request: { + query: ORGANIZATION_CONNECTION_LIST, + }, + result: { + data: { + organizationsConnection: [ + { + _id: 123, + image: null, + creator: { + firstName: 'John', + lastName: 'Doe', + }, + name: 'Palisadoes', + members: [ + { + _id: 'user1', + }, + { + _id: 'user2', + }, + ], + admins: [ + { + _id: 'user1', + }, + { + _id: 'user2', + }, + ], + createdAt: '09/11/2001', + address: { + city: 'Kingston', + countryCode: 'JM', + dependentLocality: 'Sample Dependent Locality', + line1: '123 Jamaica Street', + line2: 'Apartment 456', + postalCode: 'JM12345', + sortingCode: 'ABC-123', + state: 'Kingston Parish', + }, + }, + ], + }, + }, + }, +]; + +export const EMPTY_MOCKS = [ + { + request: { + query: USER_LIST, + + variables: { + first: 12, + skip: 0, + firstName_contains: '', + lastName_contains: '', + }, + }, + result: { + data: { + users: [], + }, + }, + }, + { + request: { + query: ORGANIZATION_CONNECTION_LIST, + }, + result: { + data: { + organizationsConnection: [], + }, + }, + }, +]; diff --git a/src/setup/askForCustomPort/askForCustomPort.test.ts b/src/setup/askForCustomPort/askForCustomPort.test.ts new file mode 100644 index 0000000000..0df6259ba1 --- /dev/null +++ b/src/setup/askForCustomPort/askForCustomPort.test.ts @@ -0,0 +1,24 @@ +import inquirer from 'inquirer'; +import { askForCustomPort } from './askForCustomPort'; + +jest.mock('inquirer'); + +describe('askForCustomPort', () => { + test('should return default port if user provides no input', async () => { + jest + .spyOn(inquirer, 'prompt') + .mockResolvedValueOnce({ customPort: '4321' }); + + const result = await askForCustomPort(); + expect(result).toBe('4321'); + }); + + test('should return user-provided port', async () => { + jest + .spyOn(inquirer, 'prompt') + .mockResolvedValueOnce({ customPort: '8080' }); + + const result = await askForCustomPort(); + expect(result).toBe('8080'); + }); +}); diff --git a/src/setup/askForCustomPort/askForCustomPort.ts b/src/setup/askForCustomPort/askForCustomPort.ts new file mode 100644 index 0000000000..8a923f678f --- /dev/null +++ b/src/setup/askForCustomPort/askForCustomPort.ts @@ -0,0 +1,14 @@ +import inquirer from 'inquirer'; + +export async function askForCustomPort(): Promise { + const { customPort } = await inquirer.prompt([ + { + type: 'input', + name: 'customPort', + message: + 'Enter custom port for development server (leave blank for default 4321):', + default: 4321, + }, + ]); + return customPort; +} diff --git a/src/setup/askForTalawaApiUrl/askForTalawaApiUrl.test.ts b/src/setup/askForTalawaApiUrl/askForTalawaApiUrl.test.ts new file mode 100644 index 0000000000..b1490222b4 --- /dev/null +++ b/src/setup/askForTalawaApiUrl/askForTalawaApiUrl.test.ts @@ -0,0 +1,58 @@ +import inquirer from 'inquirer'; +import { askForTalawaApiUrl } from './askForTalawaApiUrl'; + +jest.mock('inquirer', () => ({ + prompt: jest.fn(), +})); + +describe('askForTalawaApiUrl', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should return the provided endpoint when user enters it', async () => { + const mockPrompt = jest.spyOn(inquirer, 'prompt').mockResolvedValueOnce({ + endpoint: 'http://example.com/graphql/', + }); + + const result = await askForTalawaApiUrl(); + + expect(mockPrompt).toHaveBeenCalledWith([ + { + type: 'input', + name: 'endpoint', + message: 'Enter your talawa-api endpoint:', + default: 'http://localhost:4000/graphql/', + }, + ]); + + expect(result).toBe('http://example.com/graphql/'); + }); + + test('should return the default endpoint when the user does not enter anything', async () => { + const mockPrompt = jest + .spyOn(inquirer, 'prompt') + .mockImplementation(async (questions: any) => { + const answers: Record = {}; + questions.forEach( + (question: { name: string | number; default: any }) => { + answers[question.name] = question.default; + }, + ); + return answers; + }); + + const result = await askForTalawaApiUrl(); + + expect(mockPrompt).toHaveBeenCalledWith([ + { + type: 'input', + name: 'endpoint', + message: 'Enter your talawa-api endpoint:', + default: 'http://localhost:4000/graphql/', + }, + ]); + + expect(result).toBe('http://localhost:4000/graphql/'); + }); +}); diff --git a/src/setup/askForTalawaApiUrl/askForTalawaApiUrl.ts b/src/setup/askForTalawaApiUrl/askForTalawaApiUrl.ts new file mode 100644 index 0000000000..97daa1ac89 --- /dev/null +++ b/src/setup/askForTalawaApiUrl/askForTalawaApiUrl.ts @@ -0,0 +1,13 @@ +import inquirer from 'inquirer'; + +export async function askForTalawaApiUrl(): Promise { + const { endpoint } = await inquirer.prompt([ + { + type: 'input', + name: 'endpoint', + message: 'Enter your talawa-api endpoint:', + default: 'http://localhost:4000/graphql/', + }, + ]); + return endpoint; +} diff --git a/src/setup/askForTalawaApiUrl/setupTalawaWebSocketUrl.test.ts b/src/setup/askForTalawaApiUrl/setupTalawaWebSocketUrl.test.ts new file mode 100644 index 0000000000..3fa612bd25 --- /dev/null +++ b/src/setup/askForTalawaApiUrl/setupTalawaWebSocketUrl.test.ts @@ -0,0 +1,38 @@ +import fs from 'fs'; +import inquirer from 'inquirer'; +import { askForTalawaApiUrl } from './askForTalawaApiUrl'; + +jest.mock('fs'); +jest.mock('inquirer', () => ({ + prompt: jest.fn(), +})); + +describe('WebSocket URL Configuration', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('should convert http URL to ws WebSocket URL', async () => { + const endpoint = 'http://example.com/graphql'; + const websocketUrl = endpoint.replace(/^http(s)?:\/\//, 'ws$1://'); + + expect(websocketUrl).toBe('ws://example.com/graphql'); + }); + + test('should convert https URL to wss WebSocket URL', async () => { + const endpoint = 'https://example.com/graphql'; + const websocketUrl = endpoint.replace(/^http(s)?:\/\//, 'ws$1://'); + + expect(websocketUrl).toBe('wss://example.com/graphql'); + }); + + test('should retain default WebSocket URL if no new endpoint is provided', async () => { + jest + .spyOn(inquirer, 'prompt') + .mockResolvedValueOnce({ endpoint: 'http://localhost:4000/graphql/' }); + await askForTalawaApiUrl(); + + const writeFileSyncSpy = jest.spyOn(fs, 'writeFileSync'); + expect(writeFileSyncSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/src/setup/checkConnection/checkConnection.test.ts b/src/setup/checkConnection/checkConnection.test.ts new file mode 100644 index 0000000000..c6f5251bdf --- /dev/null +++ b/src/setup/checkConnection/checkConnection.test.ts @@ -0,0 +1,55 @@ +import { checkConnection } from './checkConnection'; + +jest.mock('node-fetch'); + +global.fetch = jest.fn((url) => { + if (url === 'http://example.com/graphql/') { + const responseInit: ResponseInit = { + status: 200, + statusText: 'OK', + headers: new Headers({ 'Content-Type': 'application/json' }), + }; + return Promise.resolve(new Response(JSON.stringify({}), responseInit)); + } else { + const errorResponseInit: ResponseInit = { + status: 500, + statusText: 'Internal Server Error', + headers: new Headers({ 'Content-Type': 'text/plain' }), + }; + return Promise.reject(new Response('Error', errorResponseInit)); + } +}); + +describe('checkConnection', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should return true and log success message if the connection is successful', async () => { + jest.spyOn(console, 'log').mockImplementation((string) => string); + const result = await checkConnection('http://example.com/graphql/'); + + expect(result).toBe(true); + expect(console.log).toHaveBeenCalledWith( + '\nChecking Talawa-API connection....', + ); + expect(console.log).toHaveBeenCalledWith( + '\nConnection to Talawa-API successful! 🎉', + ); + }); + + it('should return false and log error message if the connection fails', async () => { + jest.spyOn(console, 'log').mockImplementation((string) => string); + const result = await checkConnection( + 'http://example_not_working.com/graphql/', + ); + + expect(result).toBe(false); + expect(console.log).toHaveBeenCalledWith( + '\nChecking Talawa-API connection....', + ); + expect(console.log).toHaveBeenCalledWith( + '\nTalawa-API service is unavailable. Is it running? Check your network connectivity too.', + ); + }); +}); diff --git a/src/setup/checkConnection/checkConnection.ts b/src/setup/checkConnection/checkConnection.ts new file mode 100644 index 0000000000..601bea98ca --- /dev/null +++ b/src/setup/checkConnection/checkConnection.ts @@ -0,0 +1,15 @@ +export async function checkConnection(url: string): Promise { + console.log('\nChecking Talawa-API connection....'); + let isConnected = false; + await fetch(url) + .then(() => { + isConnected = true; + console.log('\nConnection to Talawa-API successful! 🎉'); + }) + .catch(() => { + console.log( + '\nTalawa-API service is unavailable. Is it running? Check your network connectivity too.', + ); + }); + return isConnected; +} diff --git a/src/setup/checkEnvFile/checkEnvFile.test.ts b/src/setup/checkEnvFile/checkEnvFile.test.ts new file mode 100644 index 0000000000..a23976db4a --- /dev/null +++ b/src/setup/checkEnvFile/checkEnvFile.test.ts @@ -0,0 +1,47 @@ +import fs from 'fs'; +import { checkEnvFile } from './checkEnvFile'; + +jest.mock('fs'); + +describe('checkEnvFile', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should append missing keys to the .env file', () => { + const envContent = 'EXISTING_KEY=existing_value\n'; + const envExampleContent = + 'EXISTING_KEY=existing_value\nNEW_KEY=default_value\n'; + + jest + .spyOn(fs, 'readFileSync') + .mockReturnValueOnce(envContent) + .mockReturnValueOnce(envExampleContent) + .mockReturnValueOnce(envExampleContent); + + jest.spyOn(fs, 'appendFileSync'); + + checkEnvFile(); + + expect(fs.appendFileSync).toHaveBeenCalledWith( + '.env', + 'NEW_KEY=default_value\n', + ); + }); + + it('should not append anything if all keys are present', () => { + const envContent = 'EXISTING_KEY=existing_value\n'; + const envExampleContent = 'EXISTING_KEY=existing_value\n'; + + jest + .spyOn(fs, 'readFileSync') + .mockReturnValueOnce(envContent) + .mockReturnValueOnce(envExampleContent); + + jest.spyOn(fs, 'appendFileSync'); + + checkEnvFile(); + + expect(fs.appendFileSync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/setup/checkEnvFile/checkEnvFile.ts b/src/setup/checkEnvFile/checkEnvFile.ts new file mode 100644 index 0000000000..420a7c1321 --- /dev/null +++ b/src/setup/checkEnvFile/checkEnvFile.ts @@ -0,0 +1,16 @@ +import dotenv from 'dotenv'; +import fs from 'fs'; + +dotenv.config(); + +export function checkEnvFile(): void { + const env = dotenv.parse(fs.readFileSync('.env')); + const envSample = dotenv.parse(fs.readFileSync('.env.example')); + const misplaced = Object.keys(envSample).filter((key) => !(key in env)); + if (misplaced.length > 0) { + const config = dotenv.parse(fs.readFileSync('.env.example')); + misplaced.map((key) => + fs.appendFileSync('.env', `${key}=${config[key]}\n`), + ); + } +} diff --git a/src/setup/validateRecaptcha/validateRecaptcha.test.ts b/src/setup/validateRecaptcha/validateRecaptcha.test.ts new file mode 100644 index 0000000000..c77c9ed62b --- /dev/null +++ b/src/setup/validateRecaptcha/validateRecaptcha.test.ts @@ -0,0 +1,23 @@ +import { validateRecaptcha } from './validateRecaptcha'; + +describe('validateRecaptcha', () => { + it('should return true for a valid Recaptcha string', () => { + const validRecaptcha = 'ss7BEe32HPoDKTPXQevFkVvpvPzGebE2kIRv1ok4'; + expect(validateRecaptcha(validRecaptcha)).toBe(true); + }); + + it('should return false for an invalid Recaptcha string with special characters', () => { + const invalidRecaptcha = 'invalid@recaptcha!'; + expect(validateRecaptcha(invalidRecaptcha)).toBe(false); + }); + + it('should return false for an invalid Recaptcha string with incorrect length', () => { + const invalidRecaptcha = 'shortstring'; + expect(validateRecaptcha(invalidRecaptcha)).toBe(false); + }); + + it('should return false for an invalid Recaptcha string with spaces', () => { + const invalidRecaptcha = 'invalid recaptcha string'; + expect(validateRecaptcha(invalidRecaptcha)).toBe(false); + }); +}); diff --git a/src/setup/validateRecaptcha/validateRecaptcha.ts b/src/setup/validateRecaptcha/validateRecaptcha.ts new file mode 100644 index 0000000000..dcefb860fe --- /dev/null +++ b/src/setup/validateRecaptcha/validateRecaptcha.ts @@ -0,0 +1,4 @@ +export function validateRecaptcha(string: string): boolean { + const pattern = /^[a-zA-Z0-9_-]{40}$/; + return pattern.test(string); +} diff --git a/src/setupTests.ts b/src/setupTests.ts index 8f2609b7b3..eac7093309 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -3,3 +3,33 @@ // expect(element).toHaveTextContent(/react/i) // learn more: https://github.com/testing-library/jest-dom import '@testing-library/jest-dom'; + +global.fetch = jest.fn(); + +import { format } from 'util'; + +global.console.error = function (...args): void { + throw new Error(format(...args)); +}; + +global.console.warn = function (...args): void { + throw new Error(format(...args)); +}; +Object.defineProperty(HTMLMediaElement.prototype, 'muted', { + set: () => ({}), +}); + +import { jestPreviewConfigure } from 'jest-preview'; + +// Global CSS here +import 'bootstrap/dist/css/bootstrap.css'; +import 'bootstrap/dist/js/bootstrap.min.js'; +import 'react-datepicker/dist/react-datepicker.css'; +import 'flag-icons/css/flag-icons.min.css'; + +jestPreviewConfigure({ + // Opt-in to automatic mode to preview failed test case automatically. + autoPreview: true, +}); + +jest.setTimeout(15000); diff --git a/src/state/action-creators/index.test.ts b/src/state/action-creators/index.test.ts new file mode 100644 index 0000000000..33aa642b8a --- /dev/null +++ b/src/state/action-creators/index.test.ts @@ -0,0 +1,47 @@ +import { + updateInstalled, + installPlugin, + removePlugin, + updatePluginLinks, +} from './index'; +describe('Testing rc/state/action-creators/index.ts', () => { + test('updateInstalled Should call the dispatch function provided', () => { + //checking if the updateInstalled returns a valid function or not + const temp = updateInstalled('testPlug'); + expect(typeof temp).toBe('function'); + //stubbing the childfunction to check execution + const childFunction = jest.fn(); + temp(childFunction); + expect(childFunction).toHaveBeenCalled(); + }); + + test('installPlugin Should call the dispatch function provided', () => { + //checking if the installPlugin returns a valid function or not + const temp = installPlugin('testPlug'); + expect(typeof temp).toBe('function'); + //stubbing the childfunction to check execution + const childFunction = jest.fn(); + temp(childFunction); + expect(childFunction).toHaveBeenCalled(); + }); + + test('removePlugin Should call the dispatch function provided', () => { + //checking if the removePlugin returns a valid function or not + const temp = removePlugin('testPlug'); + expect(typeof temp).toBe('function'); + //stubbing the childfunction to check execution + const childFunction = jest.fn(); + temp(childFunction); + expect(childFunction).toHaveBeenCalled(); + }); + + test('updatePluginLinks Should call the dispatch function provided', () => { + //checking if the updatePluginLinks returns a valid function or not + const temp = updatePluginLinks('testPlug'); + expect(typeof temp).toBe('function'); + //stubbing the childfunction to check execution + const childFunction = jest.fn(); + temp(childFunction); + expect(childFunction).toHaveBeenCalled(); + }); +}); diff --git a/src/state/action-creators/index.ts b/src/state/action-creators/index.ts new file mode 100644 index 0000000000..7fb06971c5 --- /dev/null +++ b/src/state/action-creators/index.ts @@ -0,0 +1,44 @@ +export const updateInstalled = (plugin: any) => { + return (dispatch: any): void => { + dispatch({ + type: 'UPDATE_INSTALLED', + payload: plugin, + }); + }; +}; + +export const installPlugin = (plugin: any) => { + return (dispatch: any): void => { + dispatch({ + type: 'INSTALL_PLUGIN', + payload: plugin, + }); + }; +}; + +export const removePlugin = (plugin: any) => { + return (dispatch: any): void => { + dispatch({ + type: 'REMOVE_PLUGIN', + payload: plugin, + }); + }; +}; + +export const updatePluginLinks = (plugins: any) => { + return (dispatch: any): void => { + dispatch({ + type: 'UPDATE_P_TARGETS', + payload: plugins, + }); + }; +}; + +export const updateTargets = (orgId: string | undefined) => { + return (dispatch: any): void => { + dispatch({ + type: 'UPDATE_TARGETS', + payload: orgId, + }); + }; +}; diff --git a/src/state/helpers/Action.test.ts b/src/state/helpers/Action.test.ts new file mode 100644 index 0000000000..a971c6c160 --- /dev/null +++ b/src/state/helpers/Action.test.ts @@ -0,0 +1,8 @@ +import type { InterfaceAction } from './Action'; + +test('Testing Reducer Action Interface', () => { + ({ + type: 'STRING_ACTION_TYPE', + payload: 'ANY_PAYLOAD', + }) as InterfaceAction; +}); diff --git a/src/state/helpers/Action.ts b/src/state/helpers/Action.ts new file mode 100644 index 0000000000..469612441f --- /dev/null +++ b/src/state/helpers/Action.ts @@ -0,0 +1,4 @@ +export interface InterfaceAction { + type: string; + payload: any; +} diff --git a/src/state/hooks.ts b/src/state/hooks.ts new file mode 100644 index 0000000000..161703cc49 --- /dev/null +++ b/src/state/hooks.ts @@ -0,0 +1,5 @@ +import { useDispatch } from 'react-redux'; +import type { AppDispatch } from './store'; + +// Use throughout your app instead of plain `useDispatch` +export const useAppDispatch = useDispatch.withTypes(); diff --git a/src/state/index.ts b/src/state/index.ts new file mode 100644 index 0000000000..f35a3d6d46 --- /dev/null +++ b/src/state/index.ts @@ -0,0 +1 @@ +export * as actionCreators from './action-creators/index'; diff --git a/src/state/reducers/index.ts b/src/state/reducers/index.ts new file mode 100644 index 0000000000..ca2b2b28ee --- /dev/null +++ b/src/state/reducers/index.ts @@ -0,0 +1,12 @@ +import { combineReducers } from 'redux'; +import routesReducer from './routesReducer'; +import pluginReducer from './pluginReducer'; +import userRoutesReducer from './userRoutesReducer'; + +export const reducers = combineReducers({ + appRoutes: routesReducer, + plugins: pluginReducer, + userRoutes: userRoutesReducer, +}); + +export type RootState = ReturnType; diff --git a/src/state/reducers/pluginReducer.test.ts b/src/state/reducers/pluginReducer.test.ts new file mode 100644 index 0000000000..571345ce2f --- /dev/null +++ b/src/state/reducers/pluginReducer.test.ts @@ -0,0 +1,102 @@ +import reducer from './pluginReducer'; +import expect from 'expect'; + +describe('Testing Plugin Reducer', () => { + it('should return the initial state', () => { + expect( + reducer(undefined, { + type: '', + payload: undefined, + }), + ).toEqual({ + installed: [], + addonStore: [], + extras: [], + }); + }); + it('should handle INSTALL_PLUGIN', () => { + expect( + reducer( + { installed: [], addonStore: [], extras: [] }, + { + type: 'INSTALL_PLUGIN', + payload: { name: 'testplug' }, + }, + ), + ).toEqual({ + installed: [{ name: 'testplug' }], + addonStore: [], + extras: [], + }); + }); + it('should handle REMOVE_PLUGIN', () => { + expect( + reducer( + { + installed: [ + { name: 'testplug2', id: 3 }, + { name: 'testplug3', id: 5 }, + ], + addonStore: [], + extras: [], + }, + { + type: 'REMOVE_PLUGIN', + payload: { id: 3 }, + }, + ), + ).toEqual({ + installed: [{ name: 'testplug3', id: 5 }], + addonStore: [], + extras: [], + }); + }); + it('should handle UPDATE_INSTALLED', () => { + expect( + reducer( + { installed: [], addonStore: [], extras: [] }, + { + type: 'UPDATE_INSTALLED', + //Here payload is expected to be as array + payload: [{ name: 'testplug-updated' }], + }, + ), + ).toEqual({ + installed: [{ name: 'testplug-updated' }], + addonStore: [], + extras: [], + }); + }); + it('should handle UPDATE_STORE', () => { + expect( + reducer( + { installed: [], addonStore: [], extras: [] }, + { + type: 'UPDATE_STORE', + //Here payload is expected to be as array + payload: [{ name: 'sample-addon' }], + }, + ), + ).toEqual({ + installed: [], + addonStore: [{ name: 'sample-addon' }], + extras: [], + }); + }); + it('should handle UPDATE_EXTRAS', () => { + expect( + reducer( + { installed: [], addonStore: [], extras: [] }, + { + type: 'UPDATE_EXTRAS', + //Here payload is expected to be as array + payload: [{ name: 'sample-addon-extra' }], + }, + ), + ).toEqual({ + installed: [], + addonStore: [{ name: 'sample-addon-extra' }], + extras: [], + }); + }); +}); diff --git a/src/state/reducers/pluginReducer.ts b/src/state/reducers/pluginReducer.ts new file mode 100644 index 0000000000..192c995182 --- /dev/null +++ b/src/state/reducers/pluginReducer.ts @@ -0,0 +1,43 @@ +import type { InterfaceAction } from 'state/helpers/Action'; + +const reducer = ( + state = INITIAL_STATE, + action: InterfaceAction, +): typeof INITIAL_STATE => { + switch (action.type) { + case 'UPDATE_INSTALLED': + return Object.assign({}, state, { + installed: [...action.payload], + }); + case 'INSTALL_PLUGIN': + return Object.assign({}, state, { + installed: [...state.installed, action.payload], + }); + case 'REMOVE_PLUGIN': + return Object.assign({}, state, { + installed: [ + ...state.installed.filter( + (plugin: any) => plugin.id !== action.payload.id, + ), + ], + }); + case 'UPDATE_STORE': + return Object.assign({}, state, { + addonStore: [...action.payload], + }); + case 'UPDATE_EXTRAS': + return Object.assign({}, state, { + addonStore: [...action.payload], + }); + default: + return state; + } +}; + +const INITIAL_STATE: any = { + installed: [], + addonStore: [], + extras: [], +}; + +export default reducer; diff --git a/src/state/reducers/routesReducer.test.ts b/src/state/reducers/routesReducer.test.ts new file mode 100644 index 0000000000..8bdc1c069b --- /dev/null +++ b/src/state/reducers/routesReducer.test.ts @@ -0,0 +1,333 @@ +import expect from 'expect'; +import reducer from './routesReducer'; + +describe('Testing Routes reducer', () => { + it('should return the initial state', () => { + expect( + reducer(undefined, { + type: '', + payload: undefined, + }), + ).toEqual({ + targets: [ + { name: 'My Organizations', url: '/orglist' }, + { name: 'Dashboard', url: '/orgdash/undefined' }, + { name: 'People', url: '/orgpeople/undefined' }, + { name: 'Tags', url: '/orgtags/undefined' }, + { name: 'Events', url: '/orgevents/undefined' }, + { name: 'Venues', url: '/orgvenues/undefined' }, + { name: 'Action Items', url: '/orgactionitems/undefined' }, + { name: 'Posts', url: '/orgpost/undefined' }, + { + name: 'Block/Unblock', + url: '/blockuser/undefined', + }, + { name: 'Advertisement', url: '/orgads/undefined' }, + { name: 'Funds', url: '/orgfunds/undefined' }, + { name: 'Membership Requests', url: '/requests/undefined' }, + { + name: 'Plugins', + subTargets: [ + { + icon: 'fa-store', + name: 'Plugin Store', + url: '/orgstore/undefined', + }, + ], + }, + { name: 'Settings', url: '/orgsetting/undefined' }, + ], + components: [ + { name: 'My Organizations', comp_id: 'orglist', component: 'OrgList' }, + { + name: 'Dashboard', + comp_id: 'orgdash', + component: 'OrganizationDashboard', + }, + { + name: 'People', + comp_id: 'orgpeople', + component: 'OrganizationPeople', + }, + { + name: 'Tags', + comp_id: 'orgtags', + component: 'OrganizationTags', + }, + { + name: 'Events', + comp_id: 'orgevents', + component: 'OrganizationEvents', + }, + { + name: 'Venues', + comp_id: 'orgvenues', + component: 'OrganizationVenues', + }, + { + name: 'Action Items', + comp_id: 'orgactionitems', + component: 'OrganizationActionItems', + }, + { name: 'Posts', comp_id: 'orgpost', component: 'OrgPost' }, + { name: 'Block/Unblock', comp_id: 'blockuser', component: 'BlockUser' }, + { + name: 'Advertisement', + comp_id: 'orgads', + component: 'Advertisements', + }, + { + name: 'Funds', + comp_id: 'orgfunds', + component: 'OrganizationFunds', + }, + { + name: 'Membership Requests', + comp_id: 'requests', + component: 'Requests', + }, + { + name: 'Plugins', + comp_id: null, + component: 'AddOnStore', + subTargets: [ + { + comp_id: 'orgstore', + component: 'AddOnStore', + icon: 'fa-store', + name: 'Plugin Store', + }, + ], + }, + { name: 'Settings', comp_id: 'orgsetting', component: 'OrgSettings' }, + { name: '', comp_id: 'member', component: 'MemberDetail' }, + ], + }); + }); + + it('should handle UPDATE_TARGETS', () => { + expect( + reducer(undefined, { + type: 'UPDATE_TARGETS', + payload: 'orgId', + }), + ).toEqual({ + targets: [ + { name: 'My Organizations', url: '/orglist' }, + { name: 'Dashboard', url: '/orgdash/orgId' }, + { name: 'People', url: '/orgpeople/orgId' }, + { name: 'Tags', url: '/orgtags/orgId' }, + { name: 'Events', url: '/orgevents/orgId' }, + { name: 'Venues', url: '/orgvenues/orgId' }, + { name: 'Action Items', url: '/orgactionitems/orgId' }, + { name: 'Posts', url: '/orgpost/orgId' }, + { name: 'Block/Unblock', url: '/blockuser/orgId' }, + { name: 'Advertisement', url: '/orgads/orgId' }, + { name: 'Funds', url: '/orgfunds/orgId' }, + { name: 'Membership Requests', url: '/requests/orgId' }, + { + name: 'Plugins', + subTargets: [ + { + icon: 'fa-store', + name: 'Plugin Store', + url: '/orgstore/orgId', + }, + ], + }, + { name: 'Settings', url: '/orgsetting/orgId' }, + ], + components: [ + { name: 'My Organizations', comp_id: 'orglist', component: 'OrgList' }, + { + name: 'Dashboard', + comp_id: 'orgdash', + component: 'OrganizationDashboard', + }, + { + name: 'People', + comp_id: 'orgpeople', + component: 'OrganizationPeople', + }, + { + name: 'Tags', + comp_id: 'orgtags', + component: 'OrganizationTags', + }, + { + name: 'Events', + comp_id: 'orgevents', + component: 'OrganizationEvents', + }, + { + name: 'Venues', + comp_id: 'orgvenues', + component: 'OrganizationVenues', + }, + { + name: 'Action Items', + comp_id: 'orgactionitems', + component: 'OrganizationActionItems', + }, + { name: 'Posts', comp_id: 'orgpost', component: 'OrgPost' }, + { name: 'Block/Unblock', comp_id: 'blockuser', component: 'BlockUser' }, + { + name: 'Advertisement', + comp_id: 'orgads', + component: 'Advertisements', + }, + { name: 'Funds', comp_id: 'orgfunds', component: 'OrganizationFunds' }, + { + name: 'Membership Requests', + comp_id: 'requests', + component: 'Requests', + }, + { + name: 'Plugins', + comp_id: null, + component: 'AddOnStore', + subTargets: [ + { + comp_id: 'orgstore', + component: 'AddOnStore', + icon: 'fa-store', + name: 'Plugin Store', + }, + ], + }, + { name: 'Settings', comp_id: 'orgsetting', component: 'OrgSettings' }, + { name: '', comp_id: 'member', component: 'MemberDetail' }, + ], + }); + }); + + it('should handle UPDATE_P_TARGETS', () => { + expect( + reducer(undefined, { + type: 'UPDATE_P_TARGETS', + payload: [{ name: 'test-target-plugin', content: 'plugin-new' }], + }), + ).toEqual({ + targets: [ + { name: 'My Organizations', url: '/orglist' }, + { name: 'Dashboard', url: '/orgdash/undefined' }, + { name: 'People', url: '/orgpeople/undefined' }, + { name: 'Tags', url: '/orgtags/undefined' }, + { name: 'Events', url: '/orgevents/undefined' }, + { name: 'Venues', url: '/orgvenues/undefined' }, + { name: 'Action Items', url: '/orgactionitems/undefined' }, + { name: 'Posts', url: '/orgpost/undefined' }, + { + name: 'Block/Unblock', + url: '/blockuser/undefined', + }, + { name: 'Advertisement', url: '/orgads/undefined' }, + { name: 'Funds', url: '/orgfunds/undefined' }, + { name: 'Membership Requests', url: '/requests/undefined' }, + { name: 'Settings', url: '/orgsetting/undefined' }, + { + comp_id: null, + component: null, + name: 'Plugins', + subTargets: [ + { name: 'test-target-plugin', content: 'plugin-new' }, + { + icon: 'fa-store', + name: 'Plugin Store', + url: '/orgstore/undefined', + }, + ], + }, + ], + components: [ + { name: 'My Organizations', comp_id: 'orglist', component: 'OrgList' }, + { + name: 'Dashboard', + comp_id: 'orgdash', + component: 'OrganizationDashboard', + }, + { + name: 'People', + comp_id: 'orgpeople', + component: 'OrganizationPeople', + }, + { + name: 'Tags', + comp_id: 'orgtags', + component: 'OrganizationTags', + }, + { + name: 'Events', + comp_id: 'orgevents', + component: 'OrganizationEvents', + }, + { + name: 'Venues', + comp_id: 'orgvenues', + component: 'OrganizationVenues', + }, + { + name: 'Action Items', + comp_id: 'orgactionitems', + component: 'OrganizationActionItems', + }, + { name: 'Posts', comp_id: 'orgpost', component: 'OrgPost' }, + { name: 'Block/Unblock', comp_id: 'blockuser', component: 'BlockUser' }, + { + name: 'Advertisement', + comp_id: 'orgads', + component: 'Advertisements', + }, + { + name: 'Funds', + comp_id: 'orgfunds', + component: 'OrganizationFunds', + }, + { + name: 'Membership Requests', + comp_id: 'requests', + component: 'Requests', + }, + { + name: 'Plugins', + comp_id: null, + component: 'AddOnStore', + subTargets: [ + { + comp_id: 'orgstore', + component: 'AddOnStore', + icon: 'fa-store', + name: 'Plugin Store', + }, + ], + }, + { name: 'Settings', comp_id: 'orgsetting', component: 'OrgSettings' }, + { name: '', comp_id: 'member', component: 'MemberDetail' }, + ], + }); + }); +}); + +describe('routesReducer', () => { + it('returns state with updated subTargets when UPDATE_P_TARGETS action is dispatched', () => { + const action = { + type: 'UPDATE_P_TARGETS', + payload: [{ name: 'New Plugin', url: '/newplugin' }], + }; + const initialState = { + targets: [{ name: 'Plugins' }], + components: [], + }; + const state = reducer(initialState, action); + const pluginsTarget = state.targets.find( + (target) => target.name === 'Plugins', + ); + // Check if pluginsTarget is defined + if (!pluginsTarget) { + throw new Error('Plugins target not found in state'); + } + expect(pluginsTarget.subTargets).toEqual([ + { name: 'New Plugin', url: '/newplugin' }, + ]); + }); +}); diff --git a/src/state/reducers/routesReducer.ts b/src/state/reducers/routesReducer.ts new file mode 100644 index 0000000000..5d50f5402d --- /dev/null +++ b/src/state/reducers/routesReducer.ts @@ -0,0 +1,139 @@ +import type { InterfaceAction } from 'state/helpers/Action'; + +export type TargetsType = { + name: string; + url?: string; + subTargets?: SubTargetType[]; +}; + +export type SubTargetType = { + name?: string; + url: string; + icon?: string; + comp_id?: string; +}; + +const reducer = ( + state = INITIAL_STATE, + action: InterfaceAction, +): typeof INITIAL_STATE => { + switch (action.type) { + case 'UPDATE_TARGETS': { + return Object.assign({}, state, { + targets: [...generateRoutes(components, action.payload)], + }); + } + case 'UPDATE_P_TARGETS': { + const filteredTargets = state.targets.filter( + (target: TargetsType) => target.name === 'Plugins', + ); + + const oldTargets: SubTargetType[] = filteredTargets[0]?.subTargets || []; + return Object.assign({}, state, { + targets: [ + ...state.targets.filter( + (target: TargetsType) => target.name !== 'Plugins', + ), + Object.assign( + {}, + { + name: 'Plugins', + comp_id: null, + component: null, + subTargets: [...action.payload, ...oldTargets], + }, + ), + ], + }); + } + default: { + return state; + } + } +}; + +export type ComponentType = { + name: string; + comp_id: string | null; + component: string | null; + subTargets?: { + name: string; + comp_id: string; + component: string; + icon?: string; + }[]; +}; + +// Note: Routes with names appear on NavBar +const components: ComponentType[] = [ + { name: 'My Organizations', comp_id: 'orglist', component: 'OrgList' }, + { name: 'Dashboard', comp_id: 'orgdash', component: 'OrganizationDashboard' }, + { name: 'People', comp_id: 'orgpeople', component: 'OrganizationPeople' }, + { name: 'Tags', comp_id: 'orgtags', component: 'OrganizationTags' }, + { name: 'Events', comp_id: 'orgevents', component: 'OrganizationEvents' }, + { name: 'Venues', comp_id: 'orgvenues', component: 'OrganizationVenues' }, + { + name: 'Action Items', + comp_id: 'orgactionitems', + component: 'OrganizationActionItems', + }, + { name: 'Posts', comp_id: 'orgpost', component: 'OrgPost' }, + { name: 'Block/Unblock', comp_id: 'blockuser', component: 'BlockUser' }, + { name: 'Advertisement', comp_id: 'orgads', component: 'Advertisements' }, + { name: 'Funds', comp_id: 'orgfunds', component: 'OrganizationFunds' }, + { name: 'Membership Requests', comp_id: 'requests', component: 'Requests' }, + { + name: 'Plugins', + comp_id: null, + component: 'AddOnStore', // Default + subTargets: [ + { + name: 'Plugin Store', + comp_id: 'orgstore', + component: 'AddOnStore', + icon: 'fa-store', + }, + ], + }, + { name: 'Settings', comp_id: 'orgsetting', component: 'OrgSettings' }, + { name: '', comp_id: 'member', component: 'MemberDetail' }, +]; + +const generateRoutes = ( + comps: ComponentType[], + currentOrg = undefined, +): TargetsType[] => { + return comps + .filter((comp) => comp.name && comp.name !== '') + .map((comp) => { + const entry: TargetsType = comp.comp_id + ? comp.comp_id === 'orglist' + ? { name: comp.name, url: `/${comp.comp_id}` } + : { name: comp.name, url: `/${comp.comp_id}/${currentOrg}` } + : { + name: comp.name, + subTargets: comp.subTargets?.map( + (subTarget: { + name: string; + comp_id: string; + component: string; + icon?: string; + }) => { + return { + name: subTarget.name, + url: `/${subTarget.comp_id}/${currentOrg}`, + icon: subTarget.icon, + }; + }, + ), + }; + return entry; + }); +}; + +const INITIAL_STATE = { + targets: generateRoutes(components), + components, +}; + +export default reducer; diff --git a/src/state/reducers/userRoutersReducer.test.ts b/src/state/reducers/userRoutersReducer.test.ts new file mode 100644 index 0000000000..e2987dcd38 --- /dev/null +++ b/src/state/reducers/userRoutersReducer.test.ts @@ -0,0 +1,96 @@ +import expect from 'expect'; +import reducer from './userRoutesReducer'; + +describe('Testing Routes reducer', () => { + it('should return the initial state', () => { + expect( + reducer(undefined, { + type: '', + payload: undefined, + }), + ).toEqual({ + targets: [ + { name: 'My Organizations', url: 'user/organizations' }, + { name: 'Posts', url: 'user/organization/undefined' }, + { name: 'People', url: 'user/people/undefined' }, + { name: 'Events', url: 'user/events/undefined' }, + { name: 'Volunteer', url: 'user/volunteer/undefined' }, + { name: 'Donate', url: 'user/donate/undefined' }, + { name: 'Campaigns', url: 'user/campaigns/undefined' }, + { name: 'My Pledges', url: 'user/pledges/undefined' }, + ], + components: [ + { + name: 'My Organizations', + comp_id: 'organizations', + component: 'Organizations', + }, + { + name: 'Posts', + comp_id: 'organization', + component: 'Posts', + }, + { name: 'People', comp_id: 'people', component: 'People' }, + { name: 'Events', comp_id: 'events', component: 'Events' }, + { + name: 'Volunteer', + comp_id: 'volunteer', + component: 'VolunteerManagement', + }, + { name: 'Donate', comp_id: 'donate', component: 'Donate' }, + { + name: 'Campaigns', + comp_id: 'campaigns', + component: 'Campaigns', + }, + { name: 'My Pledges', comp_id: 'pledges', component: 'Pledges' }, + ], + }); + }); + + it('should handle UPDATE_TARGETS', () => { + expect( + reducer(undefined, { + type: 'UPDATE_TARGETS', + payload: 'orgId', + }), + ).toEqual({ + targets: [ + { name: 'My Organizations', url: 'user/organizations' }, + { name: 'Posts', url: 'user/organization/orgId' }, + { name: 'People', url: 'user/people/orgId' }, + { name: 'Events', url: 'user/events/orgId' }, + { name: 'Volunteer', url: 'user/volunteer/orgId' }, + { name: 'Donate', url: 'user/donate/orgId' }, + { name: 'Campaigns', url: 'user/campaigns/orgId' }, + { name: 'My Pledges', url: 'user/pledges/orgId' }, + ], + components: [ + { + name: 'My Organizations', + comp_id: 'organizations', + component: 'Organizations', + }, + { + name: 'Posts', + comp_id: 'organization', + component: 'Posts', + }, + { name: 'People', comp_id: 'people', component: 'People' }, + { name: 'Events', comp_id: 'events', component: 'Events' }, + { + name: 'Volunteer', + comp_id: 'volunteer', + component: 'VolunteerManagement', + }, + { name: 'Donate', comp_id: 'donate', component: 'Donate' }, + { + name: 'Campaigns', + comp_id: 'campaigns', + component: 'Campaigns', + }, + { name: 'My Pledges', comp_id: 'pledges', component: 'Pledges' }, + ], + }); + }); +}); diff --git a/src/state/reducers/userRoutesReducer.ts b/src/state/reducers/userRoutesReducer.ts new file mode 100644 index 0000000000..e1bf5de0dc --- /dev/null +++ b/src/state/reducers/userRoutesReducer.ts @@ -0,0 +1,88 @@ +import type { InterfaceAction } from 'state/helpers/Action'; + +export type TargetsType = { + name: string; + url?: string; + subTargets?: SubTargetType[]; +}; + +export type SubTargetType = { + name?: string; + url: string; + icon?: string; + comp_id?: string; +}; + +const reducer = ( + state = INITIAL_USER_STATE, + action: InterfaceAction, +): typeof INITIAL_USER_STATE => { + switch (action.type) { + case 'UPDATE_TARGETS': { + return Object.assign({}, state, { + targets: [...generateRoutes(components, action.payload)], + }); + } + default: { + return state; + } + } +}; + +export type ComponentType = { + name: string; + comp_id: string | null; + component: string | null; + subTargets?: { + name: string; + comp_id: string; + component: string; + icon?: string; + }[]; +}; + +// Note: Routes with names appear on NavBar +const components: ComponentType[] = [ + { + name: 'My Organizations', + comp_id: 'organizations', + component: 'Organizations', + }, + { + name: 'Posts', + comp_id: 'organization', + component: 'Posts', + }, + { name: 'People', comp_id: 'people', component: 'People' }, + { name: 'Events', comp_id: 'events', component: 'Events' }, + { name: 'Volunteer', comp_id: 'volunteer', component: 'VolunteerManagement' }, + { name: 'Donate', comp_id: 'donate', component: 'Donate' }, + { + name: 'Campaigns', + comp_id: 'campaigns', + component: 'Campaigns', + }, + { name: 'My Pledges', comp_id: 'pledges', component: 'Pledges' }, +]; + +const generateRoutes = ( + comps: ComponentType[], + currentOrg = undefined, +): TargetsType[] => { + return comps + .filter((comp) => comp.name && comp.name !== '') + .map((comp) => { + const entry: TargetsType = + comp.comp_id === 'organizations' + ? { name: comp.name, url: `user/${comp.comp_id}` } + : { name: comp.name, url: `user/${comp.comp_id}/${currentOrg}` }; + return entry; + }); +}; + +const INITIAL_USER_STATE = { + targets: generateRoutes(components), + components, +}; + +export default reducer; diff --git a/src/state/store.test.tsx b/src/state/store.test.tsx new file mode 100644 index 0000000000..eedbbc84f0 --- /dev/null +++ b/src/state/store.test.tsx @@ -0,0 +1,27 @@ +import { store } from './store'; +describe('Testing src/state/store.ts', () => { + const state = store.getState(); + test('State should contain the properties appRoutes and plugins', () => { + expect(state).toHaveProperty('appRoutes'); + expect(state).toHaveProperty('plugins'); + }); + test('State schema should contain appRoutes and plugins', () => { + expect(state).toMatchObject({ + appRoutes: expect.any(Object), + plugins: expect.any(Object), + }); + }); + test('appRoutes schema should contain targets, configUrl and components', () => { + expect(state.appRoutes).toMatchObject({ + targets: expect.any(Array), + components: expect.any(Array), + }); + }); + test('plugins schema should contain installed, addOnStore and extras', () => { + expect(state.plugins).toMatchObject({ + installed: expect.any(Array), + addonStore: expect.any(Array), + extras: expect.any(Array), + }); + }); +}); diff --git a/src/state/store.ts b/src/state/store.ts new file mode 100644 index 0000000000..fa4329c693 --- /dev/null +++ b/src/state/store.ts @@ -0,0 +1,8 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { reducers } from './reducers/index'; + +export const store = configureStore({ + reducer: reducers, +}); + +export type AppDispatch = typeof store.dispatch; diff --git a/src/style/app.module.css b/src/style/app.module.css new file mode 100644 index 0000000000..45449b7bf1 --- /dev/null +++ b/src/style/app.module.css @@ -0,0 +1,507 @@ +:root { + --dropdown-border-color: #555555; + --dropdown-text-color: #555555; + --dropdown-hover-color: #eff1f7; + --grey-bg-color: #eaebef; + --subtle-blue-grey: #7c9beb; + --subtle-blue-grey-hover: #5f7e91; + --modal-width: 670px; + --modal-max-width: 680px; + --input-shadow-color: #dddddd; + --delete-button-bg: #f8d6dc; + --delete-button-color: #ff4d4f; + --search-button-bg: #a8c7fa; + --search-button-border: #555555; + --table-image-size: 50px; + --table-head-bg: var( + --bs-primary, + blue + ); /* Assuming var(--bs-primary) is defined elsewhere */ + --table-head-color: white; + --table-header-color: var(--bs-greyish-black, black); + --table-head-radius: 20px; + --table-bg-color: #eaebef; + --tablerow-bg-color: #eff1f7; + --row-background: var(--bs-white, white); + --font-size-header: 16px; +} + +.noOutline input { + outline: none; +} + +.noOutline:is(:hover, :focus, :active, :focus-visible, .show) { + outline: none !important; +} + +.closeButton { + color: var(--delete-button-color); + margin-right: 5px; + background-color: var(--delete-button-bg); + border: white; +} + +.closeButton:hover { + color: var(--delete-button-bg) !important; + background-color: var(--delete-button-color) !important; + border: white; +} + +.modalContent { + width: var(--modal-width); + max-width: var(--modal-max-width); +} + +.subtleBlueGrey { + color: var(--subtle-blue-grey); + text-decoration: none; +} + +.subtleBlueGrey:hover { + color: var(--subtle-blue-grey-hover); + text-decoration: underline; +} + +.dropdown { + background-color: white; + border: 1px solid var(--dropdown-border-color); + color: var(--dropdown-text-color); + position: relative; + display: inline-block; + margin-top: 10px; + margin-bottom: 10px; +} + +.dropdown:is(:hover, :focus, :active, :focus-visible, .show) { + background-color: transparent !important; + border: 1px solid var(--dropdown-border-color); + color: var(--dropdown-text-color) !important; +} + +.dropdownItem { + background-color: white !important; + color: var(--dropdown-text-color) !important; + border: none !important; +} + +.dropdownItem:hover, +.dropdownItem:focus, +.dropdownItem:active { + background-color: var(--dropdown-hover-color) !important; + color: var(--dropdown-text-color) !important; + outline: none !important; + box-shadow: none !important; +} + +.input { + flex: 1; + + position: relative; +} + +.btnsContainer { + display: flex; + margin: 2.5rem 0; +} + +.btnsContainer .btnsBlock { + display: flex; +} + +.btnsContainer .btnsBlock button { + margin-left: 1rem; + display: flex; + justify-content: center; + align-items: center; +} + +.btnsContainer .input { + flex: 1; + position: relative; +} + +.btnsContainer .input button { + width: 52px; +} + +.deleteButton { + background-color: var(--delete-button-bg); + color: var(--delete-button-color); + border: none; + padding: 5px 20px; + display: flex; + align-items: center; + margin-top: 20px; + margin-right: auto; + margin-left: auto; + gap: 8px; +} + +.actionItemDeleteButton { + background-color: var(--delete-button-bg); + color: var(--delete-button-color); + border: none; +} + +.createButton { + background-color: var(--grey-bg-color) !important; + color: black !important; + margin-top: 10px; + margin-left: 5px; + border: 1px solid var(--dropdown-border-color); +} + +.createButton:hover { + background-color: var(--grey-bg-color) !important; + color: black !important; + border: 1px solid var(--dropdown-border-color) !important; +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.inputField { + margin-top: 10px; + margin-bottom: 10px; + background-color: white; + box-shadow: 0 1px 1px var(--input-shadow-color); +} + +.inputFieldModal { + margin-bottom: 10px; + background-color: white; + box-shadow: 0 1px 1px var(--input-shadow-color); +} + +.inputField > button { + padding-top: 10px; + padding-bottom: 10px; +} + +.searchButton { + margin-bottom: 10px; + background-color: var(--search-button-bg); + border-color: var(--search-button-border); + position: absolute; + z-index: 10; + bottom: 0; + right: 0; + display: flex; + justify-content: center; + align-items: center; +} + +.addButton { + margin-bottom: 10px; + background-color: var(--search-button-bg); + border-color: var(--grey-bg-color); + color: #555555; +} + +.addButton:hover { + background-color: #286fe0; + border-color: var(--search-button-border); +} + +.yesButton { + background-color: var(--search-button-bg); + border-color: var(--search-button-border); +} + +.searchIcon { + color: var(--dropdown-text-color); +} + +.infoButton { + background-color: var(--search-button-bg); + border-color: var(--search-button-border); + color: var(--dropdown-text-color); + margin-right: 0.5rem; + border-radius: 0.25rem; +} + +.infoButton:hover { + background-color: #286fe0; + border-color: var(--search-button-border); +} + +.TableImage { + object-fit: cover; + /* margin-top: px !important; */ + margin-right: 5px; + width: var(--table-image-size) !important; + height: var(--table-image-size) !important; + border-radius: 100% !important; +} + +.tableHead { + color: var(--table-head-color); + border-radius: var(--table-head-radius) !important; + padding: 20px; + margin-top: 20px; +} + +.mainpageright > hr { + margin-top: 10px; + width: 100%; + margin-left: -15px; + margin-right: -15px; + margin-bottom: 20px; +} + +.rowBackground { + background-color: var(--row-background); +} + +.tableHeader { + background-color: var(--table-head-bg); + color: var(--table-header-color); + font-size: var(--font-size-header); +} + +.orgUserTagsScrollableDiv { + scrollbar-width: auto; + scrollbar-color: var(--bs-gray-400) var(--bs-white); + + max-height: calc(100vh - 18rem); + overflow: auto; + position: sticky; +} + +.errorContainer { + min-height: 100vh; +} + +.errorMessage { + margin-top: 25%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.errorIcon { + transform: scale(1.5); + color: var(--bs-danger); + margin-bottom: 1rem; +} + +.subTagsLink { + color: var(--subtle-blue-grey); + font-weight: 500; + cursor: pointer; +} + +.subTagsLink i { + visibility: hidden; +} + +.subTagsLink:hover { + color: var(--subtle-blue-grey-hover); + font-weight: 600; + text-decoration: underline; +} + +.subTagsLink:hover i { + visibility: visible; +} + +.tagsBreadCrumbs { + color: var(--bs-gray); + cursor: pointer; +} + +.orgUserTagsScrollableDiv { + scrollbar-width: auto; + scrollbar-color: var(--bs-gray-400) var(--bs-white); + + max-height: calc(100vh - 18rem); + overflow: auto; + position: sticky; +} + +/* .checkboxButton{ + background-color: transparent; +} + +.checkboxButton:checked{ + background-color: var(--subtle-blue-grey); + color:white +} */ + +input[type='checkbox']:checked + label { + background-color: var(--subtle-blue-grey) !important; +} + +input[type='radio']:checked + label:hover { + color: black !important; +} + +.actionItemsContainer { + height: 90vh; +} + +.actionItemModal { + max-width: 80vw; + margin-top: 2vh; + margin-left: 13vw; +} + +.datediv { + display: flex; + flex-direction: row; +} + +.datebox { + width: 90%; + border-radius: 7px; + outline: none; + box-shadow: none; + padding-top: 2px; + padding-bottom: 2px; + padding-right: 5px; + padding-left: 5px; + margin-right: 5px; + margin-left: 5px; +} + +hr { + border: none; + height: 1px; + background-color: var(--bs-gray-500); + margin: 1rem; +} + +.iconContainer { + display: flex; + justify-content: flex-end; +} +.icon { + margin: 1px; +} + +.message { + margin-top: 25%; + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; +} + +.preview { + display: flex; + flex-direction: row; + font-weight: 900; + font-size: 16px; + color: rgb(80, 80, 80); +} + +.rankings { + aspect-ratio: 1; + border-radius: 50%; + width: 50px; +} + +.toggleGroup { + width: 50%; + min-width: 20rem; + margin: 0.5rem 0rem; +} + +.toggleBtn { + padding: 0rem; + height: 2rem; + display: flex; + justify-content: center; + align-items: center; +} + +.toggleBtn:hover { + color: var(--bs-primary) !important; +} + +@media (max-width: 520px) { + .btnsContainer { + margin-bottom: 0; + } + + .btnsContainer .btnsBlock { + display: block; + margin-top: 1rem; + margin-right: 0; + } + + .btnsContainer .btnsBlock div { + flex: 1; + } + + .btnsContainer .btnsBlock div[title='Sort organizations'] { + margin-right: 0.5rem; + } + + .btnsContainer .btnsBlock button { + margin-bottom: 1rem; + margin-right: 0; + width: 100%; + } +} + +@media (max-width: 1120px) { + .contract { + padding-left: calc(250px + 2rem + 1.5rem); + } + + .listBox .itemCard { + width: 100%; + } +} + +@media (max-width: 1020px) { + .btnsContainer { + flex-direction: column; + margin: 1.5rem 0; + } + + .btnsContainer .btnsBlock { + margin: 1.5rem 0 0 0; + justify-content: space-between; + } + + .btnsContainer .btnsBlock button { + margin: 0; + } + + .btnsContainer .btnsBlock div button { + margin-right: 1.5rem; + } +} + +@-webkit-keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} + +@keyframes load8 { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + 100% { + -webkit-transform: rotate(360deg); + transform: rotate(360deg); + } +} diff --git a/src/utils/StaticMockLink.ts b/src/utils/StaticMockLink.ts new file mode 100644 index 0000000000..457da3dfd4 --- /dev/null +++ b/src/utils/StaticMockLink.ts @@ -0,0 +1,178 @@ +import { print } from 'graphql'; +import { equal } from '@wry/equality'; +import { invariant } from 'ts-invariant'; + +import type { Operation, FetchResult } from '@apollo/client/link/core'; +import { ApolloLink } from '@apollo/client/link/core'; + +import { + Observable, + addTypenameToDocument, + removeClientSetsFromDocument, + removeConnectionDirectiveFromDocument, + cloneDeep, +} from '@apollo/client/utilities'; + +import type { MockedResponse, ResultFunction } from '@apollo/react-testing'; + +function requestToKey(request: any, addTypename: boolean): string { + const queryString = + request.query && + print(addTypename ? addTypenameToDocument(request.query) : request.query); + const requestKey = { query: queryString }; + return JSON.stringify(requestKey); +} + +/** + * Similar to the standard Apollo MockLink, but doesn't consume a mock + * when it is used allowing it to be used in places like Storybook. + */ +export class StaticMockLink extends ApolloLink { + public operation?: Operation; + public addTypename = true; + private _mockedResponsesByKey: { [key: string]: MockedResponse[] } = {}; + + constructor(mockedResponses: readonly MockedResponse[], addTypename = true) { + super(); + this.addTypename = addTypename; + if (mockedResponses) { + mockedResponses.forEach((mockedResponse) => { + this.addMockedResponse(mockedResponse); + }); + } + } + + public addMockedResponse(mockedResponse: MockedResponse): void { + const normalizedMockedResponse = + this._normalizeMockedResponse(mockedResponse); + const key = requestToKey( + normalizedMockedResponse.request, + this.addTypename, + ); + let mockedResponses = this._mockedResponsesByKey[key]; + if (!mockedResponses) { + mockedResponses = []; + this._mockedResponsesByKey[key] = mockedResponses; + } + mockedResponses.push(normalizedMockedResponse); + } + + public request(operation: any): Observable | null { + this.operation = operation; + const key = requestToKey(operation, this.addTypename); + let responseIndex = 0; + const response = (this._mockedResponsesByKey[key] || []).find( + (res, index) => { + const requestVariables = operation.variables || {}; + const mockedResponseVariables = res.request.variables || {}; + if (equal(requestVariables, mockedResponseVariables)) { + responseIndex = index; + return true; + } + return false; + }, + ); + + let configError: Error; + + if (!response || typeof responseIndex === 'undefined') { + configError = new Error( + `No more mocked responses for the query: ${print( + operation.query, + )}, variables: ${JSON.stringify(operation.variables)}`, + ); + } else { + const { newData } = response; + if (newData) { + response.result = newData(operation.variables); + this._mockedResponsesByKey[key].push(response); + } + + if (!response.result && !response.error) { + configError = new Error( + `Mocked response should contain either result or error: ${key}`, + ); + } + } + + return new Observable((observer) => { + const timer = setTimeout( + () => { + if (configError) { + try { + // The onError function can return false to indicate that + // configError need not be passed to observer.error. For + // example, the default implementation of onError calls + // observer.error(configError) and then returns false to + // prevent this extra (harmless) observer.error call. + if (this.onError(configError, observer) !== false) { + throw configError; + } + } catch (error) { + observer.error(error); + } + } else if (response) { + if (response.error) { + observer.error(response.error); + } else { + if (response.result) { + observer.next( + typeof response.result === 'function' + ? (response.result as ResultFunction)( + operation.variables, + ) + : response.result, + ); + } + observer.complete(); + } + } + }, + (response && response.delay) || 0, + ); + + return () => { + clearTimeout(timer); + }; + }); + } + + private _normalizeMockedResponse( + mockedResponse: MockedResponse, + ): MockedResponse { + const newMockedResponse = cloneDeep(mockedResponse); + const queryWithoutConnection = removeConnectionDirectiveFromDocument( + newMockedResponse.request.query, + ); + invariant(queryWithoutConnection, 'query is required'); + newMockedResponse.request.query = queryWithoutConnection; + const query = removeClientSetsFromDocument(newMockedResponse.request.query); + if (query) { + newMockedResponse.request.query = query; + } + return newMockedResponse; + } +} + +export interface InterfaceMockApolloLink extends ApolloLink { + operation?: Operation; +} + +// Pass in multiple mocked responses, so that you can test flows that end up +// making multiple queries to the server. +// NOTE: The last arg can optionally be an `addTypename` arg. +export function mockSingleLink( + ...mockedResponses: any[] +): InterfaceMockApolloLink { + // To pull off the potential typename. If this isn't a boolean, we'll just + // set it true later. + let maybeTypename = mockedResponses[mockedResponses.length - 1]; + let mocks = mockedResponses.slice(0, mockedResponses.length - 1); + + if (typeof maybeTypename !== 'boolean') { + mocks = mockedResponses; + maybeTypename = true; + } + + return new StaticMockLink(mocks, maybeTypename); +} diff --git a/src/utils/chartToPdf.test.ts b/src/utils/chartToPdf.test.ts new file mode 100644 index 0000000000..b3094fff02 --- /dev/null +++ b/src/utils/chartToPdf.test.ts @@ -0,0 +1,168 @@ +import { + exportToCSV, + exportTrendsToCSV, + exportDemographicsToCSV, +} from './chartToPdf'; + +describe('CSV Export Functions', () => { + let mockCreateElement: jest.SpyInstance; + let mockClick: jest.SpyInstance; + let mockSetAttribute: jest.SpyInstance; + + beforeEach(() => { + // Mock URL methods + global.URL.createObjectURL = jest.fn(() => 'mock-url'); + global.URL.revokeObjectURL = jest.fn(); + + // Mock DOM methods + mockSetAttribute = jest.fn(); + mockClick = jest.fn(); + const mockLink = { + setAttribute: mockSetAttribute, + click: mockClick, + } as unknown as HTMLAnchorElement; + + mockCreateElement = jest + .spyOn(document, 'createElement') + .mockReturnValue(mockLink as HTMLAnchorElement); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('CSV Export Functions', () => { + let mockCreateElement: jest.SpyInstance; + let mockAppendChild: jest.SpyInstance; + let mockRemoveChild: jest.SpyInstance; + let mockClick: jest.SpyInstance; + let mockSetAttribute: jest.SpyInstance; + + beforeEach(() => { + // Mock URL methods + global.URL.createObjectURL = jest.fn(() => 'mock-url'); + global.URL.revokeObjectURL = jest.fn(); + + // Mock DOM methods + mockSetAttribute = jest.fn(); + mockClick = jest.fn(); + const mockLink = { + setAttribute: mockSetAttribute, + click: mockClick, + parentNode: document.body, // Add this to trigger removeChild + } as unknown as HTMLAnchorElement; + + mockCreateElement = jest + .spyOn(document, 'createElement') + .mockReturnValue(mockLink as HTMLAnchorElement); + mockAppendChild = jest + .spyOn(document.body, 'appendChild') + .mockImplementation(() => mockLink as HTMLAnchorElement); + mockRemoveChild = jest + .spyOn(document.body, 'removeChild') + .mockImplementation(() => mockLink as HTMLAnchorElement); + }); + + test('exports data to CSV with proper formatting', () => { + const data = [ + ['Header1', 'Header2'], + ['Value1', 'Value2'], + ['Value with, comma', 'Value with "quotes"'], + ]; + + exportToCSV(data, 'test.csv'); + + expect(mockCreateElement).toHaveBeenCalledWith('a'); + expect(mockSetAttribute).toHaveBeenCalledWith('href', 'mock-url'); + expect(mockSetAttribute).toHaveBeenCalledWith('download', 'test.csv'); + expect(mockAppendChild).toHaveBeenCalled(); + expect(mockClick).toHaveBeenCalled(); + expect(mockRemoveChild).toHaveBeenCalled(); + expect(URL.revokeObjectURL).toHaveBeenCalledWith('mock-url'); + }); + test('throws error if data is empty', () => { + expect(() => exportToCSV([], 'test.csv')).toThrow('Data cannot be empty'); + }); + + test('throws error if filename is empty', () => { + expect(() => exportToCSV([['data']], '')).toThrow('Filename is required'); + }); + + test('adds .csv extension if missing', () => { + const data = [['test']]; + exportToCSV(data, 'filename'); + expect(mockSetAttribute).toHaveBeenCalledWith('download', 'filename.csv'); + }); + }); + + describe('exportTrendsToCSV', () => { + test('exports attendance trends data correctly', () => { + const eventLabels = ['Event1', 'Event2']; + const attendeeCounts = [10, 20]; + const maleCounts = [5, 10]; + const femaleCounts = [4, 8]; + const otherCounts = [1, 2]; + + exportTrendsToCSV( + eventLabels, + attendeeCounts, + maleCounts, + femaleCounts, + otherCounts, + ); + + expect(mockCreateElement).toHaveBeenCalledWith('a'); + expect(mockSetAttribute).toHaveBeenCalledWith( + 'download', + 'attendance_trends.csv', + ); + expect(mockClick).toHaveBeenCalled(); + }); + }); + + describe('exportDemographicsToCSV', () => { + test('exports demographics data correctly', () => { + const selectedCategory = 'Age Groups'; + const categoryLabels = ['0-18', '19-30', '31+']; + const categoryData = [10, 20, 15]; + + exportDemographicsToCSV(selectedCategory, categoryLabels, categoryData); + + expect(mockCreateElement).toHaveBeenCalledWith('a'); + expect(mockClick).toHaveBeenCalled(); + expect(mockSetAttribute).toHaveBeenCalledWith('href', 'mock-url'); + }); + + test('throws error if selected category is empty', () => { + expect(() => exportDemographicsToCSV('', ['label'], [1])).toThrow( + 'Selected category is required', + ); + }); + + test('throws error if labels and data arrays have different lengths', () => { + expect(() => + exportDemographicsToCSV('Category', ['label1', 'label2'], [1]), + ).toThrow('Labels and data arrays must have the same length'); + }); + + test('creates safe filename with timestamp', () => { + jest.useFakeTimers(); + const mockDate = new Date('2023-01-01T00:00:00.000Z'); + jest.setSystemTime(mockDate); + + const selectedCategory = 'Age & Demographics!'; + const categoryLabels = ['Group1']; + const categoryData = [10]; + + exportDemographicsToCSV(selectedCategory, categoryLabels, categoryData); + + const expectedFilename = + 'age___demographics__demographics_2023-01-01T00-00-00.000Z.csv'; + const downloadCalls = mockSetAttribute.mock.calls.filter( + (call) => call[0] === 'download', + ); + expect(downloadCalls[0][1]).toBe(expectedFilename); + jest.useRealTimers(); + }); + }); +}); diff --git a/src/utils/chartToPdf.ts b/src/utils/chartToPdf.ts new file mode 100644 index 0000000000..d724f077cc --- /dev/null +++ b/src/utils/chartToPdf.ts @@ -0,0 +1,106 @@ +type CSVData = (string | number)[][]; + +export const exportToCSV = (data: CSVData, filename: string): void => { + if (!data?.length) { + throw new Error('Data cannot be empty'); + } + + if (!filename) { + throw new Error('Filename is required'); + } + + // Ensure .csv extension + const finalFilename = filename.endsWith('.csv') + ? filename + : `${filename}.csv`; + const csvContent = + // Properly escape and quote CSV content + 'data:text/csv;charset=utf-8,' + + data + .map((row) => + row + .map((cell) => { + const cellStr = String(cell); + // Escape double quotes by doubling them + const escapedCell = cellStr.replace(/"/g, '""'); + // Enclose cell in double quotes if it contains commas, newlines, or double quotes + return /[",\n]/.test(escapedCell) + ? `"${escapedCell}"` + : escapedCell; + }) + .join(','), + ) + .join('\n'); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + try { + link.setAttribute('href', url); + link.setAttribute('download', finalFilename); + document.body.appendChild(link); + link.click(); + } finally { + if (link.parentNode === document.body) { + document.body.removeChild(link); + } + URL.revokeObjectURL(url); // Clean up the URL object + } +}; + +export const exportTrendsToCSV = ( + eventLabels: string[], + attendeeCounts: number[], + maleCounts: number[], + femaleCounts: number[], + otherCounts: number[], +): void => { + const heading = 'Attendance Trends'; + const headers = [ + 'Date', + 'Attendee Count', + 'Male Attendees', + 'Female Attendees', + 'Other Attendees', + ]; + const data: CSVData = [ + [heading], + [], + headers, + ...eventLabels.map((label, index) => [ + label, + attendeeCounts[index], + maleCounts[index], + femaleCounts[index], + otherCounts[index], + ]), + ]; + exportToCSV(data, 'attendance_trends.csv'); +}; + +export const exportDemographicsToCSV = ( + selectedCategory: string, + categoryLabels: string[], + categoryData: number[], +): void => { + if (!selectedCategory?.trim()) { + throw new Error('Selected category is required'); + } + + if (categoryLabels.length !== categoryData.length) { + throw new Error('Labels and data arrays must have the same length'); + } + + const heading = `${selectedCategory} Demographics`; + const headers = [selectedCategory, 'Count']; + const data: CSVData = [ + [heading], + [], + headers, + ...categoryLabels.map((label, index) => [label, categoryData[index]]), + ]; + const safeCategory = selectedCategory + .replace(/[^a-z0-9]/gi, '_') + .toLowerCase(); + const timestamp = new Date().toISOString().replace(/[:]/g, '-'); + exportToCSV(data, `${safeCategory}_demographics_${timestamp}.csv`); +}; diff --git a/src/utils/convertToBase64.test.ts b/src/utils/convertToBase64.test.ts new file mode 100644 index 0000000000..51198ccc29 --- /dev/null +++ b/src/utils/convertToBase64.test.ts @@ -0,0 +1,33 @@ +import convertToBase64 from './convertToBase64'; + +describe('convertToBase64', () => { + it('should return a base64-encoded string when given a file', async () => { + const file = new File(['hello'], 'hello.txt', { type: 'text/plain' }); + const result = await convertToBase64(file); + expect(result).toMatch(/^data:text\/plain;base64,[a-zA-Z0-9+/]+={0,2}$/); + }); + + it('should return an empty string when given an invalid file', async () => { + const file = {} as File; + const result = await convertToBase64(file); + expect(result).toBe(''); + }); + + it('should handle errors thrown by FileReader', async () => { + // Arrange + const file = new File(['hello'], 'hello.txt', { type: 'text/plain' }); + const mockFileReader = jest + .spyOn(global, 'FileReader') + .mockImplementationOnce(() => { + throw new Error('Test error'); + }); + + // Act + const result = await convertToBase64(file); + + // Assert + expect(mockFileReader).toHaveBeenCalledTimes(1); + expect(mockFileReader).toHaveBeenCalledWith(); + expect(result).toBe(''); + }); +}); diff --git a/src/utils/convertToBase64.ts b/src/utils/convertToBase64.ts new file mode 100644 index 0000000000..2264d04d58 --- /dev/null +++ b/src/utils/convertToBase64.ts @@ -0,0 +1,15 @@ +const convertToBase64 = async (file: File): Promise => { + try { + const res = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = (): void => resolve(reader.result); + reader.onerror = (error): void => reject(error); + }); + return `${res}`; + } catch (error) { + return ''; + } +}; + +export default convertToBase64; diff --git a/src/utils/currency.ts b/src/utils/currency.ts new file mode 100644 index 0000000000..c7db3002f2 --- /dev/null +++ b/src/utils/currency.ts @@ -0,0 +1,330 @@ +export const currencyOptions = [ + { value: 'AED', label: 'AED' }, // United Arab Emirates Dirham + { value: 'AFN', label: 'AFN' }, // Afghan Afghani + { value: 'ALL', label: 'ALL' }, // Albanian Lek + { value: 'AMD', label: 'AMD' }, // Armenian Dram + { value: 'ANG', label: 'ANG' }, // Netherlands Antillean Guilder + { value: 'AOA', label: 'AOA' }, // Angolan Kwanza + { value: 'ARS', label: 'ARS' }, // Argentine Peso + { value: 'AUD', label: 'AUD' }, // Australian Dollar + { value: 'AWG', label: 'AWG' }, // Aruban Florin + { value: 'AZN', label: 'AZN' }, // Azerbaijani Manat + { value: 'BAM', label: 'BAM' }, // Bosnia-Herzegovina Convertible Mark + { value: 'BBD', label: 'BBD' }, // Barbadian Dollar + { value: 'BDT', label: 'BDT' }, // Bangladeshi Taka + { value: 'BGN', label: 'BGN' }, // Bulgarian Lev + { value: 'BHD', label: 'BHD' }, // Bahraini Dinar + { value: 'BIF', label: 'BIF' }, // Burundian Franc + { value: 'BMD', label: 'BMD' }, // Bermudian Dollar + { value: 'BND', label: 'BND' }, // Brunei Dollar + { value: 'BOB', label: 'BOB' }, // Bolivian Boliviano + { value: 'BRL', label: 'BRL' }, // Brazilian Real + { value: 'BSD', label: 'BSD' }, // Bahamian Dollar + { value: 'BTN', label: 'BTN' }, // Bhutanese Ngultrum + { value: 'BWP', label: 'BWP' }, // Botswanan Pula + { value: 'BYN', label: 'BYN' }, // Belarusian Ruble + { value: 'BZD', label: 'BZD' }, // Belize Dollar + { value: 'CAD', label: 'CAD' }, // Canadian Dollar + { value: 'CDF', label: 'CDF' }, // Congolese Franc + { value: 'CHF', label: 'CHF' }, // Swiss Franc + { value: 'CLP', label: 'CLP' }, // Chilean Peso + { value: 'CNY', label: 'CNY' }, // Chinese Yuan + { value: 'COP', label: 'COP' }, // Colombian Peso + { value: 'CRC', label: 'CRC' }, // Costa Rican Colón + { value: 'CUP', label: 'CUP' }, // Cuban Peso + { value: 'CVE', label: 'CVE' }, // Cape Verdean Escudo + { value: 'CZK', label: 'CZK' }, // Czech Koruna + { value: 'DJF', label: 'DJF' }, // Djiboutian Franc + { value: 'DKK', label: 'DKK' }, // Danish Krone + { value: 'DOP', label: 'DOP' }, // Dominican Peso + { value: 'DZD', label: 'DZD' }, // Algerian Dinar + { value: 'EGP', label: 'EGP' }, // Egyptian Pound + { value: 'ERN', label: 'ERN' }, // Eritrean Nakfa + { value: 'ETB', label: 'ETB' }, // Ethiopian Birr + { value: 'EUR', label: 'EUR' }, // Euro + { value: 'FJD', label: 'FJD' }, // Fijian Dollar + { value: 'FKP', label: 'FKP' }, // Falkland Islands Pound + { value: 'FOK', label: 'FOK' }, // Faroese Krona + { value: 'FRO', label: 'FRO' }, // Fijian Dollar + { value: 'GBP', label: 'GBP' }, // British Pound Sterling + { value: 'GEL', label: 'GEL' }, // Georgian Lari + { value: 'GGP', label: 'GGP' }, // Guernsey Pound + { value: 'GHS', label: 'GHS' }, // Ghanaian Cedi + { value: 'GIP', label: 'GIP' }, // Gibraltar Pound + { value: 'GMD', label: 'GMD' }, // Gambian Dalasi + { value: 'GNF', label: 'GNF' }, // Guinean Franc + { value: 'GTQ', label: 'GTQ' }, // Guatemalan Quetzal + { value: 'GYD', label: 'GYD' }, // Guyanaese Dollar + { value: 'HKD', label: 'HKD' }, // Hong Kong Dollar + { value: 'HNL', label: 'HNL' }, // Honduran Lempira + { value: 'HRK', label: 'HRK' }, // Croatian Kuna + { value: 'HTG', label: 'HTG' }, // Haitian Gourde + { value: 'HUF', label: 'HUF' }, // Hungarian Forint + { value: 'IDR', label: 'IDR' }, // Indonesian Rupiah + { value: 'ILS', label: 'ILS' }, // Israeli New Shekel + { value: 'IMP', label: 'IMP' }, // Manx pound + { value: 'INR', label: 'INR' }, // Indian Rupee + { value: 'IQD', label: 'IQD' }, // Iraqi Dinar + { value: 'IRR', label: 'IRR' }, // Iranian Rial + { value: 'ISK', label: 'ISK' }, // Icelandic Króna + { value: 'JEP', label: 'JEP' }, // Jersey Pound + { value: 'JMD', label: 'JMD' }, // Jamaican Dollar + { value: 'JOD', label: 'JOD' }, // Jordanian Dinar + { value: 'JPY', label: 'JPY' }, // Japanese Yen + { value: 'KES', label: 'KES' }, // Kenyan Shilling + { value: 'KGS', label: 'KGS' }, // Kyrgystani Som + { value: 'KHR', label: 'KHR' }, // Cambodian Riel + { value: 'KID', label: 'KID' }, // Kiribati dollar + { value: 'KMF', label: 'KMF' }, // Comorian Franc + { value: 'KRW', label: 'KRW' }, // South Korean Won + { value: 'KWD', label: 'KWD' }, // Kuwaiti Dinar + { value: 'KYD', label: 'KYD' }, // Cayman Islands Dollar + { value: 'KZT', label: 'KZT' }, // Kazakhstani Tenge + { value: 'LAK', label: 'LAK' }, // Laotian Kip + { value: 'LBP', label: 'LBP' }, // Lebanese Pound + { value: 'LKR', label: 'LKR' }, // Sri Lankan Rupee + { value: 'LRD', label: 'LRD' }, // Liberian Dollar + { value: 'LSL', label: 'LSL' }, // Lesotho Loti + { value: 'LYD', label: 'LYD' }, // Libyan Dinar + { value: 'MAD', label: 'MAD' }, // Moroccan Dirham + { value: 'MDL', label: 'MDL' }, // Moldovan Leu + { value: 'MGA', label: 'MGA' }, // Malagasy Ariary + { value: 'MKD', label: 'MKD' }, // Macedonian Denar + { value: 'MMK', label: 'MMK' }, // Myanma Kyat + { value: 'MNT', label: 'MNT' }, // Mongolian Tugrik + { value: 'MOP', label: 'MOP' }, // Macanese Pataca + { value: 'MRU', label: 'MRU' }, // Mauritanian Ouguiya + { value: 'MUR', label: 'MUR' }, // Mauritian Rupee + { value: 'MVR', label: 'MVR' }, // Maldivian Rufiyaa + { value: 'MWK', label: 'MWK' }, // Malawian Kwacha + { value: 'MXN', label: 'MXN' }, // Mexican Peso + { value: 'MYR', label: 'MYR' }, // Malaysian Ringgit + { value: 'MZN', label: 'MZN' }, // Mozambican Metical + { value: 'NAD', label: 'NAD' }, // Namibian Dollar + { value: 'NGN', label: 'NGN' }, // Nigerian Naira + { value: 'NIO', label: 'NIO' }, // Nicaraguan Córdoba + { value: 'NOK', label: 'NOK' }, // Norwegian Krone + { value: 'NPR', label: 'NPR' }, // Nepalese Rupee + { value: 'NZD', label: 'NZD' }, // New Zealand Dollar + { value: 'OMR', label: 'OMR' }, // Omani Rial + { value: 'PAB', label: 'PAB' }, // Panamanian Balboa + { value: 'PEN', label: 'PEN' }, // Peruvian Nuevo Sol + { value: 'PGK', label: 'PGK' }, // Papua New Guinean Kina + { value: 'PHP', label: 'PHP' }, // Philippine Peso + { value: 'PKR', label: 'PKR' }, // Pakistani Rupee + { value: 'PLN', label: 'PLN' }, // Polish Zloty + { value: 'PYG', label: 'PYG' }, // Paraguayan Guarani + { value: 'QAR', label: 'QAR' }, // Qatari Rial + { value: 'RON', label: 'RON' }, // Romanian Leu + { value: 'RSD', label: 'RSD' }, // Serbian Dinar + { value: 'RUB', label: 'RUB' }, // Russian Ruble + { value: 'RWF', label: 'RWF' }, // Rwandan Franc + { value: 'SAR', label: 'SAR' }, // Saudi Riyal + { value: 'SBD', label: 'SBD' }, // Solomon Islands Dollar + { value: 'SCR', label: 'SCR' }, // Seychellois Rupee + { value: 'SDG', label: 'SDG' }, // Sudanese Pound + { value: 'SEK', label: 'SEK' }, // Swedish Krona + { value: 'SGD', label: 'SGD' }, // Singapore Dollar + { value: 'SHP', label: 'SHP' }, // Saint Helena Pound + { value: 'SLL', label: 'SLL' }, // Sierra Leonean Leone + { value: 'SOS', label: 'SOS' }, // Somali Shilling + { value: 'SPL', label: 'SPL' }, // Seborgan Luigino + { value: 'SRD', label: 'SRD' }, // Surinamese Dollar + { value: 'STN', label: 'STN' }, // São Tomé and Príncipe Dobra + { value: 'SVC', label: 'SVC' }, // Salvadoran Colón + { value: 'SYP', label: 'SYP' }, // Syrian Pound + { value: 'SZL', label: 'SZL' }, // Swazi Lilangeni + { value: 'THB', label: 'THB' }, // Thai Baht + { value: 'TJS', label: 'TJS' }, // Tajikistani Somoni + { value: 'TMT', label: 'TMT' }, // Turkmenistani Manat + { value: 'TND', label: 'TND' }, // Tunisian Dinar + { value: 'TOP', label: 'TOP' }, // Tongan Pa'anga + { value: 'TRY', label: 'TRY' }, // Turkish Lira + { value: 'TTD', label: 'TTD' }, // Trinidad and Tobago Dollar + { value: 'TVD', label: 'TVD' }, // Tuvaluan Dollar + { value: 'TWD', label: 'TWD' }, // New Taiwan Dollar + { value: 'TZS', label: 'TZS' }, // Tanzanian Shilling + { value: 'UAH', label: 'UAH' }, // Ukrainian Hryvnia + { value: 'UGX', label: 'UGX' }, // Ugandan Shilling + { value: 'USD', label: 'USD' }, // United States Dollar + { value: 'UYU', label: 'UYU' }, // Uruguayan Peso + { value: 'UZS', label: 'UZS' }, // Uzbekistan Som + { value: 'VEF', label: 'VEF' }, // Venezuelan Bolívar + { value: 'VND', label: 'VND' }, // Vietnamese Dong + { value: 'VUV', label: 'VUV' }, // Vanuatu Vatu + { value: 'WST', label: 'WST' }, // Samoan Tala + { value: 'XAF', label: 'XAF' }, // CFA Franc BEAC + { value: 'XCD', label: 'XCD' }, // East Caribbean Dollar + { value: 'XDR', label: 'XDR' }, // Special Drawing Rights + { value: 'XOF', label: 'XOF' }, // CFA Franc BCEAO + { value: 'XPF', label: 'XPF' }, // CFP Franc + { value: 'YER', label: 'YER' }, // Yemeni Rial + { value: 'ZAR', label: 'ZAR' }, // South African Rand + { value: 'ZMW', label: 'ZMW' }, // Zambian Kwacha + { value: 'ZWD', label: 'ZWD' }, // Zimbabwean Dollar +]; +export const currencySymbols: { [key: string]: string } = { + AED: 'د.إ', // United Arab Emirates Dirham + AFN: '؋', // Afghan Afghani + ALL: 'L', // Albanian Lek + AMD: '֏', // Armenian Dram + ANG: 'ƒ', // Netherlands Antillean Guilder + AOA: 'Kz', // Angolan Kwanza + ARS: '$', // Argentine Peso + AUD: '$', // Australian Dollar + AWG: 'ƒ', // Aruban Florin + AZN: '₼', // Azerbaijani Manat + BAM: 'КМ', // Bosnia-Herzegovina Convertible Mark + BBD: '$', // Barbadian Dollar + BDT: '৳', // Bangladeshi Taka + BGN: 'лв', // Bulgarian Lev + BHD: '.د.ب', // Bahraini Dinar + BIF: 'FBu', // Burundian Franc + BMD: '$', // Bermudian Dollar + BND: '$', // Brunei Dollar + BOB: 'Bs.', // Bolivian Boliviano + BRL: 'R$', // Brazilian Real + BSD: '$', // Bahamian Dollar + BTN: 'Nu.', // Bhutanese Ngultrum + BWP: 'P', // Botswanan Pula + BYN: 'Br', // Belarusian Ruble + BZD: 'BZ$', // Belize Dollar + CAD: '$', // Canadian Dollar + CDF: 'FC', // Congolese Franc + CHF: 'CHF', // Swiss Franc + CLP: '$', // Chilean Peso + CNY: '¥', // Chinese Yuan + COP: '$', // Colombian Peso + CRC: '₡', // Costa Rican Colón + CUP: '₱', // Cuban Peso + CVE: '$', // Cape Verdean Escudo + CZK: 'Kč', // Czech Koruna + DJF: 'Fdj', // Djiboutian Franc + DKK: 'kr', // Danish Krone + DOP: 'RD$', // Dominican Peso + DZD: 'د.ج', // Algerian Dinar + EGP: 'ج.م', // Egyptian Pound + ERN: 'Nfk', // Eritrean Nakfa + ETB: 'ብር', // Ethiopian Birr + EUR: '€', // Euro + FJD: 'FJ$', // Fijian Dollar + FKP: '£', // Falkland Islands Pound + FOK: 'kr', // Faroese Krona + FRO: 'kr', // Fijian Dollar + GBP: '£', // British Pound Sterling + GEL: '₾', // Georgian Lari + GGP: '£', // Guernsey Pound + GHS: '₵', // Ghanaian Cedi + GIP: '£', // Gibraltar Pound + GMD: 'D', // Gambian Dalasi + GNF: 'FG', // Guinean Franc + GTQ: 'Q', // Guatemalan Quetzal + GYD: '$', // Guyanaese Dollar + HKD: '$', // Hong Kong Dollar + HNL: 'L', // Honduran Lempira + HRK: 'kn', // Croatian Kuna + HTG: 'G', // Haitian Gourde + HUF: 'Ft', // Hungarian Forint + IDR: 'Rp', // Indonesian Rupiah + ILS: '₪', // Israeli New Shekel + IMP: '£', // Manx pound + INR: '₹', // Indian Rupee + IQD: 'د.ع', // Iraqi Dinar + IRR: '﷼', // Iranian Rial + ISK: 'kr', // Icelandic Króna + JEP: '£', // Jersey Pound + JMD: 'J$', // Jamaican Dollar + JOD: 'د.ا', // Jordanian Dinar + JPY: '¥', // Japanese Yen + KES: 'KSh', // Kenyan Shilling + KGS: 'с', // Kyrgystani Som + KHR: '៛', // Cambodian Riel + KID: '$', // Kiribati dollar + KMF: 'CF', // Comorian Franc + KRW: '₩', // South Korean Won + KWD: 'د.ك', // Kuwaiti Dinar + KYD: '$', // Cayman Islands Dollar + KZT: '₸', // Kazakhstani Tenge + LAK: '₭', // Laotian Kip + LBP: 'ل.ل', // Lebanese Pound + LKR: 'රු', // Sri Lankan Rupee + LRD: '$', // Liberian Dollar + LSL: 'L', // Lesotho Loti + LYD: 'ل.د', // Libyan Dinar + MAD: 'د.م.', // Moroccan Dirham + MDL: 'L', // Moldovan Leu + MGA: 'Ar', // Malagasy Ariary + MKD: 'ден', // Macedonian Denar + MMK: 'K', // Myanma Kyat + MNT: '₮', // Mongolian Tugrik + MOP: 'MOP$', // Macanese Pataca + MRU: 'UM', // Mauritanian Ouguiya + MUR: '₨', // Mauritian Rupee + MVR: 'ރ.', // Maldivian Rufiyaa + MWK: 'MK', // Malawian Kwacha + MXN: '$', // Mexican Peso + MYR: 'RM', // Malaysian Ringgit + MZN: 'MT', // Mozambican Metical + NAD: '$', // Namibian Dollar + NGN: '₦', // Nigerian Naira + NIO: 'C$', // Nicaraguan Córdoba + NOK: 'kr', // Norwegian Krone + NPR: '₨', // Nepalese Rupee + NZD: '$', // New Zealand Dollar + OMR: 'ر.ع.', // Omani Rial + PAB: 'B/.', // Panamanian Balboa + PEN: 'S/', // Peruvian Nuevo Sol + PGK: 'K', // Papua New Guinean Kina + PHP: '₱', // Philippine Peso + PKR: '₨', // Pakistani Rupee + PLN: 'zł', // Polish Zloty + PYG: '₲', // Paraguayan Guarani + QAR: 'ر.ق', // Qatari Rial + RON: 'lei', // Romanian Leu + RSD: 'дин', // Serbian Dinar + RUB: '₽', // Russian Ruble + RWF: 'RF', // Rwandan Franc + SAR: 'ر.س', // Saudi Riyal + SBD: '$', // Solomon Islands Dollar + SCR: 'SR', // Seychellois Rupee + SDG: 'ج.س.', // Sudanese Pound + SEK: 'kr', // Swedish Krona + SGD: '$', // Singapore Dollar + SHP: '£', // Saint Helena Pound + SLL: 'Le', // Sierra Leonean Leone + SOS: 'Sh', // Somali Shilling + SPL: 'L', // Seborgan Luigino + SRD: '$', // Surinamese Dollar + STN: 'Db', // São Tomé and Príncipe Dobra + SVC: '₡', // Salvadoran Colón + SYP: '£S', // Syrian Pound + SZL: 'E', // Swazi Lilangeni + THB: '฿', // Thai Baht + TJS: 'ЅМ', // Tajikistani Somoni + TMT: 'T', // Turkmenistani Manat + TND: 'د.ت', // Tunisian Dinar + TOP: 'T$', // Tongan Pa'anga + TRY: '₺', // Turkish Lira + TTD: 'TT$', // Trinidad and Tobago Dollar + TVD: '$', // Tuvaluan Dollar + TWD: 'NT$', // New Taiwan Dollar + TZS: 'TSh', // Tanzanian Shilling + UAH: '₴', // Ukrainian Hryvnia + UGX: 'USh', // Ugandan Shilling + USD: '$', // United States Dollar + UYU: '$U', // Uruguayan Peso + UZS: 'UZS', // Uzbekistan Som + VEF: 'Bs.', // Venezuelan Bolívar + VND: '₫', // Vietnamese Dong + VUV: 'VT', // Vanuatu Vatu + WST: 'WS$', // Samoan Tala + XAF: 'FCFA', // CFA Franc BEAC + XCD: 'EC$', // East Caribbean Dollar + XDR: 'SDR', // Special Drawing Rights + XOF: 'CFA', // CFA Franc BCEAO + XPF: 'CFP', // CFP Franc + YER: '﷼', // Yemeni Rial + ZAR: 'R', // South African Rand + ZMW: 'ZK', // Zambian Kwacha + ZWD: 'Z$', // Zimbabwean Dollar +}; diff --git a/src/utils/dateFormatter.test.ts b/src/utils/dateFormatter.test.ts new file mode 100644 index 0000000000..a8e2b4b096 --- /dev/null +++ b/src/utils/dateFormatter.test.ts @@ -0,0 +1,37 @@ +import { formatDate } from './dateFormatter'; + +describe('formatDate', () => { + test('formats date with st suffix', () => { + expect(formatDate('2023-01-01')).toBe('1st Jan 2023'); + expect(formatDate('2023-05-21')).toBe('21st May 2023'); + expect(formatDate('2023-10-31')).toBe('31st Oct 2023'); + }); + + test('formats date with nd suffix', () => { + expect(formatDate('2023-06-02')).toBe('2nd Jun 2023'); + expect(formatDate('2023-09-22')).toBe('22nd Sep 2023'); + }); + + test('formats date with rd suffix', () => { + expect(formatDate('2023-07-03')).toBe('3rd Jul 2023'); + expect(formatDate('2023-08-23')).toBe('23rd Aug 2023'); + }); + + test('formats date with th suffix', () => { + expect(formatDate('2023-02-04')).toBe('4th Feb 2023'); + expect(formatDate('2023-03-11')).toBe('11th Mar 2023'); + expect(formatDate('2023-04-12')).toBe('12th Apr 2023'); + expect(formatDate('2023-05-13')).toBe('13th May 2023'); + expect(formatDate('2023-06-24')).toBe('24th Jun 2023'); + }); + + test('throws error for empty date string', () => { + expect(() => formatDate('')).toThrow('Date string is required'); + }); + + test('throws error for invalid date string', () => { + expect(() => formatDate('invalid-date')).toThrow( + 'Invalid date string provided', + ); + }); +}); diff --git a/src/utils/dateFormatter.ts b/src/utils/dateFormatter.ts new file mode 100644 index 0000000000..92f4abc051 --- /dev/null +++ b/src/utils/dateFormatter.ts @@ -0,0 +1,33 @@ +export function formatDate(dateString: string): string { + if (!dateString) { + throw new Error('Date string is required'); + } + const date = new Date(dateString); + if (isNaN(date.getTime())) { + throw new Error('Invalid date string provided'); + } + const day = date.getDate(); + const year = date.getFullYear(); + + const getSuffix = (day: number): string => { + if (day >= 11 && day <= 13) return 'th'; + const lastDigit = day % 10; + switch (lastDigit) { + case 1: + return 'st'; + case 2: + return 'nd'; + case 3: + return 'rd'; + default: + return 'th'; + } + }; + const suffix = getSuffix(day); + + const monthName = new Intl.DateTimeFormat('en', { month: 'short' }).format( + date, + ); + + return `${day}${suffix} ${monthName} ${year}`; +} diff --git a/src/utils/errorHandler.test.tsx b/src/utils/errorHandler.test.tsx new file mode 100644 index 0000000000..45f46e6389 --- /dev/null +++ b/src/utils/errorHandler.test.tsx @@ -0,0 +1,39 @@ +type TFunction = (key: string, options?: Record) => string; + +import { errorHandler } from './errorHandler'; +import { toast } from 'react-toastify'; + +jest.mock('react-toastify', () => ({ + toast: { + error: jest.fn(), + }, +})); + +describe('Test if errorHandler is working properly', () => { + const t: TFunction = (key: string) => key; + const tErrors: TFunction = (key: string, options?: Record) => + key; + + it('should call toast.error with the correct message if error message is "Failed to fetch"', () => { + const error = new Error('Failed to fetch'); + errorHandler(t, error); + + expect(toast.error).toHaveBeenCalledWith(tErrors('talawaApiUnavailable')); + }); + + it('should call toast.error with the error message if it is not "Failed to fetch"', () => { + const error = new Error('Some other error message'); + errorHandler(t, error); + + expect(toast.error).toHaveBeenCalledWith(error.message); + }); + + it('should call toast.error with the error message if error object is falsy', () => { + const error = null; + errorHandler(t, error); + + expect(toast.error).toHaveBeenCalledWith( + tErrors('unknownError', { msg: error }), + ); + }); +}); diff --git a/src/utils/errorHandler.tsx b/src/utils/errorHandler.tsx new file mode 100644 index 0000000000..b7a22210a8 --- /dev/null +++ b/src/utils/errorHandler.tsx @@ -0,0 +1,24 @@ +type TFunction = (key: string, options?: Record) => string; + +import { toast } from 'react-toastify'; +import i18n from './i18n'; +/** + This function is used to handle api errors in the application. + It takes in the error object and displays the error message to the user. + If the error is due to the Talawa API being unavailable, it displays a custom message. +*/ +export const errorHandler = (a: unknown, error: unknown): void => { + const tErrors: TFunction = i18n.getFixedT(null, 'errors'); + if (error instanceof Error) { + switch (error.message) { + case 'Failed to fetch': + toast.error(tErrors('talawaApiUnavailable') as string); + break; + // Add more cases as needed + default: + toast.error(error.message); + } + } else { + toast.error(tErrors('unknownError', { msg: error }) as string); + } +}; diff --git a/src/utils/fieldTypes.ts b/src/utils/fieldTypes.ts new file mode 100644 index 0000000000..b17fb9833a --- /dev/null +++ b/src/utils/fieldTypes.ts @@ -0,0 +1,3 @@ +const availableFieldTypes = ['String', 'Boolean', 'Date', 'Number']; + +export default availableFieldTypes; diff --git a/src/utils/formEnumFields.ts b/src/utils/formEnumFields.ts new file mode 100644 index 0000000000..03b033f185 --- /dev/null +++ b/src/utils/formEnumFields.ts @@ -0,0 +1,352 @@ +const countryOptions = [ + { value: 'af', label: 'Afghanistan' }, + { value: 'al', label: 'Albania' }, + { value: 'dz', label: 'Algeria' }, + { value: 'ad', label: 'Andorra' }, + { value: 'ao', label: 'Angola' }, + { value: 'ai', label: 'Anguilla' }, + { value: 'ag', label: 'Antigua and Barbuda' }, + { value: 'ar', label: 'Argentina' }, + { value: 'am', label: 'Armenia' }, + { value: 'aw', label: 'Aruba' }, + { value: 'au', label: 'Australia' }, + { value: 'at', label: 'Austria' }, + { value: 'az', label: 'Azerbaijan' }, + { value: 'bs', label: 'Bahamas' }, + { value: 'bh', label: 'Bahrain' }, + { value: 'bd', label: 'Bangladesh' }, + { value: 'bb', label: 'Barbados' }, + { value: 'by', label: 'Belarus' }, + { value: 'be', label: 'Belgium' }, + { value: 'bz', label: 'Belize' }, + { value: 'bj', label: 'Benin' }, + { value: 'bm', label: 'Bermuda' }, + { value: 'bt', label: 'Bhutan' }, + { value: 'bo', label: 'Bolivia' }, + { value: 'ba', label: 'Bosnia and Herzegovina' }, + { value: 'bw', label: 'Botswana' }, + { value: 'br', label: 'Brazil' }, + { value: 'bn', label: 'Brunei' }, + { value: 'bg', label: 'Bulgaria' }, + { value: 'bf', label: 'Burkina Faso' }, + { value: 'bi', label: 'Burundi' }, + { value: 'cv', label: 'Cabo Verde' }, + { value: 'kh', label: 'Cambodia' }, + { value: 'cm', label: 'Cameroon' }, + { value: 'ca', label: 'Canada' }, + { value: 'ky', label: 'Cayman Islands' }, + { value: 'cf', label: 'Central African Republic' }, + { value: 'td', label: 'Chad' }, + { value: 'cl', label: 'Chile' }, + { value: 'cn', label: 'China' }, + { value: 'co', label: 'Colombia' }, + { value: 'km', label: 'Comoros' }, + { value: 'cg', label: 'Congo' }, + { value: 'cr', label: 'Costa Rica' }, + { value: 'hr', label: 'Croatia' }, + { value: 'cu', label: 'Cuba' }, + { value: 'cy', label: 'Cyprus' }, + { value: 'cz', label: 'Czechia' }, + { value: 'dk', label: 'Denmark' }, + { value: 'dj', label: 'Djibouti' }, + { value: 'dm', label: 'Dominica' }, + { value: 'do', label: 'Dominican Republic' }, + { value: 'ec', label: 'Ecuador' }, + { value: 'eg', label: 'Egypt' }, + { value: 'sv', label: 'El Salvador' }, + { value: 'gq', label: 'Equatorial Guinea' }, + { value: 'er', label: 'Eritrea' }, + { value: 'ee', label: 'Estonia' }, + { value: 'et', label: 'Ethiopia' }, + { value: 'fj', label: 'Fiji' }, + { value: 'fi', label: 'Finland' }, + { value: 'fr', label: 'France' }, + { value: 'ga', label: 'Gabon' }, + { value: 'gm', label: 'Gambia' }, + { value: 'ge', label: 'Georgia' }, + { value: 'de', label: 'Germany' }, + { value: 'gh', label: 'Ghana' }, + { value: 'gi', label: 'Gibraltar' }, + { value: 'gr', label: 'Greece' }, + { value: 'gl', label: 'Greenland' }, + { value: 'gd', label: 'Grenada' }, + { value: 'gt', label: 'Guatemala' }, + { value: 'gn', label: 'Guinea' }, + { value: 'gw', label: 'Guinea-Bissau' }, + { value: 'gy', label: 'Guyana' }, + { value: 'ht', label: 'Haiti' }, + { value: 'hn', label: 'Honduras' }, + { value: 'hu', label: 'Hungary' }, + { value: 'is', label: 'Iceland' }, + { value: 'in', label: 'India' }, + { value: 'id', label: 'Indonesia' }, + { value: 'ir', label: 'Iran' }, + { value: 'iq', label: 'Iraq' }, + { value: 'ie', label: 'Ireland' }, + { value: 'il', label: 'Israel' }, + { value: 'it', label: 'Italy' }, + { value: 'jm', label: 'Jamaica' }, + { value: 'jp', label: 'Japan' }, + { value: 'jo', label: 'Jordan' }, + { value: 'kz', label: 'Kazakhstan' }, + { value: 'ke', label: 'Kenya' }, + { value: 'ki', label: 'Kiribati' }, + { value: 'kw', label: 'Kuwait' }, + { value: 'kg', label: 'Kyrgyzstan' }, + { value: 'la', label: 'Laos' }, + { value: 'lv', label: 'Latvia' }, + { value: 'lb', label: 'Lebanon' }, + { value: 'ls', label: 'Lesotho' }, + { value: 'lr', label: 'Liberia' }, + { value: 'ly', label: 'Libya' }, + { value: 'li', label: 'Liechtenstein' }, + { value: 'lt', label: 'Lithuania' }, + { value: 'lu', label: 'Luxembourg' }, + { value: 'mk', label: 'North Macedonia' }, + { value: 'mg', label: 'Madagascar' }, + { value: 'mw', label: 'Malawi' }, + { value: 'my', label: 'Malaysia' }, + { value: 'mv', label: 'Maldives' }, + { value: 'ml', label: 'Mali' }, + { value: 'mt', label: 'Malta' }, + { value: 'mh', label: 'Marshall Islands' }, + { value: 'mr', label: 'Mauritania' }, + { value: 'mu', label: 'Mauritius' }, + { value: 'mx', label: 'Mexico' }, + { value: 'fm', label: 'Micronesia' }, + { value: 'md', label: 'Moldova' }, + { value: 'mc', label: 'Monaco' }, + { value: 'mn', label: 'Mongolia' }, + { value: 'me', label: 'Montenegro' }, + { value: 'ma', label: 'Morocco' }, + { value: 'mz', label: 'Mozambique' }, + { value: 'mm', label: 'Myanmar' }, + { value: 'na', label: 'Namibia' }, + { value: 'nr', label: 'Nauru' }, + { value: 'np', label: 'Nepal' }, + { value: 'nl', label: 'Netherlands' }, + { value: 'nz', label: 'New Zealand' }, + { value: 'ni', label: 'Nicaragua' }, + { value: 'ne', label: 'Niger' }, + { value: 'ng', label: 'Nigeria' }, + { value: 'kp', label: 'North Korea' }, + { value: 'no', label: 'Norway' }, + { value: 'om', label: 'Oman' }, + { value: 'pk', label: 'Pakistan' }, + { value: 'pw', label: 'Palau' }, + { value: 'pa', label: 'Panama' }, + { value: 'pg', label: 'Papua New Guinea' }, + { value: 'py', label: 'Paraguay' }, + { value: 'pe', label: 'Peru' }, + { value: 'ph', label: 'Philippines' }, + { value: 'pl', label: 'Poland' }, + { value: 'pt', label: 'Portugal' }, + { value: 'qa', label: 'Qatar' }, + { value: 'ro', label: 'Romania' }, + { value: 'ru', label: 'Russia' }, + { value: 'rw', label: 'Rwanda' }, + { value: 'lc', label: 'Saint Lucia' }, + { value: 'vc', label: 'Saint Vincent and the Grenadines' }, + { value: 'ws', label: 'Samoa' }, + { value: 'sm', label: 'San Marino' }, + { value: 'st', label: 'Sao Tome and Principe' }, + { value: 'sa', label: 'Saudi Arabia' }, + { value: 'sn', label: 'Senegal' }, + { value: 'rs', label: 'Serbia' }, + { value: 'sc', label: 'Seychelles' }, + { value: 'sl', label: 'Sierra Leone' }, + { value: 'sg', label: 'Singapore' }, + { value: 'sk', label: 'Slovakia' }, + { value: 'si', label: 'Slovenia' }, + { value: 'sb', label: 'Solomon Islands' }, + { value: 'so', label: 'Somalia' }, + { value: 'za', label: 'South Africa' }, + { value: 'kr', label: 'South Korea' }, + { value: 'ss', label: 'South Sudan' }, + { value: 'es', label: 'Spain' }, + { value: 'lk', label: 'Sri Lanka' }, + { value: 'sd', label: 'Sudan' }, + { value: 'sr', label: 'Suriname' }, + { value: 'sz', label: 'Eswatini' }, + { value: 'se', label: 'Sweden' }, + { value: 'ch', label: 'Switzerland' }, + { value: 'sy', label: 'Syria' }, + { value: 'tw', label: 'Taiwan' }, + { value: 'tj', label: 'Tajikistan' }, + { value: 'tz', label: 'Tanzania' }, + { value: 'th', label: 'Thailand' }, + { value: 'tl', label: 'Timor-Leste' }, + { value: 'tg', label: 'Togo' }, + { value: 'to', label: 'Tonga' }, + { value: 'tt', label: 'Trinidad and Tobago' }, + { value: 'tn', label: 'Tunisia' }, + { value: 'tr', label: 'Turkey' }, + { value: 'tm', label: 'Turkmenistan' }, + { value: 'tv', label: 'Tuvalu' }, + { value: 'ug', label: 'Uganda' }, + { value: 'ua', label: 'Ukraine' }, + { value: 'ae', label: 'United Arab Emirates' }, + { value: 'gb', label: 'United Kingdom' }, + { value: 'us', label: 'United States' }, + { value: 'uy', label: 'Uruguay' }, + { value: 'uz', label: 'Uzbekistan' }, + { value: 'vu', label: 'Vanuatu' }, + { value: 'va', label: 'Vatican City' }, + { value: 've', label: 'Venezuela' }, + { value: 'vn', label: 'Vietnam' }, + { value: 'ye', label: 'Yemen' }, + { value: 'zm', label: 'Zambia' }, + { value: 'zw', label: 'Zimbabwe' }, +]; + +const educationGradeEnum = [ + { + value: 'NO_GRADE', + label: 'No-Grade', + }, + { + value: 'PRE_KG', + label: 'Pre-Kg', + }, + { + value: 'KG', + label: 'Kg', + }, + { + value: 'GRADE_1', + label: 'Grade-1', + }, + { + value: 'GRADE_2', + label: 'Grade-2', + }, + { + value: 'GRADE_3', + label: 'Grade-3', + }, + { + value: 'GRADE_4', + label: 'Grade-4', + }, + { + value: 'GRADE_5', + label: 'Grade-5', + }, + { + value: 'GRADE_6', + label: 'Grade-6', + }, + { + value: 'GRADE_7', + label: 'Grade-7', + }, + { + value: 'GRADE_8', + label: 'Grade-8', + }, + { + value: 'GRADE_9', + label: 'Grade-9', + }, + { + value: 'GRADE_10', + label: 'Grade-10', + }, + { + value: 'GRADE_11', + label: 'Grade-11', + }, + { + value: 'GRADE_12', + label: 'Grade-12', + }, + { + value: 'GRADUATE', + label: 'Graduate', + }, +]; + +const maritalStatusEnum = [ + { + value: 'SINGLE', + label: 'Single', + }, + { + value: 'ENGAGED', + label: 'Engaged', + }, + { + value: 'MARRIED', + label: 'Married', + }, + { + value: 'DIVORCED', + label: 'Divorced', + }, + { + value: 'WIDOWED', + label: 'Widowed', + }, + { + value: 'SEPARATED', + label: 'Separated', + }, +]; + +const genderEnum = [ + { + value: 'MALE', + label: 'Male', + }, + { + value: 'FEMALE', + label: 'Female', + }, + { + value: 'OTHER', + label: 'Other', + }, +]; + +const employmentStatusEnum = [ + { + value: 'FULL_TIME', + label: 'Full-Time', + }, + { + value: 'PART_TIME', + label: 'Part-Time', + }, + { + value: 'UNEMPLOYED', + label: 'Unemployed', + }, +]; + +const userRoleEnum = [ + { + value: 'USER', + label: 'User', + }, + { + value: 'ADMIN', + label: 'Admin', + }, + { + value: 'SUPERADMIN', + label: 'Super Admin', + }, + { + value: 'NON_USER', + label: 'Non-User', + }, +]; + +export { + countryOptions, + educationGradeEnum, + maritalStatusEnum, + genderEnum, + employmentStatusEnum, + userRoleEnum, +}; diff --git a/src/utils/getOrganizationId.ts b/src/utils/getOrganizationId.ts new file mode 100644 index 0000000000..0a7c283e39 --- /dev/null +++ b/src/utils/getOrganizationId.ts @@ -0,0 +1,8 @@ +/* istanbul ignore next */ +const getOrganizationId = (url: string): string => { + const id = url?.split('=')[1]; + + return id?.split('#')[0]; +}; + +export default getOrganizationId; diff --git a/src/utils/getRefreshToken.test.ts b/src/utils/getRefreshToken.test.ts new file mode 100644 index 0000000000..58de898a66 --- /dev/null +++ b/src/utils/getRefreshToken.test.ts @@ -0,0 +1,54 @@ +// SKIP_LOCALSTORAGE_CHECK +import { refreshToken } from './getRefreshToken'; + +jest.mock('@apollo/client', () => { + const originalModule = jest.requireActual('@apollo/client'); + + return { + __esModule: true, + ...originalModule, + ApolloClient: jest.fn(() => ({ + mutate: jest.fn(() => + Promise.resolve({ + data: { + refreshToken: { + accessToken: 'newAccessToken', + refreshToken: 'newRefreshToken', + }, + }, + }), + ), + })), + }; +}); + +describe('refreshToken', () => { + // Mock window.location.reload() + const { location } = window; + delete (global.window as any).location; + global.window.location = { ...location, reload: jest.fn() }; + + // Mock localStorage.setItem() and localStorage.clear() + + Storage.prototype.setItem = jest.fn(); + Storage.prototype.clear = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns true when the token is refreshed successfully', async () => { + const result = await refreshToken(); + + expect(localStorage.setItem).toHaveBeenCalledWith( + 'Talawa-admin_token', + JSON.stringify('newAccessToken'), + ); + expect(localStorage.setItem).toHaveBeenCalledWith( + 'Talawa-admin_refreshToken', + JSON.stringify('newRefreshToken'), + ); + expect(result).toBe(true); + expect(window.location.reload).toHaveBeenCalled(); + }); +}); diff --git a/src/utils/getRefreshToken.ts b/src/utils/getRefreshToken.ts new file mode 100644 index 0000000000..5d6f8aa2ce --- /dev/null +++ b/src/utils/getRefreshToken.ts @@ -0,0 +1,35 @@ +import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'; +import { BACKEND_URL } from 'Constant/constant'; +import { REFRESH_TOKEN_MUTATION } from 'GraphQl/Mutations/mutations'; +import useLocalStorage from './useLocalstorage'; + +export async function refreshToken(): Promise { + const client = new ApolloClient({ + link: new HttpLink({ + uri: BACKEND_URL, + }), + cache: new InMemoryCache(), + }); + + const { getItem, setItem } = useLocalStorage(); + + const refreshToken = getItem('refreshToken'); + /* istanbul ignore next */ + try { + const { data } = await client.mutate({ + mutation: REFRESH_TOKEN_MUTATION, + variables: { + refreshToken: refreshToken, + }, + }); + + setItem('token', data.refreshToken.accessToken); + setItem('refreshToken', data.refreshToken.refreshToken); + + window.location.reload(); + return true; + } catch (error) { + console.error('Failed to refresh token', error); + return false; + } +} diff --git a/src/utils/i18n.ts b/src/utils/i18n.ts new file mode 100644 index 0000000000..3aafbd843d --- /dev/null +++ b/src/utils/i18n.ts @@ -0,0 +1,27 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import HttpApi from 'i18next-http-backend'; + +import { languageArray } from './languages'; + +i18n + .use(initReactI18next) + .use(LanguageDetector) + .use(HttpApi) + .init({ + ns: ['translation', 'errors', 'common'], + defaultNS: 'translation', + fallbackLng: 'en', + supportedLngs: languageArray, + detection: { + order: ['cookie', 'htmlTag', 'localStorage', 'path', 'subdomain'], + caches: ['cookie'], + }, + backend: { + loadPath: '/locales/{{lng}}/{{ns}}.json', + }, + // debug: true, + }); + +export default i18n; diff --git a/src/utils/i18nForTest.ts b/src/utils/i18nForTest.ts new file mode 100644 index 0000000000..673b4b0750 --- /dev/null +++ b/src/utils/i18nForTest.ts @@ -0,0 +1,34 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; +import HttpApi from 'i18next-http-backend'; + +import { languageArray } from './languages'; +import translationEnglish from '../../public/locales/en/translation.json'; +import translationCommonEnglish from '../../public/locales/en/common.json'; +import translationErrorEnglish from '../../public/locales/en/errors.json'; + +i18n + .use(LanguageDetector) + .use(HttpApi) + .use(initReactI18next) + .init({ + ns: ['translation', 'errors', 'common'], + defaultNS: 'translation', + fallbackLng: 'en', + supportedLngs: languageArray, + detection: { + order: ['cookie', 'htmlTag', 'localStorage', 'path', 'subdomain'], + caches: ['cookie'], + }, + resources: { + en: { + translation: translationEnglish, + common: translationCommonEnglish, + errors: translationErrorEnglish, + }, + }, + react: { useSuspense: false }, + }); + +export default i18n; diff --git a/src/utils/interfaces.ts b/src/utils/interfaces.ts new file mode 100644 index 0000000000..7ab84a61c1 --- /dev/null +++ b/src/utils/interfaces.ts @@ -0,0 +1,701 @@ +export interface InterfaceUserType { + user: { + firstName: string; + lastName: string; + image: string | null; + email: string; + }; +} + +export interface InterfaceUserInfo { + _id: string; + firstName: string; + lastName: string; + image?: string | null; +} + +// Base interface for common event properties +export interface InterfaceBaseEvent { + _id: string; + title: string; + description: string; + startDate: string; + endDate: string; + location: string; + startTime: string; + endTime: string; + allDay: boolean; + recurring: boolean; +} + +export interface InterfaceActionItemCategoryInfo { + _id: string; + name: string; + isDisabled: boolean; + createdAt: string; + creator: { _id: string; firstName: string; lastName: string }; +} + +export interface InterfaceActionItemCategoryList { + actionItemCategoriesByOrganization: InterfaceActionItemCategoryInfo[]; +} + +export interface InterfaceActionItemInfo { + _id: string; + assigneeType: 'EventVolunteer' | 'EventVolunteerGroup' | 'User'; + assignee: InterfaceEventVolunteerInfo | null; + assigneeGroup: InterfaceVolunteerGroupInfo | null; + assigneeUser: InterfaceUserInfo | null; + assigner: InterfaceUserInfo; + actionItemCategory: { + _id: string; + name: string; + }; + preCompletionNotes: string; + postCompletionNotes: string | null; + assignmentDate: Date; + dueDate: Date; + completionDate: Date | null; + isCompleted: boolean; + event: { + _id: string; + title: string; + } | null; + creator: InterfaceUserInfo; + allottedHours: number | null; +} + +export interface InterfaceActionItemList { + actionItemsByOrganization: InterfaceActionItemInfo[]; +} + +export interface InterfaceMembersList { + organizations: { + _id: string; + members: InterfaceMemberInfo[]; + }[]; +} + +export interface InterfaceMemberInfo { + _id: string; + firstName: string; + lastName: string; + email: string; + image: string; + createdAt: string; + organizationsBlockedBy: { + _id: string; + }[]; +} + +export interface InterfaceOrgConnectionInfoType { + _id: string; + image: string | null; + creator: { + _id: string; + firstName: string; + lastName: string; + }; + name: string; + members: { + _id: string; + }[]; + admins: { + _id: string; + }[]; + createdAt: string; + address: InterfaceAddress; +} +export interface InterfaceOrgConnectionType { + organizationsConnection: InterfaceOrgConnectionInfoType[]; +} + +export interface InterfaceQueryOrganizationsListObject { + _id: string; + image: string | null; + creator: { + firstName: string; + lastName: string; + email: string; + }; + name: string; + description: string; + address: InterfaceAddress; + userRegistrationRequired: boolean; + visibleInSearch: boolean; + members: { + _id: string; + firstName: string; + lastName: string; + email: string; + }[]; + admins: { + _id: string; + firstName: string; + lastName: string; + email: string; + createdAt: string; + }[]; + membershipRequests: { + _id: string; + user: { + firstName: string; + lastName: string; + email: string; + }; + }[]; + blockedUsers: { + _id: string; + firstName: string; + lastName: string; + email: string; + }[]; +} + +export interface InterfaceQueryOrganizationListObject { + _id: string; + image: string | null; + creator: { + firstName: string; + lastName: string; + }; + name: string; + members: { + _id: string; + }[]; + admins: { + _id: string; + }[]; + createdAt: string; + address: InterfaceAddress; +} + +export interface InterfacePostForm { + posttitle: string; + postinfo: string; + postphoto: string | null; + postvideo: string | null; + pinned: boolean; +} +export interface InterfaceQueryOrganizationPostListItem { + posts: { + edges: { + node: { + _id: string; + title: string; + text: string; + imageUrl: string | null; + videoUrl: string | null; + creator: { + _id: string; + firstName: string; + lastName: string; + email: string; + }; + createdAt: string; + likeCount: number; + commentCount: number; + pinned: boolean; + + likedBy: { _id: string }[]; + comments: { + _id: string; + text: string; + creator: { _id: string }; + createdAt: string; + likeCount: number; + likedBy: { _id: string }[]; + }[]; + }; + cursor: string; + }[]; + pageInfo: { + startCursor: string; + endCursor: string; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + totalCount: number; + }; +} + +export interface InterfaceTagData { + _id: string; + name: string; + parentTag: { _id: string }; + usersAssignedTo: { + totalCount: number; + }; + childTags: { + totalCount: number; + }; + ancestorTags: { + _id: string; + name: string; + }[]; +} + +interface InterfaceTagNodeData { + edges: { + node: InterfaceTagData; + cursor: string; + }[]; + pageInfo: { + startCursor: string; + endCursor: string; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + totalCount: number; +} + +interface InterfaceTagMembersData { + edges: { + node: { + _id: string; + firstName: string; + lastName: string; + }; + }[]; + pageInfo: { + startCursor: string; + endCursor: string; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + totalCount: number; +} + +export interface InterfaceQueryOrganizationUserTags { + userTags: InterfaceTagNodeData; +} + +export interface InterfaceQueryUserTagChildTags { + name: string; + childTags: InterfaceTagNodeData; + ancestorTags: { + _id: string; + name: string; + }[]; +} + +export interface InterfaceQueryUserTagsAssignedMembers { + name: string; + usersAssignedTo: InterfaceTagMembersData; + ancestorTags: { + _id: string; + name: string; + }[]; +} + +export interface InterfaceQueryUserTagsMembersToAssignTo { + name: string; + usersToAssignTo: InterfaceTagMembersData; +} + +export interface InterfaceQueryOrganizationAdvertisementListItem { + advertisements: { + edges: { + node: { + _id: string; + name: string; + mediaUrl: string; + endDate: string; + startDate: string; + type: 'BANNER' | 'MENU' | 'POPUP'; + }; + cursor: string; + }[]; + pageInfo: { + startCursor: string; + endCursor: string; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; + totalCount: number; + }; +} + +export interface InterfaceQueryOrganizationFundCampaigns { + name: string; + isArchived: boolean; + campaigns: { + _id: string; + name: string; + fundingGoal: number; + startDate: Date; + endDate: Date; + createdAt: string; + currency: string; + }[]; +} +export interface InterfaceUserCampaign { + _id: string; + name: string; + fundingGoal: number; + startDate: Date; + endDate: Date; + currency: string; +} +export interface InterfaceQueryFundCampaignsPledges { + fundId: { + name: string; + }; + name: string; + fundingGoal: number; + currency: string; + startDate: Date; + endDate: Date; + pledges: InterfacePledgeInfo[]; +} +export interface InterfaceFundInfo { + _id: string; + name: string; + refrenceNumber: string; + taxDeductible: boolean; + isArchived: boolean; + isDefault: boolean; + createdAt: string; + organizationId: string; + creator: { _id: string; firstName: string; lastName: string }; +} +export interface InterfaceCampaignInfo { + _id: string; + name: string; + fundingGoal: number; + startDate: Date; + endDate: Date; + createdAt: string; + currency: string; +} +export interface InterfacePledgeInfo { + _id: string; + campaign?: { _id: string; name: string; endDate: Date }; + amount: number; + currency: string; + endDate: string; + startDate: string; + users: InterfaceUserInfo[]; +} +export interface InterfaceQueryOrganizationEventListItem + extends InterfaceBaseEvent { + isPublic: boolean; + isRegisterable: boolean; +} + +export interface InterfaceQueryBlockPageMemberListItem { + _id: string; + firstName: string; + lastName: string; + email: string; + organizationsBlockedBy: { + _id: string; + }[]; +} + +export interface InterfaceQueryUserListItem { + user: { + _id: string; + firstName: string; + lastName: string; + image: string | null; + email: string; + organizationsBlockedBy: { + _id: string; + name: string; + image: string | null; + address: InterfaceAddress; + creator: { + _id: string; + firstName: string; + lastName: string; + email: string; + image: string | null; + }; + createdAt: string; + }[]; + joinedOrganizations: { + _id: string; + name: string; + address: InterfaceAddress; + image: string | null; + createdAt: string; + creator: { + _id: string; + firstName: string; + lastName: string; + email: string; + image: string | null; + }; + }[]; + createdAt: string; + registeredEvents: { _id: string }[]; + membershipRequests: { _id: string }[]; + }; + appUserProfile: { + _id: string; + adminFor: { _id: string }[]; + isSuperAdmin: boolean; + createdOrganizations: { _id: string }[]; + createdEvents: { _id: string }[]; + eventAdmin: { _id: string }[]; + }; +} + +export interface InterfaceQueryVenueListItem { + _id: string; + name: string; + description: string | null; + image: string | null; + capacity: string; +} + +export interface InterfaceAddress { + city: string; + countryCode: string; + dependentLocality: string; + line1: string; + line2: string; + postalCode: string; + sortingCode: string; + state: string; +} +export interface InterfaceCreateFund { + fundName: string; + fundRef: string; + isDefault: boolean; + isArchived: boolean; + taxDeductible: boolean; +} + +export interface InterfacePostCard { + id: string; + creator: { + firstName: string; + lastName: string; + email: string; + id: string; + }; + postedAt: string; + image: string | null; + video: string | null; + text: string; + title: string; + likeCount: number; + commentCount: number; + comments: { + id: string; + creator: { + id: string; + firstName: string; + lastName: string; + email: string; + }; + likeCount: number; + likedBy: { + id: string; + }[]; + text: string; + }[]; + likedBy: { + firstName: string; + lastName: string; + id: string; + }[]; + fetchPosts: () => void; +} + +export interface InterfaceCreatePledge { + pledgeUsers: InterfaceUserInfo[]; + pledgeAmount: number; + pledgeCurrency: string; + pledgeStartDate: Date; + pledgeEndDate: Date; +} + +export interface InterfaceQueryMembershipRequestsListItem { + organizations: { + _id: string; + membershipRequests: { + _id: string; + user: { + _id: string; + firstName: string; + lastName: string; + email: string; + }; + }[]; + }[]; +} + +export interface InterfaceAgendaItemCategoryInfo { + _id: string; + name: string; + description: string; + createdBy: { + _id: string; + firstName: string; + lastName: string; + }; +} + +export interface InterfaceAgendaItemCategoryList { + agendaItemCategoriesByOrganization: InterfaceAgendaItemCategoryInfo[]; +} + +export interface InterfaceAddOnSpotAttendeeProps { + show: boolean; + handleClose: () => void; + reloadMembers: () => void; +} + +export interface InterfaceFormData { + firstName: string; + lastName: string; + email: string; + phoneNo: string; + gender: string; +} + +export interface InterfaceAgendaItemInfo { + _id: string; + title: string; + description: string; + duration: string; + attachments: string[]; + createdBy: { + _id: string; + firstName: string; + lastName: string; + }; + urls: string[]; + users: { + _id: string; + firstName: string; + lastName: string; + }[]; + sequence: number; + categories: { + _id: string; + name: string; + }[]; + organization: { + _id: string; + name: string; + }; + relatedEvent: { + _id: string; + title: string; + }; +} + +export interface InterfaceAgendaItemList { + agendaItemByEvent: InterfaceAgendaItemInfo[]; +} + +export interface InterfaceMapType { + [key: string]: string; +} + +export interface InterfaceCustomFieldData { + type: string; + name: string; +} + +export interface InterfaceEventVolunteerInfo { + _id: string; + hasAccepted: boolean; + hoursVolunteered: number | null; + user: InterfaceUserInfo; + assignments: { + _id: string; + }[]; + groups: { + _id: string; + name: string; + volunteers: { + _id: string; + }[]; + }[]; +} + +export interface InterfaceVolunteerGroupInfo { + _id: string; + name: string; + description: string | null; + event: { + _id: string; + }; + volunteersRequired: number | null; + createdAt: string; + creator: InterfaceUserInfo; + leader: InterfaceUserInfo; + volunteers: { + _id: string; + user: InterfaceUserInfo; + }[]; + assignments: { + _id: string; + actionItemCategory: { + _id: string; + name: string; + }; + allottedHours: number; + isCompleted: boolean; + }[]; +} + +export interface InterfaceCreateVolunteerGroup { + name: string; + description: string | null; + leader: InterfaceUserInfo | null; + volunteersRequired: number | null; + volunteerUsers: InterfaceUserInfo[]; +} + +export interface InterfaceUserEvents extends InterfaceBaseEvent { + volunteerGroups: { + _id: string; + name: string; + volunteersRequired: number; + description: string; + volunteers: { _id: string }[]; + }[]; + volunteers: { + _id: string; + user: { + _id: string; + }; + }[]; +} + +export interface InterfaceVolunteerMembership { + _id: string; + status: string; + createdAt: string; + event: { + _id: string; + title: string; + startDate: string; + }; + volunteer: { + _id: string; + user: InterfaceUserInfo; + }; + group: { + _id: string; + name: string; + }; +} + +export interface InterfaceVolunteerRank { + rank: number; + hoursVolunteered: number; + user: { + _id: string; + firstName: string; + lastName: string; + email: string; + image: string | null; + }; +} diff --git a/src/utils/languages.ts b/src/utils/languages.ts new file mode 100644 index 0000000000..9e8e019784 --- /dev/null +++ b/src/utils/languages.ts @@ -0,0 +1,31 @@ +const languageArray = ['en', 'fr', 'hi', 'sp', 'zh']; + +const languages = [ + { + code: 'en', + name: 'English', // english + country_code: 'gb', + }, + { + code: 'fr', + name: 'Français', // french + country_code: 'fr', + }, + { + code: 'hi', + name: 'हिन्दी', // hindi + country_code: 'in', + }, + { + code: 'sp', + name: 'Español', // spanish + country_code: 'ar', + }, + { + code: 'zh', + name: '中國人', // chinese (traditional) + country_code: 'cn', + }, +]; + +export { languageArray, languages }; diff --git a/src/utils/linkValid.test.tsx b/src/utils/linkValid.test.tsx new file mode 100644 index 0000000000..0cf8a9785b --- /dev/null +++ b/src/utils/linkValid.test.tsx @@ -0,0 +1,15 @@ +import { isValidLink } from './linkValidator'; + +describe('Testing link validator', () => { + it('returns true for a valid link', () => { + const validLink = 'https://www.example.com'; + const result = isValidLink(validLink); + expect(result).toBe(true); + }); + + it('returns false for an invalid link', () => { + const invalidLink = 'not a valid link'; + const result = isValidLink(invalidLink); + expect(result).toBe(false); + }); +}); diff --git a/src/utils/linkValidator.ts b/src/utils/linkValidator.ts new file mode 100644 index 0000000000..fac4766a3b --- /dev/null +++ b/src/utils/linkValidator.ts @@ -0,0 +1,8 @@ +export const isValidLink = (link: string): boolean => { + try { + new URL(link); + return true; + } catch (error) { + return false; + } +}; diff --git a/src/utils/organizationTagsUtils.ts b/src/utils/organizationTagsUtils.ts new file mode 100644 index 0000000000..56654a3abb --- /dev/null +++ b/src/utils/organizationTagsUtils.ts @@ -0,0 +1,117 @@ +// This file will contain the utililities for organization tags + +import type { ApolloError } from '@apollo/client'; +import type { + InterfaceQueryOrganizationUserTags, + InterfaceQueryUserTagChildTags, + InterfaceQueryUserTagsAssignedMembers, + InterfaceQueryUserTagsMembersToAssignTo, +} from './interfaces'; + +// This is the style object for mui's data grid used to list the data (tags and member data) +export const dataGridStyle = { + '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { + outline: 'none !important', + }, + '&.MuiDataGrid-root .MuiDataGrid-columnHeader:focus-within': { + outline: 'none', + }, + '& .MuiDataGrid-row:hover': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-row.Mui-hovered': { + backgroundColor: 'transparent', + }, + '& .MuiDataGrid-root': { + borderRadius: '0.1rem', + }, + '& .MuiDataGrid-main': { + borderRadius: '0.1rem', + }, + '& .MuiDataGrid-topContainer': { + position: 'fixed', + top: 290, + zIndex: 1, + }, + '& .MuiDataGrid-virtualScrollerContent': { + marginTop: 6.5, + }, +}; + +// the data chunk size for tag related queries +export const TAGS_QUERY_DATA_CHUNK_SIZE = 10; + +// the tag action type +export type TagActionType = 'assignToTags' | 'removeFromTags'; + +// the sortedByType +export type SortedByType = 'ASCENDING' | 'DESCENDING'; + +// Interfaces for tag queries: +// 1. Base interface for Apollo query results +interface InterfaceBaseQueryResult { + loading: boolean; + error?: ApolloError; + refetch?: () => void; +} + +// 2. Generic pagination options +interface InterfacePaginationVariables { + after?: string | null; + first?: number | null; +} + +// 3. Generic fetch more options +interface InterfaceBaseFetchMoreOptions { + variables: InterfacePaginationVariables; + updateQuery?: (prev: T, options: { fetchMoreResult: T }) => T; +} + +// 4. Query interfaces +export interface InterfaceOrganizationTagsQuery + extends InterfaceBaseQueryResult { + data?: { + organizations: InterfaceQueryOrganizationUserTags[]; + }; + fetchMore: ( + options: InterfaceBaseFetchMoreOptions<{ + organizations: InterfaceQueryOrganizationUserTags[]; + }>, + ) => void; +} + +export interface InterfaceOrganizationSubTagsQuery + extends InterfaceBaseQueryResult { + data?: { + getChildTags: InterfaceQueryUserTagChildTags; + }; + fetchMore: ( + options: InterfaceBaseFetchMoreOptions<{ + getChildTags: InterfaceQueryUserTagChildTags; + }>, + ) => void; +} + +export interface InterfaceTagAssignedMembersQuery + extends InterfaceBaseQueryResult { + data?: { + getAssignedUsers: InterfaceQueryUserTagsAssignedMembers; + }; + fetchMore: ( + options: InterfaceBaseFetchMoreOptions<{ + getAssignedUsers: InterfaceQueryUserTagsAssignedMembers; + }>, + ) => void; +} + +export interface InterfaceTagUsersToAssignToQuery + extends InterfaceBaseQueryResult { + data?: { + getUsersToAssignTo: InterfaceQueryUserTagsMembersToAssignTo; + }; + fetchMore: ( + options: InterfaceBaseFetchMoreOptions<{ + getUsersToAssignTo: InterfaceQueryUserTagsMembersToAssignTo; + }>, + ) => void; +} diff --git a/src/utils/recurrenceUtils/index.ts b/src/utils/recurrenceUtils/index.ts new file mode 100644 index 0000000000..9b376fc83d --- /dev/null +++ b/src/utils/recurrenceUtils/index.ts @@ -0,0 +1,3 @@ +export * from './recurrenceTypes'; +export * from './recurrenceConstants'; +export * from './recurrenceUtilityFunctions'; diff --git a/src/utils/recurrenceUtils/recurrenceConstants.ts b/src/utils/recurrenceUtils/recurrenceConstants.ts new file mode 100644 index 0000000000..0b2dd68b4f --- /dev/null +++ b/src/utils/recurrenceUtils/recurrenceConstants.ts @@ -0,0 +1,87 @@ +/* + Recurrence constants +*/ + +import { + Frequency, + RecurrenceEndOption, + RecurringEventMutationType, + WeekDays, +} from './recurrenceTypes'; + +// recurrence frequency mapping +export const frequencies = { + [Frequency.DAILY]: 'Day', + [Frequency.WEEKLY]: 'Week', + [Frequency.MONTHLY]: 'Month', + [Frequency.YEARLY]: 'Year', +}; + +// recurrence days options to select from in the UI +export const daysOptions = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + +// recurrence days array +export const Days = [ + WeekDays.SUNDAY, + WeekDays.MONDAY, + WeekDays.TUESDAY, + WeekDays.WEDNESDAY, + WeekDays.THURSDAY, + WeekDays.FRIDAY, + WeekDays.SATURDAY, +]; + +// constants for recurrence end options +export const endsNever = RecurrenceEndOption.never; +export const endsOn = RecurrenceEndOption.on; +export const endsAfter = RecurrenceEndOption.after; + +// recurrence end options array +export const recurrenceEndOptions = [endsNever, endsOn, endsAfter]; + +// different types of updations / deletions on recurring events +export const thisInstance = RecurringEventMutationType.thisInstance; +export const thisAndFollowingInstances = + RecurringEventMutationType.thisAndFollowingInstances; +export const allInstances = RecurringEventMutationType.allInstances; + +export const recurringEventMutationOptions = [ + thisInstance, + thisAndFollowingInstances, + allInstances, +]; + +// array of week days containing 'MO' to 'FR +export const mondayToFriday = Days.filter( + (day) => day !== WeekDays.SATURDAY && day !== WeekDays.SUNDAY, +); + +// names of week days +export const dayNames = { + [WeekDays.SUNDAY]: 'Sunday', + [WeekDays.MONDAY]: 'Monday', + [WeekDays.TUESDAY]: 'Tuesday', + [WeekDays.WEDNESDAY]: 'Wednesday', + [WeekDays.THURSDAY]: 'Thursday', + [WeekDays.FRIDAY]: 'Friday', + [WeekDays.SATURDAY]: 'Saturday', +}; + +// names of months +export const monthNames = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +// week day's occurences in month +export const weekDayOccurences = ['First', 'Second', 'Third', 'Fourth', 'Last']; diff --git a/src/utils/recurrenceUtils/recurrenceTypes.ts b/src/utils/recurrenceUtils/recurrenceTypes.ts new file mode 100644 index 0000000000..684c98ad87 --- /dev/null +++ b/src/utils/recurrenceUtils/recurrenceTypes.ts @@ -0,0 +1,59 @@ +/* + Recurrence types +*/ + +// interface for the recurrenceRuleStateData that would be sent to the backend +export interface InterfaceRecurrenceRuleState { + recurrenceStartDate: Date; + recurrenceEndDate: Date | null; + frequency: Frequency; + weekDays: WeekDays[]; + interval: number; + count: number | undefined; + weekDayOccurenceInMonth: number | undefined; +} + +// interface for the RecurrenceRule document that would be fetched from the backend +export interface InterfaceRecurrenceRule { + recurrenceStartDate: string; + recurrenceEndDate: string | null; + frequency: Frequency; + weekDays: WeekDays[]; + interval: number; + count: number | null; + weekDayOccurenceInMonth: number | null; +} + +// recurrence frequency +export enum Frequency { + DAILY = 'DAILY', + WEEKLY = 'WEEKLY', + MONTHLY = 'MONTHLY', + YEARLY = 'YEARLY', +} + +// recurrence week days +export enum WeekDays { + SUNDAY = 'SUNDAY', + MONDAY = 'MONDAY', + TUESDAY = 'TUESDAY', + WEDNESDAY = 'WEDNESDAY', + THURSDAY = 'THURSDAY', + FRIDAY = 'FRIDAY', + SATURDAY = 'SATURDAY', +} + +// recurrence end options +// i.e. whether it 'never' ends, ends 'on' a certain date, or 'after' a certain number of occurences +export enum RecurrenceEndOption { + never = 'never', + on = 'on', + after = 'after', +} + +// update / delete options of recurring events +export enum RecurringEventMutationType { + thisInstance = 'thisInstance', + thisAndFollowingInstances = 'thisAndFollowingInstances', + allInstances = 'allInstances', +} diff --git a/src/utils/recurrenceUtils/recurrenceUtilityFunctions.ts b/src/utils/recurrenceUtils/recurrenceUtilityFunctions.ts new file mode 100644 index 0000000000..d316174042 --- /dev/null +++ b/src/utils/recurrenceUtils/recurrenceUtilityFunctions.ts @@ -0,0 +1,252 @@ +/* + Recurrence utility functions +*/ + +import dayjs from 'dayjs'; +import { + Days, + dayNames, + mondayToFriday, + monthNames, + weekDayOccurences, +} from './recurrenceConstants'; +import { Frequency } from './recurrenceTypes'; +import type { + WeekDays, + InterfaceRecurrenceRuleState, + InterfaceRecurrenceRule, +} from './recurrenceTypes'; + +// function that generates the recurrence rule text to display +// e.g. - 'Weekly on Sunday, until Feburary 23, 2029' +export const getRecurrenceRuleText = ( + recurrenceRuleState: InterfaceRecurrenceRuleState, +): string => { + let recurrenceRuleText = ''; + const { + recurrenceStartDate, + recurrenceEndDate, + frequency, + weekDays, + interval, + count, + weekDayOccurenceInMonth, + } = recurrenceRuleState; + + switch (frequency) { + case Frequency.DAILY: + if (interval && interval > 1) { + recurrenceRuleText = `Every ${interval} days`; + } else { + recurrenceRuleText = 'Daily'; + } + break; + + case Frequency.WEEKLY: + if (isMondayToFriday(weekDays)) { + if (interval && interval > 1) { + recurrenceRuleText = `Every ${interval} weeks, `; + } + recurrenceRuleText += 'Monday to Friday'; + break; + } + if (interval && interval > 1) { + recurrenceRuleText = `Every ${interval} weeks on `; + } else { + recurrenceRuleText = 'Weekly on '; + } + recurrenceRuleText += getWeekDaysString(weekDays); + break; + + case Frequency.MONTHLY: + if (interval && interval > 1) { + recurrenceRuleText = `Every ${interval} months on `; + } else { + recurrenceRuleText = 'Monthly on '; + } + + if (weekDayOccurenceInMonth) { + const getOccurence = + weekDayOccurenceInMonth !== -1 ? weekDayOccurenceInMonth - 1 : 4; + recurrenceRuleText += `${weekDayOccurences[getOccurence]} ${dayNames[Days[recurrenceStartDate.getDay()]]}`; + } else { + recurrenceRuleText += `Day ${recurrenceStartDate.getDate()}`; + } + break; + + case Frequency.YEARLY: + if (interval && interval > 1) { + recurrenceRuleText = `Every ${interval} years on `; + } else { + recurrenceRuleText = 'Annually on '; + } + recurrenceRuleText += `${monthNames[recurrenceStartDate.getMonth()]} ${recurrenceStartDate.getDate()}`; + break; + } + + if (recurrenceEndDate) { + const options = { year: 'numeric', month: 'long', day: 'numeric' }; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + recurrenceRuleText += `, until ${recurrenceEndDate.toLocaleDateString('en-US', options)}`; + } + + if (count) { + recurrenceRuleText += `, ${count} ${count > 1 ? 'times' : 'time'}`; + } + + return recurrenceRuleText; +}; + +// function that generates a string of selected week days for the recurrence rule text +// e.g. - for an array ['MONDAY', 'TUESDAY', 'FRIDAY'], it would output: 'Monday, Tuesday & Friday' +const getWeekDaysString = (weekDays: WeekDays[]): string => { + const fullDayNames = weekDays.map((day) => dayNames[day]); + + let weekDaysString = fullDayNames.join(', '); + + const lastCommaIndex = weekDaysString.lastIndexOf(','); + if (lastCommaIndex !== -1) { + weekDaysString = + weekDaysString.substring(0, lastCommaIndex) + + ' &' + + weekDaysString.substring(lastCommaIndex + 1); + } + + return weekDaysString; +}; + +// function that checks if the array contains all days from Monday to Friday +const isMondayToFriday = (weekDays: WeekDays[]): boolean => { + return mondayToFriday.every((day) => weekDays.includes(day)); +}; + +// function that returns the occurence of the weekday in a month, +// i.e. First Monday, Second Monday, Last Monday, etc. +export const getWeekDayOccurenceInMonth = (date: Date): number => { + const dayOfMonth = date.getDate(); + + // Calculate the current occurrence + const occurrence = Math.ceil(dayOfMonth / 7); + + return occurrence; +}; + +// function that checks whether it's the last occurence of the weekday in that month +export const isLastOccurenceOfWeekDay = (date: Date): boolean => { + const currentDay = date.getDay(); + + const lastOccurenceInMonth = new Date( + date.getFullYear(), + date.getMonth() + 1, + 0, + ); + + // set the lastOccurenceInMonth to that day's last occurence + while (lastOccurenceInMonth.getDay() !== currentDay) { + lastOccurenceInMonth.setDate(lastOccurenceInMonth.getDate() - 1); + } + + return date.getDate() === lastOccurenceInMonth.getDate(); +}; + +// function that evaluates whether the startDate or endDate of a recurring event instance have changed +export const haveInstanceDatesChanged = ( + instanceOriginalStartDate: string, + instanceOriginalEndDate: string, + instanceNewStartDate: string, + instanceNewEndDate: string, +): boolean => { + return ( + instanceOriginalStartDate !== instanceNewStartDate || + instanceOriginalEndDate !== instanceNewEndDate + ); +}; + +// function that checks whether the recurrence rule has changed +export const hasRecurrenceRuleChanged = ( + originalRecurrencerule: InterfaceRecurrenceRule | null, + recurrenceRuleState: InterfaceRecurrenceRuleState, +): boolean => { + if (!originalRecurrencerule) { + return false; + } + + const newRecurrenceRule = getRecurrenceRule(recurrenceRuleState); + + const recurrenceProperties = Object.keys( + newRecurrenceRule, + ) as (keyof InterfaceRecurrenceRule)[]; + + for (const recurrenceProperty of recurrenceProperties) { + if (recurrenceProperty === 'weekDays') { + if ( + weekDaysHaveChanged( + originalRecurrencerule.weekDays, + newRecurrenceRule.weekDays, + ) + ) { + return true; + } + } else if ( + originalRecurrencerule[recurrenceProperty] !== + newRecurrenceRule[recurrenceProperty] + ) { + return true; + } + } + + return false; +}; + +// function that returns the recurrence rule object based on the current recurrence rule state +const getRecurrenceRule = ( + recurrenceRuleState: InterfaceRecurrenceRuleState, +): InterfaceRecurrenceRule => { + const { + recurrenceStartDate, + recurrenceEndDate, + frequency, + weekDays, + interval, + count, + weekDayOccurenceInMonth, + } = recurrenceRuleState; + + const originalRecurrencerule = { + recurrenceStartDate: dayjs(recurrenceStartDate).format('YYYY-MM-DD'), + recurrenceEndDate: recurrenceEndDate + ? dayjs(recurrenceEndDate).format('YYYY-MM-DD') + : null, + frequency, + weekDays: weekDays?.length ? weekDays : [], + interval, + count: count ?? null, + weekDayOccurenceInMonth: weekDayOccurenceInMonth ?? null, + }; + + return originalRecurrencerule; +}; + +// function to check whether recurrence weekDays have been changed +const weekDaysHaveChanged = ( + originalWeekDays: WeekDays[], + currentWeekDays: WeekDays[], +): boolean => { + if (originalWeekDays.length !== currentWeekDays.length) { + return true; + } + + // Sort both arrays + const sortedOriginalWeekDays = [...originalWeekDays].sort(); + const sortedCurrentWeekDays = [...currentWeekDays].sort(); + + // Compare arrays + for (let i = 0; i < sortedOriginalWeekDays.length; i++) { + if (sortedOriginalWeekDays[i] !== sortedCurrentWeekDays[i]) { + return true; + } + } + + return false; +}; diff --git a/src/utils/timezoneUtils/dateTimeConfig.ts b/src/utils/timezoneUtils/dateTimeConfig.ts new file mode 100644 index 0000000000..8d352bcde6 --- /dev/null +++ b/src/utils/timezoneUtils/dateTimeConfig.ts @@ -0,0 +1,28 @@ +// dateTimeConfig.ts + +export const dateTimeFields = { + directFields: [ + 'createdAt', + 'birthDate', + 'updatedAt', + 'recurrenceStartDate', + 'recurrenceEndDate', + 'pluginCreatedBy', + 'dueDate', + 'completionDate', + 'startCursor', + 'endCursor', + ], + pairedFields: [ + { + dateField: 'startDate', + timeField: 'startTime', + combinedField: 'startDateTime', + }, + { + dateField: 'endDate', + timeField: 'endTime', + combinedField: 'endDateTime', + }, + ], +}; diff --git a/src/utils/timezoneUtils/dateTimeMiddleware.test.ts b/src/utils/timezoneUtils/dateTimeMiddleware.test.ts new file mode 100644 index 0000000000..b14702b546 --- /dev/null +++ b/src/utils/timezoneUtils/dateTimeMiddleware.test.ts @@ -0,0 +1,226 @@ +import { requestMiddleware, responseMiddleware } from './dateTimeMiddleware'; +import type { Operation, FetchResult } from '@apollo/client/core'; +import { Observable } from '@apollo/client/core'; +import { gql } from '@apollo/client'; +import type { DocumentNode } from 'graphql'; + +const DUMMY_QUERY: DocumentNode = gql` + query GetDummyData { + dummyData { + id + } + } +`; + +describe('Date Time Middleware Tests', () => { + describe('Request Middleware', () => { + it('should convert local date and time to UTC format in request variables', () => { + const operation: Operation = { + query: DUMMY_QUERY, + operationName: 'GetDummyData', + variables: { startDate: '2023-09-01', startTime: '12:00:00' }, + getContext: jest.fn(() => ({})), + setContext: jest.fn(), + extensions: {}, + }; + + const forward = jest.fn( + (op) => + new Observable((observer) => { + expect(op.variables['startDate']).toBe('2023-09-01'); + expect(op.variables['startTime']).toMatch( + /\d{2}:\d{2}:\d{2}.\d{3}Z/, + ); + observer.complete(); + }), + ); + + const observable = requestMiddleware.request(operation, forward); + expect(observable).not.toBeNull(); + observable?.subscribe(() => { + expect(forward).toHaveBeenCalled(); + }); + }); + }); + + describe('Response Middleware', () => { + it('should convert UTC date and time to local format in response data', () => { + const testResponse: FetchResult = { + data: { createdAt: '2023-09-01T12:00:00.000Z' }, + extensions: {}, + context: {}, + }; + + const operation: Operation = { + query: DUMMY_QUERY, + operationName: 'GetDummyData', + variables: {}, + getContext: jest.fn(() => ({})), + setContext: jest.fn(), + extensions: {}, + }; + + const forward = jest.fn( + () => + new Observable((observer) => { + observer.next(testResponse); + observer.complete(); + }), + ); + + const observable = responseMiddleware.request(operation, forward); + expect(observable).not.toBeNull(); + return new Promise((resolve, reject) => { + observable?.subscribe({ + next: (response: FetchResult) => { + if (!response.data) { + reject(new Error('Expected response.data to be defined')); + return; + } + + // Now it's safe to assume response.data exists for the following expectations + expect(response.data['createdAt']).not.toBe( + '2023-09-01T12:00:00.000Z', + ); + resolve(); + }, + error: reject, + }); + }).then(() => { + expect(forward).toHaveBeenCalled(); + }); + }); + }); + + describe('Date Time Middleware Edge Cases', () => { + it('should handle invalid date formats gracefully in request middleware', () => { + const operation: Operation = { + query: DUMMY_QUERY, + operationName: 'GetDummyData', + variables: { startDate: 'not-a-date', startTime: '25:99:99' }, + getContext: jest.fn(() => ({})), + setContext: jest.fn(), + extensions: {}, + }; + + const forward = jest.fn( + (op) => + new Observable((observer) => { + expect(op.variables['startDate']).toBe('not-a-date'); + expect(op.variables['startTime']).toBe('25:99:99'); + observer.complete(); + }), + ); + + const observable = requestMiddleware.request(operation, forward); + expect(observable).not.toBeNull(); + observable?.subscribe(() => { + expect(forward).toHaveBeenCalled(); + }); + }); + + it('should not break when encountering invalid dates in response middleware', () => { + const testResponse: FetchResult = { + data: { createdAt: 'invalid-date-time' }, + extensions: {}, + context: {}, + }; + + const operation: Operation = { + query: DUMMY_QUERY, + operationName: 'GetDummyData', + variables: {}, + getContext: jest.fn(() => ({})), + setContext: jest.fn(), + extensions: {}, + }; + + const forward = jest.fn( + () => + new Observable((observer) => { + observer.next(testResponse); + observer.complete(); + }), + ); + + const observable = responseMiddleware.request(operation, forward); + + expect(observable).not.toBeNull(); + return new Promise((resolve, reject) => { + observable?.subscribe({ + next: (response: FetchResult) => { + // Ensure there's always an assertion + expect(response.data).toBeTruthy(); // This ensures `response.data` is defined and truthy + + if (!response.data) { + // Explicitly throw an error if response.data is null or undefined + reject(new Error('Expected response.data to be defined')); + return; + } + + // Now it's safe to assume response.data exists for the following expectations + expect(response.data['createdAt']).toBe('invalid-date-time'); + resolve(); + }, + error: reject, + }); + }).then(() => { + expect(forward).toHaveBeenCalled(); + }); + }); + }); + + describe('Recursive Date Conversion in Nested Objects', () => { + it('should recursively convert date and time in deeply nested objects in request middleware', () => { + const operation: Operation = { + query: DUMMY_QUERY, + operationName: 'GetDummyData', + variables: { + event: { + startDate: '2023-10-01', + startTime: '08:00:00', + details: { + endDate: '2023-10-01', + endTime: '18:00:00', + additionalInfo: { + createdAt: '2023-10-01T08:00:00.000Z', + }, + }, + }, + }, + getContext: jest.fn(() => ({})), + setContext: jest.fn(), + extensions: {}, + }; + + const forward = jest.fn( + (op) => + new Observable((observer) => { + expect(op.variables.event.startDate).toBe('2023-10-01'); + expect(op.variables.event.startTime).toMatch( + /\d{2}:\d{2}:\d{2}.\d{3}Z/, + ); + expect(op.variables.event.details.endDate).toBe('2023-10-01'); + expect(op.variables.event.details.endTime).toMatch( + /\d{2}:\d{2}:\d{2}.\d{3}Z/, + ); + expect(op.variables.event.details.additionalInfo.createdAt).toMatch( + /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/, + ); + observer.complete(); + }), + ); + + const observable = requestMiddleware.request(operation, forward); + expect(observable).not.toBeNull(); + return new Promise((resolve, reject) => { + observable?.subscribe({ + complete: resolve, + error: reject, + }); + }).then(() => { + expect(forward).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/utils/timezoneUtils/dateTimeMiddleware.ts b/src/utils/timezoneUtils/dateTimeMiddleware.ts new file mode 100644 index 0000000000..70fc20a026 --- /dev/null +++ b/src/utils/timezoneUtils/dateTimeMiddleware.ts @@ -0,0 +1,99 @@ +import { ApolloLink } from '@apollo/client/core'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import { dateTimeFields } from './dateTimeConfig'; + +// Extend dayjs with the necessary plugins +dayjs.extend(utc); +dayjs.extend(timezone); + +const combineDateTime = (date: string, time: string): string => { + return `${date}T${time}`; +}; + +const splitDateTime = (dateTimeStr: string): { date: string; time: string } => { + const dateTime = dayjs.utc(dateTimeStr); + return { + date: dateTime.format('YYYY-MM-DD'), + time: dateTime.format('HH:mm:ss.SSS[Z]'), + }; +}; + +const convertUTCToLocal = (dateStr: string): string => { + if (dayjs(dateStr).isValid()) { + return dayjs.utc(dateStr).local().format('YYYY-MM-DDTHH:mm:ss.SSS'); + } + return dateStr; +}; + +const convertLocalToUTC = (dateStr: string): string => { + if (dayjs(dateStr).isValid()) { + const result = dayjs(dateStr).utc().format('YYYY-MM-DDTHH:mm:ss.SSS[Z]'); + return result; + } + return dateStr; +}; + +const traverseAndConvertDates = ( + obj: Record, + convertFn: (dateStr: string) => string, + splitFn: (dateTimeStr: string) => { date: string; time: string }, +): void => { + if (typeof obj !== 'object' || obj === null) return; + + Object.keys(obj).forEach((key) => { + const value = obj[key]; + + // Handle paired date and time fields + dateTimeFields.pairedFields.forEach(({ dateField, timeField }) => { + if (key === dateField && obj[timeField]) { + const combinedDateTime = combineDateTime( + obj[dateField] as string, + obj[timeField] as string, + ); + const convertedDateTime = convertFn(combinedDateTime); + const { date, time } = splitFn(convertedDateTime); + obj[dateField] = date; // Restore the original date field + obj[timeField] = time; // Restore the original time field + } + }); + + // Convert simple date/time fields + if (dateTimeFields.directFields.includes(key)) { + obj[key] = convertFn(value as string); + } + + if (typeof value === 'object' && value !== null) { + traverseAndConvertDates( + value as Record, + convertFn, + splitFn, + ); // Recursive call for nested objects/arrays + } + }); +}; + +// Request middleware to convert local time to UTC time +export const requestMiddleware = new ApolloLink((operation, forward) => { + traverseAndConvertDates( + operation.variables, + convertLocalToUTC, + splitDateTime, + ); + return forward(operation); +}); + +// Response middleware to convert UTC time to local time +export const responseMiddleware = new ApolloLink((operation, forward) => { + return forward(operation).map((response) => { + if (response.data) { + traverseAndConvertDates( + response.data as Record, + convertUTCToLocal, + splitDateTime, + ); + } + return response; + }); +}); diff --git a/src/utils/timezoneUtils/index.ts b/src/utils/timezoneUtils/index.ts new file mode 100644 index 0000000000..887072b20c --- /dev/null +++ b/src/utils/timezoneUtils/index.ts @@ -0,0 +1,2 @@ +export * from './dateTimeConfig'; +export * from './dateTimeMiddleware'; diff --git a/src/utils/useLocalstorage.test.ts b/src/utils/useLocalstorage.test.ts new file mode 100644 index 0000000000..7483da4da2 --- /dev/null +++ b/src/utils/useLocalstorage.test.ts @@ -0,0 +1,137 @@ +import { + getStorageKey, + getItem, + setItem, + removeItem, + useLocalStorage, +} from './useLocalstorage'; + +describe('Storage Helper Functions', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('generates correct storage key', () => { + const key = 'testKey'; + const prefix = 'TestPrefix'; + const storageKey = getStorageKey(prefix, key); + expect(storageKey).toBe('TestPrefix_testKey'); + }); + + it('gets item from local storage', () => { + const key = 'testKey'; + const prefix = 'TestPrefix'; + const value = 'data'; + localStorage.setItem('TestPrefix_testKey', JSON.stringify(value)); + + const retrievedValue = getItem(prefix, key); + + expect(retrievedValue).toEqual(value); + }); + + it('returns null when getting a non-existent item', () => { + const key = 'nonExistentKey'; + const prefix = 'TestPrefix'; + + const retrievedValue = getItem(prefix, key); + + expect(retrievedValue).toBeNull(); + }); + + it('sets item in local storage', () => { + const key = 'testKey'; + const prefix = 'TestPrefix'; + const value = { some: 'data' }; + + setItem(prefix, key, value); + + const storedData = localStorage.getItem('TestPrefix_testKey'); + const parsedData = storedData ? JSON.parse(storedData) : null; + + expect(parsedData).toEqual(value); + }); + + it('removes item from local storage', () => { + const key = 'testKey'; + const prefix = 'TestPrefix'; + const value = 'data'; + localStorage.setItem('TestPrefix_testKey', value); + + removeItem(prefix, key); + + const retrievedValue = localStorage.getItem('TestPrefix_testKey'); + expect(retrievedValue).toBeNull(); + }); + + it('uses default prefix for useLocalStorage', () => { + const storageHelper = useLocalStorage(); + const key = 'testKey'; + const value = { some: 'data' }; + + storageHelper.setItem(key, value); + + const storedData = localStorage.getItem('Talawa-admin_testKey'); + const parsedData = storedData ? JSON.parse(storedData) : null; + + expect(parsedData).toEqual(value); + }); + + it('uses provided prefix for useLocalStorage', () => { + const customPrefix = 'CustomPrefix'; + const storageHelper = useLocalStorage(customPrefix); + const key = 'testKey'; + const value = { some: 'data' }; + + storageHelper.setItem(key, value); + + const storedData = localStorage.getItem('CustomPrefix_testKey'); + const parsedData = storedData ? JSON.parse(storedData) : null; + + expect(parsedData).toEqual(value); + }); + + it('calls getStorageKey with the correct parameters', () => { + const customPrefix = 'CustomPrefix'; + const storageHelper = useLocalStorage(customPrefix); + const key = 'testKey'; + + const spyGetStorageKey = jest.spyOn(storageHelper, 'getStorageKey'); + storageHelper.getStorageKey(key); + + expect(spyGetStorageKey).toHaveBeenCalledWith(key); + }); + + it('calls getItem with the correct parameters', () => { + const customPrefix = 'CustomPrefix'; + const storageHelper = useLocalStorage(customPrefix); + const key = 'testKey'; + + const spyGetItem = jest.spyOn(storageHelper, 'getItem'); + storageHelper.getItem(key); + + expect(spyGetItem).toHaveBeenCalledWith(key); + }); + + it('calls setItem with the correct parameters', () => { + const customPrefix = 'CustomPrefix'; + const storageHelper = useLocalStorage(customPrefix); + const key = 'testKey'; + const value = 'data'; + + const spySetItem = jest.spyOn(storageHelper, 'setItem'); + storageHelper.setItem(key, value); + + expect(spySetItem).toHaveBeenCalledWith(key, value); + }); + + it('calls removeItem with the correct parameters', () => { + const customPrefix = 'CustomPrefix'; + const storageHelper = useLocalStorage(customPrefix); + const key = 'testKey'; + + const spyRemoveItem = jest.spyOn(storageHelper, 'removeItem'); + storageHelper.removeItem(key); + + expect(spyRemoveItem).toHaveBeenCalledWith(key); + }); +}); diff --git a/src/utils/useLocalstorage.ts b/src/utils/useLocalstorage.ts new file mode 100644 index 0000000000..9650381085 --- /dev/null +++ b/src/utils/useLocalstorage.ts @@ -0,0 +1,72 @@ +/** + * Helper interface for managing localStorage operations. + */ +interface InterfaceStorageHelper { + getItem: (key: string) => any; + setItem: (key: string, value: any) => void; + removeItem: (key: string) => void; + getStorageKey: (key: string) => string; +} + +const PREFIX = 'Talawa-admin'; + +/** + * Generates the prefixed key for storage. + * @param prefix - Prefix to be added to the key, common for all keys. + * @param key - The unique name identifying the value. + * @returns - Prefixed key. + */ +export const getStorageKey = (prefix: string, key: string): string => { + return `${prefix}_${key}`; +}; + +/** + * Retrieves the stored value for the given key from local storage. + * @param prefix - Prefix to be added to the key, common for all keys. + * @param key - The unique name identifying the value. + * @returns - The stored value for the given key from local storage. + */ +export const getItem = (prefix: string, key: string): any => { + const prefixedKey = getStorageKey(prefix, key); + const storedData = localStorage.getItem(prefixedKey); + return storedData ? JSON.parse(storedData) : null; +}; + +/** + * Sets the value for the given key in local storage. + * @param prefix - Prefix to be added to the key, common for all keys. + * @param key - The unique name identifying the value. + * @param value - The value for the key. + */ +export const setItem = (prefix: string, key: string, value: any): void => { + const prefixedKey = getStorageKey(prefix, key); + localStorage.setItem(prefixedKey, JSON.stringify(value)); +}; + +/** + * Removes the value associated with the given key from local storage. + * @param prefix - Prefix to be added to the key, common for all keys. + * @param key - The unique name identifying the value. + */ +export const removeItem = (prefix: string, key: string): void => { + const prefixedKey = getStorageKey(prefix, key); + localStorage.removeItem(prefixedKey); +}; + +/** + * Custom hook for simplified localStorage operations. + * @param prefix - Prefix to be added to the key, common for all keys. Default is 'Talawa-admin'. + * @returns - Functions to getItem, setItem, removeItem, and getStorageKey. + */ +export const useLocalStorage = ( + prefix: string = PREFIX, +): InterfaceStorageHelper => { + return { + getItem: (key: string) => getItem(prefix, key), + setItem: (key: string, value: any) => setItem(prefix, key, value), + removeItem: (key: string) => removeItem(prefix, key), + getStorageKey: (key: string) => getStorageKey(prefix, key), + }; +}; + +export default useLocalStorage; diff --git a/src/utils/useSession.test.tsx b/src/utils/useSession.test.tsx new file mode 100644 index 0000000000..32287ccbb0 --- /dev/null +++ b/src/utils/useSession.test.tsx @@ -0,0 +1,544 @@ +import type { ReactNode } from 'react'; +import React from 'react'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { MockedProvider } from '@apollo/client/testing'; +import { toast } from 'react-toastify'; +import useSession from './useSession'; +import { GET_COMMUNITY_SESSION_TIMEOUT_DATA } from 'GraphQl/Queries/Queries'; +import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations'; +import { errorHandler } from 'utils/errorHandler'; +import { BrowserRouter } from 'react-router-dom'; + +jest.mock('react-toastify', () => ({ + toast: { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('utils/errorHandler', () => ({ + errorHandler: jest.fn(), +})); + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const MOCKS = [ + { + request: { + query: GET_COMMUNITY_SESSION_TIMEOUT_DATA, + }, + result: { + data: { + getCommunityData: { + timeout: 30, + }, + }, + }, + delay: 100, + }, + { + request: { + query: REVOKE_REFRESH_TOKEN, + }, + result: { + data: { + revokeRefreshTokenForUser: true, + }, + }, + }, +]; + +const wait = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +describe('useSession Hook', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(window, 'addEventListener').mockImplementation(jest.fn()); + jest.spyOn(window, 'removeEventListener').mockImplementation(jest.fn()); + Object.defineProperty(global, 'localStorage', { + value: { + clear: jest.fn(), + }, + writable: true, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + test('should handle visibility change to visible', async () => { + jest.useFakeTimers(); + + const { result } = renderHook(() => useSession(), { + wrapper: ({ children }: { children?: ReactNode }) => ( + + {children} + + ), + }); + + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + }); + + act(() => { + result.current.startSession(); + }); + + // Simulate visibility change + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + + act(() => { + jest.advanceTimersByTime(15 * 60 * 1000); + }); + + await waitFor(() => { + expect(window.addEventListener).toHaveBeenCalledWith( + 'mousemove', + expect.any(Function), + ); + expect(window.addEventListener).toHaveBeenCalledWith( + 'keydown', + expect.any(Function), + ); + expect(toast.warning).toHaveBeenCalledWith('sessionWarning'); + }); + + jest.useRealTimers(); + }); + + test('should handle visibility change to hidden and ensure no warning appears in 15 minutes', async () => { + jest.useFakeTimers(); + + const { result } = renderHook(() => useSession(), { + wrapper: ({ children }: { children?: ReactNode }) => ( + + {children} + + ), + }); + + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + writable: true, + }); + + act(() => { + result.current.startSession(); + }); + + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + + act(() => { + jest.advanceTimersByTime(15 * 60 * 1000); + }); + + await waitFor(() => { + expect(window.removeEventListener).toHaveBeenCalledWith( + 'mousemove', + expect.any(Function), + ); + expect(window.removeEventListener).toHaveBeenCalledWith( + 'keydown', + expect.any(Function), + ); + expect(toast.warning).not.toHaveBeenCalled(); + }); + + jest.useRealTimers(); + }); + + test('should register event listeners on startSession', async () => { + const addEventListenerMock = jest.fn(); + const originalWindowAddEventListener = window.addEventListener; + const originalDocumentAddEventListener = document.addEventListener; + + window.addEventListener = addEventListenerMock; + document.addEventListener = addEventListenerMock; + + const { result } = renderHook(() => useSession(), { + wrapper: ({ children }: { children?: ReactNode }) => ( + + {children} + + ), + }); + + act(() => { + result.current.startSession(); + }); + + expect(addEventListenerMock).toHaveBeenCalledWith( + 'mousemove', + expect.any(Function), + ); + expect(addEventListenerMock).toHaveBeenCalledWith( + 'keydown', + expect.any(Function), + ); + expect(addEventListenerMock).toHaveBeenCalledWith( + 'visibilitychange', + expect.any(Function), + ); + + window.addEventListener = originalWindowAddEventListener; + document.addEventListener = originalDocumentAddEventListener; + }); + + test('should call handleLogout after session timeout', async () => { + jest.useFakeTimers(); + + const { result } = renderHook(() => useSession(), { + wrapper: ({ children }: { children?: ReactNode }) => ( + + {children} + + ), + }); + + act(() => { + result.current.startSession(); + }); + + act(() => { + jest.advanceTimersByTime(31 * 60 * 1000); + }); + + await waitFor(() => { + expect(global.localStorage.clear).toHaveBeenCalled(); + expect(toast.warning).toHaveBeenCalledTimes(2); + expect(toast.warning).toHaveBeenNthCalledWith(1, 'sessionWarning'); + expect(toast.warning).toHaveBeenNthCalledWith(2, 'sessionLogout', { + autoClose: false, + }); + }); + }); + + test('should show a warning toast before session expiration', async () => { + jest.useFakeTimers(); + + const { result } = renderHook(() => useSession(), { + wrapper: ({ children }: { children?: ReactNode }) => ( + + {children} + + ), + }); + + act(() => { + result.current.startSession(); + }); + + act(() => { + jest.advanceTimersByTime(15 * 60 * 1000); + }); + + await waitFor(() => + expect(toast.warning).toHaveBeenCalledWith('sessionWarning'), + ); + + jest.useRealTimers(); + }); + + test('should handle error when revoking token fails', async () => { + const consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(); + + const errorMocks = [ + { + request: { + query: GET_COMMUNITY_SESSION_TIMEOUT_DATA, + }, + result: { + data: { + getCommunityData: { + timeout: 30, + }, + }, + }, + delay: 1000, + }, + { + request: { + query: REVOKE_REFRESH_TOKEN, + }, + error: new Error('Failed to revoke refresh token'), + }, + ]; + + const { result } = renderHook(() => useSession(), { + wrapper: ({ children }: { children?: ReactNode }) => ( + + {children} + + ), + }); + + act(() => { + result.current.startSession(); + result.current.handleLogout(); + }); + + await waitFor(() => + expect(consoleErrorMock).toHaveBeenCalledWith( + 'Error revoking refresh token:', + expect.any(Error), + ), + ); + + consoleErrorMock.mockRestore(); + }); + + test('should set session timeout based on fetched data', async () => { + jest.spyOn(global, 'setTimeout'); + + const { result } = renderHook(() => useSession(), { + wrapper: ({ children }: { children?: ReactNode }) => ( + + {children} + + ), + }); + + act(() => { + result.current.startSession(); + }); + + expect(global.setTimeout).toHaveBeenCalled(); + }); + + test('should call errorHandler on query error', async () => { + const errorMocks = [ + { + request: { + query: GET_COMMUNITY_SESSION_TIMEOUT_DATA, + }, + error: new Error('An error occurred'), + }, + ]; + + const { result } = renderHook(() => useSession(), { + wrapper: ({ children }: { children?: ReactNode }) => ( + + {children} + + ), + }); + + act(() => { + result.current.startSession(); + }); + + await waitFor(() => expect(errorHandler).toHaveBeenCalled()); + }); + //dfghjkjhgfds + + test('should remove event listeners on endSession', async () => { + const { result } = renderHook(() => useSession(), { + wrapper: ({ children }: { children?: ReactNode }) => ( + + {children} + + ), + }); + + // Mock the removeEventListener functions for both window and document + const removeEventListenerMock = jest.fn(); + + // Temporarily replace the real methods with the mock + const originalWindowRemoveEventListener = window.removeEventListener; + const originalDocumentRemoveEventListener = document.removeEventListener; + + window.removeEventListener = removeEventListenerMock; + document.removeEventListener = removeEventListenerMock; + + // await waitForNextUpdate(); + + act(() => { + result.current.startSession(); + }); + + act(() => { + result.current.endSession(); + }); + + // Test that event listeners were removed + expect(removeEventListenerMock).toHaveBeenCalledWith( + 'mousemove', + expect.any(Function), + ); + expect(removeEventListenerMock).toHaveBeenCalledWith( + 'keydown', + expect.any(Function), + ); + expect(removeEventListenerMock).toHaveBeenCalledWith( + 'visibilitychange', + expect.any(Function), + ); + + // Restore the original removeEventListener functions + window.removeEventListener = originalWindowRemoveEventListener; + document.removeEventListener = originalDocumentRemoveEventListener; + }); + + test('should call initialize timers when session is still active when the user returns to the tab', async () => { + jest.useFakeTimers(); + jest.spyOn(global, 'setTimeout').mockImplementation(jest.fn()); + + const { result } = renderHook(() => useSession(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + jest.advanceTimersByTime(1000); + + // Set initial visibility state to visible + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + }); + + // Start the session + act(() => { + result.current.startSession(); + jest.advanceTimersByTime(10 * 60 * 1000); // Fast-forward + }); + + // Simulate the user leaving the tab (set visibility to hidden) + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + writable: true, + }); + + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + + // Fast-forward time by more than the session timeout + act(() => { + jest.advanceTimersByTime(5 * 60 * 1000); // Fast-forward + }); + + // Simulate the user returning to the tab + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + }); + + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + + jest.advanceTimersByTime(1000); + + expect(global.setTimeout).toHaveBeenCalled(); + + // Restore real timers + jest.useRealTimers(); + }); + + test('should call handleLogout when session expires due to inactivity away from tab', async () => { + jest.useFakeTimers(); // Use fake timers to control time + + const { result } = renderHook(() => useSession(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + jest.advanceTimersByTime(1000); + + // Set initial visibility state to visible + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + }); + + // Start the session + act(() => { + result.current.startSession(); + jest.advanceTimersByTime(10 * 60 * 1000); // Fast-forward + }); + + // Simulate the user leaving the tab (set visibility to hidden) + Object.defineProperty(document, 'visibilityState', { + value: 'hidden', + writable: true, + }); + + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + + // Fast-forward time by more than the session timeout + act(() => { + jest.advanceTimersByTime(32 * 60 * 1000); // Fast-forward by 32 minutes + }); + + // Simulate the user returning to the tab + Object.defineProperty(document, 'visibilityState', { + value: 'visible', + writable: true, + }); + + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + + jest.advanceTimersByTime(250); + + await waitFor(() => { + expect(global.localStorage.clear).toHaveBeenCalled(); + expect(toast.warning).toHaveBeenCalledWith('sessionLogout', { + autoClose: false, + }); + }); + + // Restore real timers + jest.useRealTimers(); + }); + + test('should handle logout and revoke token', async () => { + jest.useFakeTimers(); + + const { result } = renderHook(() => useSession(), { + wrapper: ({ children }: { children?: ReactNode }) => ( + + {children} + + ), + }); + + act(() => { + result.current.startSession(); + result.current.handleLogout(); + }); + + await waitFor(() => { + expect(global.localStorage.clear).toHaveBeenCalled(); + expect(toast.warning).toHaveBeenCalledWith('sessionLogout', { + autoClose: false, + }); + }); + + jest.useRealTimers(); + }); +}); diff --git a/src/utils/useSession.tsx b/src/utils/useSession.tsx new file mode 100644 index 0000000000..4279e7c850 --- /dev/null +++ b/src/utils/useSession.tsx @@ -0,0 +1,164 @@ +import { useMutation, useQuery } from '@apollo/client'; +import { REVOKE_REFRESH_TOKEN } from 'GraphQl/Mutations/mutations'; +import { GET_COMMUNITY_SESSION_TIMEOUT_DATA } from 'GraphQl/Queries/Queries'; +import { t } from 'i18next'; +import { useEffect, useState, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { toast } from 'react-toastify'; +import { errorHandler } from 'utils/errorHandler'; + +type UseSessionReturnType = { + startSession: () => void; + endSession: () => void; + handleLogout: () => void; + extendSession: () => void; //for when logged in already, simply extend session +}; + +/** + * Custom hook for managing user session timeouts in a React application. + * + * This hook handles: + * - Starting and ending the user session. + * - Displaying a warning toast at half of the session timeout duration. + * - Logging the user out and displaying a session expiration toast when the session times out. + * - Automatically resetting the timers when user activity is detected. + * - Pausing session timers when the tab is inactive and resuming them when it becomes active again. + * + * @returns UseSessionReturnType - An object with methods to start and end the session, and to handle logout. + */ +const useSession = (): UseSessionReturnType => { + const { t: tCommon } = useTranslation('common'); + + let startTime: number; + let timeoutDuration: number; + const [sessionTimeout, setSessionTimeout] = useState(30); + // const sessionTimeout = 30; + const sessionTimerRef = useRef(null); + const warningTimerRef = useRef(null); + const navigate = useNavigate(); + + const [revokeRefreshToken] = useMutation(REVOKE_REFRESH_TOKEN); + const { data, error: queryError } = useQuery( + GET_COMMUNITY_SESSION_TIMEOUT_DATA, + ); + + useEffect(() => { + if (queryError) { + errorHandler(t, queryError as Error); + } else { + const sessionTimeoutData = data?.getCommunityData; + if (sessionTimeoutData) { + setSessionTimeout(sessionTimeoutData.timeout); + } + } + }, [data, queryError]); + + const resetTimers = (): void => { + if (sessionTimerRef.current) clearTimeout(sessionTimerRef.current); + if (warningTimerRef.current) clearTimeout(warningTimerRef.current); + }; + + const endSession = (): void => { + resetTimers(); + window.removeEventListener('mousemove', extendSession); + window.removeEventListener('keydown', extendSession); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + + const handleLogout = async (): Promise => { + try { + await revokeRefreshToken(); + } catch (error) { + console.error('Error revoking refresh token:', error); + // toast.error('Failed to revoke session. Please try again.'); + } + localStorage.clear(); + endSession(); + navigate('/'); + toast.warning(tCommon('sessionLogout'), { autoClose: false }); + }; + + const initializeTimers = ( + timeLeft?: number, + warningTimeLeft?: number, + ): void => { + const warningTime = warningTimeLeft ?? sessionTimeout / 2; + const sessionTimeoutInMilliseconds = + (timeLeft || sessionTimeout) * 60 * 1000; + const warningTimeInMilliseconds = warningTime * 60 * 1000; + + timeoutDuration = sessionTimeoutInMilliseconds; + startTime = Date.now(); + + warningTimerRef.current = setTimeout(() => { + toast.warning(tCommon('sessionWarning')); + }, warningTimeInMilliseconds); + + sessionTimerRef.current = setTimeout(async () => { + await handleLogout(); + }, sessionTimeoutInMilliseconds); + }; + + const extendSession = (): void => { + resetTimers(); + initializeTimers(); + }; + + const startSession = (): void => { + resetTimers(); + initializeTimers(); + window.removeEventListener('mousemove', extendSession); + window.removeEventListener('keydown', extendSession); + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('mousemove', extendSession); + window.addEventListener('keydown', extendSession); + document.addEventListener('visibilitychange', handleVisibilityChange); + }; + + const handleVisibilityChange = async (): Promise => { + if (document.visibilityState === 'hidden') { + window.removeEventListener('mousemove', extendSession); + window.removeEventListener('keydown', extendSession); + resetTimers(); // Optionally reset timers to prevent them from running in the background + } else if (document.visibilityState === 'visible') { + window.removeEventListener('mousemove', extendSession); + window.removeEventListener('keydown', extendSession); // Ensure no duplicates + window.addEventListener('mousemove', extendSession); + window.addEventListener('keydown', extendSession); + + // Calculate remaining time now that the tab is active again + const elapsedTime = Date.now() - startTime; + const remainingTime = timeoutDuration - elapsedTime; + + const remainingSessionTime = Math.max(remainingTime, 0); // Ensures the remaining time is non-negative and measured in ms; + + if (remainingSessionTime > 0) { + // Calculate remaining warning time only if session time is positive + const remainingWarningTime = Math.max(remainingSessionTime / 2, 0); + initializeTimers( + remainingSessionTime / 60 / 1000, + remainingWarningTime / 60 / 1000, + ); + } else { + // Handle session expiration immediately if time has run out + await handleLogout(); + } + } + }; + + useEffect(() => { + return () => { + endSession(); + }; + }, []); + + return { + startSession, + endSession, + handleLogout, + extendSession, + }; +}; + +export default useSession; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/talawa-admin-docs/.nojekyll b/talawa-admin-docs/.nojekyll new file mode 100644 index 0000000000..e2ac6616ad --- /dev/null +++ b/talawa-admin-docs/.nojekyll @@ -0,0 +1 @@ +TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. \ No newline at end of file diff --git a/talawa-admin-docs/Dockerfile b/talawa-admin-docs/Dockerfile new file mode 100644 index 0000000000..e69de29bb2 diff --git a/talawa-admin-docs/README.md b/talawa-admin-docs/README.md new file mode 100644 index 0000000000..82dc89754c --- /dev/null +++ b/talawa-admin-docs/README.md @@ -0,0 +1,52 @@ +talawa-admin / [Modules](modules.md) + +# Talawa Admin +💬 Join the community on Slack. The link can be found in the `Talawa` [README.md](https://github.com/PalisadoesFoundation/talawa) file. + +![talawa-logo-lite-200x200](https://github.com/PalisadoesFoundation/talawa-admin/assets/16875803/26291ec5-d3c1-4135-8bc7-80885dff613d) + +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) +[![GitHub stars](https://img.shields.io/github/stars/PalisadoesFoundation/talawa-admin.svg?style=social&label=Star&maxAge=2592000)](https://github.com/PalisadoesFoundation/talawa-admin) +[![GitHub forks](https://img.shields.io/github/forks/PalisadoesFoundation/talawa-admin.svg?style=social&label=Fork&maxAge=2592000)](https://github.com/PalisadoesFoundation/talawa-admin) +[![codecov](https://codecov.io/gh/PalisadoesFoundation/talawa-admin/branch/develop/graph/badge.svg?token=II0R0RREES)](https://codecov.io/gh/PalisadoesFoundation/talawa-admin) + +Talawa is a modular open source project to manage group activities of both non-profit organizations and businesses. + +Core features include: + +1. Membership management +2. Groups management +3. Event registrations +4. Recurring meetings +5. Facilities registrations + +`talawa` is based on the original `quito` code created by the [Palisadoes Foundation][pfd] as part of its annual Calico Challenge program. Calico provides paid summer internships for Jamaican university students to work on selected open source projects. They are mentored by software professionals and receive stipends based on the completion of predefined milestones. Calico was started in 2015. Visit [The Palisadoes Foundation's website](http://www.palisadoes.org/) for more details on its origin and activities. + +# Table of Contents + +\