diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 08e1985ae7..bdde403b77 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -13,6 +13,7 @@ reviews: drafts: false base_branches: - develop + - develop-postgres - main chat: auto_reply: true diff --git a/.eslintrc.json b/.eslintrc.json index 253591edde..b7af4acadc 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -170,6 +170,11 @@ "package-lock.json", "tsconfig.json", "docs/**", - "examples/**" + "examples/**", + "docs/docusaurus.config.ts", + "docs/sidebars.ts", + "docs/src/**", + "docs/blog/**", + "pyproject.toml" ] } diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000000..a6046cda39 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +ignore = E402,E722,E203,F401,W503 +max-line-length = 80 diff --git a/.github/workflows/css_check.py b/.github/workflows/css_check.py deleted file mode 100644 index 4c6aef06d2..0000000000 --- a/.github/workflows/css_check.py +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: UTF-8 -*- -"""Check TypeScript files for CSS violations and embedded CSS.""" - -import argparse -import os -import re -import sys - - -def check_embedded_css(content: str) -> list: - """ - Check for embedded CSS in the content. - - Args: - content: The content of the file to check. - - Returns: - A list of embedded CSS violations found. - """ - embedded_css_pattern = r"#([0-9a-fA-F]{3}){1,2}" # Matches CSS color codes - return re.findall(embedded_css_pattern, content) - - -def check_files( - directory: str, exclude_files: list, exclude_directories: list, allowed_css_patterns: list -) -> tuple: - """ - Check TypeScript files for CSS violations and print correct CSS imports. - - Args: - directory: The directory to check. - exclude_files: List of files to exclude from analysis. - exclude_directories: List of directories to exclude from analysis. - allowed_css_patterns: List of allowed CSS file patterns. - - Returns: - A tuple containing lists of violations, correct CSS imports, and embedded CSS violations. - """ - violations = [] - correct_css_imports = [] - embedded_css_violations = [] - - # Normalize exclude paths - exclude_files = set(os.path.abspath(file) for file in exclude_files) - exclude_directories = set(os.path.abspath(dir) for dir in exclude_directories) - - for root, _, files in os.walk(directory): - # Skip excluded directories - if any(root.startswith(exclude_dir) for exclude_dir in exclude_directories): - continue - - for file in files: - file_path = os.path.abspath(os.path.join(root, file)) - - # Skip excluded files - if file_path in exclude_files: - continue - - # Process TypeScript files - if file.endswith((".ts", ".tsx")) and "test" not in root: - try: - with open(file_path, "r", encoding="utf-8") as f: - content = f.read() - except (IOError, UnicodeDecodeError) as e: - print(f"Error reading file {file_path}: {e}") - continue - - # Check for CSS imports with an improved regex pattern - css_imports = re.findall( - r'import\s+.*?["\'](.*?\.css)["\'];', content - ) - for css_file in css_imports: - # Check if the CSS import matches the allowed patterns - if any(css_file.endswith(pattern) for pattern in allowed_css_patterns): - correct_css_imports.append( - f"Correct CSS import ({css_file}) in {file_path}" - ) - else: - violations.append( - f"Invalid CSS import ({css_file}) in {file_path}" - ) - - # Check for embedded CSS - embedded_css = check_embedded_css(content) - if embedded_css: - embedded_css_violations.append( - f"Embedded CSS found in {file_path}: {', '.join(embedded_css)}" - ) - - return violations, correct_css_imports, embedded_css_violations - - -def main(): - """Run the CSS check script.""" - parser = argparse.ArgumentParser( - description="Check for CSS violations in TypeScript files." - ) - parser.add_argument("--directory", required=True, help="Directory to check.") - parser.add_argument( - "--exclude_files", - nargs="*", - default=[], - help="Specific files to exclude from analysis.", - ) - parser.add_argument( - "--exclude_directories", - nargs="*", - default=[], - help="Directories to exclude from analysis.", - ) - parser.add_argument( - "--allowed_css_patterns", - nargs="*", - default=["app.module.css"], - help="Allowed CSS file patterns.", - ) - args = parser.parse_args() - - violations, correct_css_imports, embedded_css_violations = check_files( - directory=args.directory, - exclude_files=args.exclude_files, - exclude_directories=args.exclude_directories, - allowed_css_patterns=args.allowed_css_patterns, - ) - - if violations: - print("\nCSS Import Violations:") - print("\n".join(violations)) - - if embedded_css_violations: - print("\nEmbedded CSS Violations:") - print("\n".join(embedded_css_violations)) - - if correct_css_imports: - print("\nCorrect CSS Imports:") - print("\n".join(correct_css_imports)) - else: - print("\nNo correct CSS imports found.") - - if violations or embedded_css_violations: - sys.exit(1) # Exit with error code if violations found - else: - print("\nNo CSS violations found.") - sys.exit(0) # Exit with success code - - -if __name__ == "__main__": - main() - diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 20c283c797..a6f0dc0b5f 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -40,19 +40,6 @@ jobs: chmod +x ./.github/workflows/scripts/countline.py ./.github/workflows/scripts/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 - # Run the CSS import check script - - name: Check for CSS violations and print correct imports - run: | - if [ ! -f ./.github/workflows/css_check.py ]; then - echo "Error: CSS check script not found" - exit 1 - fi - chmod +x ./.github/workflows/css_check.py - ./.github/workflows/css_check.py --directory . || { - echo "Error: CSS check failed" - exit 1 - } - - name: Get changed TypeScript files id: changed-files uses: tj-actions/changed-files@v45 @@ -109,6 +96,9 @@ jobs: uses: tj-actions/changed-files@v45 with: files: | + .flake8 + .pydocstyle + pyproject.toml .env* vitest.config.js src/App.tsx @@ -255,27 +245,50 @@ jobs: uses: tj-actions/changed-files@v45 - name: Run Jest Tests - if: steps.changed-files.outputs.only_changed != 'true' + if: steps.changed-files.outputs.any_changed == 'true' env: NODE_V8_COVERAGE: './coverage/jest' run: | npm run test -- --watchAll=false --coverage + - name: Upload Jest Coverage to Codecov + if: steps.changed-files.outputs.any_changed == 'true' + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/jest/lcov.info + flags: jest + fail_ci_if_error: true + - name: Run Vitest Tests - if: steps.changed-files.outputs.only_changed != 'true' + if: steps.changed-files.outputs.any_changed == 'true' env: NODE_V8_COVERAGE: './coverage/vitest' run: | npm run test:vitest:coverage - - name: Merge Coverage Reports - if: steps.changed-files.outputs.only_changed != 'true' + - name: Upload Vitest Coverage to Codecov + if: steps.changed-files.outputs.any_changed == 'true' + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/vitest/lcov.info + flags: vitest + fail_ci_if_error: true + + - name: Merge Jest and Vitest Coverage Reports run: | - mkdir -p coverage - if ! npx lcov-result-merger 'coverage/*/lcov.info' > 'coverage/lcov.info'; then - echo "Failed to merge coverage reports" - exit 1 - fi + mkdir -p ./coverage + npx lcov-result-merger './coverage/*/lcov.info' './coverage/lcov.info' + + - name: Upload Combined Coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + gcov_ignore: 'docs/' + files: ./coverage/lcov.info + flags: combined + fail_ci_if_error: true - name: TypeScript compilation for changed files run: | @@ -285,16 +298,6 @@ jobs: 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 - gcov_ignore: 'docs/' - fail_ci_if_error: false - files: './coverage/lcov.info' - name: '${{env.CODECOV_UNIQUE_NAME}}' - - name: Test acceptable level of code coverage uses: VeryGoodOpenSource/very_good_coverage@v3 with: @@ -462,3 +465,54 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} GITHUB_REPOSITORY: ${{ github.repository }} + + + Python-Compliance: + name: Check Python Code Style + runs-on: ubuntu-latest + needs: [Code-Quality-Checks] + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 + + - name: Cache pip packages + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python3 -m venv venv + source venv/bin/activate + python -m pip install --upgrade pip + pip install -r .github/workflows/requirements.txt + + - name: Run Black Formatter Check + run: | + source venv/bin/activate + black --check . + + - name: Run Flake8 Linter + run: | + source venv/bin/activate + flake8 --docstring-convention google --ignore E402,E722,E203,F401,W503 .github + + - name: Run pydocstyle + run: | + source venv/bin/activate + pydocstyle --convention=google --add-ignore=D415,D205 .github + + - name: Run docstring compliance check + run: | + source venv/bin/activate + python .github/workflows/scripts/check_docstrings.py --directories .github diff --git a/.github/workflows/push-deploy-website.yml b/.github/workflows/push-deploy-website.yml index e03dab9840..5af7c90094 100644 --- a/.github/workflows/push-deploy-website.yml +++ b/.github/workflows/push-deploy-website.yml @@ -24,7 +24,7 @@ jobs: name: Deploy https://docs-admin.talawa.io website runs-on: ubuntu-latest # Run only if the develop-postgres branch and not dependabot - if: ${{ github.actor != 'dependabot[bot]' }} + if: ${{ github.actor != 'dependabot[bot]' && github.event.pull_request.base.ref == 'develop-postgres' }} environment: # This "name" has to be the repos' branch that contains # the current active website. There must be an entry for diff --git a/.github/workflows/requirements.txt b/.github/workflows/requirements.txt new file mode 100644 index 0000000000..d27230d569 --- /dev/null +++ b/.github/workflows/requirements.txt @@ -0,0 +1,14 @@ +############################################################################# +# DO NOT DELETE +############################################################################# +# +# Required for GitHub Action workflow python checks +# +############################################################################# +############################################################################# + +black +pydocstyle +flake8 +flake8-docstrings +docstring_parser diff --git a/.github/workflows/scripts/check_docstrings.py b/.github/workflows/scripts/check_docstrings.py new file mode 100755 index 0000000000..814729765c --- /dev/null +++ b/.github/workflows/scripts/check_docstrings.py @@ -0,0 +1,773 @@ +#!/usr/bin/env python3 +"""Script to check for docstrings.""" + +import os +import re +import sys +import argparse +from collections import namedtuple +from docstring_parser import parse + +Violation = namedtuple("Violation", "line function issue action") + + +def validate_docstring(file_path): + """Validate docstrings in a file for compliance with the Google style guide. + + Args: + file_path (str): Path to the Python file to validate. + + Returns: + list: List of violations found in the file, with details about + the issue and corrective action. + + """ + # Initialize key variables + violations = [] + + # Read the file for processing + try: + with open(file_path, "r", encoding="utf-8") as fh_: + lines_with_hard_returns = fh_.readlines() + + except Exception: + return violations + + # Remove hard returns at the end of each line read + lines = [_.rstrip() for _ in lines_with_hard_returns] + + # Evaluate each line + for line_number, line in enumerate(lines): + + # Identify sections of the file that are functions or methods + if re.match(r"^\s*def ", line): + # Get the function name and its arguments + function = extract_function_arguments(line_number, lines) + + # Ignore test functions in test files + if ignore_function(function, file_path): + continue + + # Skip if there are python decorator exceptions + decorator = function_has_decorator(line_number, lines) + if bool(decorator): + if decorator_in_docstring_exception_list(decorator): + continue + + # Get the docstring + docstring = extract_docstring(function.name, line_number, lines) + if bool(docstring.violations): + # Add the violation to the list + violations.extend(docstring.violations) + + # Evaluate the relationship between the + # declared variables and the docstring + if bool(docstring.fatal) is False: + bad = match_arguments_to_docstring( + function, docstring, line_number + ) + if bool(bad): + violations.extend(bad) + + # Return + return violations + + +def ignore_function(function, file_path): + """Extract the docstring from a list of lines read from a file. + + Args: + function: Function object + file_path: Path to file under test + + Returns: + result: True if function must be ignored + + """ + # Initialize key variables + result = False + ignores = ["test_", "tearDownClass", "setUpClass", "setUp", "tearDown"] + + # Ignore test functions in test files + for ignore in ignores: + if function.name.startswith(ignore) and ("test_" in file_path): + result = True + + # Return + return result + + +def match_arguments_to_docstring(function, docstring, line_number): + """Extract the docstring from a list of lines read from a file. + + Args: + function: Function object + docstring: Docstring object + line_number: Number on which the function resides + + Returns: + result: Violation object list + + """ + # Initialize key variables + violations = [] + bad_argument_function = False + bad_argument_docstring = False + arguments_function = function.arguments + arguments_docstring = [_.arg_name for _ in docstring.parser.params] + + # Violation if the arguments don't match and return + if sorted(arguments_function) != sorted(arguments_docstring): + violations.append( + Violation( + line=line_number + 1, + function=function.name, + issue="""\ +The arguments defined in the docstring don't match those of the function.""", + action="""\ +Adjust your docstring to match the listed function arguments.""", + ) + ) + return violations + + ###################################################################### + # Logic below only works when both the function and doctring have args + ###################################################################### + + # Check whether docstring arguments match function arguments + for argument_function in arguments_function: + # Track whether the argument is defined + # in the docstring parameters + for argument_docstring in arguments_docstring: + if argument_docstring not in arguments_function: + violations.append( + Violation( + line=line_number + 1, + function=function.name, + issue=f"""\ +Argument '{argument_docstring}' defined in the docstring is not \ +an argument in the function""", + action=f"""\ +Remove argument '{argument_docstring}' from the docstring""", + ) + ) + bad_argument_function = True + break + if bad_argument_function: + break + + # We found an error, no need to continue generating violations + if not bad_argument_function: + # Check whether docstring arguments match function arguments + for argument_docstring in arguments_docstring: + # Track whether the argument is defined + # in the function parameters + for argument_function in arguments_function: + if argument_function not in arguments_docstring: + violations.append( + Violation( + line=line_number + 1, + function=function.name, + issue=f"""\ + Argument '{argument_function}' defined in the function is not \ + an argument in the docstring""", + action=f"""\ + Add argument '{argument_function}' to the Docstring""", + ) + ) + bad_argument_docstring = True + break + if bad_argument_docstring: + break + + # Return + return violations + + +def function_has_decorator(start, lines): + """Extract the arguments of a function read from a file. + + Args: + start: Starting line to process + lines: The file as a list of strings split by a new line separator + + Returns: + result: The decorator line + + """ + # Initialize key variable + result = None + + # Return + if start > 0: + previous_line = lines[start - 1].strip() + if previous_line.startswith("@"): + result = previous_line + return result + + +def decorator_in_docstring_exception_list(item): + """Extract the arguments of a function read from a file. + + Args: + item: Decorator to check + + Returns: + result: True if an exception + + """ + # Initialize key variable + result = False + exceptions = ["@property"] + property_exceptions = ["setter", "getter"] + + # Return + for exception in exceptions: + if exception in item.strip(): + result = True + break + + for exception in property_exceptions: + regex = f"^@[a-zA-Z0-9_]*.{exception}$" + if re.match(regex, item): + result = True + break + + # Return + return result + + +def extract_function_arguments(start, lines): + """Extract the arguments of a function read from a file. + + Args: + start: Starting line to process + lines: List of lines in the file + + Returns: + result: Function object + + """ + # Initialize key variables + func = "" + possibles = lines[start:] + arguments = [] + Function = namedtuple("Function", "name arguments") + method_keywords = ["self", "cls"] + + # Process the function + for line in possibles: + if bool(line) is False: + continue + elif ("'''" not in line) and ('"""' not in line): + func = f"{func}{line.strip()}" + else: + break + + # Get the arguments + items = func.split("(")[1].split(",") + name = func.split()[1].split("(")[0].strip() + for item in items: + result = item.split(")")[0].split("=")[0].strip() + if bool(result): + # Sometimes arguments have colons. We need everything before. + arguments.append(result.split(":")[0].strip()) + + # Fix arguments for methods + for keyword in method_keywords: + if keyword in arguments: + arguments.remove(keyword) + + # Return + result = Function(name=name, arguments=arguments) + return result + + +def extract_docstring(func_name, line_number, lines): + """Extract the docstring from a list of lines read from a file. + + Args: + line_number: Line where the function starts + lines: The file as a list of strings split by a new line separator + func_name: Name of the function for the docstring + + Returns: + result: namedtuple containing the docstring, and status + + """ + # Initialize key variables + violations = [] + parser = None + fatal = False + Docstring = namedtuple( + "Docstring", "violations docstring parser arguments fatal" + ) + docstring = "" + arguments = [] + found_start = False + found_end = False + + # Process Docstring + docstring_start = line_number + while docstring_start < len(lines): + if bool(is_docstring_delimiter(lines[docstring_start])) is False: + docstring_start += 1 + else: + found_start = True + break + + # Identify the start of the Docstring + if bool(found_start) is True: + # Identify the end of the docstring + docstring_end = docstring_start + 1 + while docstring_end < len(lines): + if bool(is_docstring_delimiter(lines[docstring_end])) is False: + docstring_end += 1 + else: + found_end = True + break + + # Check to make sure there are defined arguments + if bool(found_end) is False: + violations.append( + Violation( + line=line_number + 1, + function=func_name, + issue="""\ +Single line docstring without 'Args:' or 'Results:' sections defined.""", + action="""Define the 'Args:' or 'Results:' sections.""", + ) + ) + fatal = True + + # Extract lines within the docstring area + if found_start and found_end: + + # Get the lines of the Docstring, strip hard returns + valid_lines = lines[docstring_start : docstring_end + 1] + + # Convert the docstring lines to a string + docstring = "\n".join(valid_lines) + + # Parse the docstring + try: + parser = parse(docstring) + + except Exception as e: + violations.append( + Violation( + line=docstring_start, + function=func_name, + issue="Docstring parsing error", + action=f"""\ +Ensure the docstring is properly formatted: {e}""", + ) + ) + + # Evaluate Docstring description + docstring_evaluation = evaluate_docstring_description( + func_name, docstring_start, parser + ) + if bool(docstring_evaluation): + violations.extend(docstring_evaluation) + + # Evaluate the Args: section + argument_evaluation = evaluate_docstring_args( + func_name, docstring_start, docstring, parser + ) + if bool(argument_evaluation.violations): + violations.extend(argument_evaluation.violations) + else: + # Update docstring arguments as they are valid + arguments = argument_evaluation.arguments + + # Evaluate the Returns: section + bad_returns = evaluate_docstring_returns( + func_name, docstring_start, docstring, parser + ) + if bool(bad_returns): + violations.extend(bad_returns) + + else: + violations.append( + Violation( + line=docstring_start, + function=func_name, + issue="Unclosed docstring", + action="""\ +Ensure the docstring is properly closed with triple quotes.""", + ) + ) + + else: + violations.append( + Violation( + line=docstring_start, + function=func_name, + issue="Missing docstring", + action="""\ +Add a Google-style docstring to describe this function.""", + ) + ) + + # Return result + result = Docstring( + docstring=docstring, + violations=violations if bool(violations) else None, + parser=parser, + arguments=arguments, + fatal=fatal, + ) + return result + + +def evaluate_docstring_description(func_name, docstring_start, parser): + """Evaluate the Docstring description for validity. + + Args: + func_name: Function name + docstring_start: Line in file on which the docstring starts + parser: Docstring parser + + Returns: + violations: List of Violations objects + + """ + # Initialize key variables + violations = [] + + # Ensure there is an Docstring description + short_description = ( + parser.short_description.strip().replace("'''", "").replace('"""', "") + ) + if bool(short_description) is False: + violations.append( + Violation( + line=docstring_start, + function=func_name, + issue="Docstring doesn't have a valid description", + action="""\ +Add a docstring description to the first line.""", + ) + ) + + if bool(parser.blank_after_short_description) is False: + violations.append( + Violation( + line=docstring_start, + function=func_name, + issue="\ +The Docstring's short description on the first line doesn't \ +have a blank line after it.", + action="""\ +Add the trailing blank line.""", + ) + ) + + return violations + + +def evaluate_docstring_args(func_name, docstring_start, docstring, parser): + """Evaluate the Docstring arguments for validity. + + Args: + func_name: Function name + docstring_start: Line in file on which the docstring starts + docstring: Docstring + parser: Docstring parser + + Returns: + result: DocstringEvaluation object + + """ + # Initialize key variables + DocstringEvaluation = namedtuple( + "DocstringEvaluation", "violations arguments" + ) + violations = [] + arguments = [] + docstring_no_multiple_white_space = " ".join(docstring.split()) + + if "Args: None " in docstring_no_multiple_white_space: + return DocstringEvaluation(violations=violations, arguments=arguments) + else: + # Check for Args section + if "Args:" not in docstring: + violations.append( + Violation( + line=docstring_start, + function=func_name, + issue="Missing 'Args' section", + action="""\ +Add an 'Args:' section listing the arguments this function accepts.""", + ) + ) + else: + # Ensure there is an Args section + if bool(parser.params) is False: + violations.append( + Violation( + line=docstring_start, + function=func_name, + issue="Docstring doesn't have a valid 'Args:' section", + action="""\ +Add an 'Args:' section with values to the function's docstring""", + ) + ) + else: + # Evaluate each argument + for argument in parser.params: + if bool(argument.arg_name) is False: + violations.append( + Violation( + line=docstring_start, + function=func_name, + issue="""\ +Docstring has no 'Args:' section variable name and description.""", + action="""\ +Add an 'Args:' section with a variable name and description to \ +the function's docstring""", + ) + ) + if bool(argument.description) is False: + violations.append( + Violation( + line=docstring_start, + function=func_name, + issue=f"""\ +Docstring 'Args:' section variable '{argument.arg_name}' \ +needs a description.""", + action="Add description to the variable.", + ) + ) + + # Get the valid arguments + if bool(violations) is False: + arguments = [_.arg_name for _ in parser.params] + + # Return + result = DocstringEvaluation(violations=violations, arguments=arguments) + return result + + +def evaluate_docstring_returns(func_name, docstring_start, docstring, parser): + """Determine whether string is docstring start or stop. + + Args: + func_name: Function name + docstring_start: Line in file on which the docstring starts + docstring: Docstring + parser: Docstring parser + + Returns: + violations: list of violations + + """ + # Initialize key variables + violations = [] + docstring_no_multiple_white_space = " ".join(docstring.split()) + + # Check for Returns section + if "Returns:" not in docstring: + violations.append( + Violation( + line=docstring_start, + function=func_name, + issue="Missing 'Returns:' section", + action="""\ +Add a 'Returns:' section describing the return value.""", + ) + ) + elif "Returns: None " not in docstring_no_multiple_white_space: + + # The parser fails if the 'Args:' section is set to None AND there + # is a valid 'Returns:' section + # This is a workaround where we search for 'Returns: VARIABLE: ' + regex = r"^.*\s+Returns: (\S+): ([a-zA-Z0-9_]*).*$" + regex_match = re.match(regex, docstring_no_multiple_white_space) + if bool(parser.params) is False: + if bool(regex_match) is False: + violations.append( + Violation( + line=docstring_start, + function=func_name, + issue="""\ +Docstring has improperly formatted 'Returns:' section""", + action="""\ +Add a correctly formatted 'Returns:' section to the function's docstring""", + ) + ) + else: + if bool(regex_match.group(2)) is False: + violations.append( + Violation( + line=docstring_start, + function=func_name, + issue="""\ +Docstring 'Returns:' section with no description""", + action="""\ +Add a description to the 'Returns:' section to the function's docstring""", + ) + ) + return violations + + # Ensure there is an Returns section + if bool(parser.returns) is False: + violations.append( + Violation( + line=docstring_start, + function=func_name, + issue="Docstring has no 'Returns:' section", + action="""\ +Add a 'Returns:' section to the function's docstring""", + ) + ) + return violations + + # Ensure there is an Returns section value + if bool(parser.returns.type_name) is False: + violations.append( + Violation( + line=docstring_start, + function=func_name, + issue="""\ +Docstring has no 'Returns:' section variable name and description. \ +If the return value is 'None', then use 'None'""", + action="""\ +Add a 'Returns:' section with a variable name and description to \ +the function's docstring""", + ) + ) + + elif bool(parser.returns.description) is False: + violations.append( + Violation( + line=docstring_start, + function=func_name, + issue=f"""\ +Docstring 'Returns:' section variable \ +'{parser.returns.type_name}' needs a description.""", + action="""Add description to the variable.""", + ) + ) + + # Return + return violations + + +def is_docstring_delimiter(line): + """Determine whether string is docstring start or stop. + + Args: + line: String of text + + Returns: + result: True if it's a delimiter + + """ + # Return + result = bool( + line.strip().startswith('"""') or line.strip().startswith("'''") + ) + return result + + +def check_directory(directory, exclude_dirs=None): + """Check all Python files in a directory for docstring compliance. + + Specified directories are excluded. + + Args: + directory (str): Directory to scan. + exclude_dirs (list): List of directories to exclude. + + Returns: + dict: Dictionary of file violations. + """ + # Initialize key variables + all_violations = {} + _exclude_dirs = exclude_dirs if bool(exclude_dirs) else [] + + # Recursive directory search for files + for root, dirs, files in os.walk(directory): + # Skip excluded directories + dirs[:] = [ + d for d in dirs if os.path.join(root, d) not in _exclude_dirs + ] + + # Process files in each directory + for file in files: + if file.endswith(".py"): + # Print start of processing + file_path = os.path.join(root, file) + + # Identify violations in the file + violations = validate_docstring(file_path) + + # Add any found violations + if violations: + all_violations[file_path] = violations + + # Return + return all_violations + + +def main(): + """Start checking the docstrings. + + Args: + None + + Returns: + None + """ + # Header for the help menu of the application + parser = argparse.ArgumentParser( + description="""\ +This script checks specified directories for compliance with the \ +Google Docstring 'Args' and 'Returns' sections.""", + formatter_class=argparse.RawTextHelpFormatter, + ) + + # CLI argument for starting + parser.add_argument( + "--directories", + required=False, + default=".", + nargs="+", + type=str, + help="Directories to scan for docsctring compliant python files.", + ) + args = parser.parse_args() + + # Process the directories + for directory in args.directories: + # Identify violations + violations = check_directory(directory, exclude_dirs=None) + + # Create a message for the violation + if violations: + print("") + for file, issues in sorted(violations.items()): + for issue in issues: + print( + f"""\ +File Docstring Error: {file} +Line : {issue.line} +Function: {issue.function} +Issue: {issue.issue} +Corrective Action: {issue.action} +""" + ) + print( + f"""\ +Follow the online 'Google Python Style Guide' for our docstring expectations. +There are {len(violations)} docstring violations +""" + ) + + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/scripts/code_coverage_disable_check.py b/.github/workflows/scripts/code_coverage_disable_check.py index 1a55c3f720..f47b24fb21 100644 --- a/.github/workflows/scripts/code_coverage_disable_check.py +++ b/.github/workflows/scripts/code_coverage_disable_check.py @@ -8,7 +8,7 @@ This script enforces proper code coverage practices in the project. -NOTE: +Note: This script complies with our python3 coding and documentation standards. It complies with: @@ -27,8 +27,7 @@ def has_code_coverage_disable(file_path): - """ - Check if a TypeScript file contains code coverage disable statements. + """Check if a TypeScript file contains code coverage disable statements. Args: file_path (str): Path to the TypeScript file. @@ -38,10 +37,11 @@ def has_code_coverage_disable(file_path): otherwise. """ code_coverage_disable_pattern = re.compile( - r"""//?\s*istanbul\s+ignore(?:\s+(?:next|-line))?[^\n]*| - /\*\s*istanbul\s+ignore\s+(?:next|-line)\s*\*/""", + r"/\*\s*istanbul\s+ignore" + r".*?\*/|//?\s*istanbul\s+ignore(?:\s+(?:next|-line))?[^\n]*", re.IGNORECASE, ) + try: with open(file_path, "r", encoding="utf-8") as file: content = file.read() @@ -58,8 +58,7 @@ def has_code_coverage_disable(file_path): def check_code_coverage(files_or_dirs): - """ - Check TypeScript files for code coverage disable statements. + """Check TypeScript files for code coverage disable statements. Args: files_or_dirs (list): List of files or directories to check. @@ -88,7 +87,8 @@ def check_code_coverage(files_or_dirs): file_path = os.path.join(root, file_name) if has_code_coverage_disable(file_path): print( - f"""File {file_path} contains code coverage disable statement.""" + f"""\ +File {file_path} contains code coverage disable statement.""" ) code_coverage_found = True elif os.path.isfile(item): @@ -103,7 +103,9 @@ def check_code_coverage(files_or_dirs): ): if has_code_coverage_disable(item): print( - f"""File {item} contains code coverage disable statement. Please remove it and add the appropriate tests.""" + f"""\ +File {item} contains code coverage disable statement. \ +Please remove it and add the appropriate tests.""" ) code_coverage_found = True @@ -113,6 +115,9 @@ def check_code_coverage(files_or_dirs): def arg_parser_resolver(): """Resolve the CLI arguments provided by the user. + Args: + None + Returns: result: Parsed argument object """ @@ -137,8 +142,7 @@ def arg_parser_resolver(): def main(): - """ - Execute the script's main functionality. + """Execute the script's main functionality. This function serves as the entry point for the script. It performs the following tasks: @@ -148,6 +152,12 @@ def main(): 3. Provides informative messages based on the analysis. 4. Exits with an error if code coverage disable statements are found. + Args: + None + + Returns: + None + Raises: SystemExit: If an error occurs during execution. """ diff --git a/.github/workflows/scripts/compare_translations.py b/.github/workflows/scripts/compare_translations.py index ef65b6c52b..e1474d9ad5 100644 --- a/.github/workflows/scripts/compare_translations.py +++ b/.github/workflows/scripts/compare_translations.py @@ -1,12 +1,13 @@ """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: +Attributes: FileTranslation : Named tuple to represent a combination of file and missing translations. @@ -37,7 +38,8 @@ Example: python compare_translations.py -NOTE: + +Note: This script complies with our python3 coding and documentation standards and should be used as a reference guide. It complies with: @@ -47,6 +49,7 @@ 4) Flake8 """ + # standard imports import argparse import json @@ -56,19 +59,21 @@ # Named tuple for file and missing # translations combination -FileTranslation = namedtuple("FileTranslation", - ["file", "missing_translations"]) +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. +def compare_translations( + default_translation, other_translation, default_file, other_file +): + """Compare two translations for missing and/or 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 + other_file (str): The name of the other translation file. Returns: @@ -79,22 +84,27 @@ def compare_translations(default_translation, # 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}'." + 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 + # 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}'." + 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. + """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). + parent_key (str): The base key for recursion to track key hierarchy. Returns: dict: A flattened dictionary with concatenated keys. @@ -104,7 +114,7 @@ def flatten_json(nested_json, parent_key=""): 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)) @@ -114,6 +124,7 @@ def flatten_json(nested_json, parent_key=""): return flat_dict + def load_translation(filepath): """Load translation from a file. @@ -154,7 +165,6 @@ def check_translations(directory): languages = os.listdir(directory) languages.remove("en") # Exclude default language directory - error_found = False for language in languages: @@ -166,7 +176,10 @@ def check_translations(directory): # Compare translations and get detailed error messages errors = compare_translations( - default_translation, other_translation, f"en/{file}", f"{language}/{file}" + default_translation, + other_translation, + f"en/{file}", + f"{language}/{file}", ) if errors: error_found = True @@ -174,7 +187,6 @@ def check_translations(directory): for error in errors: print(f" - {error}") - if error_found: sys.exit(1) # Exit with an error status code else: @@ -183,26 +195,38 @@ def check_translations(directory): def main(): - """ + """Compare translations. - Parse command-line arguments, check for the existence of the specified directory - and call check_translations with the provided or default directory. + Parse command-line arguments, check for the existence of the specified + directory and call check_translations with the provided or default + directory. + + Args: + None + + Returns: + None """ + # Initialize key variables parser = argparse.ArgumentParser( - description="Check and print missing translations for all non-default languages." + 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).", + 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.") + print( + f"Error: The specified directory '{args.directory}' does not exist." + ) sys.exit(1) check_translations(args.directory) diff --git a/.github/workflows/scripts/countline.py b/.github/workflows/scripts/countline.py index d0b03c503f..5c3ee5d117 100755 --- a/.github/workflows/scripts/countline.py +++ b/.github/workflows/scripts/countline.py @@ -10,8 +10,7 @@ This script was created to help improve code quality by encouraging contributors to create reusable code. -NOTE: - +Note: This script complies with our python3 coding and documentation standards and should be used as a reference guide. It complies with: @@ -24,7 +23,6 @@ your pull requests. """ - # Standard imports import os import sys diff --git a/.github/workflows/scripts/eslint_disable_check.py b/.github/workflows/scripts/eslint_disable_check.py index a24a80949e..45ce52b84a 100644 --- a/.github/workflows/scripts/eslint_disable_check.py +++ b/.github/workflows/scripts/eslint_disable_check.py @@ -10,7 +10,7 @@ This script enforces code quality practices in the project. -NOTE: +Note: This script complies with our python3 coding and documentation standards. It complies with: @@ -28,8 +28,7 @@ def has_eslint_disable(file_path): - """ - Check if a TypeScript file contains eslint-disable statements. + """Check if a TypeScript file contains eslint-disable statements. Args: file_path (str): Path to the TypeScript file. @@ -37,9 +36,10 @@ def has_eslint_disable(file_path): Returns: bool: True if eslint-disable statement is found, False otherwise. """ + # Initialize key variables eslint_disable_pattern = re.compile( - r"""\/\/\s*eslint-disable(?:-next-line - |-line)?[^\n]*|\/\*\s*eslint-disable[^\*]*\*\/""", + r"\/\/\s*eslint-disable(?:-next-line" + r"|-line)?[^\n]*|\/\*\s*eslint-disable[^\*]*\*\/", re.IGNORECASE, ) @@ -59,8 +59,7 @@ def has_eslint_disable(file_path): def check_eslint(files_or_directories): - """ - Check TypeScript files for eslint-disable statements. + """Check TypeScript files for eslint-disable statements. Args: files_or_directories (list): List of files or directories to check. @@ -75,7 +74,11 @@ def check_eslint(files_or_directories): # If it's a file, directly check it if item.endswith(".ts") or item.endswith(".tsx"): if has_eslint_disable(item): - print(f"File {item} contains eslint-disable statement. Please remove them and ensure the code adheres to the specified ESLint rules.") + print( + f"""\ +File {item} contains eslint-disable statement. Please remove them and \ +ensure the code adheres to the specified ESLint rules.""" + ) eslint_found = True elif os.path.isdir(item): # If it's a directory, walk through it and check all @@ -99,6 +102,8 @@ def check_eslint(files_or_directories): def arg_parser_resolver(): """Resolve the CLI arguments provided by the user. + Args: None + Returns: result: Parsed argument object """ @@ -123,11 +128,17 @@ def arg_parser_resolver(): def main(): - """ - Execute the script's main functionality. + """Execute the script's main functionality. + + Args: + None + + Returns: + None This function serves as the entry point for the script. It performs the following tasks: + 1. Validates and retrieves the files and directories to check from command line arguments. 2. Recursively checks TypeScript files for eslint-disable statements. diff --git a/.github/workflows/scripts/talawa_admin_md_mdx_format_adjuster.py b/.github/workflows/scripts/talawa_admin_md_mdx_format_adjuster.py deleted file mode 100644 index cd76a30cf6..0000000000 --- a/.github/workflows/scripts/talawa_admin_md_mdx_format_adjuster.py +++ /dev/null @@ -1,96 +0,0 @@ -#!/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"(? 0) { console.info( '\x1b[34m%s\x1b[0m', - '\nInfo: Consider using custom hook functions.', + '\nInfo: Consider using custom hook functions.' ); console.info( - 'Please use the getItem, setItem, and removeItem functions provided by the custom hook useLocalStorage.\n', + 'Please use the getItem, setItem, and removeItem functions provided by the custom hook useLocalStorage.\n' ); process.exit(1); diff --git a/src/App.tsx b/src/App.tsx index fbb394bc9d..a12f3e7ceb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -38,7 +38,7 @@ 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 Chat from 'screens/UserPortal/Chat/Chat'; import { useQuery } from '@apollo/client'; import { CHECK_AUTH } from 'GraphQl/Queries/Queries'; import Advertisements from 'components/Advertisements/Advertisements'; @@ -191,8 +191,8 @@ function app(): JSX.Element { }> } /> } /> - {/* } /> */} }> + } /> } /> } /> } /> diff --git a/src/components/EventCalendar/EventHeader.spec.tsx b/src/components/EventCalendar/EventHeader.spec.tsx index be1ba4bd78..84b8ceafec 100644 --- a/src/components/EventCalendar/EventHeader.spec.tsx +++ b/src/components/EventCalendar/EventHeader.spec.tsx @@ -69,7 +69,7 @@ describe('EventHeader Component', () => { fireEvent.click(getByTestId('eventType')); await act(async () => { - fireEvent.click(getByTestId('events')); + fireEvent.click(getByTestId('Events')); }); expect(handleChangeView).toHaveBeenCalledTimes(1); diff --git a/src/components/EventCalendar/EventHeader.tsx b/src/components/EventCalendar/EventHeader.tsx index d338de3b82..9201e8b696 100644 --- a/src/components/EventCalendar/EventHeader.tsx +++ b/src/components/EventCalendar/EventHeader.tsx @@ -1,9 +1,10 @@ import React, { useState } from 'react'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import { Search } from '@mui/icons-material'; import styles from '../../style/app.module.css'; import { ViewType } from '../../screens/OrganizationEvents/OrganizationEvents'; import { useTranslation } from 'react-i18next'; +import SortingButton from 'subComponents/SortingButton'; /** * Props for the EventHeader component. @@ -63,58 +64,30 @@ function eventHeader({
-
- - - {viewType} - - - - {ViewType.MONTH} - - - {ViewType.DAY} - - - {ViewType.YEAR} - - - -
-
- - - {t('eventType')} - - - - Events - - - Workshops - - - -
+ + console.log(`Selected: ${value}`)} + dataTestIdPrefix="eventType" + className={styles.dropdown} + buttonLabel={t('eventType')} + /> -
-
-
+
+ +
+
- - - 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') + setFilteringBy(value as FilterPeriod)} + dataTestIdPrefix="filter-dropdown" + className={`${styles.dropdown} mx-4`} + buttonLabel="Filter" + /> + + setSortOrder(value as 'ascending' | 'descending') } - > - Ascending - Descending - + dataTestIdPrefix="sort-dropdown" + buttonLabel="Sort" + />
- {/*

{totalMembers}

*/} diff --git a/src/components/EventStats/Statistics/AverageRating.tsx b/src/components/EventStats/Statistics/AverageRating.tsx index 9f1a157e01..f2e22338ec 100644 --- a/src/components/EventStats/Statistics/AverageRating.tsx +++ b/src/components/EventStats/Statistics/AverageRating.tsx @@ -4,7 +4,7 @@ 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'; - +import styles from '../../../style/app.module.css'; // Props for the AverageRating component type ModalPropType = { data: { @@ -33,7 +33,7 @@ type FeedbackType = { export const AverageRating = ({ data }: ModalPropType): JSX.Element => { return ( <> - +

Average Review Score

@@ -50,13 +50,9 @@ export const AverageRating = ({ data }: ModalPropType): JSX.Element => { icon={} size="medium" emptyIcon={} - sx={{ - '& .MuiRating-iconFilled': { - color: '#ff6d75', // Color for filled stars - }, - '& .MuiRating-iconHover': { - color: '#ff3d47', // Color for star on hover - }, + classes={{ + iconFilled: styles.ratingFilled, + iconHover: styles.ratingHover, }} />
diff --git a/src/components/LeftDrawer/LeftDrawer.spec.tsx b/src/components/LeftDrawer/LeftDrawer.spec.tsx index dc22717e3d..a0aaf9336c 100644 --- a/src/components/LeftDrawer/LeftDrawer.spec.tsx +++ b/src/components/LeftDrawer/LeftDrawer.spec.tsx @@ -220,4 +220,40 @@ describe('Testing Left Drawer component for ADMIN', () => { expect(global.window.location.pathname).toContain('/orglist'); }); + + it('Should set hideDrawer to false when initially null', async () => { + const mockSetHideDrawer = vi.fn(); + await act(async () => { + render( + + + + + + + , + ); + }); + expect(mockSetHideDrawer).toHaveBeenCalledWith(false); + expect(mockSetHideDrawer).toHaveBeenCalledTimes(1); + }); + + it('Should not call setHideDrawer when hideDrawer has a value', async () => { + const mockSetHideDrawer = vi.fn(); + await act(async () => { + render( + + + + + + + , + ); + }); + expect(mockSetHideDrawer).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/LeftDrawer/LeftDrawer.tsx b/src/components/LeftDrawer/LeftDrawer.tsx index eabf9722f8..1ef8192ae1 100644 --- a/src/components/LeftDrawer/LeftDrawer.tsx +++ b/src/components/LeftDrawer/LeftDrawer.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import Button from 'react-bootstrap/Button'; import { useTranslation } from 'react-i18next'; import { NavLink } from 'react-router-dom'; @@ -31,6 +31,12 @@ const leftDrawer = ({ const { getItem } = useLocalStorage(); const superAdmin = getItem('SuperAdmin'); + useEffect(() => { + if (hideDrawer === null) { + setHideDrawer(false); + } + }, []); + /** * Handles link click to hide the drawer on smaller screens. */ diff --git a/src/components/LeftDrawerOrg/LeftDrawerOrg.spec.tsx b/src/components/LeftDrawerOrg/LeftDrawerOrg.spec.tsx index 3fa6c0205e..f0e5d446d1 100644 --- a/src/components/LeftDrawerOrg/LeftDrawerOrg.spec.tsx +++ b/src/components/LeftDrawerOrg/LeftDrawerOrg.spec.tsx @@ -472,4 +472,47 @@ describe('Testing LeftDrawerOrg component for SUPERADMIN', () => { , ); }); + + test('Should set hideDrawer to false when initially null', async () => { + const mockSetHideDrawer = vi.fn(); + render( + + + + + + + + + , + ); + await wait(); + expect(mockSetHideDrawer).toHaveBeenCalledWith(false); + expect(mockSetHideDrawer).toHaveBeenCalledTimes(1); + }); + + test('Should not call setHideDrawer when hideDrawer has a value', async () => { + const mockSetHideDrawer = vi.fn(); + render( + + + + + + + + + , + ); + await wait(); + expect(mockSetHideDrawer).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/LeftDrawerOrg/LeftDrawerOrg.tsx b/src/components/LeftDrawerOrg/LeftDrawerOrg.tsx index 35173a930e..de9583843c 100644 --- a/src/components/LeftDrawerOrg/LeftDrawerOrg.tsx +++ b/src/components/LeftDrawerOrg/LeftDrawerOrg.tsx @@ -74,6 +74,13 @@ const leftDrawerOrg = ({ () => getIdFromPath(location.pathname), [location.pathname], ); + + useEffect(() => { + if (hideDrawer === null) { + setHideDrawer(false); + } + }, []); + // Check if the current page is admin profile page useEffect(() => { diff --git a/src/components/OrgAdminListCard/OrgAdminListCard.spec.tsx b/src/components/OrgAdminListCard/OrgAdminListCard.spec.tsx index a68b253c0a..5bea7110aa 100644 --- a/src/components/OrgAdminListCard/OrgAdminListCard.spec.tsx +++ b/src/components/OrgAdminListCard/OrgAdminListCard.spec.tsx @@ -10,6 +10,7 @@ import i18nForTest from 'utils/i18nForTest'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; import { StaticMockLink } from 'utils/StaticMockLink'; import { vi, beforeEach, afterEach, expect, it, describe } from 'vitest'; +import { errorHandler } from 'utils/errorHandler'; // Make sure this import is available const MOCKS = [ { @@ -26,6 +27,7 @@ const MOCKS = [ }, }, ]; + const link = new StaticMockLink(MOCKS, true); async function wait(ms = 100): Promise { await act(() => { @@ -58,6 +60,7 @@ const renderOrgAdminListCard = (props: { , ); }; + vi.mock('i18next-browser-languagedetector', async () => ({ ...(await vi.importActual('i18next-browser-languagedetector')), init: vi.fn(), @@ -65,6 +68,12 @@ vi.mock('i18next-browser-languagedetector', async () => ({ detect: vi.fn(() => 'en'), cacheUserLanguage: vi.fn(), })); + +// Add the mock for errorHandler +vi.mock('utils/errorHandler', () => ({ + errorHandler: vi.fn(), +})); + describe('Testing Organization Admin List Card', () => { global.alert = vi.fn(); @@ -107,4 +116,93 @@ describe('Testing Organization Admin List Card', () => { expect(orgListScreen).toBeInTheDocument(); }); }); + + it('should not call toast or reload if no data is returned from mutation', async () => { + // Simulate a failure or empty response from the mutation + const noDataMocks = [ + { + request: { + query: REMOVE_ADMIN_MUTATION, + variables: { userid: '456', orgid: '987' }, + }, + result: { + data: null, // Simulating no data returned + }, + }, + ]; + + const noDataLink = new StaticMockLink(noDataMocks, true); + + const props = { + toggleRemoveModal: vi.fn(), + id: '456', + }; + + render( + + + + } + /> + + + , + ); + + // Simulate user click on "Yes" + userEvent.click(screen.getByTestId('removeAdminBtn')); + + await waitFor(() => { + // Verify that neither toast.success nor window.location.reload are called + expect(global.alert).not.toHaveBeenCalled(); + expect(window.location.reload).not.toHaveBeenCalled(); + }); + }); + + it('should call errorHandler when mutation fails', async () => { + // Override the mock to simulate a failure + const failingMocks = [ + { + request: { + query: REMOVE_ADMIN_MUTATION, + variables: { userid: '456', orgid: '987' }, + }, + error: new Error('Failed to remove admin'), + }, + ]; + + const failingLink = new StaticMockLink(failingMocks, true); + + const props = { + toggleRemoveModal: vi.fn(), + id: '456', + }; + + render( + + + + } + /> + + + , + ); + + // Simulate user click on "Yes" + userEvent.click(screen.getByTestId('removeAdminBtn')); + + // Wait for the errorHandler to be called + await waitFor(() => { + // Verify that errorHandler was called with the expected arguments + expect(errorHandler).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Error), + ); + }); + }); }); diff --git a/src/components/OrgAdminListCard/OrgAdminListCard.tsx b/src/components/OrgAdminListCard/OrgAdminListCard.tsx index f8fc454823..2efd90a0b9 100644 --- a/src/components/OrgAdminListCard/OrgAdminListCard.tsx +++ b/src/components/OrgAdminListCard/OrgAdminListCard.tsx @@ -50,7 +50,6 @@ function orgAdminListCard(props: InterfaceOrgPeopleListCardProps): JSX.Element { }, 2000); } } catch (error: unknown) { - /* istanbul ignore next */ errorHandler(t, error); } }; diff --git a/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.spec.tsx b/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.spec.tsx index 27eec94851..90c2a105ce 100644 --- a/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.spec.tsx +++ b/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.spec.tsx @@ -125,9 +125,9 @@ describe('Testing Organisation Action Item Categories', () => { // Filter by All fireEvent.click(filterBtn); await waitFor(() => { - expect(screen.getByTestId('statusAll')).toBeInTheDocument(); + expect(screen.getByTestId('all')).toBeInTheDocument(); }); - fireEvent.click(screen.getByTestId('statusAll')); + fireEvent.click(screen.getByTestId('all')); await waitFor(() => { expect(screen.getByText('Category 1')).toBeInTheDocument(); @@ -137,9 +137,9 @@ describe('Testing Organisation Action Item Categories', () => { // Filter by Disabled fireEvent.click(filterBtn); await waitFor(() => { - expect(screen.getByTestId('statusDisabled')).toBeInTheDocument(); + expect(screen.getByTestId('disabled')).toBeInTheDocument(); }); - fireEvent.click(screen.getByTestId('statusDisabled')); + fireEvent.click(screen.getByTestId('disabled')); await waitFor(() => { expect(screen.queryByText('Category 1')).toBeNull(); expect(screen.getByText('Category 2')).toBeInTheDocument(); @@ -154,9 +154,9 @@ describe('Testing Organisation Action Item Categories', () => { fireEvent.click(filterBtn); await waitFor(() => { - expect(screen.getByTestId('statusActive')).toBeInTheDocument(); + expect(screen.getByTestId('active')).toBeInTheDocument(); }); - fireEvent.click(screen.getByTestId('statusActive')); + fireEvent.click(screen.getByTestId('active')); await waitFor(() => { expect(screen.getByText('Category 1')).toBeInTheDocument(); expect(screen.queryByText('Category 2')).toBeNull(); diff --git a/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.tsx b/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.tsx index 3f1001c88b..a1f31e6da1 100644 --- a/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.tsx +++ b/src/components/OrgSettings/ActionItemCategories/OrgActionItemCategories.tsx @@ -1,6 +1,6 @@ import type { FC } from 'react'; import React, { useCallback, useEffect, useState } from 'react'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import styles from '../../../style/app.module.css'; import { useTranslation } from 'react-i18next'; @@ -8,13 +8,7 @@ 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 { Circle, Search, WarningAmberRounded } from '@mui/icons-material'; import { DataGrid, type GridCellParams, @@ -23,6 +17,7 @@ import { import dayjs from 'dayjs'; import { Chip, Stack } from '@mui/material'; import CategoryModal from './CategoryModal'; +import SortingButton from 'subComponents/SortingButton'; enum ModalState { SAME = 'same', @@ -311,63 +306,47 @@ const OrgActionItemCategories: FC = ({
- - - - {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')} - - - + + setSortBy(value as 'createdAt_DESC' | 'createdAt_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + className={styles.dropdown} + /> + + setStatus(value === 'all' ? null : (value as CategoryStatus)) + } + dataTestIdPrefix="filter" + buttonLabel={t('status')} + className={styles.dropdown} + />
+
- {/* Dropdown for filtering members */} - - {/* Dropdown for sorting by name */} - + : t('searchByLastName') + } + onSortChange={(value) => + setSearchByFirstName(value === 'searchByFirstName') + } + dataTestIdPrefix="nameFilter" + className={`${styles.createButton} mt-2`} + />
diff --git a/src/screens/CommunityProfile/CommunityProfile.spec.tsx b/src/screens/CommunityProfile/CommunityProfile.spec.tsx index 86b788201d..1cfb70a2cd 100644 --- a/src/screens/CommunityProfile/CommunityProfile.spec.tsx +++ b/src/screens/CommunityProfile/CommunityProfile.spec.tsx @@ -11,6 +11,11 @@ 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'; +import { errorHandler } from 'utils/errorHandler'; + +vi.mock('utils/errorHandler', () => ({ + errorHandler: vi.fn(), +})); const MOCKS1 = [ { @@ -138,6 +143,146 @@ const link1 = new StaticMockLink(MOCKS1, true); const link2 = new StaticMockLink(MOCKS2, true); const link3 = new StaticMockLink(MOCKS3, true); +const LOADING_MOCK = [ + { + request: { + query: GET_COMMUNITY_DATA, + }, + result: { + data: { + getCommunityData: null, + }, + }, + delay: 100, // Add delay to ensure loading state is rendered + }, +]; + +const ERROR_MOCK = [ + { + request: { + query: GET_COMMUNITY_DATA, + }, + result: { + data: { + getCommunityData: null, + }, + }, + }, + { + request: { + query: UPDATE_COMMUNITY, + variables: { + data: { + name: 'Test Name', + websiteLink: 'https://test.com', + logo: '', + socialMediaUrls: { + facebook: '', + instagram: '', + X: '', + linkedIn: '', + gitHub: '', + youTube: '', + reddit: '', + slack: '', + }, + }, + }, + }, + error: new Error('Mutation error'), + }, +]; + +const RESET_ERROR_MOCKS = [ + { + request: { + query: GET_COMMUNITY_DATA, + }, + result: { + data: { + getCommunityData: { + _id: 'test-id-123', + name: 'Test Community', + websiteLink: 'https://test.com', + logoUrl: 'test-logo.png', + socialMediaUrls: { + facebook: 'https://facebook.com/test', + instagram: 'https://instagram.com/test', + X: 'https://twitter.com/test', + linkedIn: 'https://linkedin.com/test', + gitHub: 'https://github.com/test', + youTube: 'https://youtube.com/test', + reddit: 'https://reddit.com/test', + slack: 'https://slack.com/test', + }, + }, + }, + }, + }, + { + request: { + query: RESET_COMMUNITY, + variables: { + resetPreLoginImageryId: 'test-id-123', + }, + }, + error: new Error('Failed to reset community profile'), + }, +]; + +const BASE64_MOCKS = [ + { + request: { + query: GET_COMMUNITY_DATA, + }, + result: { + data: { + getCommunityData: null, + }, + }, + }, +]; + +const UPDATE_SUCCESS_MOCKS = [ + { + request: { + query: GET_COMMUNITY_DATA, + }, + result: { + data: { + getCommunityData: null, + }, + }, + }, + { + request: { + query: UPDATE_COMMUNITY, + variables: { + data: { + name: 'Test Name', + websiteLink: 'https://test.com', + logo: '', + socialMediaUrls: { + facebook: '', + instagram: '', + X: '', + linkedIn: '', + gitHub: '', + youTube: '', + reddit: '', + slack: '', + }, + }, + }, + }, + result: { + data: { + updateCommunity: true, + }, + }, + }, +]; + const profileVariables = { name: 'Name', websiteLink: 'https://website.com', @@ -329,4 +474,146 @@ describe('Testing Community Profile Screen', () => { expect(screen.getByTestId(/reddit/i)).toHaveValue(''); expect(screen.getByTestId(/slack/i)).toHaveValue(''); }); + + test('should show loader while data is being fetched', async () => { + render( + + + + + + + , + ); + + // Loader should be present during loading state + expect(screen.getByTestId('spinner-wrapper')).toBeInTheDocument(); + }); + + test('should handle mutation error correctly', async () => { + render( + + + + + + + , + ); + + const nameInput = screen.getByPlaceholderText(/Community Name/i); + const websiteInput = screen.getByPlaceholderText(/Website Link/i); + const logoInput = screen.getByTestId('fileInput'); + + userEvent.type(nameInput, 'Test Name'); + userEvent.type(websiteInput, 'https://test.com'); + userEvent.upload( + logoInput, + new File([''], 'test.png', { type: 'image/png' }), + ); + + const submitButton = screen.getByTestId('saveChangesBtn'); + userEvent.click(submitButton); + await wait(); + + expect(errorHandler).toHaveBeenCalled(); + expect(errorHandler).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Error), + ); + }); + + test('should handle error during reset operation', async () => { + render( + + + + + + + , + ); + + const nameInput = await screen.findByPlaceholderText( + /Community Name/i, + {}, + { timeout: 2000 }, + ); + const websiteInput = await screen.findByPlaceholderText( + /Website Link/i, + {}, + { timeout: 2000 }, + ); + + expect(nameInput).toHaveValue('Test Community'); + expect(websiteInput).toHaveValue('https://test.com'); + + const resetButton = screen.getByTestId('resetChangesBtn'); + userEvent.click(resetButton); + await wait(); + + // Verify error handler was called + expect(errorHandler).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Error), + ); + // Verify toast success was not called (since there was an error) + expect(toast.success).not.toHaveBeenCalled(); + }); + + test('should handle null base64 conversion when updating logo', async () => { + render( + + + + + + + , + ); + + const mockFile = new File([''], 'test.png', { type: 'image/png' }); + vi.mock('utils/convertToBase64', () => ({ + default: vi.fn().mockResolvedValue(null), + })); + + const fileInput = screen.getByTestId('fileInput') as HTMLInputElement; + userEvent.upload(fileInput, mockFile); + await wait(); + + // Ensure state or UI behavior when base64 conversion fails + expect(fileInput.value).toBe(''); + + // Ensure no success toast is shown for null conversion + expect(toast.success).not.toHaveBeenCalled(); + }); + + test('should show success toast when profile is updated successfully', async () => { + render( + + + + + + + , + ); + + const nameInput = screen.getByPlaceholderText(/Community Name/i); + const websiteInput = screen.getByPlaceholderText(/Website Link/i); + const logoInput = screen.getByTestId('fileInput'); + + userEvent.type(nameInput, 'Test Name'); + userEvent.type(websiteInput, 'https://test.com'); + userEvent.upload( + logoInput, + new File([''], 'test.png', { type: 'image/png' }), + ); + + const submitButton = screen.getByTestId('saveChangesBtn'); + userEvent.click(submitButton); + await wait(); + + expect(toast.success).toHaveBeenCalledWith(expect.any(String)); + }); }); diff --git a/src/screens/CommunityProfile/CommunityProfile.tsx b/src/screens/CommunityProfile/CommunityProfile.tsx index 05f328ced0..349069f9bc 100644 --- a/src/screens/CommunityProfile/CommunityProfile.tsx +++ b/src/screens/CommunityProfile/CommunityProfile.tsx @@ -150,7 +150,6 @@ const CommunityProfile = (): JSX.Element => { }); toast.success(t('profileChangedMsg') as string); } catch (error: unknown) { - /* istanbul ignore next */ errorHandler(t, error as Error); } }; @@ -183,7 +182,6 @@ const CommunityProfile = (): JSX.Element => { }); toast.success(t(`resetData`) as string); } catch (error: unknown) { - /* istanbul ignore next */ errorHandler(t, error as Error); } }; diff --git a/src/screens/EventVolunteers/Requests/Requests.tsx b/src/screens/EventVolunteers/Requests/Requests.tsx index b19be3d2a0..d8efd92a90 100644 --- a/src/screens/EventVolunteers/Requests/Requests.tsx +++ b/src/screens/EventVolunteers/Requests/Requests.tsx @@ -1,9 +1,9 @@ import React, { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, 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 { Search, WarningAmberRounded } from '@mui/icons-material'; import { useMutation, useQuery } from '@apollo/client'; import Loader from 'components/Loader/Loader'; @@ -20,6 +20,7 @@ import dayjs from 'dayjs'; import { UPDATE_VOLUNTEER_MEMBERSHIP } from 'GraphQl/Mutations/EventVolunteerMutation'; import { toast } from 'react-toastify'; import { debounce } from '@mui/material'; +import SortingButton from 'subComponents/SortingButton'; const dataGridStyle = { '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { @@ -279,30 +280,18 @@ function requests(): JSX.Element {
- - - - {tCommon('sort')} - - - setSortBy('createdAt_DESC')} - data-testid="createdAt_DESC" - > - {t('latest')} - - setSortBy('createdAt_ASC')} - data-testid="createdAt_ASC" - > - {t('earliest')} - - - + + setSortBy(value as 'createdAt_DESC' | 'createdAt_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + />
diff --git a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.tsx b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.tsx index 3c70b1db49..b8577acaac 100644 --- a/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.tsx +++ b/src/screens/EventVolunteers/VolunteerGroups/VolunteerGroups.tsx @@ -1,9 +1,9 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import { Navigate, useParams } from 'react-router-dom'; -import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import { Search, WarningAmberRounded } from '@mui/icons-material'; import { useQuery } from '@apollo/client'; @@ -21,6 +21,7 @@ import { EVENT_VOLUNTEER_GROUP_LIST } from 'GraphQl/Queries/EventVolunteerQuerie import VolunteerGroupModal from './VolunteerGroupModal'; import VolunteerGroupDeleteModal from './VolunteerGroupDeleteModal'; import VolunteerGroupViewModal from './VolunteerGroupViewModal'; +import SortingButton from 'subComponents/SortingButton'; enum ModalState { SAME = 'same', @@ -321,56 +322,29 @@ function volunteerGroups(): JSX.Element {
- - - - {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')} - - - + setSearchBy(value as 'leader' | 'group')} + dataTestIdPrefix="searchByToggle" + buttonLabel={tCommon('searchBy', { item: '' })} + /> + + setSortBy(value as 'volunteers_DESC' | 'volunteers_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + />
- - - - {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')} - - - + + setSortBy( + value as 'hoursVolunteered_DESC' | 'hoursVolunteered_ASC', + ) + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + /> + + setStatus(value as VolunteerStatus)} + dataTestIdPrefix="filter" + buttonLabel={t('status')} + />
- - - - {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')} - - - + + setSortBy( + value as + | 'amount_ASC' + | 'amount_DESC' + | 'endDate_ASC' + | 'endDate_DESC', + ) + } + dataTestIdPrefix="filter" + buttonLabel={tCommon('sort')} + />
- - - - {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')} - - - + + setSortBy(value as 'hours_DESC' | 'hours_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + /> + setTimeFrame(value as TimeFrame)} + dataTestIdPrefix="timeFrame" + buttonLabel={t('timeFrame')} + type="filter" + />
diff --git a/src/screens/ManageTag/ManageTag.spec.tsx b/src/screens/ManageTag/ManageTag.spec.tsx index 5d86ed3c17..03c7eea393 100644 --- a/src/screens/ManageTag/ManageTag.spec.tsx +++ b/src/screens/ManageTag/ManageTag.spec.tsx @@ -50,7 +50,6 @@ vi.mock('react-toastify', () => ({ }, })); -/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ vi.mock('../../components/AddPeopleToTag/AddPeopleToTag', async () => { return await import('./ManageTagMockComponents/MockAddPeopleToTag'); }); @@ -58,7 +57,6 @@ vi.mock('../../components/AddPeopleToTag/AddPeopleToTag', async () => { vi.mock('../../components/TagActions/TagActions', async () => { return await import('./ManageTagMockComponents/MockTagActions'); }); -/* eslint-enable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports */ const renderManageTag = (link: ApolloLink): RenderResult => { return render( @@ -372,9 +370,9 @@ describe('Manage Tag Page', () => { userEvent.click(screen.getByTestId('sortPeople')); await waitFor(() => { - expect(screen.getByTestId('oldest')).toBeInTheDocument(); + expect(screen.getByTestId('ASCENDING')).toBeInTheDocument(); }); - userEvent.click(screen.getByTestId('oldest')); + userEvent.click(screen.getByTestId('ASCENDING')); // returns the tags in reverse order await waitFor(() => { @@ -389,9 +387,9 @@ describe('Manage Tag Page', () => { userEvent.click(screen.getByTestId('sortPeople')); await waitFor(() => { - expect(screen.getByTestId('latest')).toBeInTheDocument(); + expect(screen.getByTestId('DESCENDING')).toBeInTheDocument(); }); - userEvent.click(screen.getByTestId('latest')); + userEvent.click(screen.getByTestId('DESCENDING')); // reverse the order again await waitFor(() => { diff --git a/src/screens/ManageTag/ManageTag.tsx b/src/screens/ManageTag/ManageTag.tsx index 38466f6f11..0832b2ab3e 100644 --- a/src/screens/ManageTag/ManageTag.tsx +++ b/src/screens/ManageTag/ManageTag.tsx @@ -2,13 +2,11 @@ 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'; @@ -39,6 +37,7 @@ import InfiniteScrollLoader from 'components/InfiniteScrollLoader/InfiniteScroll import EditUserTagModal from './EditUserTagModal'; import RemoveUserTagModal from './RemoveUserTagModal'; import UnassignUserTagModal from './UnassignUserTagModal'; +import SortingButton from 'subComponents/SortingButton'; /** * Component that renders the Manage Tag screen when the app navigates to '/orgtags/:orgId/manageTag/:tagId'. @@ -378,36 +377,19 @@ function ManageTag(): JSX.Element {
- + sortingOptions={[ + { label: tCommon('Latest'), value: 'DESCENDING' }, + { label: tCommon('Oldest'), value: 'ASCENDING' }, + ]} + selectedOption={assignedMemberSortOrder} + onSortChange={(value) => + setAssignedMemberSortOrder(value as SortedByType) + } + dataTestIdPrefix="sortPeople" + buttonLabel={tCommon('sort')} + />
-
- -
+ {superAdmin && (
- - + sortingOptions={[ + { label: t('Latest'), value: 'latest' }, + { label: t('Oldest'), value: 'oldest' }, + ]} + selectedOption={sortingOption} + onSortChange={handleSorting} + dataTestIdPrefix="sortpost" + dropdownTestId="sort" + className={`${styles.dropdown} `} + buttonLabel={t('sortPost')} + />
))}
- - {/* 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)} - - ))} - - diff --git a/src/screens/OrganizationActionItems/OrganizationActionItems.spec.tsx b/src/screens/OrganizationActionItems/OrganizationActionItems.spec.tsx index 7ae0fc58eb..89bcc5d824 100644 --- a/src/screens/OrganizationActionItems/OrganizationActionItems.spec.tsx +++ b/src/screens/OrganizationActionItems/OrganizationActionItems.spec.tsx @@ -252,11 +252,11 @@ describe('Testing Organization Action Items Screen', () => { }); await waitFor(() => { - expect(screen.getByTestId('statusAll')).toBeInTheDocument(); + expect(screen.getByTestId('all')).toBeInTheDocument(); }); await act(() => { - fireEvent.click(screen.getByTestId('statusAll')); + fireEvent.click(screen.getByTestId('all')); }); await waitFor(() => { @@ -269,11 +269,11 @@ describe('Testing Organization Action Items Screen', () => { }); await waitFor(() => { - expect(screen.getByTestId('statusPending')).toBeInTheDocument(); + expect(screen.getByTestId('pending')).toBeInTheDocument(); }); await act(() => { - fireEvent.click(screen.getByTestId('statusPending')); + fireEvent.click(screen.getByTestId('pending')); }); await waitFor(() => { @@ -314,11 +314,11 @@ describe('Testing Organization Action Items Screen', () => { }); await waitFor(() => { - expect(screen.getByTestId('statusCompleted')).toBeInTheDocument(); + expect(screen.getByTestId('completed')).toBeInTheDocument(); }); await act(() => { - fireEvent.click(screen.getByTestId('statusCompleted')); + fireEvent.click(screen.getByTestId('completed')); }); await waitFor(() => { diff --git a/src/screens/OrganizationActionItems/OrganizationActionItems.tsx b/src/screens/OrganizationActionItems/OrganizationActionItems.tsx index 6061ba7e7d..26a8c77bc3 100644 --- a/src/screens/OrganizationActionItems/OrganizationActionItems.tsx +++ b/src/screens/OrganizationActionItems/OrganizationActionItems.tsx @@ -1,15 +1,9 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import { Navigate, useParams } from 'react-router-dom'; -import { - Circle, - FilterAltOutlined, - Search, - Sort, - WarningAmberRounded, -} from '@mui/icons-material'; +import { Circle, Search, WarningAmberRounded } from '@mui/icons-material'; import dayjs from 'dayjs'; import { useQuery } from '@apollo/client'; @@ -32,6 +26,7 @@ import ItemModal from './ItemModal'; import ItemDeleteModal from './ItemDeleteModal'; import Avatar from 'components/Avatar/Avatar'; import ItemUpdateStatusModal from './ItemUpdateStatusModal'; +import SortingButton from 'subComponents/SortingButton'; enum ItemStatus { Pending = 'pending', @@ -141,6 +136,11 @@ function organizationActionItems(): JSX.Element { [], ); + // Trigger refetch on sortBy or status change + useEffect(() => { + actionItemsRefetch(); + }, [sortBy, status, actionItemsRefetch]); + if (actionItemsLoading) { return ; } @@ -186,7 +186,7 @@ function organizationActionItems(): JSX.Element { className={styles.TableImage} /> ) : ( -
+
-
-
- - - - {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')} - - - -
+
+ + setSearchBy(value as 'assignee' | 'category') + } + dataTestIdPrefix="searchByToggle" + buttonLabel={tCommon('searchBy', { item: '' })} + className={styles.dropdown} // Pass a custom class name if needed + /> + + setSortBy(value as 'dueDate_DESC' | 'dueDate_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + className={styles.dropdown} // Pass a custom class name if needed + /> + + setStatus(value === 'all' ? null : (value as ItemStatus)) + } + dataTestIdPrefix="filter" + buttonLabel={t('status')} + className={styles.dropdown} // Pass a custom class name if needed + />
- - - - {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')} - - - + + setSortBy( + value as + | 'fundingGoal_ASC' + | 'fundingGoal_DESC' + | 'endDate_ASC' + | 'endDate_DESC', + ) + } + dataTestIdPrefix="filter" + buttonLabel={tCommon('sort')} + />
-
- - - - {tCommon('sort')} - - - setSortBy('createdAt_DESC')} - data-testid="createdAt_DESC" - > - {t('createdLatest')} - - setSortBy('createdAt_ASC')} - data-testid="createdAt_ASC" - > - {t('createdEarliest')} - - - -
+ + setSortBy(value as 'createdAt_DESC' | 'createdAt_ASC') + } + dataTestIdPrefix="filter" + buttonLabel={tCommon('sort')} + />
- - - - {t('sort')} - - - { - setState(2); - }} - > - - {tCommon('users')} - - - { - setState(0); - }} - > - - {tCommon('members')} - - - { - setState(1); - }} - > - - {tCommon('admins')} - - - - +
- +
diff --git a/src/screens/OrganizationTags/OrganizationTags.tsx b/src/screens/OrganizationTags/OrganizationTags.tsx index 558eb4eaf8..0b233cfaef 100644 --- a/src/screens/OrganizationTags/OrganizationTags.tsx +++ b/src/screens/OrganizationTags/OrganizationTags.tsx @@ -1,13 +1,11 @@ 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'; @@ -30,7 +28,7 @@ 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'; - +import SortingButton from 'subComponents/SortingButton'; /** * Component that renders the Organization Tags screen when the app navigates to '/orgtags/:orgId'. * @@ -294,6 +292,10 @@ function OrganizationTags(): JSX.Element { }, ]; + const handleSortChange = (value: string): void => { + setTagSortOrder(value === 'latest' ? 'DESCENDING' : 'ASCENDING'); + }; + return ( <> @@ -312,40 +314,24 @@ function OrganizationTags(): JSX.Element { />
- + : tCommon('Oldest') + } + onSortChange={handleSortChange} + dataTestIdPrefix="sortTags" + className={styles.dropdown} + />
-
-
- - -
+
+ +
- - - - {tagSortOrder === 'DESCENDING' - ? tCommon('Latest') - : tCommon('Oldest')} - - - setTagSortOrder('DESCENDING')} - > - {tCommon('Latest')} - - setTagSortOrder('ASCENDING')} - > - {tCommon('Oldest')} - - - + setTagSortOrder(value as SortedByType)} + dataTestIdPrefix="sortTags" + buttonLabel={tCommon('sort')} + />
- {/* 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')} - - - + + setSortBy( + value as + | 'fundingGoal_ASC' + | 'fundingGoal_DESC' + | 'endDate_ASC' + | 'endDate_DESC', + ) + } + dataTestIdPrefix="filter" + buttonLabel={tCommon('sort')} + />
{/* Button to navigate to the user's pledges */} diff --git a/src/screens/UserPortal/Chat/Chat.spec.tsx b/src/screens/UserPortal/Chat/Chat.spec.tsx index 178022b4e7..564fb290bf 100644 --- a/src/screens/UserPortal/Chat/Chat.spec.tsx +++ b/src/screens/UserPortal/Chat/Chat.spec.tsx @@ -29,6 +29,7 @@ import { UNREAD_CHAT_LIST, } from 'GraphQl/Queries/PlugInQueries'; import useLocalStorage from 'utils/useLocalstorage'; +import { StaticMockLink } from 'utils/StaticMockLink'; // import userEvent from '@testing-library/user-event'; /** @@ -4289,9 +4290,27 @@ describe('Testing Chat Screen [User Portal]', () => { { _id: '1', isGroup: false, - users: [{ _id: '1', firstName: 'John', lastName: 'Doe' }], + users: [ + { + _id: '1', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + image: null, + }, + ], messages: [], + name: null, + image: null, unseenMessagesByUsers: '{}', + creator: { + _id: '1', + firstName: 'John', + lastName: 'Doe', + email: 'johndoe@example.com', + }, + organization: null, + admins: [], }, ], }; @@ -4319,13 +4338,44 @@ describe('Testing Chat Screen [User Portal]', () => { }, }, }, + { + request: { + query: UNREAD_CHAT_LIST, + }, + result: { + data: { + getUnreadChatsByUserId: [], + }, + }, + }, + { + request: { + query: GROUP_CHAT_LIST, + }, + result: { + data: { + getGroupChatsByUserId: [], + }, + }, + }, + { + request: { + query: CHATS_LIST, + variables: { id: '1' }, + }, + result: { + data: mockChatsData, + }, + }, ]; - + const link = new StaticMockLink(mocks, true); render( - + - + + + , @@ -4387,6 +4437,7 @@ describe('Testing Chat Screen [User Portal]', () => { const contactCards = await screen.findAllByTestId('contactCardContainer'); expect(contactCards).toHaveLength(1); }); + test('Screen should be rendered properly', async () => { render( @@ -4514,6 +4565,7 @@ describe('Testing Chat Screen [User Portal]', () => { fireEvent.click(await screen.findByTestId('allChat')); }); }); + it('should fetch and set group chats when filterType is "group"', async () => { setItem('userId', '1'); diff --git a/src/screens/UserPortal/Chat/Chat.tsx b/src/screens/UserPortal/Chat/Chat.tsx index 993f380f72..5311d7faae 100644 --- a/src/screens/UserPortal/Chat/Chat.tsx +++ b/src/screens/UserPortal/Chat/Chat.tsx @@ -111,25 +111,6 @@ export default function chat(): JSX.Element { const { getItem } = useLocalStorage(); const userId = getItem('userId'); - React.useEffect(() => { - if (filterType === 'all') { - chatsListRefetch(); - if (chatsListData && chatsListData.chatsByUserId) { - setChats(chatsListData.chatsByUserId); - } - } else if (filterType === 'unread') { - unreadChatListRefetch(); - if (unreadChatListData && unreadChatListData.getUnreadChatsByUserId) { - setChats(unreadChatListData.getUnreadChatsByUserId); - } - } else if (filterType === 'group') { - groupChatListRefetch(); - if (groupChatListData && groupChatListData.getGroupChatsByUserId) { - setChats(groupChatListData.getGroupChatsByUserId); - } - } - }, [filterType]); - const [createDirectChatModalisOpen, setCreateDirectChatModalisOpen] = useState(false); @@ -180,6 +161,28 @@ export default function chat(): JSX.Element { }); }, [selectedContact]); + React.useEffect(() => { + async function getChats(): Promise { + if (filterType === 'all') { + await chatsListRefetch(); + if (chatsListData && chatsListData.chatsByUserId) { + setChats(chatsListData.chatsByUserId); + } + } else if (filterType === 'unread') { + await unreadChatListRefetch(); + if (unreadChatListData && unreadChatListData.getUnreadChatsByUserId) { + setChats(unreadChatListData.getUnreadChatsByUserId); + } + } else if (filterType === 'group') { + await groupChatListRefetch(); + if (groupChatListData && groupChatListData.getGroupChatsByUserId) { + setChats(groupChatListData.getGroupChatsByUserId); + } + } + } + getChats(); + }, [filterType]); + React.useEffect(() => { if (chatsListData && chatsListData?.chatsByUserId.length) { setChats(chatsListData.chatsByUserId); diff --git a/src/screens/UserPortal/Pledges/Pledge.test.tsx b/src/screens/UserPortal/Pledges/Pledge.spec.tsx similarity index 90% rename from src/screens/UserPortal/Pledges/Pledge.test.tsx rename to src/screens/UserPortal/Pledges/Pledge.spec.tsx index 3d5eef94c2..a41d95687b 100644 --- a/src/screens/UserPortal/Pledges/Pledge.test.tsx +++ b/src/screens/UserPortal/Pledges/Pledge.spec.tsx @@ -21,20 +21,31 @@ import { EMPTY_MOCKS, MOCKS, USER_PLEDGES_ERROR } from './PledgesMocks'; import type { ApolloLink } from '@apollo/client'; import Pledges from './Pledges'; import useLocalStorage from 'utils/useLocalstorage'; +import { vi, expect, describe, it } from 'vitest'; -jest.mock('react-toastify', () => ({ +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useParams: () => ({ orgId: 'orgId' }), + }; +}); + +vi.mock('react-toastify', () => ({ toast: { - success: jest.fn(), - error: jest.fn(), + success: vi.fn(), + error: vi.fn(), }, })); -jest.mock('@mui/x-date-pickers/DateTimePicker', () => { +vi.mock('@mui/x-date-pickers/DateTimePicker', async () => { + const actualModule = await vi.importActual( + '@mui/x-date-pickers/DesktopDateTimePicker', + ); return { - DateTimePicker: jest.requireActual( - '@mui/x-date-pickers/DesktopDateTimePicker', - ).DesktopDateTimePicker, + DateTimePicker: actualModule.DesktopDateTimePicker, }; }); + const { setItem } = useLocalStorage(); const link1 = new StaticMockLink(MOCKS); @@ -71,15 +82,8 @@ describe('Testing User Pledge Screen', () => { setItem('userId', 'userId'); }); - beforeAll(() => { - jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ orgId: 'orgId' }), - })); - }); - afterAll(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); afterEach(() => { @@ -95,32 +99,35 @@ describe('Testing User Pledge Screen', () => { }); }); + // This test works: 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(); }); }); + // So let's structure our failing test similarly: it('should redirect to fallback URL if URL params are undefined', async () => { render( - + - - - } /> -
} - /> - - + + + + } /> + } /> + + + , ); + await waitFor(() => { expect(screen.getByTestId('paramsError')).toBeInTheDocument(); }); diff --git a/src/screens/UserPortal/Pledges/Pledges.tsx b/src/screens/UserPortal/Pledges/Pledges.tsx index 33e8bf63c2..2ab8214265 100644 --- a/src/screens/UserPortal/Pledges/Pledges.tsx +++ b/src/screens/UserPortal/Pledges/Pledges.tsx @@ -1,8 +1,8 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { Dropdown, Form, Button, ProgressBar } from 'react-bootstrap'; +import { 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 { Search, 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'; @@ -21,6 +21,7 @@ import { currencySymbols } from 'utils/currency'; import PledgeDeleteModal from 'screens/FundCampaignPledge/PledgeDeleteModal'; import { Navigate, useParams } from 'react-router-dom'; import PledgeModal from '../Campaigns/PledgeModal'; +import SortingButton from 'subComponents/SortingButton'; const dataGridStyle = { '&.MuiDataGrid-root .MuiDataGrid-cell:focus-within': { @@ -393,75 +394,40 @@ const Pledges = (): JSX.Element => {
-
- +
+ + setSearchBy(value as 'pledgers' | 'campaigns') + } + dataTestIdPrefix="searchByDrpdwn" + buttonLabel={t('searchBy')} + /> - - - - {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')} - - - + + setSortBy( + value as + | 'amount_ASC' + | 'amount_DESC' + | 'endDate_ASC' + | 'endDate_DESC', + ) + } + dataTestIdPrefix="filter" + buttonLabel={tCommon('sort')} + />
diff --git a/src/screens/UserPortal/Posts/Posts.test.tsx b/src/screens/UserPortal/Posts/Posts.spec.tsx similarity index 89% rename from src/screens/UserPortal/Posts/Posts.test.tsx rename to src/screens/UserPortal/Posts/Posts.spec.tsx index 433e36f94a..83b626ba20 100644 --- a/src/screens/UserPortal/Posts/Posts.test.tsx +++ b/src/screens/UserPortal/Posts/Posts.spec.tsx @@ -16,17 +16,29 @@ import i18nForTest from 'utils/i18nForTest'; import Home from './Posts'; import useLocalStorage from 'utils/useLocalstorage'; import { DELETE_POST_MUTATION } from 'GraphQl/Mutations/mutations'; +import { expect, describe, it, vi } from 'vitest'; const { setItem } = useLocalStorage(); -jest.mock('react-toastify', () => ({ +vi.mock('react-toastify', () => ({ toast: { - error: jest.fn(), - info: jest.fn(), - success: jest.fn(), + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), }, })); +const mockUseParams = vi.fn().mockReturnValue({ orgId: 'orgId' }); + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useParams: () => mockUseParams(), + useNavigate: () => vi.fn(), + }; +}); + const MOCKS = [ { request: { @@ -262,31 +274,27 @@ const renderHomeScreen = (): RenderResult => Object.defineProperty(window, 'matchMedia', { writable: true, - value: jest.fn().mockImplementation((query) => ({ + value: vi.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(), + addListener: vi.fn(), // Deprecated + removeListener: vi.fn(), // Deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), })), }); describe('Testing Home Screen: User Portal', () => { - beforeAll(() => { - jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: () => ({ orgId: 'orgId' }), - })); + beforeEach(() => { + mockUseParams.mockReturnValue({ orgId: 'orgId' }); }); - afterAll(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); }); - test('Check if HomeScreen renders properly', async () => { + it('Check if HomeScreen renders properly', async () => { renderHomeScreen(); await wait(); @@ -294,7 +302,7 @@ describe('Testing Home Screen: User Portal', () => { expect(startPostBtn).toBeInTheDocument(); }); - test('StartPostModal should render on click of StartPost btn', async () => { + it('StartPostModal should render on click of StartPost btn', async () => { renderHomeScreen(); await wait(); @@ -306,7 +314,7 @@ describe('Testing Home Screen: User Portal', () => { expect(startPostModal).toBeInTheDocument(); }); - test('StartPostModal should close on clicking the close button', async () => { + it('StartPostModal should close on clicking the close button', async () => { renderHomeScreen(); await wait(); @@ -325,7 +333,6 @@ describe('Testing Home Screen: User Portal', () => { 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(); @@ -342,7 +349,7 @@ describe('Testing Home Screen: User Portal', () => { expect(screen.getByTestId('postImageInput')).toHaveValue(''); }); - test('Check whether Posts render in PostCard', async () => { + it('Check whether Posts render in PostCard', async () => { setItem('userId', '640d98d9eb6a743d75341067'); renderHomeScreen(); await wait(); @@ -359,7 +366,7 @@ describe('Testing Home Screen: User Portal', () => { expect(screen.queryByText('This is the post two')).toBeInTheDocument(); }); - test('Checking if refetch works after deleting this post', async () => { + it('Checking if refetch works after deleting this post', async () => { setItem('userId', '640d98d9eb6a743d75341067'); renderHomeScreen(); expect(screen.queryAllByTestId('dropdown')).not.toBeNull(); @@ -371,11 +378,15 @@ describe('Testing Home Screen: User Portal', () => { }); 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 }), - })); + beforeEach(() => { + mockUseParams.mockReturnValue({ orgId: undefined }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('Redirect to /user when organizationId is falsy', async () => { render( diff --git a/src/screens/UserPortal/Posts/Posts.tsx b/src/screens/UserPortal/Posts/Posts.tsx index ceb10c9b49..1c03e64d70 100644 --- a/src/screens/UserPortal/Posts/Posts.tsx +++ b/src/screens/UserPortal/Posts/Posts.tsx @@ -23,6 +23,7 @@ import useLocalStorage from 'utils/useLocalstorage'; import styles from './Posts.module.css'; import convertToBase64 from 'utils/convertToBase64'; import Carousel from 'react-multi-carousel'; +import { TAGS_QUERY_DATA_CHUNK_SIZE } from 'utils/organizationTagsUtils'; import 'react-multi-carousel/lib/styles.css'; const responsive = { @@ -156,7 +157,10 @@ export default function home(): JSX.Element { const userId: string | null = getItem('userId'); const { data: userData } = useQuery(USER_DETAILS, { - variables: { id: userId }, + variables: { + id: userId, + first: TAGS_QUERY_DATA_CHUNK_SIZE, // This is for tagsAssignedWith pagination + }, }); const user: InterfaceQueryUserListItem | undefined = userData?.user; diff --git a/src/screens/UserPortal/Volunteer/Actions/Actions.tsx b/src/screens/UserPortal/Volunteer/Actions/Actions.tsx index 9bc23969c2..9fc2c44884 100644 --- a/src/screens/UserPortal/Volunteer/Actions/Actions.tsx +++ b/src/screens/UserPortal/Volunteer/Actions/Actions.tsx @@ -1,9 +1,9 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import { Navigate, useParams } from 'react-router-dom'; -import { Circle, Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import { Circle, Search, WarningAmberRounded } from '@mui/icons-material'; import dayjs from 'dayjs'; import { useQuery } from '@apollo/client'; @@ -22,6 +22,7 @@ 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'; +import SortingButton from 'subComponents/SortingButton'; enum ModalState { VIEW = 'view', @@ -373,54 +374,29 @@ function actions(): JSX.Element {
- - - - {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')} - - - + + setSearchBy(value as 'assignee' | 'category') + } + dataTestIdPrefix="searchByToggle" + buttonLabel={tCommon('searchBy', { item: '' })} + /> + + setSortBy(value as 'dueDate_DESC' | 'dueDate_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + />
diff --git a/src/screens/UserPortal/Volunteer/Groups/GroupModal.test.tsx b/src/screens/UserPortal/Volunteer/Groups/GroupModal.spec.tsx similarity index 96% rename from src/screens/UserPortal/Volunteer/Groups/GroupModal.test.tsx rename to src/screens/UserPortal/Volunteer/Groups/GroupModal.spec.tsx index 1d83d9a872..2bfed36882 100644 --- a/src/screens/UserPortal/Volunteer/Groups/GroupModal.test.tsx +++ b/src/screens/UserPortal/Volunteer/Groups/GroupModal.spec.tsx @@ -16,16 +16,22 @@ import { toast } from 'react-toastify'; import type { InterfaceGroupModal } from './GroupModal'; import GroupModal from './GroupModal'; import userEvent from '@testing-library/user-event'; +import { vi } from 'vitest'; -jest.mock('react-toastify', () => ({ +vi.mock('react-toastify', () => ({ toast: { - success: jest.fn(), - error: jest.fn(), + success: vi.fn(), + error: vi.fn(), }, })); const link1 = new StaticMockLink(MOCKS); const link2 = new StaticMockLink(UPDATE_ERROR_MOCKS); + +/** + * Translations for test cases + */ + const t = { ...JSON.parse( JSON.stringify( @@ -36,12 +42,16 @@ const t = { ...JSON.parse(JSON.stringify(i18n.getDataByLanguage('en')?.errors ?? {})), }; +/** + * Props for `GroupModal` component used in tests + */ + const itemProps: InterfaceGroupModal[] = [ { isOpen: true, - hide: jest.fn(), + hide: vi.fn(), eventId: 'eventId', - refetchGroups: jest.fn(), + refetchGroups: vi.fn(), group: { _id: 'groupId', name: 'Group 1', @@ -79,9 +89,9 @@ const itemProps: InterfaceGroupModal[] = [ }, { isOpen: true, - hide: jest.fn(), + hide: vi.fn(), eventId: 'eventId', - refetchGroups: jest.fn(), + refetchGroups: vi.fn(), group: { _id: 'groupId', name: 'Group 1', diff --git a/src/screens/UserPortal/Volunteer/Groups/Groups.tsx b/src/screens/UserPortal/Volunteer/Groups/Groups.tsx index 160dc0b23a..4cd2470010 100644 --- a/src/screens/UserPortal/Volunteer/Groups/Groups.tsx +++ b/src/screens/UserPortal/Volunteer/Groups/Groups.tsx @@ -1,11 +1,10 @@ import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Button, Dropdown, Form } from 'react-bootstrap'; +import { Button, Form } from 'react-bootstrap'; import { Navigate, useParams } from 'react-router-dom'; - -import { Search, Sort, WarningAmberRounded } from '@mui/icons-material'; - +import { Search, WarningAmberRounded } from '@mui/icons-material'; import { useQuery } from '@apollo/client'; +import { debounce, Stack } from '@mui/material'; import type { InterfaceVolunteerGroupInfo } from 'utils/interfaces'; import Loader from 'components/Loader/Loader'; @@ -14,13 +13,13 @@ import { type GridCellParams, type GridColDef, } from '@mui/x-data-grid'; -import { debounce, Stack } from '@mui/material'; import Avatar from 'components/Avatar/Avatar'; import styles from '../../../../style/app.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'; +import SortingButton from 'subComponents/SortingButton'; enum ModalState { EDIT = 'edit', @@ -313,56 +312,27 @@ function groups(): JSX.Element {
- - - - {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')} - - - + setSearchBy(value as 'leader' | 'group')} + dataTestIdPrefix="searchByToggle" + buttonLabel={tCommon('searchBy', { item: '' })} + /> + + setSortBy(value as 'volunteers_DESC' | 'volunteers_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + />
diff --git a/src/screens/UserPortal/Volunteer/Invitations/Invitations.spec.tsx b/src/screens/UserPortal/Volunteer/Invitations/Invitations.spec.tsx index 867f95c1aa..2c8d0835ca 100644 --- a/src/screens/UserPortal/Volunteer/Invitations/Invitations.spec.tsx +++ b/src/screens/UserPortal/Volunteer/Invitations/Invitations.spec.tsx @@ -171,7 +171,7 @@ describe('Testing Invvitations Screen', () => { expect(filter).toBeInTheDocument(); fireEvent.click(filter); - const filterAll = await screen.findByTestId('filterAll'); + const filterAll = await screen.findByTestId('all'); expect(filterAll).toBeInTheDocument(); fireEvent.click(filterAll); @@ -189,7 +189,7 @@ describe('Testing Invvitations Screen', () => { expect(filter).toBeInTheDocument(); fireEvent.click(filter); - const filterGroup = await screen.findByTestId('filterGroup'); + const filterGroup = await screen.findByTestId('group'); expect(filterGroup).toBeInTheDocument(); fireEvent.click(filterGroup); @@ -210,7 +210,7 @@ describe('Testing Invvitations Screen', () => { expect(filter).toBeInTheDocument(); fireEvent.click(filter); - const filterIndividual = await screen.findByTestId('filterIndividual'); + const filterIndividual = await screen.findByTestId('individual'); expect(filterIndividual).toBeInTheDocument(); fireEvent.click(filterIndividual); diff --git a/src/screens/UserPortal/Volunteer/Invitations/Invitations.tsx b/src/screens/UserPortal/Volunteer/Invitations/Invitations.tsx index a79b64251d..35dbe67264 100644 --- a/src/screens/UserPortal/Volunteer/Invitations/Invitations.tsx +++ b/src/screens/UserPortal/Volunteer/Invitations/Invitations.tsx @@ -1,14 +1,9 @@ import React, { useMemo, useState } from 'react'; -import { Dropdown, Form, Button } from 'react-bootstrap'; +import { 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 { Search, WarningAmberRounded } from '@mui/icons-material'; import { TbCalendarEvent } from 'react-icons/tb'; import { FaUserGroup } from 'react-icons/fa6'; import { debounce, Stack } from '@mui/material'; @@ -21,6 +16,7 @@ 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'; +import SortingButton from 'subComponents/SortingButton'; enum ItemFilter { Group = 'group', @@ -120,7 +116,7 @@ const Invitations = (): JSX.Element => { // loads the invitations when the component mounts if (invitationLoading) return ; if (invitationError) { - // Displays an error message if there is an issue loading the invvitations + // Displays an error message if there is an issue loading the invitations return (
@@ -162,63 +158,30 @@ const Invitations = (): JSX.Element => {
- {/* 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')} - - - + + setSortBy(value as 'createdAt_DESC' | 'createdAt_ASC') + } + dataTestIdPrefix="sort" + buttonLabel={tCommon('sort')} + /> + + setFilter(value === 'all' ? null : (value as ItemFilter)) + } + dataTestIdPrefix="filter" + buttonLabel={t('filter')} + type="filter" + />
diff --git a/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.tsx b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.tsx index bd61ca97e0..eecb874210 100644 --- a/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.tsx +++ b/src/screens/UserPortal/Volunteer/UpcomingEvents/UpcomingEvents.tsx @@ -1,5 +1,5 @@ import React, { useMemo, useState } from 'react'; -import { Dropdown, Form, Button } from 'react-bootstrap'; +import { Form, Button } from 'react-bootstrap'; import styles from '../VolunteerManagement.module.css'; import { useTranslation } from 'react-i18next'; import { Navigate, useParams } from 'react-router-dom'; @@ -19,7 +19,7 @@ import { Stack, debounce, } from '@mui/material'; -import { Circle, Search, Sort, WarningAmberRounded } from '@mui/icons-material'; +import { Circle, Search, WarningAmberRounded } from '@mui/icons-material'; import { GridExpandMoreIcon } from '@mui/x-data-grid'; import useLocalStorage from 'utils/useLocalstorage'; @@ -31,6 +31,7 @@ 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'; +import SortingButton from 'subComponents/SortingButton'; /** * The `UpcomingEvents` component displays list of upcoming events for the user to volunteer. @@ -90,7 +91,7 @@ const UpcomingEvents = (): JSX.Element => { } }; - // Fetches upcomin events based on the organization ID, search term, and sorting order + // Fetches upcoming events based on the organization ID, search term, and sorting order const { data: eventsData, loading: eventsLoading, @@ -169,31 +170,18 @@ const UpcomingEvents = (): JSX.Element => {
- - - - {tCommon('searchBy', { item: '' })} - - - setSearchBy('title')} - data-testid="title" - > - {t('name')} - - setSearchBy('location')} - data-testid="location" - > - {tCommon('location')} - - - + + setSearchBy(value as 'title' | 'location') + } + dataTestIdPrefix="searchByToggle" + buttonLabel={tCommon('searchBy', { item: '' })} + />
diff --git a/src/screens/Users/Users.tsx b/src/screens/Users/Users.tsx index 936807f1ec..ef9f001f4d 100644 --- a/src/screens/Users/Users.tsx +++ b/src/screens/Users/Users.tsx @@ -1,13 +1,11 @@ import { useQuery } from '@apollo/client'; import React, { useEffect, useState } from 'react'; -import { Dropdown, Form, Table } from 'react-bootstrap'; +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 FilterListIcon from '@mui/icons-material/FilterList'; -import SortIcon from '@mui/icons-material/Sort'; import { ORGANIZATION_CONNECTION_LIST, USER_LIST, @@ -19,6 +17,8 @@ import type { InterfaceQueryUserListItem } from 'utils/interfaces'; import styles from '../../style/app.module.css'; import useLocalStorage from 'utils/useLocalstorage'; import type { ApolloError } from '@apollo/client'; +import SortingButton from 'subComponents/SortingButton'; + /** * 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 @@ -372,74 +372,29 @@ const Users = (): JSX.Element => {
- - + +
diff --git a/src/style/app.module.css b/src/style/app.module.css index b016e9aaaf..6763be4b24 100644 --- a/src/style/app.module.css +++ b/src/style/app.module.css @@ -48,7 +48,8 @@ --subtle-blue-grey-hover: #5f7e91; --white: #fff; --black: black; - + --rating-star-filled: #ff6d75; + --rating-star-hover: #ff3d47; /* Background and Border */ --table-bg: #eaebef; --tablerow-bg: #eff1f7; @@ -273,13 +274,13 @@ } .dropdown { - background-color: var(--bs-white); + background-color: var(--bs-white) !important; border: 1px solid var(--brown-color); - color: var(--brown-color); + color: var(--brown-color) !important; position: relative; display: inline-block; - margin-top: 10px; - margin-bottom: 10px; + /* margin-top: 10px; + margin-bottom: 10px; */ } .dropdown:is(:hover, :focus, :active, :focus-visible, .show) { @@ -388,8 +389,8 @@ .createButton { background-color: var(--grey-bg-color) !important; color: var(--black-color) !important; - margin-top: 10px; - margin-left: 5px; + /* margin-top: 10px; */ + /* margin-left: 5px; */ border: 1px solid var(--brown-color); } @@ -705,7 +706,7 @@ hr { top: 50%; width: 50%; height: 1px; - background: #fff; + background: var(--white); content: ''; } .pageNotFound h1.head span:before { @@ -942,7 +943,17 @@ hr { .card { width: fit-content; } +.cardContainer { + width: 300px; +} +.ratingFilled { + color: var(--rating-star-filled); /* Color for filled stars */ +} + +.ratingHover { + color: var(--rating-star-hover); /* Color for star on hover */ +} .cardHeader { padding: 1.25rem 1rem 1rem 1rem; border-bottom: 1px solid var(--bs-gray-200); @@ -1769,14 +1780,6 @@ input[type='radio']:checked + label:hover { box-shadow: 0 1px 1px var(--brand-primary); } -.dropdowns { - background-color: var(--bs-white); - border: 1px solid var(--light-green); - position: relative; - display: inline-block; - color: var(--light-green); -} - .chipIcon { height: 0.9rem !important; } @@ -3743,7 +3746,7 @@ button[data-testid='createPostBtn'] { .primaryText { font-weight: bold; - color: var(--bs-emphasis-color, #000); + color: var(--bs-emphasis-color, var(--black-color)); @extend .reusable-text-ellipsis; /* Referencing the reusable class from the general section */ } @@ -3796,7 +3799,7 @@ button[data-testid='createPostBtn'] { .inactiveButton { background-color: transparent; - color: var(--bs-emphasis-color, #000); + color: var(--bs-emphasis-color, var(--black-color)); &:hover { background-color: var(--grey-bg-color); @@ -5967,3 +5970,72 @@ button[data-testid='createPostBtn'] { margin-left: 13vw; max-width: 80vw; } + +.btnsContainer .input { + width: 70%; +} + +.btnsContainer .inputContainer button { + width: 52px; +} + +.largeBtnsWrapper { + display: flex; +} + +.listBox { + width: 100%; + flex: 1; +} + +@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/subComponents/SortingButton.tsx b/src/subComponents/SortingButton.tsx new file mode 100644 index 0000000000..7ce7703d39 --- /dev/null +++ b/src/subComponents/SortingButton.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { Dropdown } from 'react-bootstrap'; +import SortIcon from '@mui/icons-material/Sort'; +import FilterAltOutlined from '@mui/icons-material/FilterAltOutlined'; +import PropTypes from 'prop-types'; +import styles from '../style/app.module.css'; + +interface InterfaceSortingOption { + /** The label to display for the sorting option */ + label: string; + /** The value associated with the sorting option */ + value: string; +} + +interface InterfaceSortingButtonProps { + /** The title attribute for the Dropdown */ + title?: string; + /** The list of sorting options to display in the Dropdown */ + sortingOptions: InterfaceSortingOption[]; + /** The currently selected sorting option */ + selectedOption?: string; + /** Callback function to handle sorting option change */ + onSortChange: (value: string) => void; + /** The prefix for data-testid attributes for testing */ + dataTestIdPrefix: string; + /** The data-testid attribute for the Dropdown */ + dropdownTestId?: string; + /** Custom class name for the Dropdown */ + className?: string; + /** Optional prop for custom button label */ + buttonLabel?: string; + /** Type to determine the icon to display: 'sort' or 'filter' */ + type?: 'sort' | 'filter'; +} + +/** + * SortingButton component renders a Dropdown with sorting options. + * It allows users to select a sorting option and triggers a callback on selection. + * + * @param props - The properties for the SortingButton component. + * @returns The rendered SortingButton component. + */ +const SortingButton: React.FC = ({ + title, + sortingOptions, + selectedOption, + onSortChange, + dataTestIdPrefix, + dropdownTestId, + className = styles.dropdown, + buttonLabel, + type = 'sort', +}) => { + // Determine the icon based on the type + const IconComponent = type === 'filter' ? FilterAltOutlined : SortIcon; + + return ( + + ); +}; + +SortingButton.propTypes = { + title: PropTypes.string, + sortingOptions: PropTypes.arrayOf( + PropTypes.exact({ + label: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + }).isRequired, + ).isRequired, + selectedOption: PropTypes.string, + onSortChange: PropTypes.func.isRequired, + dataTestIdPrefix: PropTypes.string.isRequired, + dropdownTestId: PropTypes.string, + buttonLabel: PropTypes.string, // Optional prop for custom button label + type: PropTypes.oneOf(['sort', 'filter']), // Type to determine the icon +}; + +export default SortingButton; diff --git a/src/utils/StaticMockLink.spec.ts b/src/utils/StaticMockLink.spec.ts new file mode 100644 index 0000000000..15c5cc3443 --- /dev/null +++ b/src/utils/StaticMockLink.spec.ts @@ -0,0 +1,725 @@ +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { StaticMockLink, mockSingleLink } from './StaticMockLink'; +import type { Observer } from '@apollo/client'; +import type { MockedResponse } from '@apollo/react-testing'; +import { gql, Observable } from '@apollo/client'; +import { print } from 'graphql'; +import type { FetchResult } from '@apollo/client/link/core'; +import { equal } from '@wry/equality'; +class TestableStaticMockLink extends StaticMockLink { + public setErrorHandler( + handler: (error: unknown, observer?: Observer) => false | void, + ): void { + this.onError = handler; + } +} + +const TEST_QUERY = gql` + query TestQuery($id: ID!) { + item(id: $id) { + id + name + } + } +`; +const mockQuery = gql` + query TestQuery { + test { + id + name + } + } +`; +const sampleQuery = gql` + query SampleQuery($id: ID!) { + user(id: $id) { + id + name + } + } +`; + +const sampleResponse = { + data: { + user: { + id: '1', + name: 'Test User', + __typename: 'User', + }, + }, +}; +describe('StaticMockLink', () => { + const sampleQuery = gql` + query SampleQuery($id: ID!) { + user(id: $id) { + id + name + } + } + `; + + const sampleVariables = { id: '1' }; + + const sampleResponse = { + data: { + user: { + id: '1', + name: 'John Doe', + __typename: 'User', + }, + }, + }; + + let mockLink: StaticMockLink; + + beforeEach((): void => { + mockLink = new StaticMockLink([], true); + }); + + test('should create instance with empty mocked responses', () => { + expect(mockLink).toBeInstanceOf(StaticMockLink); + expect(mockLink.addTypename).toBe(true); + }); + + test('should add mocked response', () => { + const mockedResponse = { + request: { + query: sampleQuery, + variables: sampleVariables, + }, + result: sampleResponse, + }; + + mockLink.addMockedResponse(mockedResponse); + // This is Mocked Response + return new Promise((resolve) => { + const observable = mockLink.request({ + query: sampleQuery, + variables: sampleVariables, + }); + + observable?.subscribe({ + next: (response) => { + expect(response).toEqual(sampleResponse); + }, + complete: () => { + resolve(); + }, + }); + }); + }); + + test('should handle delayed responses', () => { + vi.useFakeTimers(); // Start using fake timers + const delay = 100; + const mockedResponse = { + request: { + query: sampleQuery, + variables: sampleVariables, + }, + result: sampleResponse, + delay, + }; + + mockLink.addMockedResponse(mockedResponse); + + let completed = false; + + return new Promise((resolve) => { + const observable = mockLink.request({ + query: sampleQuery, + variables: sampleVariables, + }); + + observable?.subscribe({ + next: (response) => { + expect(response).toEqual(sampleResponse); + completed = true; + }, + complete: () => { + expect(completed).toBe(true); + resolve(); + }, + error: (error) => { + throw error; + }, + }); + + vi.advanceTimersByTime(delay); // Advance time by the delay + }).finally(() => { + vi.useRealTimers(); // Restore real timers + }); + }); + + test('should handle errors in response', () => { + const errorResponse = { + request: { + query: sampleQuery, + variables: sampleVariables, + }, + error: new Error('GraphQL Error'), + }; + + mockLink.addMockedResponse(errorResponse); + + return new Promise((resolve) => { + const observable = mockLink.request({ + query: sampleQuery, + variables: sampleVariables, + }); + + observable?.subscribe({ + error: (error) => { + expect(error.message).toBe('GraphQL Error'); + resolve(); + }, + }); + }); + }); + + test('should handle dynamic results using newData', () => { + const dynamicResponse = { + request: { + query: sampleQuery, + variables: { id: '2' }, // Changed to match the request variables + }, + result: sampleResponse, + newData: (variables: { id: string }) => ({ + data: { + user: { + id: variables.id, + name: `User ${variables.id}`, + __typename: 'User', + }, + }, + }), + }; + + mockLink.addMockedResponse(dynamicResponse); + + return new Promise((resolve) => { + const observable = mockLink.request({ + query: sampleQuery, + variables: { id: '2' }, // Matches the request variables in mocked response + }); + + observable?.subscribe({ + next: (response) => { + expect(response).toEqual({ + data: { + user: { + id: '2', + name: 'User 2', + __typename: 'User', + }, + }, + }); + }, + complete: () => { + resolve(); + }, + error: (error) => { + // Add error handling to help debug test failures + console.error('Test error:', error); + throw error; + }, + }); + }); + }); + test('should error when no matching response is found', () => { + return new Promise((resolve) => { + const observable = mockLink.request({ + query: sampleQuery, + variables: sampleVariables, + }); + + observable?.subscribe({ + error: (error) => { + expect(error.message).toContain( + 'No more mocked responses for the query', + ); + resolve(); + }, + }); + }); + }); +}); + +describe('mockSingleLink', () => { + test('should create StaticMockLink with default typename', () => { + const mockedResponse = { + request: { + query: gql` + query { + hello + } + `, + variables: {}, + }, + result: { data: { hello: 'world' } }, + }; + + const link = mockSingleLink(mockedResponse); + expect(link).toBeInstanceOf(StaticMockLink); + }); + + test('should create StaticMockLink with specified typename setting', () => { + const mockedResponse = { + request: { + query: gql` + query { + hello + } + `, + variables: {}, + }, + result: { data: { hello: 'world' } }, + }; + + const link = mockSingleLink(mockedResponse, false); + expect((link as StaticMockLink).addTypename).toBe(false); + }); + + test('should handle non-matching variables between request and mocked response', () => { + const mockLink = new StaticMockLink([]); + const mockedResponse = { + request: { + query: sampleQuery, + variables: { id: '1' }, + }, + result: sampleResponse, + }; + + mockLink.addMockedResponse(mockedResponse); + + return new Promise((resolve) => { + const observable = mockLink.request({ + query: sampleQuery, + variables: { id: '2' }, // Different variables + }); + + observable?.subscribe({ + error: (error) => { + expect(error.message).toContain('No more mocked responses'); + resolve(); + }, + }); + }); + }); + + test('should handle matching query but mismatched variable structure', () => { + const mockLink = new StaticMockLink([]); + const mockedResponse = { + request: { + query: sampleQuery, + variables: { id: '1', extra: 'field' }, + }, + result: sampleResponse, + }; + + mockLink.addMockedResponse(mockedResponse); + + return new Promise((resolve) => { + const observable = mockLink.request({ + query: sampleQuery, + variables: { id: '1' }, // Missing extra field + }); + + observable?.subscribe({ + error: (error) => { + expect(error.message).toContain('No more mocked responses'); + resolve(); + }, + }); + }); + }); + + test('should handle onError behavior correctly', async () => { + const mockLink = new TestableStaticMockLink([], true); + const handlerSpy = vi.fn().mockReturnValue(undefined); // Return undefined to trigger error throw + + mockLink.setErrorHandler(handlerSpy); + + await new Promise((resolve) => { + const observable = mockLink.request({ + query: gql` + query TestQuery { + field + } + `, + variables: {}, + }); + + observable?.subscribe({ + next: () => { + throw new Error('Should not succeed'); + }, + error: (error) => { + // Verify the error handler was called + expect(handlerSpy).toHaveBeenCalledTimes(1); + + // Verify we got the expected error + expect(error.message).toContain('No more mocked responses'); + + resolve(); + }, + }); + }); + }, 10000); + it('should throw an error if a mocked response lacks result and error', () => { + const mockedResponses = [ + { + request: { query: mockQuery }, + // Missing `result` and `error` + }, + ]; + + const link = new StaticMockLink(mockedResponses); + + const operation = { + query: mockQuery, + variables: {}, + }; + + const observable = link.request(operation); + + expect(observable).toBeInstanceOf(Observable); + + // Subscribe to the observable and expect an error + observable?.subscribe({ + next: () => { + // This shouldn't be called + throw new Error('next() should not be called'); + }, + error: (err) => { + // Check the error message + expect(err.message).toContain( + 'Mocked response should contain either result or error', + ); + }, + complete: () => { + // This shouldn't be called + throw new Error('complete() should not be called'); + }, + }); + }); + + it('should return undefined when no mocked response matches operation variables', () => { + const mockedResponses = [ + { + request: { + query: mockQuery, + variables: { id: '123' }, + }, + result: { data: { test: { id: '123', name: 'Test Name' } } }, + }, + ]; + + const link = new StaticMockLink(mockedResponses); + + // Simulate operation with unmatched variables + const operation = { + query: mockQuery, + variables: { id: '999' }, + }; + + const key = JSON.stringify({ + query: link.addTypename + ? print(mockQuery) // Add typename if necessary + : print(mockQuery), + }); + + const mockedResponsesByKey = link['_mockedResponsesByKey'][key]; + + // Emulate the internal logic + let responseIndex = -1; + const response = (mockedResponsesByKey || []).find((res, index) => { + const requestVariables = operation.variables || {}; + const mockedResponseVariables = res.request.variables || {}; + if (equal(requestVariables, mockedResponseVariables)) { + responseIndex = index; + return true; + } + return false; + }); + + // Assertions + expect(response).toBeUndefined(); + expect(responseIndex).toBe(-1); + }); + + test('should initialize with empty mocked responses array', () => { + // Test with null/undefined + const mockLinkNull = new StaticMockLink( + null as unknown as readonly MockedResponse[], + ); + expect(mockLinkNull).toBeInstanceOf(StaticMockLink); + + // Test with defined responses + const mockResponses: readonly MockedResponse[] = [ + { + request: { + query: sampleQuery, + variables: { id: '1' }, + }, + result: { + data: { + user: { + id: '1', + name: 'Test User', + __typename: 'User', + }, + }, + }, + }, + { + request: { + query: sampleQuery, + variables: { id: '2' }, + }, + result: { + data: { + user: { + id: '2', + name: 'Test User 2', + __typename: 'User', + }, + }, + }, + }, + ]; + + const mockLink = new StaticMockLink(mockResponses, true); + + // Verify responses were added via constructor + const observable1 = mockLink.request({ + query: sampleQuery, + variables: { id: '1' }, + }); + + const observable2 = mockLink.request({ + query: sampleQuery, + variables: { id: '2' }, + }); + + return Promise.all([ + new Promise((resolve) => { + observable1?.subscribe({ + next: (response) => { + expect(response?.data?.user?.id).toBe('1'); + resolve(); + }, + }); + }), + new Promise((resolve) => { + observable2?.subscribe({ + next: (response) => { + expect(response?.data?.user?.id).toBe('2'); + resolve(); + }, + }); + }), + ]); + }); + + test('should handle undefined operation variables', () => { + const mockLink = new StaticMockLink([]); + const mockedResponse: MockedResponse = { + request: { + query: sampleQuery, + }, + result: { + data: { + user: { + id: '1', + name: 'Test User', + __typename: 'User', + }, + }, + }, + }; + + mockLink.addMockedResponse(mockedResponse); + + const observable = mockLink.request({ + query: sampleQuery, + // Intentionally omitting variables + }); + + return new Promise((resolve) => { + observable?.subscribe({ + next: (response) => { + expect(response?.data?.user?.id).toBe('1'); + resolve(); + }, + }); + }); + }); + + test('should handle response with direct result value', async () => { + const mockResponse: MockedResponse = { + request: { + query: TEST_QUERY, + variables: { id: '1' }, + }, + result: { + data: { + item: { + id: '1', + name: 'Test Item', + __typename: 'Item', + }, + }, + }, + }; + + const link = new StaticMockLink([mockResponse]); + + return new Promise((resolve, reject) => { + const observable = link.request({ + query: TEST_QUERY, + variables: { id: '1' }, + }); + + if (!observable) { + reject(new Error('Observable is null')); + return; + } + + observable.subscribe({ + next(response) { + expect(response).toEqual(mockResponse.result); + resolve(); + }, + error: reject, + }); + }); + }); + + test('should handle response with result function', async () => { + const mockResponse: MockedResponse = { + request: { + query: TEST_QUERY, + variables: { id: '1' }, + }, + result: (variables: { id: string }) => ({ + data: { + item: { + id: variables.id, + name: `Test Item ${variables.id}`, + __typename: 'Item', + }, + }, + }), + }; + + const link = new StaticMockLink([mockResponse]); + + return new Promise((resolve, reject) => { + const observable = link.request({ + query: TEST_QUERY, + variables: { id: '1' }, + }); + + if (!observable) { + reject(new Error('Observable is null')); + return; + } + + observable.subscribe({ + next(response) { + expect(response).toEqual({ + data: { + item: { + id: '1', + name: 'Test Item 1', + __typename: 'Item', + }, + }, + }); + resolve(); + }, + error: reject, + }); + }); + }); + + test('should handle response with error', async () => { + const testError = new Error('Test error'); + const mockResponse: MockedResponse = { + request: { + query: TEST_QUERY, + variables: { id: '1' }, + }, + error: testError, + }; + + const link = new StaticMockLink([mockResponse]); + + return new Promise((resolve, reject) => { + const observable = link.request({ + query: TEST_QUERY, + variables: { id: '1' }, + }); + + if (!observable) { + reject(new Error('Observable is null')); + return; + } + + observable.subscribe({ + next() { + reject(new Error('Should not have called next')); + }, + error(error) { + expect(error).toBe(testError); + resolve(); + }, + }); + }); + }); + + test('should respect response delay', async () => { + const mockResponse: MockedResponse = { + request: { + query: TEST_QUERY, + variables: { id: '1' }, + }, + result: { + data: { + item: { + id: '1', + name: 'Test Item', + __typename: 'Item', + }, + }, + }, + delay: 50, + }; + + const link = new StaticMockLink([mockResponse]); + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + const observable = link.request({ + query: TEST_QUERY, + variables: { id: '1' }, + }); + + if (!observable) { + reject(new Error('Observable is null')); + return; + } + + observable.subscribe({ + next(response) { + const elapsed = Date.now() - startTime; + expect(elapsed).toBeGreaterThanOrEqual(50); + expect(response).toEqual(mockResponse.result); + resolve(); + }, + error: reject, + }); + }); + }); +}); diff --git a/vitest.setup.ts b/vitest.setup.ts index 7b0828bfa8..bc1f5663d0 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -1 +1,28 @@ import '@testing-library/jest-dom'; +import { cleanup } from '@testing-library/react'; + +// Basic cleanup after each test +afterEach(() => { + cleanup(); +}); + +// Simple console error handler for React 18 warnings +const originalError = console.error; +beforeAll(() => { + console.error = (...args: unknown[]) => { + const firstArg = args[0]; + if ( + typeof firstArg === 'string' && + /Warning: ReactDOM.render is no longer supported in React 18./.test( + firstArg, + ) + ) { + return; + } + originalError.call(console, ...args); + }; +}); + +afterAll(() => { + console.error = originalError; +});