From 6ac3cf2279b490727ace1102b30474b58b7b11d4 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 17 Mar 2026 10:09:04 +0000 Subject: [PATCH 01/28] feat: add OPAL TDIA extraction skill and script for markdown conversion --- .../opal-frontend/opal-ticket-tdia/SKILL.md | 52 ++ .../opal-ticket-tdia/agents/openai.yaml | 4 + .../opal-ticket-tdia/scripts/extract_tdia.py | 659 ++++++++++++++++++ 3 files changed, 715 insertions(+) create mode 100644 .codex/skills/opal-frontend/opal-ticket-tdia/SKILL.md create mode 100644 .codex/skills/opal-frontend/opal-ticket-tdia/agents/openai.yaml create mode 100644 .codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py diff --git a/.codex/skills/opal-frontend/opal-ticket-tdia/SKILL.md b/.codex/skills/opal-frontend/opal-ticket-tdia/SKILL.md new file mode 100644 index 0000000000..c5674220a5 --- /dev/null +++ b/.codex/skills/opal-frontend/opal-ticket-tdia/SKILL.md @@ -0,0 +1,52 @@ +--- +name: opal-ticket-tdia +description: Read OPAL TDIA exports and extract implementation context before planning, implementing, or reviewing a ticket. Use when a ticket has a saved Confluence HTML view-source TDIA and Codex needs scope, impacted frontend/backend/data areas, testing expectations, assumptions, tech decisions, or NFRs before touching code. +--- + +# Opal Ticket TDIA + +## Use HTML as the primary source +- Treat the TDIA as an input constraint, not as proof that the current code matches the design. +- Prefer a local saved Confluence HTML file supplied by the user or stored under `.codex-docs/tdia/`. +- Preferred artifact: the browser-saved Confluence “View Source” page (`.html`), because it preserves headings, lists, links, and tables. +- If the ticket only includes a Confluence URL, stop and ask for a saved HTML export. +- Read the extracted markdown, not the raw source artifact, whenever possible to keep context lean. +- Default to a full extraction so no design detail is silently skipped. Narrow with `--section` only when the user clearly wants a subset. + +## Extract the TDIA before coding +- Run `python3 scripts/extract_tdia.py ` first. +- If the TDIA will be reused, save the extraction with `--output .codex-docs/tdia/extracted/.md`. +- The script accepts `.html` and `.htm`. +- By default the script extracts the full TDIA, including the document preamble/title block and all discovered headings. +- If you only need a few sections, pass `--section` multiple times to narrow the output. + +Example: + +```bash +python3 .codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py \ + '/Users/maxholland/Downloads/View Source.html' \ + --output .codex-docs/tdia/extracted/fae-convert-defendant-type.md +``` + +## Pull implementation-relevant sections +- After extracting the full TDIA, always read `Overview and Scope`, `Assumptions`, and `Tech Decisions` when they exist. +- For frontend tickets, read `Opal User Portal (FE)` and any child sections such as `Pages`, `Global Components`, `Services (API Only)`, `Feature Toggles`, or feature-specific notes. +- Read backend or data sections only when the ticket touches them: `Opal Services (BE)`, `REST API Endpoints`, `Opal Database (DB)`, `Azure Infrastructure`, `Libra / GOB`, or `ETL`. +- Read the full testing section that applies to the ticket: `Integration / Component Tests`, `Frontend tests`, `Backend tests`, `E2E / Functional Tests`, `Non-Functional Tests`, `Automated Accessibility Tests`, `Manual Accessibility Tests`, and `Release-based Testing`. +- Read `Non-Functional Requirements`, `Response Time Targets`, `Specific NFRs`, and related NFR subsections whenever behavior, performance, accessibility, privacy, or resilience could be affected. + +## Implement against the codebase, not only the document +- After extracting the TDIA, inspect the existing code paths, routes, services, tests, and toggles in the repo. +- Verify that the ticket still matches the current implementation and highlight any mismatch between the ticket, TDIA, and codebase. +- Preserve TDIA terms and IDs exactly when quoting or summarizing them. +- Do not invent missing requirements, waivers, approvals, or SYS-NFR identifiers. + +## Handle extraction limits explicitly +- If the HTML extraction contains Confluence macro placeholders or saved-page noise, say so and call out the affected sections. +- If a section expected by the ticket is missing from the TDIA, continue with the codebase evidence but call out the gap. +- If a section is duplicated because of a table of contents or repeated layout artifacts, rely on the extracted section body with the most substantive content. + +## Output expectations +- Summarize the TDIA sections used before or alongside implementation work. +- Include the TDIA source path in the final summary when it materially influenced the change. +- Keep secrets, tokens, and PII out of extracted notes, code, and tests. diff --git a/.codex/skills/opal-frontend/opal-ticket-tdia/agents/openai.yaml b/.codex/skills/opal-frontend/opal-ticket-tdia/agents/openai.yaml new file mode 100644 index 0000000000..a6eb551e4d --- /dev/null +++ b/.codex/skills/opal-frontend/opal-ticket-tdia/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "OPAL TDIA" + short_description: "Read TDIA HTML before ticket work" + default_prompt: "Use $opal-ticket-tdia to extract context from the TDIA HTML and use it to implement the ticket." diff --git a/.codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py b/.codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py new file mode 100644 index 0000000000..974d130ae4 --- /dev/null +++ b/.codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py @@ -0,0 +1,659 @@ +#!/usr/bin/env python3 +"""Extract OPAL TDIA content from saved Confluence HTML into markdown.""" + +from __future__ import annotations + +import argparse +import re +import sys +from collections import OrderedDict +from dataclasses import dataclass +from html.parser import HTMLParser +from pathlib import Path + +KNOWN_HEADINGS = [ + 'Status', + 'HMCTS Approvals', + 'Overview and Scope', + 'Design Sprint Tickets', + 'Design Collateral', + 'E2E Interactions', + 'Opal User Portal (FE)', + 'Pages', + 'Global Components', + 'Services (API Only)', + 'Feature Toggles', + 'Design Notes', + 'Payload Generation', + 'Opal Services (BE)', + 'REST API Endpoints', + 'Non-API Services', + 'Opal Database (DB)', + 'Tables', + 'Indexes', + 'Sequences', + 'Stored Procedures', + 'Data', + 'Azure Infrastructure', + 'Libra / GOB', + 'Stored Procedures and Gateway Actions', + 'ETL', + 'Test and QA', + 'Integration / Component Tests', + 'Frontend tests', + 'Backend tests', + 'E2E / Functional Tests', + 'Non-Functional Tests', + 'Automated Accessibility Tests', + 'Manual Accessibility Tests', + 'Release-based Testing', + 'Tech Concerns', + 'Tech Decisions', + 'Tech Debt', + 'Non-Functional Requirements', + 'Managed Data Set Configuration', + 'Response Time Targets', + 'Personal Data Processing Operations', + 'Non-Business Critical Applications / Components', + 'Specific NFRs', + 'Assumptions', +] + +HEADING_LEVELS = { + 'Status': 1, + 'HMCTS Approvals': 1, + 'Overview and Scope': 1, + 'Design Sprint Tickets': 1, + 'Design Collateral': 1, + 'E2E Interactions': 1, + 'Opal User Portal (FE)': 1, + 'Opal Services (BE)': 1, + 'Opal Database (DB)': 1, + 'Azure Infrastructure': 1, + 'Libra / GOB': 1, + 'ETL': 1, + 'Test and QA': 1, + 'Tech Concerns': 1, + 'Tech Decisions': 1, + 'Tech Debt': 1, + 'Non-Functional Requirements': 1, + 'Assumptions': 1, + 'Pages': 2, + 'Global Components': 2, + 'Services (API Only)': 2, + 'Feature Toggles': 2, + 'Design Notes': 2, + 'Payload Generation': 2, + 'REST API Endpoints': 2, + 'Non-API Services': 2, + 'Tables': 2, + 'Indexes': 2, + 'Sequences': 2, + 'Stored Procedures': 2, + 'Data': 2, + 'Stored Procedures and Gateway Actions': 2, + 'Integration / Component Tests': 2, + 'E2E / Functional Tests': 2, + 'Non-Functional Tests': 2, + 'Automated Accessibility Tests': 3, + 'Manual Accessibility Tests': 3, + 'Release-based Testing': 3, + 'Managed Data Set Configuration': 2, + 'Response Time Targets': 2, + 'Personal Data Processing Operations': 2, + 'Non-Business Critical Applications / Components': 2, + 'Specific NFRs': 2, + 'Convert Account Page': 3, + 'Frontend tests': 3, + 'Backend tests': 3, + 'Performance Testing': 4, + 'Security Testing': 4, + 'Operational Acceptance Testing': 4, +} + +DOCUMENT_PREAMBLE = 'Document Preamble' +VOID_TAGS = {'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr'} +IMPLICIT_HEADINGS = set(KNOWN_HEADINGS) | set(HEADING_LEVELS) + + +@dataclass +class SectionBlock: + key: str + title: str + content: str + position: int + + +def normalize_inline(text: str) -> str: + text = text.replace('\xa0', ' ') + text = re.sub(r'[ \t\r\f\v]+', ' ', text) + text = re.sub(r' *\n *', '\n', text) + return text + + +def normalize_block(text: str) -> str: + text = normalize_inline(text) + text = re.sub(r'\n{3,}', '\n\n', text) + return text.strip() + + +def normalize_cell(text: str) -> str: + return normalize_block(text).replace('\n', '
') + + +def heading_level(title: str) -> int: + return HEADING_LEVELS.get(title, 2) + + +def build_section_key(path_titles: list[str]) -> str: + return ' > '.join(path_titles) + + +def sanitize_markdown(text: str) -> str: + text = text.strip() + if not text: + return '_Section not found in extracted text._' + return text.replace('\n#', '\n\\#') + + +def resolve_requested_sections(requested: list[str] | None) -> list[str]: + if not requested: + return [] + + resolved: list[str] = [] + seen = set() + for item in requested: + if item in seen: + continue + seen.add(item) + resolved.append(item) + return resolved + + +def match_requested_sections(sections: dict[str, SectionBlock], selector: str) -> list[SectionBlock]: + if selector in sections: + return [sections[selector]] + + matched = [block for key, block in sections.items() if key.endswith(f' > {selector}') or block.title == selector] + matched.sort(key=lambda block: (block.position, block.key)) + return matched + + +def build_output(source_path: Path, sections: dict[str, SectionBlock], requested: list[str], saved_from_url: str | None = None) -> str: + if requested: + resolved: list[SectionBlock] = [] + missing: list[str] = [] + + for selector in requested: + matched = match_requested_sections(sections, selector) + if matched: + resolved.extend(matched) + else: + missing.append(selector) + else: + resolved = sorted(sections.values(), key=lambda block: (block.position, block.key)) + missing = [] + + unique_resolved: list[SectionBlock] = [] + seen_keys = set() + for block in resolved: + if block.key in seen_keys: + continue + seen_keys.add(block.key) + unique_resolved.append(block) + + lines = [ + f'# TDIA Extraction: {source_path.name}', + '', + f'- Source: `{source_path}`', + '- Input type: html', + ] + + if saved_from_url: + lines.append(f'- Saved from: {saved_from_url}') + + lines.extend( + [ + f'- Sections requested: {"all" if not requested else len(requested)}', + f'- Sections found: {len(unique_resolved)}', + ] + ) + + if missing: + lines.append(f'- Sections missing: {", ".join(missing)}') + + for block in unique_resolved: + lines.extend(['', f'## {block.key}', '', sanitize_markdown(block.content)]) + + return '\n'.join(lines).rstrip() + '\n' + + +def extract_saved_from_url(raw_html: str) -> str | None: + match = re.search(r'', raw_html, re.IGNORECASE) + if not match: + return None + return match.group(1).strip() or None + + +class ConfluenceHtmlSectionParser(HTMLParser): + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.sections: OrderedDict[str, dict[str, object]] = OrderedDict() + self.heading_stack: list[str] = [] + self.section_counter = 0 + self.in_body = False + + self.current_heading_level: int | None = None + self.current_heading_text: list[str] = [] + self.orphan_fragments: list[str] = [] + + self.current_paragraph: list[str] | None = None + self.current_list_item: list[str] | None = None + self.list_stack: list[dict[str, object]] = [] + + self.current_table: list[tuple[list[str], bool]] | None = None + self.current_row: list[tuple[str, bool]] | None = None + self.current_cell: list[str] | None = None + self.current_cell_is_header = False + + self.active_link: dict[str, object] | None = None + + self.ignore_depth = 0 + self.skip_depth = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + attrs_dict = {key: value or '' for key, value in attrs} + + if tag == 'body': + self.in_body = True + return + + if not self.in_body: + return + + if self.skip_depth: + if tag not in VOID_TAGS: + self.skip_depth += 1 + return + + if self.ignore_depth: + if tag not in VOID_TAGS: + self.ignore_depth += 1 + return + + if tag in {'script', 'style', 'template'}: + self.ignore_depth = 1 + return + + if self.should_skip_subtree(attrs_dict): + self.skip_depth = 1 + return + + if re.fullmatch(r'h[1-6]', tag): + self.flush_orphan_fragments() + self.flush_paragraph() + self.flush_lists() + self.flush_table() + self.current_heading_level = int(tag[1]) + self.current_heading_text = [] + return + + if tag == 'p' and self.current_table is None: + self.flush_orphan_fragments() + self.current_paragraph = [] + return + + if tag in {'ul', 'ol'} and self.current_table is None: + self.flush_orphan_fragments() + self.list_stack.append({'ordered': tag == 'ol', 'items': []}) + return + + if tag == 'li' and self.current_table is None: + self.current_list_item = [] + return + + if tag == 'table': + self.flush_orphan_fragments() + self.flush_paragraph() + self.flush_lists() + self.current_table = [] + return + + if tag == 'tr' and self.current_table is not None: + self.current_row = [] + return + + if tag in {'th', 'td'} and self.current_row is not None: + self.current_cell = [] + self.current_cell_is_header = tag == 'th' + return + + if tag == 'a': + self.active_link = {'href': attrs_dict.get('href', ''), 'text': []} + return + + if tag == 'br': + self.append_fragment('\n') + return + + if tag == 'img': + macro_text = self.macro_text(attrs_dict) + if macro_text: + self.append_fragment(macro_text) + + def handle_endtag(self, tag: str) -> None: + if tag == 'body': + self.flush_orphan_fragments() + self.in_body = False + return + + if not self.in_body: + return + + if self.skip_depth: + self.skip_depth -= 1 + return + + if self.ignore_depth: + self.ignore_depth -= 1 + return + + if tag == 'a' and self.active_link is not None: + href = str(self.active_link.get('href', '')).strip() + text = normalize_block(''.join(self.active_link.get('text', []))) + rendered = '' + if href and text: + if text == href: + rendered = href + else: + rendered = f'[{text}]({href})' + elif text: + rendered = text + elif href: + rendered = href + self.active_link = None + if rendered: + self.append_fragment(rendered) + return + + if re.fullmatch(r'h[1-6]', tag) and self.current_heading_level is not None: + title = normalize_block(''.join(self.current_heading_text)) + if title: + self.start_section(title, self.current_heading_level) + self.current_heading_level = None + self.current_heading_text = [] + return + + if tag == 'p' and self.current_table is None: + self.flush_paragraph() + return + + if tag == 'li' and self.current_list_item is not None: + item = normalize_block(''.join(self.current_list_item)) + if item and self.list_stack: + self.list_stack[-1]['items'].append(item) + self.current_list_item = None + return + + if tag in {'ul', 'ol'} and self.list_stack: + list_state = self.list_stack.pop() + items = list_state['items'] + if items: + ordered = bool(list_state['ordered']) + prefix = lambda index: f'{index}. ' if ordered else '- ' + block = '\n'.join(f'{prefix(index)}{item}' for index, item in enumerate(items, start=1)) + self.emit_block(block) + return + + if tag in {'th', 'td'} and self.current_cell is not None and self.current_row is not None: + cell_text = normalize_cell(''.join(self.current_cell)) + self.current_row.append((cell_text, self.current_cell_is_header)) + self.current_cell = None + self.current_cell_is_header = False + return + + if tag == 'tr' and self.current_row is not None and self.current_table is not None: + if any(cell for cell, _ in self.current_row): + row_cells = [cell for cell, _ in self.current_row] + is_header = any(is_header for _, is_header in self.current_row) + self.current_table.append((row_cells, is_header)) + self.current_row = None + return + + if tag == 'table': + self.flush_table() + + def handle_data(self, data: str) -> None: + if not self.in_body or self.skip_depth or self.ignore_depth: + return + self.append_fragment(data) + + def should_skip_subtree(self, attrs: dict[str, str]) -> bool: + element_id = attrs.get('id', '') + element_class = attrs.get('class', '') + return element_id.startswith('give-freely-root') or 'give-freely-root' in element_class + + def macro_text(self, attrs: dict[str, str]) -> str | None: + if 'editor-inline-macro' not in attrs.get('class', ''): + return None + + macro_name = attrs.get('data-macro-name', '').strip() + if macro_name == 'toc': + return None + + if macro_name == 'status': + params = attrs.get('data-macro-parameters', '') + match = re.search(r'(?:^|\|)title=([^|]+)', params) + if match: + return match.group(1).strip() + default_param = attrs.get('data-macro-default-parameter', '').strip() + return default_param or None + + if macro_name == 'jira': + return '_Confluence jira macro omitted in saved HTML export._' + + return f'_Confluence {macro_name} macro omitted in saved HTML export._' + + def append_fragment(self, text: str) -> None: + if not text: + return + + if self.active_link is not None: + self.active_link['text'].append(text) + return + + target = self.current_target() + if target is not None: + target.append(text) + else: + self.orphan_fragments.append(text) + + def current_target(self) -> list[str] | None: + if self.current_cell is not None: + return self.current_cell + if self.current_heading_level is not None: + return self.current_heading_text + if self.current_list_item is not None: + return self.current_list_item + if self.current_paragraph is not None: + return self.current_paragraph + return None + + def start_section(self, title: str, level: int) -> None: + while len(self.heading_stack) >= level: + self.heading_stack.pop() + self.heading_stack.append(title) + key = build_section_key(self.heading_stack) + self.ensure_section(key, title) + + def ensure_section(self, key: str, title: str) -> dict[str, object]: + if key not in self.sections: + self.sections[key] = { + 'title': title, + 'blocks': [], + 'position': self.section_counter, + } + self.section_counter += 1 + return self.sections[key] + + def current_section(self) -> dict[str, object]: + if not self.heading_stack: + return self.ensure_section(DOCUMENT_PREAMBLE, DOCUMENT_PREAMBLE) + + key = build_section_key(self.heading_stack) + return self.ensure_section(key, self.heading_stack[-1]) + + def emit_block(self, text: str) -> None: + block = normalize_block(text) + if not block: + return + section = self.current_section() + section['blocks'].append(block) + + def flush_orphan_fragments(self) -> None: + if not self.orphan_fragments: + return + + text = normalize_block(''.join(self.orphan_fragments)) + self.orphan_fragments = [] + if not text: + return + + if text in IMPLICIT_HEADINGS: + self.start_section(text, heading_level(text)) + return + + self.emit_block(text) + + def flush_paragraph(self) -> None: + if self.current_paragraph is None: + return + self.emit_block(''.join(self.current_paragraph)) + self.current_paragraph = None + + def flush_lists(self) -> None: + while self.list_stack: + list_state = self.list_stack.pop() + items = list_state['items'] + if not items: + continue + ordered = bool(list_state['ordered']) + prefix = lambda index: f'{index}. ' if ordered else '- ' + block = '\n'.join(f'{prefix(index)}{item}' for index, item in enumerate(items, start=1)) + self.emit_block(block) + + def flush_table(self) -> None: + if self.current_table is None: + return + table_markdown = render_table(self.current_table) + if table_markdown: + self.emit_block(table_markdown) + self.current_table = None + + def finalize(self) -> dict[str, SectionBlock]: + self.flush_orphan_fragments() + self.flush_paragraph() + self.flush_lists() + self.flush_table() + + sections: dict[str, SectionBlock] = {} + for key, raw in self.sections.items(): + title = str(raw['title']) + blocks = raw['blocks'] + content = '\n\n'.join(blocks) + sections[key] = SectionBlock( + key=key, + title=title, + content=content, + position=int(raw['position']), + ) + return sections + + +def render_table(rows: list[tuple[list[str], bool]]) -> str: + cleaned_rows = [(cells, is_header) for cells, is_header in rows if any(cell.strip() for cell in cells)] + if not cleaned_rows: + return '' + + widths = [len(cells) for cells, _ in cleaned_rows] + column_count = max(widths) + + normalized_rows: list[tuple[list[str], bool]] = [] + for cells, is_header in cleaned_rows: + padded = cells + [''] * (column_count - len(cells)) + normalized_rows.append((padded, is_header)) + + header_cells, header_is_header = normalized_rows[0] + if not header_is_header: + header_cells = [f'Column {index}' for index in range(1, column_count + 1)] + body_rows = [cells for cells, _ in normalized_rows] + else: + body_rows = [cells for cells, _ in normalized_rows[1:]] + + header = '| ' + ' | '.join(escape_table_cell(cell) for cell in header_cells) + ' |' + separator = '| ' + ' | '.join('---' for _ in range(column_count)) + ' |' + lines = [header, separator] + + for cells in body_rows: + lines.append('| ' + ' | '.join(escape_table_cell(cell) for cell in cells) + ' |') + + return '\n'.join(lines) + + +def escape_table_cell(text: str) -> str: + return text.replace('|', '\\|') + + +def parse_html_sections(html_path: Path) -> tuple[dict[str, SectionBlock], str | None]: + raw_html = html_path.read_text(encoding='utf-8') + parser = ConfluenceHtmlSectionParser() + parser.feed(raw_html) + parser.close() + sections = parser.finalize() + return sections, extract_saved_from_url(raw_html) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('source_path', help='Path to a saved Confluence TDIA HTML file') + parser.add_argument( + '--section', + action='append', + dest='sections', + help='Repeat to limit output to specific headings or section paths. Defaults to extracting the full TDIA.', + ) + parser.add_argument('--output', help='Write markdown output to a file instead of stdout') + return parser.parse_args() + + +def main() -> int: + args = parse_args() + source_path = Path(args.source_path).expanduser().resolve() + + if not source_path.exists(): + print(f'[ERROR] Source file not found: {source_path}', file=sys.stderr) + return 1 + + suffix = source_path.suffix.lower() + requested = resolve_requested_sections(args.sections) + + if suffix not in {'.html', '.htm'}: + print(f'[ERROR] Unsupported file type: {source_path.suffix}', file=sys.stderr) + print('[ERROR] Expected .html or .htm', file=sys.stderr) + return 1 + + sections, saved_from_url = parse_html_sections(source_path) + output = build_output(source_path=source_path, sections=sections, requested=requested, saved_from_url=saved_from_url) + + if args.output: + output_path = Path(args.output).expanduser().resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(output, encoding='utf-8') + else: + sys.stdout.write(output) + + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) From 1bd624114a0c23e6b19f526a8c69c3768c3a6838 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 17 Mar 2026 10:49:36 +0000 Subject: [PATCH 02/28] feat: enhance TDIA extraction script to support E2E interactions images and improved output structure --- .../opal-frontend/opal-ticket-tdia/SKILL.md | 11 +- .../opal-ticket-tdia/scripts/extract_tdia.py | 234 +++++++++++++++++- 2 files changed, 231 insertions(+), 14 deletions(-) diff --git a/.codex/skills/opal-frontend/opal-ticket-tdia/SKILL.md b/.codex/skills/opal-frontend/opal-ticket-tdia/SKILL.md index c5674220a5..5f027bed48 100644 --- a/.codex/skills/opal-frontend/opal-ticket-tdia/SKILL.md +++ b/.codex/skills/opal-frontend/opal-ticket-tdia/SKILL.md @@ -8,16 +8,19 @@ description: Read OPAL TDIA exports and extract implementation context before pl ## Use HTML as the primary source - Treat the TDIA as an input constraint, not as proof that the current code matches the design. - Prefer a local saved Confluence HTML file supplied by the user or stored under `.codex-docs/tdia/`. -- Preferred artifact: the browser-saved Confluence “View Source” page (`.html`), because it preserves headings, lists, links, and tables. +- Preferred artifact: the browser-saved Confluence “View Source” page (`.html`) together with its companion `*_files/` folder, because that preserves headings, lists, links, tables, and embedded images. - If the ticket only includes a Confluence URL, stop and ask for a saved HTML export. +- If the TDIA includes an `E2E Interactions` diagram, check the saved page’s companion `*_files/` folder first. Only ask for a separate exported image if the saved page assets do not contain it. - Read the extracted markdown, not the raw source artifact, whenever possible to keep context lean. - Default to a full extraction so no design detail is silently skipped. Narrow with `--section` only when the user clearly wants a subset. ## Extract the TDIA before coding - Run `python3 scripts/extract_tdia.py ` first. -- If the TDIA will be reused, save the extraction with `--output .codex-docs/tdia/extracted/.md`. +- If the TDIA will be reused, prefer `--output-root .codex-docs/tdia` so the script creates `.codex-docs/tdia//source.html`, `.codex-docs/tdia//extracted.md`, and `images/` beside them when embedded images are found. - The script accepts `.html` and `.htm`. - By default the script extracts the full TDIA, including the document preamble/title block and all discovered headings. +- The script automatically detects embedded images from the saved page’s sibling `*_files/` folder and copies `E2E Interactions` images into `images/`, then references them from the `E2E Interactions` section in `extracted.md`. +- If the saved page assets do not contain the E2E diagram, pass `--e2e-image ` to supply it explicitly. - If you only need a few sections, pass `--section` multiple times to narrow the output. Example: @@ -25,7 +28,8 @@ Example: ```bash python3 .codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py \ '/Users/maxholland/Downloads/View Source.html' \ - --output .codex-docs/tdia/extracted/fae-convert-defendant-type.md + --output-root .codex-docs/tdia \ + --e2e-image '/Users/maxholland/Downloads/e2e-interactions.png' ``` ## Pull implementation-relevant sections @@ -43,6 +47,7 @@ python3 .codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py \ ## Handle extraction limits explicitly - If the HTML extraction contains Confluence macro placeholders or saved-page noise, say so and call out the affected sections. +- If the `E2E Interactions` section refers to a diagram but no image was provided, ask for the exported image and keep a note in `extracted.md` until it is added. - If a section expected by the ticket is missing from the TDIA, continue with the codebase evidence but call out the gap. - If a section is duplicated because of a table of contents or repeated layout artifacts, rely on the extracted section body with the most substantive content. diff --git a/.codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py b/.codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py index 974d130ae4..d9ffc35320 100644 --- a/.codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py +++ b/.codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py @@ -5,6 +5,7 @@ import argparse import re +import shutil import sys from collections import OrderedDict from dataclasses import dataclass @@ -124,6 +125,13 @@ class SectionBlock: position: int +@dataclass +class ImageRef: + section_key: str + source_path: str + title: str | None = None + + def normalize_inline(text: str) -> str: text = text.replace('\xa0', ' ') text = re.sub(r'[ \t\r\f\v]+', ' ', text) @@ -179,7 +187,15 @@ def match_requested_sections(sections: dict[str, SectionBlock], selector: str) - return matched -def build_output(source_path: Path, sections: dict[str, SectionBlock], requested: list[str], saved_from_url: str | None = None) -> str: +def build_output( + source_path: Path, + sections: dict[str, SectionBlock], + requested: list[str], + saved_from_url: str | None = None, + e2e_image_markdown: str | None = None, + e2e_image_missing_note: str | None = None, + section_image_markdown: dict[str, list[str]] | None = None, +) -> str: if requested: resolved: list[SectionBlock] = [] missing: list[str] = [] @@ -223,11 +239,136 @@ def build_output(source_path: Path, sections: dict[str, SectionBlock], requested lines.append(f'- Sections missing: {", ".join(missing)}') for block in unique_resolved: - lines.extend(['', f'## {block.key}', '', sanitize_markdown(block.content)]) + content = sanitize_markdown(block.content) + image_markdown = [] + if section_image_markdown: + image_markdown = section_image_markdown.get(block.key, []) + + if block.key == 'E2E Interactions': + if image_markdown: + content = f'{content}\n\n### Diagram Images\n\n' + '\n\n'.join(image_markdown) + elif e2e_image_markdown: + content = f'{content}\n\n### Diagram Image\n\n{e2e_image_markdown}' + elif e2e_image_missing_note: + content = f'{content}\n\n{e2e_image_missing_note}' + lines.extend(['', f'## {block.key}', '', content]) return '\n'.join(lines).rstrip() + '\n' +def derive_tdia_name(sections: dict[str, SectionBlock], fallback: str) -> str: + preamble = sections.get(DOCUMENT_PREAMBLE) + if preamble: + lines = [line.strip() for line in preamble.content.splitlines() if line.strip()] + for line in lines: + tdia_match = re.match(r'^TDIA:\s*(.+)$', line, re.IGNORECASE) + if tdia_match: + return tdia_match.group(1).strip() + + design_match = re.search(r'\bfor\s+(.+)$', line, re.IGNORECASE) + if 'tech design ia' in line.lower() and design_match: + return design_match.group(1).strip() + + return fallback + + +def slugify(value: str) -> str: + slug = value.strip().lower() + slug = re.sub(r'[^a-z0-9]+', '-', slug) + slug = re.sub(r'-{2,}', '-', slug).strip('-') + return slug or 'tdia' + + +def copy_e2e_image(target_dir: Path, image_path: Path) -> tuple[Path, str]: + image_dir = target_dir / 'images' + image_dir.mkdir(parents=True, exist_ok=True) + + safe_name = f'e2e-interactions{image_path.suffix.lower()}' + target_path = image_dir / safe_name + shutil.copy2(image_path, target_path) + markdown = f'![E2E Interactions](images/{target_path.name})' + return target_path, markdown + + +def resolve_companion_asset(html_path: Path, source_path: str) -> Path | None: + files_dir = html_path.with_name(f'{html_path.stem}_files') + if not files_dir.exists(): + return None + + cleaned = source_path.strip() + if cleaned.startswith('./'): + cleaned = cleaned[2:] + + candidate = (html_path.parent / cleaned).resolve() + if candidate.exists(): + return candidate + + fallback = files_dir / Path(cleaned).name + if fallback.exists(): + return fallback.resolve() + + return None + + +def copy_section_images( + target_dir: Path, + html_path: Path, + image_refs: list[ImageRef], + allowed_sections: set[str] | None = None, +) -> dict[str, list[str]]: + if not image_refs: + return {} + + image_dir = target_dir / 'images' + image_dir.mkdir(parents=True, exist_ok=True) + + by_section: dict[str, list[str]] = {} + seen_targets: set[tuple[str, str]] = set() + + for image_ref in image_refs: + if allowed_sections is not None and image_ref.section_key not in allowed_sections: + continue + + source_asset = resolve_companion_asset(html_path, image_ref.source_path) + if source_asset is None: + continue + + target_name = source_asset.name + target_path = image_dir / target_name + key = (image_ref.section_key, target_name) + if key not in seen_targets: + shutil.copy2(source_asset, target_path) + seen_targets.add(key) + + alt = image_ref.title or image_ref.section_key + by_section.setdefault(image_ref.section_key, []).append(f'![{alt}](images/{target_name})') + + return by_section + + +def write_output_bundle( + output_root: Path, + source_path: Path, + output_markdown: str, + sections: dict[str, SectionBlock], + e2e_image_path: Path | None = None, +) -> Path: + tdia_name = derive_tdia_name(sections, fallback=source_path.stem) + target_dir = output_root / slugify(tdia_name) + target_dir.mkdir(parents=True, exist_ok=True) + + source_target = target_dir / f'source{source_path.suffix.lower()}' + if source_path.resolve() != source_target.resolve(): + shutil.copy2(source_path, source_target) + + if e2e_image_path is not None: + copy_e2e_image(target_dir, e2e_image_path) + + markdown_target = target_dir / 'extracted.md' + markdown_target.write_text(output_markdown, encoding='utf-8') + return markdown_target + + def extract_saved_from_url(raw_html: str) -> str | None: match = re.search(r'', raw_html, re.IGNORECASE) if not match: @@ -239,6 +380,7 @@ class ConfluenceHtmlSectionParser(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) self.sections: OrderedDict[str, dict[str, object]] = OrderedDict() + self.section_images: list[ImageRef] = [] self.heading_stack: list[str] = [] self.section_counter = 0 self.in_body = False @@ -337,6 +479,11 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None return if tag == 'img': + embedded_image = self.embedded_image_ref(attrs_dict) + if embedded_image is not None: + self.section_images.append(embedded_image) + return + macro_text = self.macro_text(attrs_dict) if macro_text: self.append_fragment(macro_text) @@ -454,6 +601,17 @@ def macro_text(self, attrs: dict[str, str]) -> str | None: return f'_Confluence {macro_name} macro omitted in saved HTML export._' + def embedded_image_ref(self, attrs: dict[str, str]) -> ImageRef | None: + if 'confluence-embedded-image' not in attrs.get('class', ''): + return None + + source_path = attrs.get('src', '').strip() + if not source_path: + return None + + title = attrs.get('data-element-title') or attrs.get('data-linked-resource-default-alias') or attrs.get('title') or None + return ImageRef(section_key=self.current_section_key(), source_path=source_path, title=title) + def append_fragment(self, text: str) -> None: if not text: return @@ -503,6 +661,11 @@ def current_section(self) -> dict[str, object]: key = build_section_key(self.heading_stack) return self.ensure_section(key, self.heading_stack[-1]) + def current_section_key(self) -> str: + if not self.heading_stack: + return DOCUMENT_PREAMBLE + return build_section_key(self.heading_stack) + def emit_block(self, text: str) -> None: block = normalize_block(text) if not block: @@ -550,7 +713,7 @@ def flush_table(self) -> None: self.emit_block(table_markdown) self.current_table = None - def finalize(self) -> dict[str, SectionBlock]: + def finalize(self) -> tuple[dict[str, SectionBlock], list[ImageRef]]: self.flush_orphan_fragments() self.flush_paragraph() self.flush_lists() @@ -567,7 +730,7 @@ def finalize(self) -> dict[str, SectionBlock]: content=content, position=int(raw['position']), ) - return sections + return sections, self.section_images def render_table(rows: list[tuple[list[str], bool]]) -> str: @@ -604,13 +767,13 @@ def escape_table_cell(text: str) -> str: return text.replace('|', '\\|') -def parse_html_sections(html_path: Path) -> tuple[dict[str, SectionBlock], str | None]: +def parse_html_sections(html_path: Path) -> tuple[dict[str, SectionBlock], list[ImageRef], str | None]: raw_html = html_path.read_text(encoding='utf-8') parser = ConfluenceHtmlSectionParser() parser.feed(raw_html) parser.close() - sections = parser.finalize() - return sections, extract_saved_from_url(raw_html) + sections, section_images = parser.finalize() + return sections, section_images, extract_saved_from_url(raw_html) def parse_args() -> argparse.Namespace: @@ -622,7 +785,16 @@ def parse_args() -> argparse.Namespace: dest='sections', help='Repeat to limit output to specific headings or section paths. Defaults to extracting the full TDIA.', ) - parser.add_argument('--output', help='Write markdown output to a file instead of stdout') + output_group = parser.add_mutually_exclusive_group() + output_group.add_argument('--output', help='Write markdown output to a specific file') + output_group.add_argument( + '--output-root', + help='Create `.codex-docs/tdia//` style output under this root with `source.html` and `extracted.md`.', + ) + parser.add_argument( + '--e2e-image', + help='Optional path to the E2E interactions diagram image. When used with `--output-root`, the image is copied to `images/` beside `extracted.md`.', + ) return parser.parse_args() @@ -634,6 +806,17 @@ def main() -> int: print(f'[ERROR] Source file not found: {source_path}', file=sys.stderr) return 1 + if args.e2e_image and not args.output_root: + print('[ERROR] `--e2e-image` requires `--output-root` so the image can be copied beside `extracted.md`.', file=sys.stderr) + return 1 + + e2e_image_path: Path | None = None + if args.e2e_image: + e2e_image_path = Path(args.e2e_image).expanduser().resolve() + if not e2e_image_path.exists(): + print(f'[ERROR] E2E image not found: {e2e_image_path}', file=sys.stderr) + return 1 + suffix = source_path.suffix.lower() requested = resolve_requested_sections(args.sections) @@ -642,10 +825,39 @@ def main() -> int: print('[ERROR] Expected .html or .htm', file=sys.stderr) return 1 - sections, saved_from_url = parse_html_sections(source_path) - output = build_output(source_path=source_path, sections=sections, requested=requested, saved_from_url=saved_from_url) + sections, section_images, saved_from_url = parse_html_sections(source_path) + e2e_image_markdown = None + e2e_image_missing_note = None + section_image_markdown: dict[str, list[str]] | None = None + if e2e_image_path is not None: + e2e_image_markdown = f'![E2E Interactions](images/e2e-interactions{e2e_image_path.suffix.lower()})' + elif 'E2E Interactions' in sections and not any(ref.section_key == 'E2E Interactions' for ref in section_images): + e2e_image_missing_note = '_E2E interactions image not provided. Ask the user for the diagram export and store it under `images/` beside this file._' + + if args.output_root: + output_root = Path(args.output_root).expanduser().resolve() + tdia_name = derive_tdia_name(sections, fallback=source_path.stem) + target_dir = output_root / slugify(tdia_name) + section_image_markdown = copy_section_images( + target_dir=target_dir, + html_path=source_path, + image_refs=section_images, + allowed_sections={'E2E Interactions'}, + ) + + output = build_output( + source_path=source_path, + sections=sections, + requested=requested, + saved_from_url=saved_from_url, + e2e_image_markdown=e2e_image_markdown, + e2e_image_missing_note=e2e_image_missing_note, + section_image_markdown=section_image_markdown, + ) - if args.output: + if args.output_root: + write_output_bundle(output_root, source_path, output, sections, e2e_image_path=e2e_image_path) + elif args.output: output_path = Path(args.output).expanduser().resolve() output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(output, encoding='utf-8') From 4fe4182f588a60ad427b211aea48bd01e9de85f1 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 17 Mar 2026 10:55:38 +0000 Subject: [PATCH 03/28] feat: add OPAL Ticket Context skill and agent configuration for TDIA markdown processing --- .../opal-ticket-context/SKILL.md | 45 +++++++++++++++++++ .../opal-ticket-context/agents/openai.yaml | 4 ++ 2 files changed, 49 insertions(+) create mode 100644 .codex/skills/opal-frontend/opal-ticket-context/SKILL.md create mode 100644 .codex/skills/opal-frontend/opal-ticket-context/agents/openai.yaml diff --git a/.codex/skills/opal-frontend/opal-ticket-context/SKILL.md b/.codex/skills/opal-frontend/opal-ticket-context/SKILL.md new file mode 100644 index 0000000000..2c23b201f0 --- /dev/null +++ b/.codex/skills/opal-frontend/opal-ticket-context/SKILL.md @@ -0,0 +1,45 @@ +--- +name: opal-ticket-context +description: Read extracted OPAL TDIA markdown under `.codex-docs/tdia/.../extracted.md` and use it as pre-implementation context for a ticket. Use when a ticket already has a related TDIA folder in `.codex-docs/tdia/` and Codex needs to identify impacted frontend/backend/data areas, tests, toggles, APIs, assumptions, and NFRs before changing code. +--- + +# Opal Ticket Context + +## Use the extracted TDIA markdown first +- Treat `.codex-docs/tdia//extracted.md` as the primary design artifact for ticket preparation. +- Prefer the TDIA folder explicitly named by the user or linked from the ticket. +- If the correct TDIA folder is not obvious, stop and ask which folder under `.codex-docs/tdia/` applies. +- If no extracted TDIA exists yet, use `$opal-ticket-tdia` first to create it. + +## Build ticket context in this order +1. Read the ticket and identify the feature, scope, and likely affected journey. +2. Read the related `extracted.md`. +3. Pull the implementation-relevant TDIA sections for the ticket. +4. Inspect the codebase paths that match those sections. +5. Implement only after the ticket, TDIA, and codebase view are aligned. + +## Pull the right TDIA sections +- Always read `Overview and Scope`, `Assumptions`, `Tech Decisions`, and `Non-Functional Requirements` when they exist. +- For frontend tickets, read `Opal User Portal (FE)` and its relevant child sections such as `Pages`, `Global Components`, `Services (API Only)`, `Feature Toggles`, `Design Notes`, `Payload Generation`, validators, resolvers, state store, or guards if present. +- For backend tickets, read `Opal Services (BE)` and `REST API Endpoints`. +- For database-impacting tickets, read `Opal Database (DB)`, `Libra / GOB`, and any related `Stored Procedures`, `Data`, or design notes. +- For testing work, read `Test and QA` plus `Integration / Component Tests`, `E2E / Functional Tests`, `Non-Functional Tests`, and accessibility sections. +- For flows with diagrams, read `E2E Interactions` and inspect any images referenced from `images/`. + +## Convert the TDIA into implementation context +- Summarize the impacted routes, pages, APIs, services, feature toggles, data entities, and tests before touching code. +- Preserve TDIA wording and identifiers exactly when they materially affect implementation. +- Translate the TDIA into concrete repo targets: routes, components, services, validators, resolvers, state, tests, backend handlers, and DB integration points. +- Use the repo as the source of truth for current implementation details; the TDIA provides design intent, not guaranteed current state. + +## Handle mismatches explicitly +- If the ticket and TDIA disagree, call out the mismatch before implementing. +- If the codebase and TDIA disagree, call out the mismatch and favor code inspection for current behavior while preserving TDIA constraints where still applicable. +- If the TDIA is broader than the ticket, constrain the implementation to the ticket scope. +- Do not invent missing approvals, waivers, SYS-NFR IDs, routes, or API behavior. + +## Output expectations +- State which TDIA markdown file was used. +- Summarize the sections that informed the implementation. +- Identify the likely code areas to inspect before editing. +- Keep secrets, tokens, and PII out of notes, code, and tests. diff --git a/.codex/skills/opal-frontend/opal-ticket-context/agents/openai.yaml b/.codex/skills/opal-frontend/opal-ticket-context/agents/openai.yaml new file mode 100644 index 0000000000..2f41d5d4ab --- /dev/null +++ b/.codex/skills/opal-frontend/opal-ticket-context/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "OPAL Ticket Context" + short_description: "Read TDIA markdown before tickets" + default_prompt: "Use $opal-ticket-context to read the related extracted TDIA markdown and prepare implementation context for this ticket." From 846bbed7a672dc7c714d65c4010d9dedcd8abe2e Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 17 Mar 2026 11:31:01 +0000 Subject: [PATCH 04/28] feat: add convert-to-company action visibility assertions and related functionality in defendant details --- .../details.defendant.actions.ts | 16 +++++ .../AccountEnquiriesViewDetails.feature | 3 + ...tEnquiriesViewDetailsAccessibility.feature | 8 ++- .../opal/flows/account-enquiry.flow.ts | 16 +++++ .../searchForAccount/account-enquiry.steps.ts | 10 +++ ...ndant-details-defendant-tab.component.html | 25 +++++--- ...nt-details-defendant-tab.component.spec.ts | 40 +++++++----- ...fendant-details-defendant-tab.component.ts | 11 ++-- ...fines-acc-defendant-details.component.html | 3 +- ...es-acc-defendant-details.component.spec.ts | 62 +++++++++++++++++++ .../fines-acc-defendant-details.component.ts | 39 +++++++++--- 11 files changed, 192 insertions(+), 41 deletions(-) diff --git a/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts b/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts index 54b57e166f..7d1cfe9039 100644 --- a/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts +++ b/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts @@ -56,4 +56,20 @@ export class AccountDetailsDefendantActions { assertDefendantNameContains(expected: string): void { cy.get(L.defendant.fields.name, this.common.getTimeoutOptions()).should('contain.text', expected); } + + /** + * Asserts that the convert-to-company action is visible in the Defendant tab. + */ + assertConvertToCompanyActionVisible(): void { + cy.get(L.actions.convertToCompany, this.common.getTimeoutOptions()) + .should('be.visible') + .and('contain.text', 'Convert to a company account'); + } + + /** + * Asserts that the convert-to-company action is not rendered in the Defendant tab. + */ + assertConvertToCompanyActionNotPresent(): void { + cy.get(L.actions.convertToCompany, this.common.getTimeoutOptions()).should('not.exist'); + } } diff --git a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature index 8f93ad7d88..592bbd842e 100644 --- a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature +++ b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature @@ -27,6 +27,7 @@ Feature: Account Enquiries – View Account Details Then I should see the page header contains "Mr John ACCDETAILSURNAME{uniqUpper}" # AC3 – Navigate to Defendant details When I go to the Defendant details section and the header is "Defendant details" + Then I should see the convert to company account action @PO-1593 @866 @PO-1110 @PO-1127 Scenario: Defendant edit warning retains changes when I stay on the form @@ -79,6 +80,7 @@ Feature: Account Enquiries – View Account Details Then I should see the account header contains "Accdetail comp{uniq}" # AC3 – Navigate to Company details When I go to the Defendant details section and the header is "Company details" + Then I should not see the convert to company account action @967 @PO-1111 @PO-1128 Scenario: Company edit warning retains changes when I stay on the form @@ -131,6 +133,7 @@ Feature: Account Enquiries – View Account Details Then I should see the page header contains "Miss Jane TESTNONPAYEE{uniqUpper}" # AC3 – Navigate to Defendant details When I go to the Defendant details section and the header is "Defendant details" + Then I should not see the convert to company account action @PO-2315 @PO-1663 Scenario: Defendant edit warning retains changes for a non-paying account when I stay diff --git a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetailsAccessibility.feature b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetailsAccessibility.feature index b8f0ab876e..b363a0c2e0 100644 --- a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetailsAccessibility.feature +++ b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetailsAccessibility.feature @@ -21,7 +21,9 @@ Feature: Account Enquiries - View Account Details Accessibility ## Check Accessibility on Search Results Page Then I check the page for accessibility And I select the latest published account and verify the header is "Mr John ACCDETAILSURNAME{uniqUpper}" - ## Check Accessibility on Account Details Page + And I go to the Defendant details section and the header is "Defendant details" + And I should see the convert to company account action + ## Check Accessibility on Defendant Details Page Then I check the page for accessibility Scenario: Check Account Details View Accessibility with Axe-Core for Company Account @@ -38,6 +40,8 @@ Feature: Account Enquiries - View Account Details Accessibility When I search for the account by company name "Accdetail comp{uniq}" # Check Accessibility on Company Search Results Page Then I check the page for accessibility - # Check Accessibility on Company Account Details Page + # Check Accessibility on Company Defendant Details Page And I select the latest published account and verify the header is "Accdetail comp{uniqUpper}" + And I go to the Defendant details section and the header is "Company details" + And I should not see the convert to company account action Then I check the page for accessibility diff --git a/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts b/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts index 947d4d2dcc..7c3bc7c7b2 100644 --- a/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts +++ b/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts @@ -213,6 +213,22 @@ export class AccountEnquiryFlow { this.defendantDetails.assertSectionHeader(headerText); } + /** + * Asserts the Defendant tab shows the convert-to-company action. + */ + public assertConvertToCompanyActionVisible(): void { + logAE('method', 'assertConvertToCompanyActionVisible()'); + this.defendantDetails.assertConvertToCompanyActionVisible(); + } + + /** + * Asserts the Defendant tab does not show the convert-to-company action. + */ + public assertConvertToCompanyActionNotPresent(): void { + logAE('method', 'assertConvertToCompanyActionNotPresent()'); + this.defendantDetails.assertConvertToCompanyActionNotPresent(); + } + /** * Navigates to the Parent/Guardian tab and asserts a specific section header. * diff --git a/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts b/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts index 026390add6..971c36fbb0 100644 --- a/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts +++ b/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts @@ -114,6 +114,16 @@ When('I go to the Defendant details section and the header is {string}', (expect flow().goToDefendantDetailsAndAssert(expectedWithUniq); }); +Then('I should see the convert to company account action', () => { + log('assert', 'Convert to company account action is visible'); + flow().assertConvertToCompanyActionVisible(); +}); + +Then('I should not see the convert to company account action', () => { + log('assert', 'Convert to company account action is absent'); + flow().assertConvertToCompanyActionNotPresent(); +}); + /** * @step Navigates to the Parent or guardian details section and validates the header text. * diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html index 60c0df72f2..ce6baff2ca 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html @@ -1,5 +1,5 @@ @if (tabData.defendant_account_party; as party) { -
+
@if (party.party_details.organisation_flag) { @@ -29,13 +29,18 @@

Defendant Details

summaryListId="defendantDetails" >
- + @if (showConvertToCompanyAction) { +
+
+

Actions

+

+ Convert to a company account +

+
+ } } diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts index 720cddd768..af4fb4a5ad 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts @@ -23,22 +23,21 @@ describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { expect(component).toBeTruthy(); }); - it('should handle convert account click when partyType is a company', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(component.convertAccount, 'emit'); - component.tabData.defendant_account_party.party_details.organisation_flag = true; - component.handleConvertAccount(); - expect(component.convertAccount.emit).toHaveBeenCalledWith(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY); + it('should not render the actions column when convert-to-company is not enabled', () => { + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.textContent).not.toContain('Actions'); + expect(compiled.textContent).not.toContain('Convert to a company account'); }); - it('should handle convert account click when partyType is an individual', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(component.convertAccount, 'emit'); - component.tabData.defendant_account_party.party_details.organisation_flag = false; - component.handleConvertAccount(); - expect(component.convertAccount.emit).toHaveBeenCalledWith( - FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, - ); + it('should render a fixed convert-to-company action when enabled', () => { + component.showConvertToCompanyAction = true; + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.textContent).toContain('Actions'); + expect(compiled.textContent).toContain('Convert to a company account'); }); it('should handle change defendant details when partyType is a company', () => { @@ -60,4 +59,17 @@ describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, ); }); + + it('should emit convert-to-company when the action is clicked', () => { + component.showConvertToCompanyAction = true; + fixture.detectChanges(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(component.convertToCompanyAccount, 'emit'); + + const convertLink = fixture.nativeElement.querySelector('.govuk-grid-column-one-third a') as HTMLAnchorElement; + convertLink.click(); + + expect(component.convertToCompanyAccount.emit).toHaveBeenCalledWith(); + }); }); diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.ts index adf8c85427..10ac1ce81a 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.ts @@ -14,16 +14,13 @@ import { FinesAccPartyDetails } from '../fines-acc-party-details/fines-acc-party export class FinesAccDefendantDetailsDefendantTabComponent { @Input({ required: true }) tabData!: IOpalFinesAccountDefendantAccountParty; @Input() hasAccountMaintenencePermission: boolean = false; + @Input() showConvertToCompanyAction: boolean = false; @Input() style: IFinesAccSummaryTabsContentStyles = FINES_ACC_SUMMARY_TABS_CONTENT_STYLES; @Output() changeDefendantDetails = new EventEmitter(); - @Output() convertAccount = new EventEmitter(); + @Output() convertToCompanyAccount = new EventEmitter(); - public handleConvertAccount(): void { - if (this.tabData.defendant_account_party.party_details.organisation_flag) { - this.convertAccount.emit(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY); - } else { - this.convertAccount.emit(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL); - } + public handleConvertToCompanyAccount(): void { + this.convertToCompanyAccount.emit(); } public handleChangeDefendantDetails(): void { diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.html b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.html index 045f4d7578..af901e4a99 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.html +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.html @@ -160,10 +160,11 @@

Business Unit:

} } diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts index befe3bd353..8e75559a57 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts @@ -248,6 +248,19 @@ describe('FinesAccDefendantDetailsComponent', () => { ); }); + it('should navigate to the company amend page when convert-to-company is triggered', () => { + routerSpy.navigate.mockClear(); + + component.navigateToConvertToCompanyAccountPage(); + + expect(routerSpy.navigate).toHaveBeenCalledWith( + [`../party/${FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY}/amend`], + { + relativeTo: component['activatedRoute'], + }, + ); + }); + it('should navigate to access-denied if user lacks permission for the add account note page', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(component['permissionsService'], 'hasBusinessUnitPermissionAccess').mockReturnValue(false); @@ -276,6 +289,55 @@ describe('FinesAccDefendantDetailsComponent', () => { }); }); + it('should navigate to access-denied if user lacks permission for convert-to-company', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(component['permissionsService'], 'hasBusinessUnitPermissionAccess').mockReturnValue(false); + component.navigateToConvertToCompanyAccountPage(); + + expect(routerSpy.navigate).toHaveBeenCalledWith(['/access-denied'], { + relativeTo: component['activatedRoute'], + }); + }); + + it('should show convert-to-company action for an adult individual account with account maintenance permission', () => { + component.accountData.party_details.organisation_flag = false; + component.accountData.debtor_type = 'Defendant'; + component.accountData.is_youth = false; + + expect(component.canShowConvertToCompanyAction).toBe(true); + }); + + it('should show convert-to-company action for a youth individual account with account maintenance permission', () => { + component.accountData.party_details.organisation_flag = false; + component.accountData.debtor_type = 'Defendant'; + component.accountData.is_youth = true; + + expect(component.canShowConvertToCompanyAction).toBe(true); + }); + + it('should hide convert-to-company action for an account with parent or guardian to pay', () => { + component.accountData.party_details.organisation_flag = false; + component.accountData.debtor_type = component.debtorTypes.parentGuardian; + + expect(component.canShowConvertToCompanyAction).toBe(false); + }); + + it('should hide convert-to-company action for a company account', () => { + component.accountData.party_details.organisation_flag = true; + component.accountData.debtor_type = 'Defendant'; + + expect(component.canShowConvertToCompanyAction).toBe(false); + }); + + it('should hide convert-to-company action when account maintenance permission is not available', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(component['permissionsService'], 'hasBusinessUnitPermissionAccess').mockReturnValue(false); + component.accountData.party_details.organisation_flag = false; + component.accountData.debtor_type = 'Defendant'; + + expect(component.canShowConvertToCompanyAction).toBe(false); + }); + it('should navigate to the change defendant payment terms access denied page if user does not have the relevant permission', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(component['permissionsService'], 'hasBusinessUnitPermissionAccess').mockReturnValue(false); diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts index f6b136cc90..91a79aef19 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts @@ -55,6 +55,7 @@ import { FINES_ACCOUNT_TYPES } from '../../constants/fines-account-types.constan import { IOpalFinesResultRefData } from '@services/fines/opal-fines-service/interfaces/opal-fines-result-ref-data.interface'; import { FinesAccDefendantDetailsEnforcementTab } from './fines-acc-defendant-details-enforcement-tab/fines-acc-defendant-details-enforcement-tab.component'; import { FinesAccSummaryHeaderComponent } from '../fines-acc-summary-header/fines-acc-summary-header.component'; +import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-party-types.constant'; @Component({ selector: 'app-fines-acc-defendant-details', @@ -179,6 +180,14 @@ export class FinesAccDefendantDetailsComponent extends AbstractTabData implement } } + private hasAccountMaintenancePermissionInBusinessUnit(): boolean { + return this.permissionsService.hasBusinessUnitPermissionAccess( + FINES_PERMISSIONS['account-maintenance'], + Number(this.accountStore.business_unit_id()!), + this.userState.business_unit_users, + ); + } + /** * Initializes and sets up the observable data stream for the fines draft tab component. * @@ -385,19 +394,23 @@ export class FinesAccDefendantDetailsComponent extends AbstractTabData implement this.refreshFragment$.complete(); } + public get canShowConvertToCompanyAction(): boolean { + const isAdultOrYouthOnlyAccount = this.accountData.debtor_type !== this.debtorTypes.parentGuardian; + + return ( + !this.accountData.party_details.organisation_flag && + isAdultOrYouthOnlyAccount && + this.hasAccountMaintenancePermissionInBusinessUnit() + ); + } + /** * Navigates to the amend party details page for the specified party type. * Or navigates to the access-denied page if the user lacks the required permission in this BU. * @param partyType */ public navigateToAmendPartyDetailsPage(partyType: string): void { - if ( - this.permissionsService.hasBusinessUnitPermissionAccess( - FINES_PERMISSIONS['account-maintenance'], - Number(this.accountStore.business_unit_id()!), - this.userState.business_unit_users, - ) - ) { + if (this.hasAccountMaintenancePermissionInBusinessUnit()) { this['router'].navigate([`../party/${partyType}/amend`], { relativeTo: this.activatedRoute, }); @@ -408,6 +421,18 @@ export class FinesAccDefendantDetailsComponent extends AbstractTabData implement } } + public navigateToConvertToCompanyAccountPage(): void { + if (this.hasAccountMaintenancePermissionInBusinessUnit()) { + this['router'].navigate([`../party/${FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY}/amend`], { + relativeTo: this.activatedRoute, + }); + } else { + this['router'].navigate(['/access-denied'], { + relativeTo: this.activatedRoute, + }); + } + } + /** * Navigates to the amend payment terms page or amend denied page based on user permissions and account status. */ From c94f221a1b91573e59eba1b46c5471c4abc700ef Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 17 Mar 2026 12:00:20 +0000 Subject: [PATCH 05/28] feat: implement convert-to-company functionality with confirmation and pre-populated fields --- .../convert.account.actions.ts | 75 ++++++ .../details.defendant.actions.ts | 7 + .../edit.company-details.actions.ts | 30 +++ .../AccountEnquiriesViewDetails.feature | 17 ++ ...tEnquiriesViewDetailsAccessibility.feature | 3 + .../opal/flows/account-enquiry.flow.ts | 47 ++++ .../account.convert.locators.ts | 14 ++ .../searchForAccount/account-enquiry.steps.ts | 37 +++ .../fines-acc-convert.component.html | 17 ++ .../fines-acc-convert.component.spec.ts | 213 ++++++++++++++++++ .../fines-acc-convert.component.ts | 94 ++++++++ ...es-acc-defendant-details.component.spec.ts | 2 +- .../fines-acc-defendant-details.component.ts | 11 +- ...es-acc-defendant-routing-paths.constant.ts | 1 + ...s-acc-defendant-routing-titles.constant.ts | 1 + .../fines-acc/routing/fines-acc.routes.ts | 12 + ...s-acc-defendant-routing-paths.interface.ts | 1 + ...oad-transform-defendant-data.utils.spec.ts | 25 ++ 18 files changed, 603 insertions(+), 4 deletions(-) create mode 100644 cypress/e2e/functional/opal/actions/account-details/convert.account.actions.ts create mode 100644 cypress/shared/selectors/account-details/account.convert.locators.ts create mode 100644 src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.html create mode 100644 src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts create mode 100644 src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts diff --git a/cypress/e2e/functional/opal/actions/account-details/convert.account.actions.ts b/cypress/e2e/functional/opal/actions/account-details/convert.account.actions.ts new file mode 100644 index 0000000000..cb304ec2aa --- /dev/null +++ b/cypress/e2e/functional/opal/actions/account-details/convert.account.actions.ts @@ -0,0 +1,75 @@ +import { AccountConvertLocators as L } from '../../../../../shared/selectors/account-details/account.convert.locators'; +import { createScopedLogger } from '../../../../../support/utils/log.helper'; +import { CommonActions } from '../common/common.actions'; + +const log = createScopedLogger('AccountConvertActions'); + +/** Actions and assertions for the convert-account confirmation page. */ +export class AccountConvertActions { + private static readonly DEFAULT_TIMEOUT = 10_000; + private readonly common = new CommonActions(); + + /** + * Normalizes visible text for resilient assertions. + * + * @param value - Raw text content. + * @returns Lower-cased single-spaced text. + */ + private normalize(value: string): string { + return value.replace(/\s+/g, ' ').trim().toLowerCase(); + } + + /** + * Asserts the convert-to-company confirmation page content. + * + * @param expectedCaptionName - Expected defendant name in the page caption. + */ + public assertOnConvertToCompanyConfirmation(expectedCaptionName: string): void { + log('assert', 'Convert to company confirmation page is visible', { expectedCaptionName }); + cy.location('pathname', { timeout: this.common.getPathTimeout() }).should('include', '/convert/company'); + + cy.get(L.page.caption, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }) + .should('be.visible') + .invoke('text') + .then((text) => { + const actual = this.normalize(text); + expect(actual).to.include(this.normalize(expectedCaptionName)); + expect(actual).to.include('-'); + }); + + cy.get(L.page.header, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }) + .should('be.visible') + .should(($el) => { + const text = this.normalize($el.text()); + expect(text).to.include('are you sure you want to convert this account to a company account?'); + }); + + cy.get(L.page.warningText, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }) + .should('be.visible') + .should(($el) => { + const text = this.normalize($el.text()); + expect(text).to.include( + this.normalize('Certain data related to individual accounts, such as employment details, will be removed.'), + ); + }); + + cy.get(L.page.confirmButton, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }).should('exist'); + cy.get(L.page.cancelLink, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }).should('exist'); + } + + /** + * Clicks the confirmation button to continue to Company details. + */ + public confirmConvertToCompany(): void { + log('action', 'Confirming account conversion to company'); + cy.get(L.page.confirmButton, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }).should('be.visible').click(); + } + + /** + * Clicks the cancel link to return to Defendant details. + */ + public cancelConvertToCompany(): void { + log('action', 'Cancelling account conversion to company'); + cy.get(L.page.cancelLink, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }).should('be.visible').click(); + } +} diff --git a/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts b/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts index 7d1cfe9039..58da56ce4c 100644 --- a/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts +++ b/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts @@ -66,6 +66,13 @@ export class AccountDetailsDefendantActions { .and('contain.text', 'Convert to a company account'); } + /** + * Clicks the convert-to-company action from the Defendant tab. + */ + startConvertToCompanyAccount(): void { + cy.get(L.actions.convertToCompany, this.common.getTimeoutOptions()).should('be.visible').click(); + } + /** * Asserts that the convert-to-company action is not rendered in the Defendant tab. */ diff --git a/cypress/e2e/functional/opal/actions/account-details/edit.company-details.actions.ts b/cypress/e2e/functional/opal/actions/account-details/edit.company-details.actions.ts index ea79865e68..c066ed4d15 100644 --- a/cypress/e2e/functional/opal/actions/account-details/edit.company-details.actions.ts +++ b/cypress/e2e/functional/opal/actions/account-details/edit.company-details.actions.ts @@ -13,6 +13,19 @@ const log = createScopedLogger('EditCompanyDetailsActions'); /** Actions for editing company details within Account Details. */ export class EditCompanyDetailsActions { private readonly common = new CommonActions(); + private readonly companyFieldLocators = { + 'Address line 1': L.fields.addressLine1, + 'Address line 2': L.fields.addressLine2, + 'Address line 3': L.fields.addressLine3, + Postcode: L.fields.postcode, + 'Primary email address': L.fields.primaryEmail, + 'Secondary email address': L.fields.secondaryEmail, + 'Mobile telephone number': L.fields.mobileTelephone, + 'Home telephone number': L.fields.homeTelephone, + 'Work telephone number': L.fields.workTelephone, + 'Make and model': L.fields.vehicleMakeModel, + 'Registration number': L.fields.vehicleRegistration, + } as const; /** * Ensure we are still on the edit page (form visible, not navigated away). @@ -102,4 +115,21 @@ export class EditCompanyDetailsActions { cy.get(SummaryL.fields.name, this.common.getTimeoutOptions()).should('be.visible').and('contain.text', expected); log('done', `Verified company name contains "${expected}"`); } + + /** + * Asserts Company details form fields are pre-populated with the expected values. + * + * @param expectedFieldValues - Key/value map of ticket field labels to expected values. + */ + public assertPrefilledFieldValues(expectedFieldValues: Record): void { + Object.entries(expectedFieldValues).forEach(([fieldName, expectedValue]) => { + const fieldSelector = this.companyFieldLocators[fieldName as keyof typeof this.companyFieldLocators]; + if (!fieldSelector) { + throw new Error(`Unsupported company prefill field: ${fieldName}`); + } + + log('assert', 'Asserting company field prefill', { fieldName, expectedValue }); + cy.get(fieldSelector, this.common.getTimeoutOptions()).should('have.value', expectedValue); + }); + } } diff --git a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature index 592bbd842e..43d88bbfef 100644 --- a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature +++ b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature @@ -66,6 +66,23 @@ Feature: Account Enquiries – View Account Details And I should see the account header contains "Mr Updated ACCDETAILSURNAME{uniqUpper}" And I verify no amendments were created via API + @PO-1942 @PO-1943 + Scenario: Convert to company confirmation continues to Company details with shared fields pre-populated + When I start converting the account to a company account + Then I should see the convert to company confirmation screen for defendant "Mr John ACCDETAILSURNAME{uniqUpper}" + When I continue converting the account to a company account + Then the Company details form should be pre-populated with: + | Primary email address | John.AccDetailSurname{uniq}@test.com | + | Home telephone number | 02078259314 | + + @PO-1943 + Scenario: Convert to company confirmation cancel returns to Defendant details with no changes made + When I start converting the account to a company account + Then I should see the convert to company confirmation screen for defendant "Mr John ACCDETAILSURNAME{uniqUpper}" + When I cancel converting the account to a company account + Then I should return to the account details page Defendant tab + And I should see the convert to company account action + Rule: Company account baseline Background: # AC1 – Account setup diff --git a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetailsAccessibility.feature b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetailsAccessibility.feature index b363a0c2e0..8ebe68255c 100644 --- a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetailsAccessibility.feature +++ b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetailsAccessibility.feature @@ -25,6 +25,9 @@ Feature: Account Enquiries - View Account Details Accessibility And I should see the convert to company account action ## Check Accessibility on Defendant Details Page Then I check the page for accessibility + When I start converting the account to a company account + Then I should see the convert to company confirmation screen for defendant "Mr John ACCDETAILSURNAME{uniqUpper}" + And I check the page for accessibility Scenario: Check Account Details View Accessibility with Axe-Core for Company Account Given I create a "company" draft account with the following details and set status "Publishing Pending" using user "opal-test-10@hmcts.net": diff --git a/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts b/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts index 7c3bc7c7b2..595e42e7cf 100644 --- a/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts +++ b/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts @@ -17,6 +17,7 @@ import { CommonActions } from '../actions/common/common.actions'; import { EditDefendantDetailsActions } from '../actions/account-details/edit.defendant-details.actions'; import { EditCompanyDetailsActions } from '../actions/account-details/edit.company-details.actions'; import { EditParentGuardianDetailsActions } from '../actions/account-details/edit.parent-guardian-details.actions'; +import { AccountConvertActions } from '../actions/account-details/convert.account.actions'; import { createScopedLogger, createScopedSyncLogger } from '../../../../support/utils/log.helper'; const logAE = createScopedLogger('AccountEnquiryFlow'); @@ -71,6 +72,7 @@ export class AccountEnquiryFlow { private readonly editCompanyDetailsActions = new EditCompanyDetailsActions(); private readonly editParentGuardianActions = new EditParentGuardianDetailsActions(); private readonly paymentTerms = new AccountDetailsPaymentTermsActions(); + private readonly accountConvert = new AccountConvertActions(); /** * Ensures the test is on the Individuals Account Search page. @@ -229,6 +231,51 @@ export class AccountEnquiryFlow { this.defendantDetails.assertConvertToCompanyActionNotPresent(); } + /** + * Opens the convert-to-company confirmation page from the Defendant tab. + */ + public openConvertToCompanyConfirmation(): void { + logAE('method', 'openConvertToCompanyConfirmation()'); + this.detailsNav.goToDefendantTab(); + this.defendantDetails.startConvertToCompanyAccount(); + } + + /** + * Asserts the convert-to-company confirmation page. + * + * @param expectedCaptionName - Expected defendant name shown in the caption. + */ + public assertOnConvertToCompanyConfirmation(expectedCaptionName: string): void { + logAE('method', 'assertOnConvertToCompanyConfirmation()', { expectedCaptionName }); + this.accountConvert.assertOnConvertToCompanyConfirmation(expectedCaptionName); + } + + /** + * Confirms the convert-to-company action. + */ + public confirmConvertToCompanyAccount(): void { + logAE('method', 'confirmConvertToCompanyAccount()'); + this.accountConvert.confirmConvertToCompany(); + } + + /** + * Cancels the convert-to-company action. + */ + public cancelConvertToCompanyAccount(): void { + logAE('method', 'cancelConvertToCompanyAccount()'); + this.accountConvert.cancelConvertToCompany(); + } + + /** + * Asserts the Company details form contains the expected pre-populated values. + * + * @param expectedFieldValues - Key/value map of ticket field labels to expected values. + */ + public assertCompanyDetailsPrefilledValues(expectedFieldValues: Record): void { + logAE('method', 'assertCompanyDetailsPrefilledValues()', expectedFieldValues); + this.editCompanyDetailsActions.assertPrefilledFieldValues(expectedFieldValues); + } + /** * Navigates to the Parent/Guardian tab and asserts a specific section header. * diff --git a/cypress/shared/selectors/account-details/account.convert.locators.ts b/cypress/shared/selectors/account-details/account.convert.locators.ts new file mode 100644 index 0000000000..ff8e27f2b8 --- /dev/null +++ b/cypress/shared/selectors/account-details/account.convert.locators.ts @@ -0,0 +1,14 @@ +/** + * @file account.convert.locators.ts + * @description Selector map for the Defendant account convert confirmation page. + */ +export const AccountConvertLocators = { + page: { + root: 'main[role="main"]', + header: 'main[role="main"] h1.govuk-heading-l', + caption: 'main[role="main"] h1.govuk-heading-l .govuk-caption-l', + warningText: 'main[role="main"] p.govuk-body', + confirmButton: 'main[role="main"] button.govuk-button:contains("Yes - continue")', + cancelLink: 'main[role="main"] a.govuk-link:contains("No - cancel")', + }, +} as const; diff --git a/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts b/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts index 971c36fbb0..203ebe6270 100644 --- a/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts +++ b/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts @@ -124,6 +124,43 @@ Then('I should not see the convert to company account action', () => { flow().assertConvertToCompanyActionNotPresent(); }); +When('I start converting the account to a company account', () => { + log('step', 'Start converting account to company'); + flow().openConvertToCompanyConfirmation(); +}); + +Then( + 'I should see the convert to company confirmation screen for defendant {string}', + (expectedCaptionName: string) => { + const expectedCaptionNameWithUniq = applyUniqPlaceholder(expectedCaptionName); + log('assert', 'Convert to company confirmation screen is visible', { + expectedCaptionName: expectedCaptionNameWithUniq, + }); + flow().assertOnConvertToCompanyConfirmation(expectedCaptionNameWithUniq); + }, +); + +When('I continue converting the account to a company account', () => { + log('step', 'Continue converting account to company'); + flow().confirmConvertToCompanyAccount(); +}); + +When('I cancel converting the account to a company account', () => { + log('step', 'Cancel converting account to company'); + flow().cancelConvertToCompanyAccount(); +}); + +Then('the Company details form should be pre-populated with:', (table: DataTable) => { + const expectedFieldValues = Object.fromEntries( + Object.entries(rowsHashSafe(table)).map(([fieldName, fieldValue]) => [ + fieldName, + applyUniqPlaceholder(fieldValue), + ]), + ); + log('assert', 'Company details form is pre-populated', expectedFieldValues); + flow().assertCompanyDetailsPrefilledValues(expectedFieldValues); +}); + /** * @step Navigates to the Parent or guardian details section and validates the header text. * diff --git a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.html b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.html new file mode 100644 index 0000000000..5cf1ceb6af --- /dev/null +++ b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.html @@ -0,0 +1,17 @@ +
+ + +

{{ warningText }}

+ +
+ + +
+
diff --git a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts new file mode 100644 index 0000000000..1df7446019 --- /dev/null +++ b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts @@ -0,0 +1,213 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FinesAccConvertComponent } from './fines-acc-convert.component'; +import { FinesAccPayloadService } from '../services/fines-acc-payload.service'; +import { FinesAccountStore } from '../stores/fines-acc.store'; +import { FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK } from '../fines-acc-defendant-details/mocks/fines-acc-defendant-details-header.mock'; +import { MOCK_FINES_ACCOUNT_STATE } from '../mocks/fines-acc-state.mock'; +import { FINES_ACC_MAP_TRANSFORM_ITEMS_CONFIG } from '../services/constants/fines-acc-map-transform-items-config.constant'; +import { FINES_ACC_DEFENDANT_ROUTING_PATHS } from '../routing/constants/fines-acc-defendant-routing-paths.constant'; +import { FINES_ACC_DEFENDANT_ROUTING_TITLES } from '../routing/constants/fines-acc-defendant-routing-titles.constant'; +import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-party-types.constant'; +import { FINES_PERMISSIONS } from '@constants/fines-permissions.constant'; +import { routing } from '../routing/fines-acc.routes'; +import { TitleResolver } from '@hmcts/opal-frontend-common/resolvers/title'; +import { defendantAccountHeadingResolver } from '../routing/resolvers/defendant-account-heading.resolver'; +import { routePermissionsGuard } from '@hmcts/opal-frontend-common/guards/route-permissions'; +import { authGuard } from '@hmcts/opal-frontend-common/guards/auth'; +import { finesAccStateGuard } from '../routing/guards/fines-acc-state-guard/fines-acc-state.guard'; + +describe('FinesAccConvertComponent', () => { + let fixture: ComponentFixture; + let component: FinesAccConvertComponent; + let mockRouter: { navigate: ReturnType }; + let mockActivatedRoute: ActivatedRoute; + let mockPayloadService: { + transformPayload: ReturnType; + transformAccountHeaderForStore: ReturnType; + }; + let mockAccountStore: { + setAccountState: ReturnType; + account_number: ReturnType; + party_name: ReturnType; + }; + + const defaultHeadingData = { + ...structuredClone(FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK), + debtor_type: 'Defendant', + party_details: { + ...structuredClone(FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK.party_details), + organisation_flag: false, + individual_details: { + ...structuredClone(FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK.party_details.individual_details), + title: 'Mr', + forenames: 'Terrence', + surname: 'CONWAY-JOHNSON', + }, + }, + }; + + const configureRoute = ( + partyType = FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, + headingData = defaultHeadingData, + ) => { + mockActivatedRoute.snapshot = { + data: { defendantAccountHeadingData: headingData }, + paramMap: convertToParamMap({ accountId: '123', partyType }), + } as never; + }; + + const createComponent = () => { + fixture = TestBed.createComponent(FinesAccConvertComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }; + + beforeEach(async () => { + mockRouter = { + navigate: vi.fn().mockName('Router.navigate'), + }; + + mockActivatedRoute = { snapshot: {} as never } as ActivatedRoute; + + mockPayloadService = { + transformPayload: vi.fn().mockName('FinesAccPayloadService.transformPayload'), + transformAccountHeaderForStore: vi.fn().mockName('FinesAccPayloadService.transformAccountHeaderForStore'), + }; + mockPayloadService.transformPayload.mockImplementation((payload) => payload); + mockPayloadService.transformAccountHeaderForStore.mockReturnValue(MOCK_FINES_ACCOUNT_STATE); + + mockAccountStore = { + setAccountState: vi.fn().mockName('FinesAccountStore.setAccountState'), + account_number: vi.fn().mockName('FinesAccountStore.account_number'), + party_name: vi.fn().mockName('FinesAccountStore.party_name'), + }; + mockAccountStore.account_number.mockReturnValue('06000427N'); + mockAccountStore.party_name.mockReturnValue('Mr Terrence CONWAY-JOHNSON'); + + configureRoute(); + + await TestBed.configureTestingModule({ + imports: [FinesAccConvertComponent], + providers: [ + { provide: Router, useValue: mockRouter }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, + { provide: FinesAccPayloadService, useValue: mockPayloadService }, + { provide: FinesAccountStore, useValue: mockAccountStore }, + ], + }).compileComponents(); + }); + + it('should configure the convert route with title, permission, and heading resolver', () => { + const defendantRoute = routing.find( + (route) => route.path === `${FINES_ACC_DEFENDANT_ROUTING_PATHS.root}/:accountId`, + ); + const convertRoute = defendantRoute?.children?.find( + (child) => child.path === `${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/:partyType`, + ); + + expect(convertRoute?.path).toBe(`${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/:partyType`); + expect(convertRoute?.canActivate).toEqual([authGuard, routePermissionsGuard, finesAccStateGuard]); + expect(convertRoute?.data).toEqual({ + routePermissionId: [FINES_PERMISSIONS['account-maintenance']], + title: FINES_ACC_DEFENDANT_ROUTING_TITLES.children.convert, + }); + expect(convertRoute?.resolve).toEqual({ + title: TitleResolver, + defendantAccountHeadingData: defendantAccountHeadingResolver, + }); + }); + + it('should create', () => { + createComponent(); + + expect(component).toBeTruthy(); + }); + + it('should hydrate account state from defendantAccountHeadingData', () => { + createComponent(); + + expect(mockPayloadService.transformPayload).toHaveBeenCalledWith( + defaultHeadingData, + FINES_ACC_MAP_TRANSFORM_ITEMS_CONFIG, + ); + expect(mockPayloadService.transformAccountHeaderForStore).toHaveBeenCalledWith( + 123, + defaultHeadingData, + 'defendant', + ); + expect(mockAccountStore.setAccountState).toHaveBeenCalledWith(MOCK_FINES_ACCOUNT_STATE); + }); + + it('should render the caption, heading, warning text, and action buttons for company conversion', () => { + createComponent(); + + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.textContent).toContain('06000427N - Mr Terrence CONWAY-JOHNSON'); + expect(compiled.textContent).toContain('Are you sure you want to convert this account to a company account?'); + expect(compiled.textContent).toContain( + 'Certain data related to individual accounts, such as employment details, will be removed.', + ); + expect(compiled.textContent).toContain('Yes - continue'); + expect(compiled.textContent).toContain('No - cancel'); + }); + + it('should navigate to the company details page when continue is clicked', () => { + createComponent(); + + component.handleContinue(); + + expect(mockRouter.navigate).toHaveBeenCalledWith( + [ + '../../', + FINES_ACC_DEFENDANT_ROUTING_PATHS.children.party, + FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, + 'amend', + ], + { + relativeTo: mockActivatedRoute, + }, + ); + }); + + it('should navigate back to defendant details when cancel is clicked', () => { + createComponent(); + + component.navigateBackToAccountSummary(); + + expect(mockRouter.navigate).toHaveBeenCalledWith(['../../', FINES_ACC_DEFENDANT_ROUTING_PATHS.children.details], { + relativeTo: mockActivatedRoute, + fragment: 'defendant', + }); + }); + + it('should redirect back to defendant details when partyType is unsupported', () => { + configureRoute(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL); + + createComponent(); + + expect(mockRouter.navigate).toHaveBeenCalledWith(['../../', FINES_ACC_DEFENDANT_ROUTING_PATHS.children.details], { + relativeTo: mockActivatedRoute, + fragment: 'defendant', + }); + }); + + it('should redirect back to defendant details when the account is already a company account', () => { + configureRoute(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, { + ...defaultHeadingData, + party_details: { + ...defaultHeadingData.party_details, + organisation_flag: true, + }, + }); + + createComponent(); + + expect(mockRouter.navigate).toHaveBeenCalledWith(['../../', FINES_ACC_DEFENDANT_ROUTING_PATHS.children.details], { + relativeTo: mockActivatedRoute, + fragment: 'defendant', + }); + }); +}); diff --git a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts new file mode 100644 index 0000000000..9dc6884c33 --- /dev/null +++ b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts @@ -0,0 +1,94 @@ +import { ChangeDetectionStrategy, Component, inject, OnInit } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { GovukHeadingWithCaptionComponent } from '@hmcts/opal-frontend-common/components/govuk/govuk-heading-with-caption'; +import { GovukCancelLinkComponent } from '@hmcts/opal-frontend-common/components/govuk/govuk-cancel-link'; +import { FinesAccPayloadService } from '../services/fines-acc-payload.service'; +import { FinesAccountStore } from '../stores/fines-acc.store'; +import { IOpalFinesAccountDefendantDetailsHeader } from '../fines-acc-defendant-details/interfaces/fines-acc-defendant-details-header.interface'; +import { FINES_ACC_MAP_TRANSFORM_ITEMS_CONFIG } from '../services/constants/fines-acc-map-transform-items-config.constant'; +import { FINES_ACC_DEFENDANT_ROUTING_PATHS } from '../routing/constants/fines-acc-defendant-routing-paths.constant'; +import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-party-types.constant'; +import { FINES_ACC_DEBTOR_TYPES } from '../constants/fines-acc-debtor-types.constant'; + +@Component({ + selector: 'app-fines-acc-convert', + imports: [GovukHeadingWithCaptionComponent, GovukCancelLinkComponent], + templateUrl: './fines-acc-convert.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FinesAccConvertComponent implements OnInit { + private readonly activatedRoute = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly payloadService = inject(FinesAccPayloadService); + + public readonly accountStore = inject(FinesAccountStore); + public readonly partyTypes = FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES; + public readonly routePartyType = this.activatedRoute.snapshot.paramMap.get('partyType') ?? ''; + public readonly accountId = Number(this.activatedRoute.snapshot.paramMap.get('accountId')); + + public accountData!: IOpalFinesAccountDefendantDetailsHeader; + + private getHeaderDataFromRoute(): void { + this.accountData = this.payloadService.transformPayload( + this.activatedRoute.snapshot.data['defendantAccountHeadingData'], + FINES_ACC_MAP_TRANSFORM_ITEMS_CONFIG, + ); + this.accountStore.setAccountState( + this.payloadService.transformAccountHeaderForStore(this.accountId, this.accountData, 'defendant'), + ); + } + + private get canConvertToCompanyAccount(): boolean { + return ( + this.routePartyType === this.partyTypes.COMPANY && + !this.accountData.party_details.organisation_flag && + this.accountData.debtor_type !== FINES_ACC_DEBTOR_TYPES.parentGuardian + ); + } + + public get captionText(): string { + return `${this.accountStore.account_number() ?? ''} - ${this.accountStore.party_name() ?? ''}`; + } + + public get headingText(): string { + return 'Are you sure you want to convert this account to a company account?'; + } + + public get warningText(): string { + return 'Certain data related to individual accounts, such as employment details, will be removed.'; + } + + public ngOnInit(): void { + this.getHeaderDataFromRoute(); + + if (!this.canConvertToCompanyAccount) { + this.navigateBackToAccountSummary(); + } + } + + public handleContinue(): void { + if (!this.canConvertToCompanyAccount) { + this.navigateBackToAccountSummary(); + return; + } + + this.router.navigate( + [ + '../../', + FINES_ACC_DEFENDANT_ROUTING_PATHS.children.party, + FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, + 'amend', + ], + { + relativeTo: this.activatedRoute, + }, + ); + } + + public navigateBackToAccountSummary(): void { + this.router.navigate(['../../', FINES_ACC_DEFENDANT_ROUTING_PATHS.children.details], { + relativeTo: this.activatedRoute, + fragment: 'defendant', + }); + } +} diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts index 8e75559a57..58cb9c103b 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts @@ -254,7 +254,7 @@ describe('FinesAccDefendantDetailsComponent', () => { component.navigateToConvertToCompanyAccountPage(); expect(routerSpy.navigate).toHaveBeenCalledWith( - [`../party/${FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY}/amend`], + [`../${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/${FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY}`], { relativeTo: component['activatedRoute'], }, diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts index 91a79aef19..a01cf818ef 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts @@ -423,9 +423,14 @@ export class FinesAccDefendantDetailsComponent extends AbstractTabData implement public navigateToConvertToCompanyAccountPage(): void { if (this.hasAccountMaintenancePermissionInBusinessUnit()) { - this['router'].navigate([`../party/${FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY}/amend`], { - relativeTo: this.activatedRoute, - }); + this['router'].navigate( + [ + `../${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/${FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY}`, + ], + { + relativeTo: this.activatedRoute, + }, + ); } else { this['router'].navigate(['/access-denied'], { relativeTo: this.activatedRoute, diff --git a/src/app/flows/fines/fines-acc/routing/constants/fines-acc-defendant-routing-paths.constant.ts b/src/app/flows/fines/fines-acc/routing/constants/fines-acc-defendant-routing-paths.constant.ts index 8c6b36df90..12bd89d66f 100644 --- a/src/app/flows/fines/fines-acc/routing/constants/fines-acc-defendant-routing-paths.constant.ts +++ b/src/app/flows/fines/fines-acc/routing/constants/fines-acc-defendant-routing-paths.constant.ts @@ -4,6 +4,7 @@ export const FINES_ACC_DEFENDANT_ROUTING_PATHS: IFinesAccDefendantRoutingPaths = root: 'defendant', children: { details: 'details', + convert: 'convert', note: 'note', comments: 'comments', debtor: 'debtor', diff --git a/src/app/flows/fines/fines-acc/routing/constants/fines-acc-defendant-routing-titles.constant.ts b/src/app/flows/fines/fines-acc/routing/constants/fines-acc-defendant-routing-titles.constant.ts index f1d8b7bd04..393beb8af4 100644 --- a/src/app/flows/fines/fines-acc/routing/constants/fines-acc-defendant-routing-titles.constant.ts +++ b/src/app/flows/fines/fines-acc/routing/constants/fines-acc-defendant-routing-titles.constant.ts @@ -4,6 +4,7 @@ export const FINES_ACC_DEFENDANT_ROUTING_TITLES: IFinesAccDefendantRoutingPaths root: 'Defendant', children: { details: 'Account details', + convert: 'Convert account', note: 'Account notes', comments: 'Account comments', debtor: 'Change debtor details', diff --git a/src/app/flows/fines/fines-acc/routing/fines-acc.routes.ts b/src/app/flows/fines/fines-acc/routing/fines-acc.routes.ts index acf3c220ec..47f023cbd4 100644 --- a/src/app/flows/fines/fines-acc/routing/fines-acc.routes.ts +++ b/src/app/flows/fines/fines-acc/routing/fines-acc.routes.ts @@ -48,6 +48,18 @@ export const routing: Routes = [ }, resolve: { title: TitleResolver, defendantAccountHeadingData: defendantAccountHeadingResolver }, }, + { + path: `${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/:partyType`, + + loadComponent: () => + import('../fines-acc-convert/fines-acc-convert.component').then((c) => c.FinesAccConvertComponent), + canActivate: [authGuard, routePermissionsGuard, finesAccStateGuard], + data: { + routePermissionId: [accRootPermissionIds['account-maintenance']], + title: FINES_ACC_DEFENDANT_ROUTING_TITLES.children.convert, + }, + resolve: { title: TitleResolver, defendantAccountHeadingData: defendantAccountHeadingResolver }, + }, { path: `${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.note}/add`, diff --git a/src/app/flows/fines/fines-acc/routing/interfaces/fines-acc-defendant-routing-paths.interface.ts b/src/app/flows/fines/fines-acc/routing/interfaces/fines-acc-defendant-routing-paths.interface.ts index cc514a485e..eb0216a298 100644 --- a/src/app/flows/fines/fines-acc/routing/interfaces/fines-acc-defendant-routing-paths.interface.ts +++ b/src/app/flows/fines/fines-acc/routing/interfaces/fines-acc-defendant-routing-paths.interface.ts @@ -4,6 +4,7 @@ export interface IFinesAccDefendantRoutingPaths extends IChildRoutingPaths { root: string; children: { details: string; + convert: string; note: string; comments: string; debtor: string; diff --git a/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts b/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts index 86226176a5..33c65c247f 100644 --- a/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts +++ b/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts @@ -833,5 +833,30 @@ describe('transformDefendantAccountPartyPayload', () => { expect(result.facc_party_add_amend_convert_title).toBeNull(); expect(result.facc_party_add_amend_convert_individual_aliases).toEqual([]); }); + + it('should preserve only the shared company-compatible fields when converting an individual account to company', () => { + const result = transformDefendantAccountPartyPayload(mockDefendantData, 'company', true); + + expect(result.facc_party_add_amend_convert_organisation_name).toBeNull(); + expect(result.facc_party_add_amend_convert_title).toBeNull(); + expect(result.facc_party_add_amend_convert_forenames).toBeNull(); + expect(result.facc_party_add_amend_convert_surname).toBeNull(); + expect(result.facc_party_add_amend_convert_dob).toBeNull(); + expect(result.facc_party_add_amend_convert_national_insurance_number).toBeNull(); + expect(result.facc_party_add_amend_convert_individual_aliases).toEqual([]); + expect(result.facc_party_add_amend_convert_employer_company_name).toBeNull(); + + expect(result.facc_party_add_amend_convert_address_line_1).toBe('45 High Street'); + expect(result.facc_party_add_amend_convert_address_line_2).toBe('Flat 2B'); + expect(result.facc_party_add_amend_convert_address_line_3).toBe('Riverside'); + expect(result.facc_party_add_amend_convert_post_code).toBe('AB1 2CD'); + expect(result.facc_party_add_amend_convert_contact_email_address_1).toBe('sarah.thompson@example.com'); + expect(result.facc_party_add_amend_convert_contact_email_address_2).toBe('s.thompson@workmail.com'); + expect(result.facc_party_add_amend_convert_contact_telephone_number_mobile).toBe('07700900123'); + expect(result.facc_party_add_amend_convert_contact_telephone_number_home).toBe('02071234567'); + expect(result.facc_party_add_amend_convert_contact_telephone_number_business).toBe('01632960123'); + expect(result.facc_party_add_amend_convert_vehicle_make).toBe('Ford Focus'); + expect(result.facc_party_add_amend_convert_vehicle_registration_mark).toBe('XY21 ABC'); + }); }); }); From 9bbd96d15b3d018f2f04fd0b20e9744ad3b90cc9 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 17 Mar 2026 12:26:11 +0000 Subject: [PATCH 06/28] feat: implement convert mode for fines account conversion and update routing --- .../fines-acc-convert.component.spec.ts | 14 ++++- .../fines-acc-convert.component.ts | 3 +- ...-party-add-amend-convert-modes.constant.ts | 4 ++ ...-party-add-amend-convert-form.component.ts | 2 + ...acc-party-add-amend-convert.component.html | 1 + ...-party-add-amend-convert.component.spec.ts | 59 +++++++++++++------ ...s-acc-party-add-amend-convert.component.ts | 3 + .../fines-acc/routing/fines-acc.routes.ts | 2 +- 8 files changed, 68 insertions(+), 20 deletions(-) create mode 100644 src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-modes.constant.ts diff --git a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts index 1df7446019..d7874e0f50 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts @@ -17,6 +17,7 @@ import { defendantAccountHeadingResolver } from '../routing/resolvers/defendant- import { routePermissionsGuard } from '@hmcts/opal-frontend-common/guards/route-permissions'; import { authGuard } from '@hmcts/opal-frontend-common/guards/auth'; import { finesAccStateGuard } from '../routing/guards/fines-acc-state-guard/fines-acc-state.guard'; +import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-modes.constant'; describe('FinesAccConvertComponent', () => { let fixture: ComponentFixture; @@ -119,6 +120,17 @@ describe('FinesAccConvertComponent', () => { }); }); + it('should configure the party route to use partyType and mode route params', () => { + const defendantRoute = routing.find( + (route) => route.path === `${FINES_ACC_DEFENDANT_ROUTING_PATHS.root}/:accountId`, + ); + const partyRoute = defendantRoute?.children?.find( + (child) => child.path === `${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.party}/:partyType/:mode`, + ); + + expect(partyRoute?.path).toBe(`${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.party}/:partyType/:mode`); + }); + it('should create', () => { createComponent(); @@ -164,7 +176,7 @@ describe('FinesAccConvertComponent', () => { '../../', FINES_ACC_DEFENDANT_ROUTING_PATHS.children.party, FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, - 'amend', + FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.CONVERT, ], { relativeTo: mockActivatedRoute, diff --git a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts index 9dc6884c33..bd8a3ff311 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts @@ -9,6 +9,7 @@ import { FINES_ACC_MAP_TRANSFORM_ITEMS_CONFIG } from '../services/constants/fine import { FINES_ACC_DEFENDANT_ROUTING_PATHS } from '../routing/constants/fines-acc-defendant-routing-paths.constant'; import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-party-types.constant'; import { FINES_ACC_DEBTOR_TYPES } from '../constants/fines-acc-debtor-types.constant'; +import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-modes.constant'; @Component({ selector: 'app-fines-acc-convert', @@ -77,7 +78,7 @@ export class FinesAccConvertComponent implements OnInit { '../../', FINES_ACC_DEFENDANT_ROUTING_PATHS.children.party, FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, - 'amend', + FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.CONVERT, ], { relativeTo: this.activatedRoute, diff --git a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-modes.constant.ts b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-modes.constant.ts new file mode 100644 index 0000000000..f46b46867d --- /dev/null +++ b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-modes.constant.ts @@ -0,0 +1,4 @@ +export const FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES = { + AMEND: 'amend', + CONVERT: 'convert', +} as const; diff --git a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert-form/fines-acc-party-add-amend-convert-form.component.ts b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert-form/fines-acc-party-add-amend-convert-form.component.ts index 58d1e38342..791fc81ece 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert-form/fines-acc-party-add-amend-convert-form.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert-form/fines-acc-party-add-amend-convert-form.component.ts @@ -57,6 +57,7 @@ import { FinesAccPartyAddAmendConvertCd } from './components/fines-acc-party-add import { FinesAccPartyAddAmendConvertVd } from './components/fines-acc-party-add-amend-convert-vd/fines-acc-party-add-amend-convert-vd.component'; import { FinesAccPartyAddAmendConvertDobNi } from './components/fines-acc-party-add-amend-convert-dob-ni/fines-acc-party-add-amend-convert-dob-ni.component'; import { FINES_ACC_SUMMARY_TABS_CONTENT_STYLES } from '../../constants/fines-acc-summary-tabs-content-styles.constant'; +import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES } from '../constants/fines-acc-party-add-amend-convert-modes.constant'; const LETTERS_WITH_SPACES_PATTERN_VALIDATOR = patternValidator(LETTERS_WITH_SPACES_PATTERN, 'lettersWithSpacesPattern'); const ALPHANUMERIC_WITH_HYPHENS_SPACES_APOSTROPHES_DOT_PATTERN_VALIDATOR = patternValidator( @@ -108,6 +109,7 @@ export class FinesAccPartyAddAmendConvertFormComponent @Input({ required: true }) public isDebtor!: boolean; @Input({ required: true }) public partyType!: string; + @Input({ required: false }) public mode: string = FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.AMEND; @Input({ required: false }) public initialFormData: IFinesAccPartyAddAmendConvertForm = FINES_ACC_PARTY_ADD_AMEND_CONVERT_FORM; override fieldErrors: IFinesAccPartyAddAmendConvertFieldErrors = { diff --git a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.html b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.html index 268577bbfe..d9d52d0b39 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.html +++ b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.html @@ -1,6 +1,7 @@
{ let component: FinesAccPartyAddAmendConvert; @@ -32,6 +33,18 @@ describe('FinesAccPartyAddAmendConvert', () => { let mockUtilsService: any; // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockRouter: any; + let mockActivatedRoute: { + snapshot: { + data: { + partyAddAmendConvertData: typeof OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_EMPTY_DATA_MOCK & { version: string }; + }; + params: { + partyType: string; + accountId: string; + mode: string; + }; + }; + }; beforeEach(async () => { mockPayloadService = { @@ -57,6 +70,21 @@ describe('FinesAccPartyAddAmendConvert', () => { mockRouter = { navigate: vi.fn().mockName('Router.navigate'), }; + mockActivatedRoute = { + snapshot: { + data: { + partyAddAmendConvertData: { + ...OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_EMPTY_DATA_MOCK, + version: '1', + }, + }, + params: { + partyType: 'individual', + accountId: '123', + mode: FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.AMEND, + }, + }, + }; // Set up default return values mockPayloadService.mapDebtorAccountPartyPayload.mockReturnValue( @@ -75,23 +103,7 @@ describe('FinesAccPartyAddAmendConvert', () => { { provide: FinesAccountStore, useValue: mockFinesAccStore }, { provide: UtilsService, useValue: mockUtilsService }, { provide: Router, useValue: mockRouter }, - { - provide: ActivatedRoute, - useValue: { - snapshot: { - data: { - partyAddAmendConvertData: { - ...OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_EMPTY_DATA_MOCK, - version: '1', - }, - }, - params: { - partyType: 'individual', - accountId: '123', - }, - }, - }, - }, + { provide: ActivatedRoute, useValue: mockActivatedRoute }, ], }).compileComponents(); @@ -104,6 +116,19 @@ describe('FinesAccPartyAddAmendConvert', () => { expect(component).toBeTruthy(); }); + it('should read amend mode from the route by default', () => { + expect(component['mode']).toBe(FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.AMEND); + }); + + it('should read convert mode from the route', () => { + mockActivatedRoute.snapshot.params.mode = FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.CONVERT; + fixture = TestBed.createComponent(FinesAccPartyAddAmendConvert); + component = fixture.componentInstance; + fixture.detectChanges(); + + expect(component['mode']).toBe(FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.CONVERT); + }); + it('should handle form submission for individual party type', () => { // Arrange const mockFormData = { diff --git a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.ts b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.ts index 2d68b7d5a1..4a97415dda 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.ts @@ -9,6 +9,7 @@ import { OpalFines } from '../../services/opal-fines-service/opal-fines.service' import { FinesAccountStore } from '../stores/fines-acc.store'; import { UtilsService } from '@hmcts/opal-frontend-common/services/utils-service'; import { FINES_ACC_DEFENDANT_ROUTING_PATHS } from '../routing/constants/fines-acc-defendant-routing-paths.constant'; +import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES } from './constants/fines-acc-party-add-amend-convert-modes.constant'; @Component({ selector: 'app-fines-acc-debtor-add-amend', imports: [FinesAccPartyAddAmendConvertFormComponent], @@ -24,6 +25,8 @@ export class FinesAccPartyAddAmendConvert extends AbstractFormParentBaseComponen protected readonly finesDefendantRoutingPaths = FINES_ACC_DEFENDANT_ROUTING_PATHS; protected readonly partyType: string = this['activatedRoute'].snapshot.params['partyType']; + protected readonly mode: string = + this['activatedRoute'].snapshot.params['mode'] ?? FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.AMEND; protected readonly prefilledFormData: IFinesAccPartyAddAmendConvertForm = { formData: this.payloadService.mapDebtorAccountPartyPayload( this.partyPayload, diff --git a/src/app/flows/fines/fines-acc/routing/fines-acc.routes.ts b/src/app/flows/fines/fines-acc/routing/fines-acc.routes.ts index 47f023cbd4..07ecea48ef 100644 --- a/src/app/flows/fines/fines-acc/routing/fines-acc.routes.ts +++ b/src/app/flows/fines/fines-acc/routing/fines-acc.routes.ts @@ -130,7 +130,7 @@ export const routing: Routes = [ }, }, { - path: `${FINES_ACC_DEFENDANT_ROUTING_PATHS.children['party']}/:partyType/amend`, + path: `${FINES_ACC_DEFENDANT_ROUTING_PATHS.children['party']}/:partyType/:mode`, loadComponent: () => import('../fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component').then( From d30cc64557966f065736d7f671e4a31904b4a113 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 17 Mar 2026 12:29:24 +0000 Subject: [PATCH 07/28] feat: add assertions for company details convert route in account conversion flow --- .../account-details/edit.company-details.actions.ts | 13 +++++++++++++ .../AccountEnquiriesViewDetails.feature | 1 + .../functional/opal/flows/account-enquiry.flow.ts | 8 ++++++++ .../searchForAccount/account-enquiry.steps.ts | 5 +++++ 4 files changed, 27 insertions(+) diff --git a/cypress/e2e/functional/opal/actions/account-details/edit.company-details.actions.ts b/cypress/e2e/functional/opal/actions/account-details/edit.company-details.actions.ts index c066ed4d15..aa8a3cc5a1 100644 --- a/cypress/e2e/functional/opal/actions/account-details/edit.company-details.actions.ts +++ b/cypress/e2e/functional/opal/actions/account-details/edit.company-details.actions.ts @@ -12,6 +12,7 @@ const log = createScopedLogger('EditCompanyDetailsActions'); /** Actions for editing company details within Account Details. */ export class EditCompanyDetailsActions { + private static readonly DEFAULT_TIMEOUT = 10_000; private readonly common = new CommonActions(); private readonly companyFieldLocators = { 'Address line 1': L.fields.addressLine1, @@ -36,6 +37,18 @@ export class EditCompanyDetailsActions { log('done', 'Company edit form is visible'); } + /** + * Asserts the convert handoff lands on the Company details convert route. + */ + public assertOnConvertRoute(): void { + log('assert', 'Asserting Company details convert route'); + cy.location('pathname', { timeout: this.common.getPathTimeout() }).should( + 'match', + /\/fines\/account\/defendant\/\d+\/party\/company\/convert$/, + ); + cy.get(L.form, { timeout: EditCompanyDetailsActions.DEFAULT_TIMEOUT }).should('be.visible'); + } + /** * Update the company name field on the edit form. * diff --git a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature index 43d88bbfef..2965de1573 100644 --- a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature +++ b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature @@ -71,6 +71,7 @@ Feature: Account Enquiries – View Account Details When I start converting the account to a company account Then I should see the convert to company confirmation screen for defendant "Mr John ACCDETAILSURNAME{uniqUpper}" When I continue converting the account to a company account + Then I should be on the Company details convert route Then the Company details form should be pre-populated with: | Primary email address | John.AccDetailSurname{uniq}@test.com | | Home telephone number | 02078259314 | diff --git a/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts b/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts index 595e42e7cf..ebeab568b8 100644 --- a/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts +++ b/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts @@ -276,6 +276,14 @@ export class AccountEnquiryFlow { this.editCompanyDetailsActions.assertPrefilledFieldValues(expectedFieldValues); } + /** + * Asserts the convert flow lands on the Company details convert route. + */ + public assertOnCompanyDetailsConvertRoute(): void { + logAE('method', 'assertOnCompanyDetailsConvertRoute()'); + this.editCompanyDetailsActions.assertOnConvertRoute(); + } + /** * Navigates to the Parent/Guardian tab and asserts a specific section header. * diff --git a/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts b/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts index 203ebe6270..07e3a1d453 100644 --- a/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts +++ b/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts @@ -145,6 +145,11 @@ When('I continue converting the account to a company account', () => { flow().confirmConvertToCompanyAccount(); }); +Then('I should be on the Company details convert route', () => { + log('assert', 'Company details convert route is active'); + flow().assertOnCompanyDetailsConvertRoute(); +}); + When('I cancel converting the account to a company account', () => { log('step', 'Cancel converting account to company'); flow().cancelConvertToCompanyAccount(); From 00db3ea0b4f1242327297ae05217094eb189f062 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 17 Mar 2026 14:22:28 +0000 Subject: [PATCH 08/28] feat: Implement convert to individual account functionality - Added actions and assertions for converting a company account to an individual account in the EditDefendantDetailsActions class. - Updated feature files to include scenarios for converting to an individual account and verifying pre-filled fields. - Enhanced AccountEnquiryFlow to support individual account conversion actions and assertions. - Modified selectors to accommodate new convert action links and text. - Updated step definitions to handle new steps for converting to an individual account. - Enhanced FinesAccConvertComponent to manage conversion logic for individual accounts. - Created a new interface for convert actions to streamline handling of conversion options. - Updated tests to cover new conversion scenarios and ensure proper navigation and state management. --- .../convert.account.actions.ts | 54 +++++++++++++++ .../details.defendant.actions.ts | 31 ++++++++- .../edit.defendant-details.actions.ts | 46 +++++++++++++ .../AccountEnquiriesViewDetails.feature | 22 +++++- ...tEnquiriesViewDetailsAccessibility.feature | 6 +- .../opal/flows/account-enquiry.flow.ts | 69 +++++++++++++++++++ .../account.defendant.details.locators.ts | 7 +- .../searchForAccount/account-enquiry.steps.ts | 52 ++++++++++++++ .../fines-acc-convert.component.spec.ts | 64 ++++++++++++++++- .../fines-acc-convert.component.ts | 34 ++++++--- ...ndant-details-defendant-tab.component.html | 17 ++--- ...nt-details-defendant-tab.component.spec.ts | 53 ++++++++++++-- ...fendant-details-defendant-tab.component.ts | 11 +-- ...fines-acc-defendant-details.component.html | 4 +- ...es-acc-defendant-details.component.spec.ts | 55 +++++++++++---- .../fines-acc-defendant-details.component.ts | 39 +++++++---- ...endant-details-convert-action.interface.ts | 5 ++ ...oad-transform-defendant-data.utils.spec.ts | 53 ++++++++++++++ ...-payload-transform-defendant-data.utils.ts | 18 +++-- 19 files changed, 569 insertions(+), 71 deletions(-) create mode 100644 src/app/flows/fines/fines-acc/fines-acc-defendant-details/interfaces/fines-acc-defendant-details-convert-action.interface.ts diff --git a/cypress/e2e/functional/opal/actions/account-details/convert.account.actions.ts b/cypress/e2e/functional/opal/actions/account-details/convert.account.actions.ts index cb304ec2aa..9dc5dc88df 100644 --- a/cypress/e2e/functional/opal/actions/account-details/convert.account.actions.ts +++ b/cypress/e2e/functional/opal/actions/account-details/convert.account.actions.ts @@ -57,6 +57,44 @@ export class AccountConvertActions { cy.get(L.page.cancelLink, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }).should('exist'); } + /** + * Asserts the convert-to-individual confirmation page content. + * + * @param expectedCaptionName - Expected company name in the page caption. + */ + public assertOnConvertToIndividualConfirmation(expectedCaptionName: string): void { + log('assert', 'Convert to individual confirmation page is visible', { expectedCaptionName }); + cy.location('pathname', { timeout: this.common.getPathTimeout() }).should('include', '/convert/individual'); + + cy.get(L.page.caption, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }) + .should('be.visible') + .invoke('text') + .then((text) => { + const actual = this.normalize(text); + expect(actual).to.include(this.normalize(expectedCaptionName)); + expect(actual).to.include('-'); + }); + + cy.get(L.page.header, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }) + .should('be.visible') + .should(($el) => { + const text = this.normalize($el.text()); + expect(text).to.include('are you sure you want to convert this account to an individual account?'); + }); + + cy.get(L.page.warningText, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }) + .should('be.visible') + .should(($el) => { + const text = this.normalize($el.text()); + expect(text).to.include( + this.normalize('Some information specific to company accounts, such as company name, will be removed.'), + ); + }); + + cy.get(L.page.confirmButton, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }).should('exist'); + cy.get(L.page.cancelLink, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }).should('exist'); + } + /** * Clicks the confirmation button to continue to Company details. */ @@ -72,4 +110,20 @@ export class AccountConvertActions { log('action', 'Cancelling account conversion to company'); cy.get(L.page.cancelLink, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }).should('be.visible').click(); } + + /** + * Clicks the confirmation button to continue to Defendant details. + */ + public confirmConvertToIndividual(): void { + log('action', 'Confirming account conversion to individual'); + cy.get(L.page.confirmButton, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }).should('be.visible').click(); + } + + /** + * Clicks the cancel link to return to Defendant details. + */ + public cancelConvertToIndividual(): void { + log('action', 'Cancelling account conversion to individual'); + cy.get(L.page.cancelLink, { timeout: AccountConvertActions.DEFAULT_TIMEOUT }).should('be.visible').click(); + } } diff --git a/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts b/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts index 58da56ce4c..7a8d512311 100644 --- a/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts +++ b/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts @@ -61,22 +61,47 @@ export class AccountDetailsDefendantActions { * Asserts that the convert-to-company action is visible in the Defendant tab. */ assertConvertToCompanyActionVisible(): void { - cy.get(L.actions.convertToCompany, this.common.getTimeoutOptions()) + cy.get(L.actions.convertAction, this.common.getTimeoutOptions()) .should('be.visible') .and('contain.text', 'Convert to a company account'); } + /** + * Asserts that the convert-to-individual action is visible in the Defendant tab. + */ + assertConvertToIndividualActionVisible(): void { + cy.get(L.actions.convertAction, this.common.getTimeoutOptions()) + .should('be.visible') + .and('contain.text', 'Convert to an individual account'); + } + + /** + * Asserts that the visible convert action does not contain the company label. + */ + assertConvertToCompanyActionTextNotPresent(): void { + cy.get(L.actions.convertAction, this.common.getTimeoutOptions()) + .should('be.visible') + .and('not.contain.text', 'Convert to a company account'); + } + /** * Clicks the convert-to-company action from the Defendant tab. */ startConvertToCompanyAccount(): void { - cy.get(L.actions.convertToCompany, this.common.getTimeoutOptions()).should('be.visible').click(); + cy.get(L.actions.convertActionLink, this.common.getTimeoutOptions()).should('be.visible').click(); + } + + /** + * Clicks the convert-to-individual action from the Defendant tab. + */ + startConvertToIndividualAccount(): void { + cy.get(L.actions.convertActionLink, this.common.getTimeoutOptions()).should('be.visible').click(); } /** * Asserts that the convert-to-company action is not rendered in the Defendant tab. */ assertConvertToCompanyActionNotPresent(): void { - cy.get(L.actions.convertToCompany, this.common.getTimeoutOptions()).should('not.exist'); + cy.get(L.actions.convertAction, this.common.getTimeoutOptions()).should('not.exist'); } } diff --git a/cypress/e2e/functional/opal/actions/account-details/edit.defendant-details.actions.ts b/cypress/e2e/functional/opal/actions/account-details/edit.defendant-details.actions.ts index e2089d5f4f..1d5ce34a93 100644 --- a/cypress/e2e/functional/opal/actions/account-details/edit.defendant-details.actions.ts +++ b/cypress/e2e/functional/opal/actions/account-details/edit.defendant-details.actions.ts @@ -5,11 +5,28 @@ import { DefendantDetailsLocators as L } from '../../../../../shared/selectors/account-details/edit.defendant.details.locators'; import { createScopedLogger } from '../../../../../support/utils/log.helper'; +import { CommonActions } from '../common/common.actions'; const log = createScopedLogger('EditDefendantDetailsActions'); /** Actions for editing defendant details within Account Details. */ export class EditDefendantDetailsActions { + private static readonly DEFAULT_TIMEOUT = 10_000; + private readonly common = new CommonActions(); + private readonly defendantFieldLocators = { + 'Address line 1': L.addressLine1Input, + 'Address line 2': L.addressLine2Input, + 'Address line 3': L.addressLine3Input, + Postcode: L.postcodeInput, + 'Primary email address': L.primaryEmailInput, + 'Secondary email address': L.secondaryEmailInput, + 'Mobile telephone number': L.mobileTelInput, + 'Home telephone number': L.homeTelInput, + 'Work telephone number': L.workTelInput, + 'Make and model': L.vehicleMakeModelInput, + 'Registration number': L.vehicleRegInput, + } as const; + /** * Ensures the user is still on the edit page (form visible, not navigated away). */ @@ -19,6 +36,35 @@ export class EditDefendantDetailsActions { log('done', 'Defendant Details edit form confirmed visible'); } + /** + * Asserts the convert handoff lands on the Defendant details convert route. + */ + public assertOnConvertRoute(): void { + log('assert', 'Asserting Defendant details convert route'); + cy.location('pathname', { timeout: this.common.getPathTimeout() }).should( + 'match', + /\/fines\/account\/defendant\/\d+\/party\/individual\/convert$/, + ); + cy.get(L.form.selector, { timeout: EditDefendantDetailsActions.DEFAULT_TIMEOUT }).should('be.visible'); + } + + /** + * Asserts Defendant details form fields are pre-populated with the expected values. + * + * @param expectedFieldValues - Key/value map of ticket field labels to expected values. + */ + public assertPrefilledFieldValues(expectedFieldValues: Record): void { + Object.entries(expectedFieldValues).forEach(([fieldName, expectedValue]) => { + const fieldSelector = this.defendantFieldLocators[fieldName as keyof typeof this.defendantFieldLocators]; + if (!fieldSelector) { + throw new Error(`Unsupported defendant prefill field: ${fieldName}`); + } + + log('assert', 'Asserting defendant field prefill', { fieldName, expectedValue }); + cy.get(fieldSelector.selector, this.common.getTimeoutOptions()).should('have.value', expectedValue); + }); + } + /** * Updates the "First names" field on the edit form. * diff --git a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature index 2965de1573..b1f96819c3 100644 --- a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature +++ b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature @@ -98,7 +98,8 @@ Feature: Account Enquiries – View Account Details Then I should see the account header contains "Accdetail comp{uniq}" # AC3 – Navigate to Company details When I go to the Defendant details section and the header is "Company details" - Then I should not see the convert to company account action + Then I should see the convert to individual account action + And I should not see the convert to company account text @967 @PO-1111 @PO-1128 Scenario: Company edit warning retains changes when I stay on the form @@ -137,6 +138,25 @@ Feature: Account Enquiries – View Account Details And I should see the account header contains "Accdetail comp updated{uniq}" And I verify no amendments were created via API for company details + @PO-1956 + Scenario: Convert to individual confirmation continues to Defendant details with shared fields pre-populated + When I start converting the account to an individual account + Then I should see the convert to individual confirmation screen for company "Accdetail comp{uniq}" + When I continue converting the account to an individual account + Then I should be on the Defendant details convert route + And the Defendant details form should be pre-populated with: + | Postcode | AB23 4RN | + | Primary email address | Accdetailcomp{uniq}@test.com | + + @PO-1956 + Scenario: Convert to individual confirmation cancel returns to Defendant details with no changes made + When I start converting the account to an individual account + Then I should see the convert to individual confirmation screen for company "Accdetail comp{uniq}" + When I cancel converting the account to an individual account + Then I should return to the account details page Defendant tab + And I should see the convert to individual account action + And I should not see the convert to company account text + Rule: Non-paying defendant account baseline Background: # AC1 – Account setup diff --git a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetailsAccessibility.feature b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetailsAccessibility.feature index 8ebe68255c..1417d091ed 100644 --- a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetailsAccessibility.feature +++ b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetailsAccessibility.feature @@ -46,5 +46,9 @@ Feature: Account Enquiries - View Account Details Accessibility # Check Accessibility on Company Defendant Details Page And I select the latest published account and verify the header is "Accdetail comp{uniqUpper}" And I go to the Defendant details section and the header is "Company details" - And I should not see the convert to company account action + And I should see the convert to individual account action + And I should not see the convert to company account text Then I check the page for accessibility + When I start converting the account to an individual account + Then I should see the convert to individual confirmation screen for company "Accdetail comp{uniq}" + And I check the page for accessibility diff --git a/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts b/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts index ebeab568b8..a67d0dc9d1 100644 --- a/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts +++ b/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts @@ -223,6 +223,14 @@ export class AccountEnquiryFlow { this.defendantDetails.assertConvertToCompanyActionVisible(); } + /** + * Asserts the Defendant tab shows the convert-to-individual action. + */ + public assertConvertToIndividualActionVisible(): void { + logAE('method', 'assertConvertToIndividualActionVisible()'); + this.defendantDetails.assertConvertToIndividualActionVisible(); + } + /** * Asserts the Defendant tab does not show the convert-to-company action. */ @@ -231,6 +239,14 @@ export class AccountEnquiryFlow { this.defendantDetails.assertConvertToCompanyActionNotPresent(); } + /** + * Asserts the visible convert action is not the company-convert label. + */ + public assertConvertToCompanyActionTextNotPresent(): void { + logAE('method', 'assertConvertToCompanyActionTextNotPresent()'); + this.defendantDetails.assertConvertToCompanyActionTextNotPresent(); + } + /** * Opens the convert-to-company confirmation page from the Defendant tab. */ @@ -240,6 +256,15 @@ export class AccountEnquiryFlow { this.defendantDetails.startConvertToCompanyAccount(); } + /** + * Opens the convert-to-individual confirmation page from the Defendant tab. + */ + public openConvertToIndividualConfirmation(): void { + logAE('method', 'openConvertToIndividualConfirmation()'); + this.detailsNav.goToDefendantTab(); + this.defendantDetails.startConvertToIndividualAccount(); + } + /** * Asserts the convert-to-company confirmation page. * @@ -266,6 +291,32 @@ export class AccountEnquiryFlow { this.accountConvert.cancelConvertToCompany(); } + /** + * Asserts the convert-to-individual confirmation page. + * + * @param expectedCaptionName - Expected company name shown in the caption. + */ + public assertOnConvertToIndividualConfirmation(expectedCaptionName: string): void { + logAE('method', 'assertOnConvertToIndividualConfirmation()', { expectedCaptionName }); + this.accountConvert.assertOnConvertToIndividualConfirmation(expectedCaptionName); + } + + /** + * Confirms the convert-to-individual action. + */ + public confirmConvertToIndividualAccount(): void { + logAE('method', 'confirmConvertToIndividualAccount()'); + this.accountConvert.confirmConvertToIndividual(); + } + + /** + * Cancels the convert-to-individual action. + */ + public cancelConvertToIndividualAccount(): void { + logAE('method', 'cancelConvertToIndividualAccount()'); + this.accountConvert.cancelConvertToIndividual(); + } + /** * Asserts the Company details form contains the expected pre-populated values. * @@ -284,6 +335,24 @@ export class AccountEnquiryFlow { this.editCompanyDetailsActions.assertOnConvertRoute(); } + /** + * Asserts the convert flow lands on the Defendant details convert route. + */ + public assertOnDefendantDetailsConvertRoute(): void { + logAE('method', 'assertOnDefendantDetailsConvertRoute()'); + this.editDefendantDetailsActions.assertOnConvertRoute(); + } + + /** + * Asserts the Defendant details form contains the expected pre-populated values. + * + * @param expectedFieldValues - Key/value map of ticket field labels to expected values. + */ + public assertDefendantDetailsPrefilledValues(expectedFieldValues: Record): void { + logAE('method', 'assertDefendantDetailsPrefilledValues()', expectedFieldValues); + this.editDefendantDetailsActions.assertPrefilledFieldValues(expectedFieldValues); + } + /** * Navigates to the Parent/Guardian tab and asserts a specific section header. * diff --git a/cypress/shared/selectors/account-details/account.defendant.details.locators.ts b/cypress/shared/selectors/account-details/account.defendant.details.locators.ts index f863971b56..2aae45e269 100644 --- a/cypress/shared/selectors/account-details/account.defendant.details.locators.ts +++ b/cypress/shared/selectors/account-details/account.defendant.details.locators.ts @@ -169,7 +169,10 @@ export const AccountDefendantDetailsLocators = { /** Container for the right column actions within Defendant tab. */ sideColumn: 'app-fines-acc-defendant-details-defendant-tab .govuk-grid-column-one-third', - /** “Convert to a company account” action link. */ - convertToCompany: 'app-fines-acc-defendant-details-defendant-tab .govuk-grid-column-one-third p > a', + /** Convert action text within the Defendant tab actions column. */ + convertAction: 'app-fines-acc-defendant-details-defendant-tab .govuk-grid-column-one-third p', + + /** Interactive convert action link, when present. */ + convertActionLink: 'app-fines-acc-defendant-details-defendant-tab .govuk-grid-column-one-third p > a', }, }; diff --git a/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts b/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts index 07e3a1d453..1be62217e8 100644 --- a/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts +++ b/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts @@ -119,11 +119,21 @@ Then('I should see the convert to company account action', () => { flow().assertConvertToCompanyActionVisible(); }); +Then('I should see the convert to individual account action', () => { + log('assert', 'Convert to individual account action is visible'); + flow().assertConvertToIndividualActionVisible(); +}); + Then('I should not see the convert to company account action', () => { log('assert', 'Convert to company account action is absent'); flow().assertConvertToCompanyActionNotPresent(); }); +Then('I should not see the convert to company account text', () => { + log('assert', 'Convert to company account text is absent from the visible action'); + flow().assertConvertToCompanyActionTextNotPresent(); +}); + When('I start converting the account to a company account', () => { log('step', 'Start converting account to company'); flow().openConvertToCompanyConfirmation(); @@ -166,6 +176,48 @@ Then('the Company details form should be pre-populated with:', (table: DataTable flow().assertCompanyDetailsPrefilledValues(expectedFieldValues); }); +When('I start converting the account to an individual account', () => { + log('step', 'Start converting account to individual'); + flow().openConvertToIndividualConfirmation(); +}); + +Then( + 'I should see the convert to individual confirmation screen for company {string}', + (expectedCaptionName: string) => { + const expectedCaptionNameWithUniq = applyUniqPlaceholder(expectedCaptionName); + log('assert', 'Convert to individual confirmation screen is visible', { + expectedCaptionName: expectedCaptionNameWithUniq, + }); + flow().assertOnConvertToIndividualConfirmation(expectedCaptionNameWithUniq); + }, +); + +When('I continue converting the account to an individual account', () => { + log('step', 'Continue converting account to individual'); + flow().confirmConvertToIndividualAccount(); +}); + +Then('I should be on the Defendant details convert route', () => { + log('assert', 'Defendant details convert route is active'); + flow().assertOnDefendantDetailsConvertRoute(); +}); + +When('I cancel converting the account to an individual account', () => { + log('step', 'Cancel converting account to individual'); + flow().cancelConvertToIndividualAccount(); +}); + +Then('the Defendant details form should be pre-populated with:', (table: DataTable) => { + const expectedFieldValues = Object.fromEntries( + Object.entries(rowsHashSafe(table)).map(([fieldName, fieldValue]) => [ + fieldName, + applyUniqPlaceholder(fieldValue), + ]), + ); + log('assert', 'Defendant details form is pre-populated', expectedFieldValues); + flow().assertDefendantDetailsPrefilledValues(expectedFieldValues); +}); + /** * @step Navigates to the Parent or guardian details section and validates the header text. * diff --git a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts index d7874e0f50..55e77fda8e 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts @@ -49,6 +49,20 @@ describe('FinesAccConvertComponent', () => { }, }; + const companyHeadingData = { + ...structuredClone(FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK), + debtor_type: 'Defendant', + party_details: { + ...structuredClone(FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK.party_details), + organisation_flag: true, + organisation_details: { + organisation_name: 'Accdetail comp limited', + organisation_aliases: [], + }, + individual_details: null, + }, + }; + const configureRoute = ( partyType = FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, headingData = defaultHeadingData, @@ -166,6 +180,23 @@ describe('FinesAccConvertComponent', () => { expect(compiled.textContent).toContain('No - cancel'); }); + it('should render the caption, heading, warning text, and action buttons for individual conversion', () => { + mockAccountStore.party_name.mockReturnValue('Accdetail comp limited'); + configureRoute(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, companyHeadingData); + + createComponent(); + + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.textContent).toContain('06000427N - Accdetail comp limited'); + expect(compiled.textContent).toContain('Are you sure you want to convert this account to an individual account?'); + expect(compiled.textContent).toContain( + 'Some information specific to company accounts, such as company name, will be removed.', + ); + expect(compiled.textContent).toContain('Yes - continue'); + expect(compiled.textContent).toContain('No - cancel'); + }); + it('should navigate to the company details page when continue is clicked', () => { createComponent(); @@ -184,6 +215,26 @@ describe('FinesAccConvertComponent', () => { ); }); + it('should navigate to the defendant details page when continuing individual conversion', () => { + configureRoute(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, companyHeadingData); + + createComponent(); + + component.handleContinue(); + + expect(mockRouter.navigate).toHaveBeenCalledWith( + [ + '../../', + FINES_ACC_DEFENDANT_ROUTING_PATHS.children.party, + FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, + FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.CONVERT, + ], + { + relativeTo: mockActivatedRoute, + }, + ); + }); + it('should navigate back to defendant details when cancel is clicked', () => { createComponent(); @@ -196,7 +247,18 @@ describe('FinesAccConvertComponent', () => { }); it('should redirect back to defendant details when partyType is unsupported', () => { - configureRoute(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL); + configureRoute('unsupported-target'); + + createComponent(); + + expect(mockRouter.navigate).toHaveBeenCalledWith(['../../', FINES_ACC_DEFENDANT_ROUTING_PATHS.children.details], { + relativeTo: mockActivatedRoute, + fragment: 'defendant', + }); + }); + + it('should redirect back to defendant details when an individual account tries to convert to individual', () => { + configureRoute(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, defaultHeadingData); createComponent(); diff --git a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts index bd8a3ff311..3e198652bc 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts @@ -39,12 +39,20 @@ export class FinesAccConvertComponent implements OnInit { ); } - private get canConvertToCompanyAccount(): boolean { - return ( - this.routePartyType === this.partyTypes.COMPANY && - !this.accountData.party_details.organisation_flag && - this.accountData.debtor_type !== FINES_ACC_DEBTOR_TYPES.parentGuardian - ); + private get isSourceCompanyAccount(): boolean { + return this.accountData.party_details.organisation_flag; + } + + private get canConvertAccount(): boolean { + if (this.routePartyType === this.partyTypes.COMPANY) { + return !this.isSourceCompanyAccount && this.accountData.debtor_type !== FINES_ACC_DEBTOR_TYPES.parentGuardian; + } + + if (this.routePartyType === this.partyTypes.INDIVIDUAL) { + return this.isSourceCompanyAccount; + } + + return false; } public get captionText(): string { @@ -52,23 +60,31 @@ export class FinesAccConvertComponent implements OnInit { } public get headingText(): string { + if (this.routePartyType === this.partyTypes.INDIVIDUAL) { + return 'Are you sure you want to convert this account to an individual account?'; + } + return 'Are you sure you want to convert this account to a company account?'; } public get warningText(): string { + if (this.routePartyType === this.partyTypes.INDIVIDUAL) { + return 'Some information specific to company accounts, such as company name, will be removed.'; + } + return 'Certain data related to individual accounts, such as employment details, will be removed.'; } public ngOnInit(): void { this.getHeaderDataFromRoute(); - if (!this.canConvertToCompanyAccount) { + if (!this.canConvertAccount) { this.navigateBackToAccountSummary(); } } public handleContinue(): void { - if (!this.canConvertToCompanyAccount) { + if (!this.canConvertAccount) { this.navigateBackToAccountSummary(); return; } @@ -77,7 +93,7 @@ export class FinesAccConvertComponent implements OnInit { [ '../../', FINES_ACC_DEFENDANT_ROUTING_PATHS.children.party, - FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, + this.routePartyType, FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.CONVERT, ], { diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html index ce6baff2ca..00854d0f1e 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html @@ -1,5 +1,5 @@ @if (tabData.defendant_account_party; as party) { -
+
@if (party.party_details.organisation_flag) { @@ -29,17 +29,18 @@

Defendant Details

summaryListId="defendantDetails" >
- @if (showConvertToCompanyAction) { + @if (convertAction) {

Actions

- Convert to a company account + @if (convertAction.interactive) { + {{ + convertAction.label + }} + } @else { + {{ convertAction.label }} + }

} diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts index af4fb4a5ad..0b299e4cf5 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts @@ -3,10 +3,13 @@ import { FinesAccDefendantDetailsDefendantTabComponent } from './fines-acc-defen import { OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-account-defendant-account-party.mock'; import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES } from '../../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-party-types.constant'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { IFinesAccDefendantDetailsConvertAction } from '../interfaces/fines-acc-defendant-details-convert-action.interface'; describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { let component: FinesAccDefendantDetailsDefendantTabComponent; let fixture: ComponentFixture; + let companyConvertAction: IFinesAccDefendantDetailsConvertAction; + let individualConvertAction: IFinesAccDefendantDetailsConvertAction; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -16,6 +19,16 @@ describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { fixture = TestBed.createComponent(FinesAccDefendantDetailsDefendantTabComponent); component = fixture.componentInstance; component.tabData = structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK); + companyConvertAction = { + interactive: true, + label: 'Convert to a company account', + partyType: FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, + }; + individualConvertAction = { + interactive: true, + label: 'Convert to an individual account', + partyType: FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, + }; fixture.detectChanges(); }); @@ -23,15 +36,16 @@ describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { expect(component).toBeTruthy(); }); - it('should not render the actions column when convert-to-company is not enabled', () => { + it('should not render the actions column when no convert action is configured', () => { const compiled = fixture.nativeElement as HTMLElement; expect(compiled.textContent).not.toContain('Actions'); expect(compiled.textContent).not.toContain('Convert to a company account'); + expect(compiled.textContent).not.toContain('Convert to an individual account'); }); - it('should render a fixed convert-to-company action when enabled', () => { - component.showConvertToCompanyAction = true; + it('should render an interactive convert-to-company action when configured', () => { + component.convertAction = companyConvertAction; fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; @@ -40,6 +54,18 @@ describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { expect(compiled.textContent).toContain('Convert to a company account'); }); + it('should render an interactive convert-to-individual action when configured', () => { + component.convertAction = individualConvertAction; + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + const convertLink = fixture.nativeElement.querySelector('.govuk-grid-column-one-third a'); + + expect(compiled.textContent).toContain('Actions'); + expect(compiled.textContent).toContain('Convert to an individual account'); + expect(convertLink).not.toBeNull(); + }); + it('should handle change defendant details when partyType is a company', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(component.changeDefendantDetails, 'emit'); @@ -60,16 +86,29 @@ describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { ); }); - it('should emit convert-to-company when the action is clicked', () => { - component.showConvertToCompanyAction = true; + it('should emit convert when the interactive action is clicked', () => { + component.convertAction = companyConvertAction; + fixture.detectChanges(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(component.convertAccount, 'emit'); + + const convertLink = fixture.nativeElement.querySelector('.govuk-grid-column-one-third a') as HTMLAnchorElement; + convertLink.click(); + + expect(component.convertAccount.emit).toHaveBeenCalledWith(); + }); + + it('should emit convert when the interactive individual action is clicked', () => { + component.convertAction = individualConvertAction; fixture.detectChanges(); // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(component.convertToCompanyAccount, 'emit'); + vi.spyOn(component.convertAccount, 'emit'); const convertLink = fixture.nativeElement.querySelector('.govuk-grid-column-one-third a') as HTMLAnchorElement; convertLink.click(); - expect(component.convertToCompanyAccount.emit).toHaveBeenCalledWith(); + expect(component.convertAccount.emit).toHaveBeenCalledWith(); }); }); diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.ts index 10ac1ce81a..196a3ecc00 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.ts @@ -4,6 +4,7 @@ import { FINES_ACC_SUMMARY_TABS_CONTENT_STYLES } from '../../constants/fines-acc import { IOpalFinesAccountDefendantAccountParty } from '@services/fines/opal-fines-service/interfaces/opal-fines-account-defendant-account-party.interface'; import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES } from '../../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-party-types.constant'; import { FinesAccPartyDetails } from '../fines-acc-party-details/fines-acc-party-details.component'; +import { IFinesAccDefendantDetailsConvertAction } from '../interfaces/fines-acc-defendant-details-convert-action.interface'; @Component({ selector: 'app-fines-acc-defendant-details-defendant-tab', @@ -14,13 +15,15 @@ import { FinesAccPartyDetails } from '../fines-acc-party-details/fines-acc-party export class FinesAccDefendantDetailsDefendantTabComponent { @Input({ required: true }) tabData!: IOpalFinesAccountDefendantAccountParty; @Input() hasAccountMaintenencePermission: boolean = false; - @Input() showConvertToCompanyAction: boolean = false; + @Input() convertAction: IFinesAccDefendantDetailsConvertAction | null = null; @Input() style: IFinesAccSummaryTabsContentStyles = FINES_ACC_SUMMARY_TABS_CONTENT_STYLES; @Output() changeDefendantDetails = new EventEmitter(); - @Output() convertToCompanyAccount = new EventEmitter(); + @Output() convertAccount = new EventEmitter(); - public handleConvertToCompanyAccount(): void { - this.convertToCompanyAccount.emit(); + public handleConvertAccount(): void { + if (this.convertAction?.interactive) { + this.convertAccount.emit(); + } } public handleChangeDefendantDetails(): void { diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.html b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.html index af901e4a99..451635ff79 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.html +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.html @@ -160,11 +160,11 @@

Business Unit:

} } diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts index 58cb9c103b..f28cb5f2e9 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts @@ -248,10 +248,10 @@ describe('FinesAccDefendantDetailsComponent', () => { ); }); - it('should navigate to the company amend page when convert-to-company is triggered', () => { + it('should navigate to the company convert page when interactive convert is triggered', () => { routerSpy.navigate.mockClear(); - component.navigateToConvertToCompanyAccountPage(); + component.navigateToConvertAccountPage(); expect(routerSpy.navigate).toHaveBeenCalledWith( [`../${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/${FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY}`], @@ -289,53 +289,80 @@ describe('FinesAccDefendantDetailsComponent', () => { }); }); - it('should navigate to access-denied if user lacks permission for convert-to-company', () => { + it('should navigate to access-denied if user lacks permission for convert action', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(component['permissionsService'], 'hasBusinessUnitPermissionAccess').mockReturnValue(false); - component.navigateToConvertToCompanyAccountPage(); + component.navigateToConvertAccountPage(); expect(routerSpy.navigate).toHaveBeenCalledWith(['/access-denied'], { relativeTo: component['activatedRoute'], }); }); - it('should show convert-to-company action for an adult individual account with account maintenance permission', () => { + it('should show interactive convert-to-company action for an adult individual account with account maintenance permission', () => { component.accountData.party_details.organisation_flag = false; component.accountData.debtor_type = 'Defendant'; component.accountData.is_youth = false; - expect(component.canShowConvertToCompanyAction).toBe(true); + expect(component.convertAction).toEqual({ + interactive: true, + label: 'Convert to a company account', + partyType: FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, + }); }); - it('should show convert-to-company action for a youth individual account with account maintenance permission', () => { + it('should show interactive convert-to-company action for a youth individual account with account maintenance permission', () => { component.accountData.party_details.organisation_flag = false; component.accountData.debtor_type = 'Defendant'; component.accountData.is_youth = true; - expect(component.canShowConvertToCompanyAction).toBe(true); + expect(component.convertAction).toEqual({ + interactive: true, + label: 'Convert to a company account', + partyType: FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, + }); }); - it('should hide convert-to-company action for an account with parent or guardian to pay', () => { + it('should hide convert action for an account with parent or guardian to pay', () => { component.accountData.party_details.organisation_flag = false; component.accountData.debtor_type = component.debtorTypes.parentGuardian; - expect(component.canShowConvertToCompanyAction).toBe(false); + expect(component.convertAction).toBeNull(); }); - it('should hide convert-to-company action for a company account', () => { + it('should show display-only convert-to-individual action for a company account', () => { component.accountData.party_details.organisation_flag = true; component.accountData.debtor_type = 'Defendant'; - expect(component.canShowConvertToCompanyAction).toBe(false); + expect(component.convertAction).toEqual({ + interactive: true, + label: 'Convert to an individual account', + partyType: FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, + }); + }); + + it('should navigate to the individual convert page when interactive company convert is triggered', () => { + routerSpy.navigate.mockClear(); + component.accountData.party_details.organisation_flag = true; + component.accountData.debtor_type = 'Defendant'; + + component.navigateToConvertAccountPage(); + + expect(routerSpy.navigate).toHaveBeenCalledWith( + [`../${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/${FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL}`], + { + relativeTo: component['activatedRoute'], + }, + ); }); - it('should hide convert-to-company action when account maintenance permission is not available', () => { + it('should hide convert action when account maintenance permission is not available', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(component['permissionsService'], 'hasBusinessUnitPermissionAccess').mockReturnValue(false); component.accountData.party_details.organisation_flag = false; component.accountData.debtor_type = 'Defendant'; - expect(component.canShowConvertToCompanyAction).toBe(false); + expect(component.convertAction).toBeNull(); }); it('should navigate to the change defendant payment terms access denied page if user does not have the relevant permission', () => { diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts index a01cf818ef..ad2561bacc 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts @@ -56,6 +56,7 @@ import { IOpalFinesResultRefData } from '@services/fines/opal-fines-service/inte import { FinesAccDefendantDetailsEnforcementTab } from './fines-acc-defendant-details-enforcement-tab/fines-acc-defendant-details-enforcement-tab.component'; import { FinesAccSummaryHeaderComponent } from '../fines-acc-summary-header/fines-acc-summary-header.component'; import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-party-types.constant'; +import { IFinesAccDefendantDetailsConvertAction } from './interfaces/fines-acc-defendant-details-convert-action.interface'; @Component({ selector: 'app-fines-acc-defendant-details', @@ -394,14 +395,28 @@ export class FinesAccDefendantDetailsComponent extends AbstractTabData implement this.refreshFragment$.complete(); } - public get canShowConvertToCompanyAction(): boolean { - const isAdultOrYouthOnlyAccount = this.accountData.debtor_type !== this.debtorTypes.parentGuardian; + public get convertAction(): IFinesAccDefendantDetailsConvertAction | null { + if (!this.hasAccountMaintenancePermissionInBusinessUnit()) { + return null; + } - return ( - !this.accountData.party_details.organisation_flag && - isAdultOrYouthOnlyAccount && - this.hasAccountMaintenancePermissionInBusinessUnit() - ); + if (this.accountData.debtor_type === this.debtorTypes.parentGuardian) { + return null; + } + + if (this.accountData.party_details.organisation_flag) { + return { + interactive: true, + label: 'Convert to an individual account', + partyType: FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, + }; + } + + return { + interactive: true, + label: 'Convert to a company account', + partyType: FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, + }; } /** @@ -421,12 +436,12 @@ export class FinesAccDefendantDetailsComponent extends AbstractTabData implement } } - public navigateToConvertToCompanyAccountPage(): void { - if (this.hasAccountMaintenancePermissionInBusinessUnit()) { + public navigateToConvertAccountPage(): void { + const targetPartyType = this.convertAction?.partyType; + + if (this.hasAccountMaintenancePermissionInBusinessUnit() && this.convertAction?.interactive && targetPartyType) { this['router'].navigate( - [ - `../${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/${FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY}`, - ], + [`../${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/${targetPartyType}`], { relativeTo: this.activatedRoute, }, diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/interfaces/fines-acc-defendant-details-convert-action.interface.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/interfaces/fines-acc-defendant-details-convert-action.interface.ts new file mode 100644 index 0000000000..3c22eba45a --- /dev/null +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/interfaces/fines-acc-defendant-details-convert-action.interface.ts @@ -0,0 +1,5 @@ +export interface IFinesAccDefendantDetailsConvertAction { + interactive: boolean; + label: string; + partyType: string; +} diff --git a/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts b/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts index 33c65c247f..ae7471937b 100644 --- a/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts +++ b/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts @@ -858,5 +858,58 @@ describe('transformDefendantAccountPartyPayload', () => { expect(result.facc_party_add_amend_convert_vehicle_make).toBe('Ford Focus'); expect(result.facc_party_add_amend_convert_vehicle_registration_mark).toBe('XY21 ABC'); }); + + it('should preserve only shared fields when converting a company account to individual', () => { + const mockCompanyData = { + ...mockDefendantData, + defendant_account_party: { + ...mockDefendantData.defendant_account_party, + party_details: { + ...mockDefendantData.defendant_account_party.party_details, + organisation_flag: true, + organisation_details: { + organisation_name: 'Convert Me Ltd', + organisation_aliases: [ + { + alias_id: 'ORG-1', + sequence_number: 1, + organisation_name: 'Convert Alias Ltd', + }, + ], + }, + individual_details: null, + }, + employer_details: null, + }, + }; + + const result = transformDefendantAccountPartyPayload(mockCompanyData, 'individual', true); + + expect(result.facc_party_add_amend_convert_organisation_name).toBeNull(); + expect(result.facc_party_add_amend_convert_organisation_aliases).toEqual([]); + expect(result.facc_party_add_amend_convert_add_alias).toBe(false); + + expect(result.facc_party_add_amend_convert_title).toBeNull(); + expect(result.facc_party_add_amend_convert_forenames).toBeNull(); + expect(result.facc_party_add_amend_convert_surname).toBeNull(); + expect(result.facc_party_add_amend_convert_dob).toBeNull(); + expect(result.facc_party_add_amend_convert_national_insurance_number).toBeNull(); + expect(result.facc_party_add_amend_convert_individual_aliases).toEqual([]); + expect(result.facc_party_add_amend_convert_employer_company_name).toBeNull(); + expect(result.facc_party_add_amend_convert_employer_reference).toBeNull(); + expect(result.facc_party_add_amend_convert_employer_email_address).toBeNull(); + expect(result.facc_party_add_amend_convert_employer_telephone_number).toBeNull(); + + expect(result.facc_party_add_amend_convert_address_line_1).toBe('45 High Street'); + expect(result.facc_party_add_amend_convert_address_line_2).toBe('Flat 2B'); + expect(result.facc_party_add_amend_convert_post_code).toBe('AB1 2CD'); + expect(result.facc_party_add_amend_convert_contact_email_address_1).toBe('sarah.thompson@example.com'); + expect(result.facc_party_add_amend_convert_contact_email_address_2).toBe('s.thompson@workmail.com'); + expect(result.facc_party_add_amend_convert_contact_telephone_number_mobile).toBe('07700900123'); + expect(result.facc_party_add_amend_convert_contact_telephone_number_home).toBe('02071234567'); + expect(result.facc_party_add_amend_convert_contact_telephone_number_business).toBe('01632960123'); + expect(result.facc_party_add_amend_convert_vehicle_make).toBe('Ford Focus'); + expect(result.facc_party_add_amend_convert_vehicle_registration_mark).toBe('XY21 ABC'); + }); }); }); diff --git a/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.ts b/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.ts index 889545d0ac..327a291bfb 100644 --- a/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.ts +++ b/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.ts @@ -212,29 +212,33 @@ export const transformDefendantAccountPartyPayload = ( const { organisation_flag } = party_details; const individualDetails = party_details.individual_details; const organisationDetails = party_details.organisation_details; + const isCompany = partyType === 'company'; + const isIndividual = partyType === 'individual'; + const isParentGuardian = partyType === 'parentGuardian'; + const hasExplicitPartyType = isCompany || isIndividual || isParentGuardian; // Handle aliases based on party type let individualAliases: IFinesAccPartyAddAmendConvertIndividualAliasState[] = []; let organisationAliases: IFinesAccPartyAddAmendConvertOrganisationAliasState[] = []; let hasAliases = false; - if (organisation_flag && organisationDetails?.organisation_aliases) { + if ((isCompany || (!hasExplicitPartyType && organisation_flag)) && organisationDetails?.organisation_aliases) { organisationAliases = mapOrganisationAliasesToArrayStructure(organisationDetails.organisation_aliases); hasAliases = organisationDetails.organisation_aliases.length > 0; - } else if (!organisation_flag && individualDetails?.individual_aliases) { + } else if ( + (isIndividual || isParentGuardian || (!hasExplicitPartyType && !organisation_flag)) && + individualDetails?.individual_aliases + ) { individualAliases = mapIndividualAliasesToArrayStructure(individualDetails.individual_aliases); hasAliases = individualDetails.individual_aliases.length > 0; } - const isCompany = partyType === 'company'; - const isIndividual = partyType === 'individual'; - // Create base state with common fields const baseState = createBaseState(address, contact_details, vehicle_details, language_preferences); - if (isCompany || organisation_flag) { + if (isCompany || (!hasExplicitPartyType && organisation_flag)) { return getCompanyParty(baseState, organisationDetails, organisationAliases, hasAliases); - } else if (isIndividual && !isDebtor) { + } else if (isIndividual && !isDebtor && !organisation_flag) { // For individual party type that is not a debtor, only show fields from title to address postcode return getIndividualDebtorParty(baseState, individualDetails, individualAliases, hasAliases); } else { From b34bc93a94edf84b45c5227a0df1240e165e5e45 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 17 Mar 2026 15:13:34 +0000 Subject: [PATCH 09/28] feat: add setSuccessMessage mock to FinesAccPartyAddAmendConvert tests --- .../fines-acc-party-add-amend-convert.component.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.spec.ts index b7c2aa7e90..6063a833be 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.spec.ts @@ -28,6 +28,7 @@ describe('FinesAccPartyAddAmendConvert', () => { account_number: Mock; party_name: Mock; welsh_speaking: Mock; + setSuccessMessage: Mock; }; // eslint-disable-next-line @typescript-eslint/no-explicit-any let mockUtilsService: any; @@ -63,6 +64,7 @@ describe('FinesAccPartyAddAmendConvert', () => { account_number: vi.fn().mockReturnValue('12345ABC'), party_name: vi.fn().mockReturnValue('John Doe'), welsh_speaking: vi.fn().mockReturnValue('Yes'), + setSuccessMessage: vi.fn(), }; mockUtilsService = { scrollToTop: vi.fn().mockName('UtilsService.scrollToTop'), From 89bf8726021d68a098f919d0afb0701589654c1a Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 17 Mar 2026 15:49:47 +0000 Subject: [PATCH 10/28] feat: update defendant account party conversion logic and add test for non-debtor company to individual conversion --- ...ndant-details-defendant-tab.component.html | 2 +- ...oad-transform-defendant-data.utils.spec.ts | 34 +++++++++++++++++++ ...-payload-transform-defendant-data.utils.ts | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html index 00854d0f1e..f766a0eb73 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html @@ -1,5 +1,5 @@ @if (tabData.defendant_account_party; as party) { -
+
@if (party.party_details.organisation_flag) { diff --git a/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts b/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts index ae7471937b..adf6b34b5b 100644 --- a/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts +++ b/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts @@ -911,5 +911,39 @@ describe('transformDefendantAccountPartyPayload', () => { expect(result.facc_party_add_amend_convert_vehicle_make).toBe('Ford Focus'); expect(result.facc_party_add_amend_convert_vehicle_registration_mark).toBe('XY21 ABC'); }); + + it('should trim non-debtor-only fields when converting a non-debtor company account to individual', () => { + const mockCompanyData = { + ...mockDefendantData, + defendant_account_party: { + ...mockDefendantData.defendant_account_party, + is_debtor: false, + party_details: { + ...mockDefendantData.defendant_account_party.party_details, + organisation_flag: true, + organisation_details: { + organisation_name: 'Convert Me Ltd', + organisation_aliases: [], + }, + individual_details: null, + }, + employer_details: null, + }, + }; + + const result = transformDefendantAccountPartyPayload(mockCompanyData, 'individual', false); + + expect(result.facc_party_add_amend_convert_address_line_1).toBe('45 High Street'); + expect(result.facc_party_add_amend_convert_post_code).toBe('AB1 2CD'); + expect(result.facc_party_add_amend_convert_contact_email_address_1).toBeNull(); + expect(result.facc_party_add_amend_convert_contact_email_address_2).toBeNull(); + expect(result.facc_party_add_amend_convert_contact_telephone_number_mobile).toBeNull(); + expect(result.facc_party_add_amend_convert_contact_telephone_number_home).toBeNull(); + expect(result.facc_party_add_amend_convert_contact_telephone_number_business).toBeNull(); + expect(result.facc_party_add_amend_convert_vehicle_make).toBeNull(); + expect(result.facc_party_add_amend_convert_vehicle_registration_mark).toBeNull(); + expect(result.facc_party_add_amend_convert_language_preferences_document_language).toBeNull(); + expect(result.facc_party_add_amend_convert_language_preferences_hearing_language).toBeNull(); + }); }); }); diff --git a/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.ts b/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.ts index 327a291bfb..aa5954d2db 100644 --- a/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.ts +++ b/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.ts @@ -238,7 +238,7 @@ export const transformDefendantAccountPartyPayload = ( if (isCompany || (!hasExplicitPartyType && organisation_flag)) { return getCompanyParty(baseState, organisationDetails, organisationAliases, hasAliases); - } else if (isIndividual && !isDebtor && !organisation_flag) { + } else if (isIndividual && !isDebtor) { // For individual party type that is not a debtor, only show fields from title to address postcode return getIndividualDebtorParty(baseState, individualDetails, individualAliases, hasAliases); } else { From 339bf0953534704533659f61c7170e96eb419857 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 17 Mar 2026 16:42:34 +0000 Subject: [PATCH 11/28] feat: update image repositories to production for nodejs, opal-fines-service, and postgresql --- charts/opal-frontend/Chart.yaml | 6 +++--- charts/opal-frontend/values.dev.template.yaml | 2 +- charts/opal-frontend/values.yaml | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/charts/opal-frontend/Chart.yaml b/charts/opal-frontend/Chart.yaml index 9e202ca518..81c9ce1d35 100644 --- a/charts/opal-frontend/Chart.yaml +++ b/charts/opal-frontend/Chart.yaml @@ -9,7 +9,7 @@ maintainers: dependencies: - name: nodejs version: 3.2.0 - repository: 'oci://hmctspublic.azurecr.io/helm' + repository: 'oci://hmctsprod.azurecr.io/helm' - name: redis version: 25.3.2 repository: 'oci://registry-1.docker.io/bitnamicharts' @@ -20,10 +20,10 @@ dependencies: condition: postgresql.enabled - name: opal-fines-service version: 0.0.76 - repository: 'oci://sdshmctspublic.azurecr.io/helm' + repository: 'oci://sdshmctsprod.azurecr.io/helm' condition: opal-fines-service.enabled - name: servicebus version: 1.2.1 - repository: 'oci://hmctspublic.azurecr.io/helm' + repository: 'oci://hmctsprod.azurecr.io/helm' condition: opal-fines-service.enabled diff --git a/charts/opal-frontend/values.dev.template.yaml b/charts/opal-frontend/values.dev.template.yaml index 1e4c0a0b22..5158f5a423 100644 --- a/charts/opal-frontend/values.dev.template.yaml +++ b/charts/opal-frontend/values.dev.template.yaml @@ -21,7 +21,7 @@ opal-fines-service: enabled: ${DEV_ENABLE_OPAL_FINES_SERVICE} java: releaseNameOverride: ${SERVICE_NAME}-fines-service - image: 'sdshmctspublic.azurecr.io/opal/fines-service:${DEV_OPAL_FINES_SERVICE_IMAGE_SUFFIX}' + image: 'sdshmctsprod.azurecr.io/opal/fines-service:${DEV_OPAL_FINES_SERVICE_IMAGE_SUFFIX}' ingressHost: "opal-frontend-pr-${CHANGE_ID}-fines-service.dev.platform.hmcts.net" imagePullPolicy: Always devmemoryRequests: "1Gi" diff --git a/charts/opal-frontend/values.yaml b/charts/opal-frontend/values.yaml index 144c827fa1..ba6256b413 100644 --- a/charts/opal-frontend/values.yaml +++ b/charts/opal-frontend/values.yaml @@ -2,7 +2,7 @@ nodejs: applicationPort: 4000 aadIdentityName: opal ingressHost: opal-frontend.{{ .Values.global.environment }}.platform.hmcts.net - image: 'sdshmctspublic.azurecr.io/opal/frontend:latest' + image: 'sdshmctsprod.azurecr.io/opal/frontend:latest' keyVaults: opal: secrets: @@ -35,7 +35,7 @@ nodejs: redis: enabled: false image: - registry: hmctspublic.azurecr.io + registry: hmctsprod.azurecr.io repository: imported/bitnami/redis opal-fines-service: @@ -44,7 +44,7 @@ opal-fines-service: postgresql: enabled: false image: - registry: hmctspublic.azurecr.io + registry: hmctsprod.azurecr.io repository: imported/bitnami/postgresql tag: '17.5.0' From e29cc3935d64cca9a42f3d1218d7d79271d2a2d4 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 17 Mar 2026 16:42:51 +0000 Subject: [PATCH 12/28] feat: update Dockerfile to use production base image for node --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 79fcc0589e..a0ef7e4bd9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM hmctspublic.azurecr.io/base/node:20-alpine AS base +FROM hmctsprod.azurecr.io/base/node:20-alpine AS base COPY --chown=hmcts:hmcts . . From c9d5c21f77465e13377106ab097ae2b658b22fcf Mon Sep 17 00:00:00 2001 From: hmcts-jenkins-cnp <60659747+hmcts-jenkins-cnp[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 16:45:03 +0000 Subject: [PATCH 13/28] Bumping chart version/ fixing aliases --- charts/opal-frontend/Chart.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charts/opal-frontend/Chart.yaml b/charts/opal-frontend/Chart.yaml index 81c9ce1d35..84198e9004 100644 --- a/charts/opal-frontend/Chart.yaml +++ b/charts/opal-frontend/Chart.yaml @@ -3,7 +3,7 @@ appVersion: '1.0' description: A Helm chart for opal-frontend name: opal-frontend home: https://github.com/hmcts/opal-frontend/ -version: 0.0.304 +version: 0.0.305 maintainers: - name: HMCTS Opal team dependencies: From 060fe7181be5ab038856643043606874064cfb58 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 17 Mar 2026 16:49:17 +0000 Subject: [PATCH 14/28] feat: refactor navigation logic and improve code readability in defendant details component --- .../searchForAccount/account-enquiry.steps.ts | 10 ++-------- .../fines-acc-defendant-details.component.spec.ts | 8 ++++++-- .../fines-acc-defendant-details.component.ts | 9 +++------ ...fines-acc-party-add-amend-convert.component.spec.ts | 4 +++- 4 files changed, 14 insertions(+), 17 deletions(-) diff --git a/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts b/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts index 1be62217e8..bb1a65801f 100644 --- a/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts +++ b/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts @@ -167,10 +167,7 @@ When('I cancel converting the account to a company account', () => { Then('the Company details form should be pre-populated with:', (table: DataTable) => { const expectedFieldValues = Object.fromEntries( - Object.entries(rowsHashSafe(table)).map(([fieldName, fieldValue]) => [ - fieldName, - applyUniqPlaceholder(fieldValue), - ]), + Object.entries(rowsHashSafe(table)).map(([fieldName, fieldValue]) => [fieldName, applyUniqPlaceholder(fieldValue)]), ); log('assert', 'Company details form is pre-populated', expectedFieldValues); flow().assertCompanyDetailsPrefilledValues(expectedFieldValues); @@ -209,10 +206,7 @@ When('I cancel converting the account to an individual account', () => { Then('the Defendant details form should be pre-populated with:', (table: DataTable) => { const expectedFieldValues = Object.fromEntries( - Object.entries(rowsHashSafe(table)).map(([fieldName, fieldValue]) => [ - fieldName, - applyUniqPlaceholder(fieldValue), - ]), + Object.entries(rowsHashSafe(table)).map(([fieldName, fieldValue]) => [fieldName, applyUniqPlaceholder(fieldValue)]), ); log('assert', 'Defendant details form is pre-populated', expectedFieldValues); flow().assertDefendantDetailsPrefilledValues(expectedFieldValues); diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts index f28cb5f2e9..928e9674a6 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts @@ -254,7 +254,9 @@ describe('FinesAccDefendantDetailsComponent', () => { component.navigateToConvertAccountPage(); expect(routerSpy.navigate).toHaveBeenCalledWith( - [`../${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/${FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY}`], + [ + `../${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/${FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY}`, + ], { relativeTo: component['activatedRoute'], }, @@ -349,7 +351,9 @@ describe('FinesAccDefendantDetailsComponent', () => { component.navigateToConvertAccountPage(); expect(routerSpy.navigate).toHaveBeenCalledWith( - [`../${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/${FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL}`], + [ + `../${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/${FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL}`, + ], { relativeTo: component['activatedRoute'], }, diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts index ad2561bacc..5a01a1c089 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts @@ -440,12 +440,9 @@ export class FinesAccDefendantDetailsComponent extends AbstractTabData implement const targetPartyType = this.convertAction?.partyType; if (this.hasAccountMaintenancePermissionInBusinessUnit() && this.convertAction?.interactive && targetPartyType) { - this['router'].navigate( - [`../${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/${targetPartyType}`], - { - relativeTo: this.activatedRoute, - }, - ); + this['router'].navigate([`../${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/${targetPartyType}`], { + relativeTo: this.activatedRoute, + }); } else { this['router'].navigate(['/access-denied'], { relativeTo: this.activatedRoute, diff --git a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.spec.ts index 6063a833be..7e5c4078f9 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.spec.ts @@ -37,7 +37,9 @@ describe('FinesAccPartyAddAmendConvert', () => { let mockActivatedRoute: { snapshot: { data: { - partyAddAmendConvertData: typeof OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_EMPTY_DATA_MOCK & { version: string }; + partyAddAmendConvertData: typeof OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_EMPTY_DATA_MOCK & { + version: string; + }; }; params: { partyType: string; From 8001aef57fd6a477a74d10b40b8ce83f81804b8c Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 17 Mar 2026 17:21:44 +0000 Subject: [PATCH 15/28] feat: enhance action link styling for account conversion in defendant details --- ...-acc-defendant-details-defendant-tab.component.html | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html index f766a0eb73..3df36a87ee 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html @@ -35,9 +35,13 @@

Defendant Details

Actions

@if (convertAction.interactive) { - {{ - convertAction.label - }} + {{ convertAction.label }} } @else { {{ convertAction.label }} } From f12b3a94fca050e464a859928f2a29145c8429e3 Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 17 Mar 2026 17:42:55 +0000 Subject: [PATCH 16/28] feat: update tests and payload transformation logic for defendant account conversion --- .../fines-acc-convert.component.spec.ts | 14 +++++++--- ...nt-details-defendant-tab.component.spec.ts | 14 +++++----- ...es-acc-defendant-details.component.spec.ts | 2 ++ ...oad-transform-defendant-data.utils.spec.ts | 27 ++++++++++--------- ...-payload-transform-defendant-data.utils.ts | 27 ++++++++++--------- 5 files changed, 48 insertions(+), 36 deletions(-) diff --git a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts index 55e77fda8e..586b7464eb 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts @@ -18,6 +18,7 @@ import { routePermissionsGuard } from '@hmcts/opal-frontend-common/guards/route- import { authGuard } from '@hmcts/opal-frontend-common/guards/auth'; import { finesAccStateGuard } from '../routing/guards/fines-acc-state-guard/fines-acc-state.guard'; import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-modes.constant'; +import { IOpalFinesAccountDefendantDetailsHeader } from '../fines-acc-defendant-details/interfaces/fines-acc-defendant-details-header.interface'; describe('FinesAccConvertComponent', () => { let fixture: ComponentFixture; @@ -34,22 +35,27 @@ describe('FinesAccConvertComponent', () => { party_name: ReturnType; }; - const defaultHeadingData = { + const defaultHeadingData: IOpalFinesAccountDefendantDetailsHeader = { ...structuredClone(FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK), debtor_type: 'Defendant', party_details: { ...structuredClone(FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK.party_details), organisation_flag: false, individual_details: { - ...structuredClone(FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK.party_details.individual_details), title: 'Mr', forenames: 'Terrence', surname: 'CONWAY-JOHNSON', + date_of_birth: FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK.party_details.individual_details?.date_of_birth ?? null, + age: FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK.party_details.individual_details?.age ?? null, + national_insurance_number: + FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK.party_details.individual_details?.national_insurance_number ?? null, + individual_aliases: + FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK.party_details.individual_details?.individual_aliases ?? null, }, }, }; - const companyHeadingData = { + const companyHeadingData: IOpalFinesAccountDefendantDetailsHeader = { ...structuredClone(FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK), debtor_type: 'Defendant', party_details: { @@ -84,7 +90,7 @@ describe('FinesAccConvertComponent', () => { navigate: vi.fn().mockName('Router.navigate'), }; - mockActivatedRoute = { snapshot: {} as never } as ActivatedRoute; + mockActivatedRoute = { snapshot: {} as never } as unknown as ActivatedRoute; mockPayloadService = { transformPayload: vi.fn().mockName('FinesAccPayloadService.transformPayload'), diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts index 0b299e4cf5..d3097715d2 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts @@ -45,7 +45,7 @@ describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { }); it('should render an interactive convert-to-company action when configured', () => { - component.convertAction = companyConvertAction; + fixture.componentRef.setInput('convertAction', companyConvertAction); fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; @@ -55,11 +55,11 @@ describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { }); it('should render an interactive convert-to-individual action when configured', () => { - component.convertAction = individualConvertAction; + fixture.componentRef.setInput('convertAction', individualConvertAction); fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; - const convertLink = fixture.nativeElement.querySelector('.govuk-grid-column-one-third a'); + const convertLink = fixture.nativeElement.querySelector('.govuk-link'); expect(compiled.textContent).toContain('Actions'); expect(compiled.textContent).toContain('Convert to an individual account'); @@ -87,26 +87,26 @@ describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { }); it('should emit convert when the interactive action is clicked', () => { - component.convertAction = companyConvertAction; + fixture.componentRef.setInput('convertAction', companyConvertAction); fixture.detectChanges(); // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(component.convertAccount, 'emit'); - const convertLink = fixture.nativeElement.querySelector('.govuk-grid-column-one-third a') as HTMLAnchorElement; + const convertLink = fixture.nativeElement.querySelector('.govuk-link') as HTMLAnchorElement; convertLink.click(); expect(component.convertAccount.emit).toHaveBeenCalledWith(); }); it('should emit convert when the interactive individual action is clicked', () => { - component.convertAction = individualConvertAction; + fixture.componentRef.setInput('convertAction', individualConvertAction); fixture.detectChanges(); // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(component.convertAccount, 'emit'); - const convertLink = fixture.nativeElement.querySelector('.govuk-grid-column-one-third a') as HTMLAnchorElement; + const convertLink = fixture.nativeElement.querySelector('.govuk-link') as HTMLAnchorElement; convertLink.click(); expect(component.convertAccount.emit).toHaveBeenCalledWith(); diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts index 928e9674a6..214a3be0d6 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts @@ -250,6 +250,8 @@ describe('FinesAccDefendantDetailsComponent', () => { it('should navigate to the company convert page when interactive convert is triggered', () => { routerSpy.navigate.mockClear(); + component.accountData.party_details.organisation_flag = false; + component.accountData.debtor_type = 'Defendant'; component.navigateToConvertAccountPage(); diff --git a/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts b/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts index adf6b34b5b..2f2541bcee 100644 --- a/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts +++ b/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.spec.ts @@ -809,8 +809,7 @@ describe('transformDefendantAccountPartyPayload', () => { expect(result.facc_party_add_amend_convert_organisation_aliases).toEqual([]); }); - it('should handle company data with individual flag correctly when partyType is specified', () => { - // Test edge case: organisation_flag is false but partyType is "company" + it('should ignore organisation-only fields when the source is flagged as individual', () => { const mockMixedData = { ...mockDefendantData, defendant_account_party: { @@ -828,8 +827,10 @@ describe('transformDefendantAccountPartyPayload', () => { const result = transformDefendantAccountPartyPayload(mockMixedData, 'company', true); - // Should respect partyType parameter over organisation_flag - expect(result.facc_party_add_amend_convert_organisation_name).toBe('Override Company'); + // Organisation-only fields should not be carried across from an individual source record. + expect(result.facc_party_add_amend_convert_organisation_name).toBeNull(); + expect(result.facc_party_add_amend_convert_organisation_aliases).toEqual([]); + expect(result.facc_party_add_amend_convert_add_alias).toBe(false); expect(result.facc_party_add_amend_convert_title).toBeNull(); expect(result.facc_party_add_amend_convert_individual_aliases).toEqual([]); }); @@ -848,13 +849,13 @@ describe('transformDefendantAccountPartyPayload', () => { expect(result.facc_party_add_amend_convert_address_line_1).toBe('45 High Street'); expect(result.facc_party_add_amend_convert_address_line_2).toBe('Flat 2B'); - expect(result.facc_party_add_amend_convert_address_line_3).toBe('Riverside'); + expect(result.facc_party_add_amend_convert_address_line_3).toBeNull(); expect(result.facc_party_add_amend_convert_post_code).toBe('AB1 2CD'); expect(result.facc_party_add_amend_convert_contact_email_address_1).toBe('sarah.thompson@example.com'); - expect(result.facc_party_add_amend_convert_contact_email_address_2).toBe('s.thompson@workmail.com'); - expect(result.facc_party_add_amend_convert_contact_telephone_number_mobile).toBe('07700900123'); - expect(result.facc_party_add_amend_convert_contact_telephone_number_home).toBe('02071234567'); - expect(result.facc_party_add_amend_convert_contact_telephone_number_business).toBe('01632960123'); + expect(result.facc_party_add_amend_convert_contact_email_address_2).toBe('sarah.t@example.com'); + expect(result.facc_party_add_amend_convert_contact_telephone_number_mobile).toBe('07123 456789'); + expect(result.facc_party_add_amend_convert_contact_telephone_number_home).toBe('01234 567890'); + expect(result.facc_party_add_amend_convert_contact_telephone_number_business).toBe('09876 543210'); expect(result.facc_party_add_amend_convert_vehicle_make).toBe('Ford Focus'); expect(result.facc_party_add_amend_convert_vehicle_registration_mark).toBe('XY21 ABC'); }); @@ -904,10 +905,10 @@ describe('transformDefendantAccountPartyPayload', () => { expect(result.facc_party_add_amend_convert_address_line_2).toBe('Flat 2B'); expect(result.facc_party_add_amend_convert_post_code).toBe('AB1 2CD'); expect(result.facc_party_add_amend_convert_contact_email_address_1).toBe('sarah.thompson@example.com'); - expect(result.facc_party_add_amend_convert_contact_email_address_2).toBe('s.thompson@workmail.com'); - expect(result.facc_party_add_amend_convert_contact_telephone_number_mobile).toBe('07700900123'); - expect(result.facc_party_add_amend_convert_contact_telephone_number_home).toBe('02071234567'); - expect(result.facc_party_add_amend_convert_contact_telephone_number_business).toBe('01632960123'); + expect(result.facc_party_add_amend_convert_contact_email_address_2).toBe('sarah.t@example.com'); + expect(result.facc_party_add_amend_convert_contact_telephone_number_mobile).toBe('07123 456789'); + expect(result.facc_party_add_amend_convert_contact_telephone_number_home).toBe('01234 567890'); + expect(result.facc_party_add_amend_convert_contact_telephone_number_business).toBe('09876 543210'); expect(result.facc_party_add_amend_convert_vehicle_make).toBe('Ford Focus'); expect(result.facc_party_add_amend_convert_vehicle_registration_mark).toBe('XY21 ABC'); }); diff --git a/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.ts b/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.ts index aa5954d2db..534ff38f36 100644 --- a/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.ts +++ b/src/app/flows/fines/fines-acc/services/utils/fines-acc-payload-transform-defendant-data.utils.ts @@ -217,37 +217,40 @@ export const transformDefendantAccountPartyPayload = ( const isParentGuardian = partyType === 'parentGuardian'; const hasExplicitPartyType = isCompany || isIndividual || isParentGuardian; - // Handle aliases based on party type + // Map source aliases separately so each target branch can decide whether to keep them. let individualAliases: IFinesAccPartyAddAmendConvertIndividualAliasState[] = []; let organisationAliases: IFinesAccPartyAddAmendConvertOrganisationAliasState[] = []; - let hasAliases = false; + let hasIndividualAliases = false; + let hasOrganisationAliases = false; - if ((isCompany || (!hasExplicitPartyType && organisation_flag)) && organisationDetails?.organisation_aliases) { + if (organisation_flag && organisationDetails?.organisation_aliases) { organisationAliases = mapOrganisationAliasesToArrayStructure(organisationDetails.organisation_aliases); - hasAliases = organisationDetails.organisation_aliases.length > 0; - } else if ( - (isIndividual || isParentGuardian || (!hasExplicitPartyType && !organisation_flag)) && - individualDetails?.individual_aliases - ) { + hasOrganisationAliases = organisationDetails.organisation_aliases.length > 0; + } else if (!organisation_flag && individualDetails?.individual_aliases) { individualAliases = mapIndividualAliasesToArrayStructure(individualDetails.individual_aliases); - hasAliases = individualDetails.individual_aliases.length > 0; + hasIndividualAliases = individualDetails.individual_aliases.length > 0; } // Create base state with common fields const baseState = createBaseState(address, contact_details, vehicle_details, language_preferences); if (isCompany || (!hasExplicitPartyType && organisation_flag)) { - return getCompanyParty(baseState, organisationDetails, organisationAliases, hasAliases); + return getCompanyParty( + baseState, + organisation_flag ? organisationDetails : null, + organisationAliases, + hasOrganisationAliases, + ); } else if (isIndividual && !isDebtor) { // For individual party type that is not a debtor, only show fields from title to address postcode - return getIndividualDebtorParty(baseState, individualDetails, individualAliases, hasAliases); + return getIndividualDebtorParty(baseState, individualDetails, individualAliases, hasIndividualAliases); } else { // For parent/guardian or individual debtor, show all fields including employer details return getIndividualOrParentGuardianParty( baseState, individualDetails, individualAliases, - hasAliases, + hasIndividualAliases, employer_details, ); } From 4722d5debb88177adfe011793d64a481c7e07a8a Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 18 Mar 2026 09:31:02 +0000 Subject: [PATCH 17/28] feat: implement defendant account conversion logic and update related tests --- .../fines-acc-convert.component.spec.ts | 41 +++++++++ .../fines-acc-convert.component.ts | 23 ++++- ...nt-details-defendant-tab.component.spec.ts | 84 ++++++++++++------- ...fendant-details-defendant-tab.component.ts | 25 +++++- ...fines-acc-defendant-details.component.html | 3 +- ...es-acc-defendant-details.component.spec.ts | 67 ++------------- .../fines-acc-defendant-details.component.ts | 32 +------ 7 files changed, 145 insertions(+), 130 deletions(-) diff --git a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts index 586b7464eb..b7826fb6dd 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts @@ -19,6 +19,9 @@ import { authGuard } from '@hmcts/opal-frontend-common/guards/auth'; import { finesAccStateGuard } from '../routing/guards/fines-acc-state-guard/fines-acc-state.guard'; import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-modes.constant'; import { IOpalFinesAccountDefendantDetailsHeader } from '../fines-acc-defendant-details/interfaces/fines-acc-defendant-details-header.interface'; +import { OpalFines } from '../../services/opal-fines-service/opal-fines.service'; +import { of, throwError } from 'rxjs'; +import { OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK } from '../../services/opal-fines-service/mocks/opal-fines-account-defendant-account-party.mock'; describe('FinesAccConvertComponent', () => { let fixture: ComponentFixture; @@ -34,6 +37,9 @@ describe('FinesAccConvertComponent', () => { account_number: ReturnType; party_name: ReturnType; }; + let mockOpalFinesService: { + getDefendantAccountParty: ReturnType; + }; const defaultHeadingData: IOpalFinesAccountDefendantDetailsHeader = { ...structuredClone(FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK), @@ -106,6 +112,9 @@ describe('FinesAccConvertComponent', () => { }; mockAccountStore.account_number.mockReturnValue('06000427N'); mockAccountStore.party_name.mockReturnValue('Mr Terrence CONWAY-JOHNSON'); + mockOpalFinesService = { + getDefendantAccountParty: vi.fn().mockReturnValue(of(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK)), + }; configureRoute(); @@ -116,6 +125,7 @@ describe('FinesAccConvertComponent', () => { { provide: ActivatedRoute, useValue: mockActivatedRoute }, { provide: FinesAccPayloadService, useValue: mockPayloadService }, { provide: FinesAccountStore, useValue: mockAccountStore }, + { provide: OpalFines, useValue: mockOpalFinesService }, ], }).compileComponents(); }); @@ -170,6 +180,7 @@ describe('FinesAccConvertComponent', () => { 'defendant', ); expect(mockAccountStore.setAccountState).toHaveBeenCalledWith(MOCK_FINES_ACCOUNT_STATE); + expect(mockOpalFinesService.getDefendantAccountParty).toHaveBeenCalledWith(123, defaultHeadingData.defendant_account_party_id); }); it('should render the caption, heading, warning text, and action buttons for company conversion', () => { @@ -290,4 +301,34 @@ describe('FinesAccConvertComponent', () => { fragment: 'defendant', }); }); + + it('should redirect back to defendant details when the defendant is non-paying', () => { + mockOpalFinesService.getDefendantAccountParty.mockReturnValue( + of({ + ...OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK, + defendant_account_party: { + ...OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK.defendant_account_party, + is_debtor: false, + }, + }), + ); + + createComponent(); + + expect(mockRouter.navigate).toHaveBeenCalledWith(['../../', FINES_ACC_DEFENDANT_ROUTING_PATHS.children.details], { + relativeTo: mockActivatedRoute, + fragment: 'defendant', + }); + }); + + it('should redirect back to defendant details when loading defendant party data fails', () => { + mockOpalFinesService.getDefendantAccountParty.mockReturnValue(throwError(() => new Error('API error'))); + + createComponent(); + + expect(mockRouter.navigate).toHaveBeenCalledWith(['../../', FINES_ACC_DEFENDANT_ROUTING_PATHS.children.details], { + relativeTo: mockActivatedRoute, + fragment: 'defendant', + }); + }); }); diff --git a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts index 3e198652bc..ce026a3dd0 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts @@ -10,6 +10,7 @@ import { FINES_ACC_DEFENDANT_ROUTING_PATHS } from '../routing/constants/fines-ac import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-party-types.constant'; import { FINES_ACC_DEBTOR_TYPES } from '../constants/fines-acc-debtor-types.constant'; import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-modes.constant'; +import { OpalFines } from '../../services/opal-fines-service/opal-fines.service'; @Component({ selector: 'app-fines-acc-convert', @@ -21,6 +22,7 @@ export class FinesAccConvertComponent implements OnInit { private readonly activatedRoute = inject(ActivatedRoute); private readonly router = inject(Router); private readonly payloadService = inject(FinesAccPayloadService); + private readonly opalFinesService = inject(OpalFines); public readonly accountStore = inject(FinesAccountStore); public readonly partyTypes = FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES; @@ -28,6 +30,7 @@ export class FinesAccConvertComponent implements OnInit { public readonly accountId = Number(this.activatedRoute.snapshot.paramMap.get('accountId')); public accountData!: IOpalFinesAccountDefendantDetailsHeader; + private isDebtor = false; private getHeaderDataFromRoute(): void { this.accountData = this.payloadService.transformPayload( @@ -44,6 +47,10 @@ export class FinesAccConvertComponent implements OnInit { } private get canConvertAccount(): boolean { + if (!this.isDebtor) { + return false; + } + if (this.routePartyType === this.partyTypes.COMPANY) { return !this.isSourceCompanyAccount && this.accountData.debtor_type !== FINES_ACC_DEBTOR_TYPES.parentGuardian; } @@ -77,10 +84,18 @@ export class FinesAccConvertComponent implements OnInit { public ngOnInit(): void { this.getHeaderDataFromRoute(); - - if (!this.canConvertAccount) { - this.navigateBackToAccountSummary(); - } + this.opalFinesService + .getDefendantAccountParty(this.accountId, this.accountData.defendant_account_party_id) + .subscribe({ + next: (partyData) => { + this.isDebtor = partyData.defendant_account_party.is_debtor; + + if (!this.canConvertAccount) { + this.navigateBackToAccountSummary(); + } + }, + error: () => this.navigateBackToAccountSummary(), + }); } public handleContinue(): void { diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts index d3097715d2..3c3cfe5ef1 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.spec.ts @@ -3,13 +3,10 @@ import { FinesAccDefendantDetailsDefendantTabComponent } from './fines-acc-defen import { OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK } from '@services/fines/opal-fines-service/mocks/opal-fines-account-defendant-account-party.mock'; import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES } from '../../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-party-types.constant'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { IFinesAccDefendantDetailsConvertAction } from '../interfaces/fines-acc-defendant-details-convert-action.interface'; describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { let component: FinesAccDefendantDetailsDefendantTabComponent; let fixture: ComponentFixture; - let companyConvertAction: IFinesAccDefendantDetailsConvertAction; - let individualConvertAction: IFinesAccDefendantDetailsConvertAction; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -19,16 +16,6 @@ describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { fixture = TestBed.createComponent(FinesAccDefendantDetailsDefendantTabComponent); component = fixture.componentInstance; component.tabData = structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK); - companyConvertAction = { - interactive: true, - label: 'Convert to a company account', - partyType: FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, - }; - individualConvertAction = { - interactive: true, - label: 'Convert to an individual account', - partyType: FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, - }; fixture.detectChanges(); }); @@ -37,6 +24,9 @@ describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { }); it('should not render the actions column when no convert action is configured', () => { + fixture.componentRef.setInput('hasAccountMaintenencePermission', false); + fixture.detectChanges(); + const compiled = fixture.nativeElement as HTMLElement; expect(compiled.textContent).not.toContain('Actions'); @@ -44,8 +34,8 @@ describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { expect(compiled.textContent).not.toContain('Convert to an individual account'); }); - it('should render an interactive convert-to-company action when configured', () => { - fixture.componentRef.setInput('convertAction', companyConvertAction); + it('should render an interactive convert-to-company action for paying individual accounts with permission', () => { + fixture.componentRef.setInput('hasAccountMaintenencePermission', true); fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; @@ -54,8 +44,18 @@ describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { expect(compiled.textContent).toContain('Convert to a company account'); }); - it('should render an interactive convert-to-individual action when configured', () => { - fixture.componentRef.setInput('convertAction', individualConvertAction); + it('should render an interactive convert-to-individual action for paying company accounts with permission', () => { + fixture.componentRef.setInput('hasAccountMaintenencePermission', true); + fixture.componentRef.setInput('tabData', { + ...structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK), + defendant_account_party: { + ...structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK.defendant_account_party), + party_details: { + ...structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK.defendant_account_party.party_details), + organisation_flag: true, + }, + }, + }); fixture.detectChanges(); const compiled = fixture.nativeElement as HTMLElement; @@ -66,6 +66,24 @@ describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { expect(convertLink).not.toBeNull(); }); + it('should not render a convert action for non-paying accounts', () => { + fixture.componentRef.setInput('hasAccountMaintenencePermission', true); + fixture.componentRef.setInput('tabData', { + ...structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK), + defendant_account_party: { + ...structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK.defendant_account_party), + is_debtor: false, + }, + }); + fixture.detectChanges(); + + const compiled = fixture.nativeElement as HTMLElement; + + expect(compiled.textContent).not.toContain('Actions'); + expect(compiled.textContent).not.toContain('Convert to a company account'); + expect(compiled.textContent).not.toContain('Convert to an individual account'); + }); + it('should handle change defendant details when partyType is a company', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(component.changeDefendantDetails, 'emit'); @@ -86,29 +104,37 @@ describe('FinesAccDefendantDetailsAtAGlanceTabComponent', () => { ); }); - it('should emit convert when the interactive action is clicked', () => { - fixture.componentRef.setInput('convertAction', companyConvertAction); + it('should emit the company party type when the convert link is clicked', () => { + fixture.componentRef.setInput('hasAccountMaintenencePermission', true); fixture.detectChanges(); // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(component.convertAccount, 'emit'); + component.handleConvertAccount(); - const convertLink = fixture.nativeElement.querySelector('.govuk-link') as HTMLAnchorElement; - convertLink.click(); - - expect(component.convertAccount.emit).toHaveBeenCalledWith(); + expect(component.convertAccount.emit).toHaveBeenCalledWith(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY); }); - it('should emit convert when the interactive individual action is clicked', () => { - fixture.componentRef.setInput('convertAction', individualConvertAction); + it('should emit the individual party type when the company convert link is clicked', () => { + fixture.componentRef.setInput('hasAccountMaintenencePermission', true); + fixture.componentRef.setInput('tabData', { + ...structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK), + defendant_account_party: { + ...structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK.defendant_account_party), + party_details: { + ...structuredClone(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK.defendant_account_party.party_details), + organisation_flag: true, + }, + }, + }); fixture.detectChanges(); // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(component.convertAccount, 'emit'); + component.handleConvertAccount(); - const convertLink = fixture.nativeElement.querySelector('.govuk-link') as HTMLAnchorElement; - convertLink.click(); - - expect(component.convertAccount.emit).toHaveBeenCalledWith(); + expect(component.convertAccount.emit).toHaveBeenCalledWith( + FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, + ); }); }); diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.ts index 196a3ecc00..656514a23a 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.ts @@ -15,14 +15,33 @@ import { IFinesAccDefendantDetailsConvertAction } from '../interfaces/fines-acc- export class FinesAccDefendantDetailsDefendantTabComponent { @Input({ required: true }) tabData!: IOpalFinesAccountDefendantAccountParty; @Input() hasAccountMaintenencePermission: boolean = false; - @Input() convertAction: IFinesAccDefendantDetailsConvertAction | null = null; @Input() style: IFinesAccSummaryTabsContentStyles = FINES_ACC_SUMMARY_TABS_CONTENT_STYLES; @Output() changeDefendantDetails = new EventEmitter(); - @Output() convertAccount = new EventEmitter(); + @Output() convertAccount = new EventEmitter(); + + public get convertAction(): IFinesAccDefendantDetailsConvertAction | null { + if (!this.hasAccountMaintenencePermission || !this.tabData.defendant_account_party.is_debtor) { + return null; + } + + if (this.tabData.defendant_account_party.party_details.organisation_flag) { + return { + interactive: true, + label: 'Convert to an individual account', + partyType: FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, + }; + } + + return { + interactive: true, + label: 'Convert to a company account', + partyType: FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, + }; + } public handleConvertAccount(): void { if (this.convertAction?.interactive) { - this.convertAccount.emit(); + this.convertAccount.emit(this.convertAction.partyType); } } diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.html b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.html index 451635ff79..e2344ad711 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.html +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.html @@ -160,11 +160,10 @@

Business Unit:

} } diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts index 214a3be0d6..9a5d97538c 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.spec.ts @@ -248,12 +248,9 @@ describe('FinesAccDefendantDetailsComponent', () => { ); }); - it('should navigate to the company convert page when interactive convert is triggered', () => { + it('should navigate to the company convert page when convert is triggered', () => { routerSpy.navigate.mockClear(); - component.accountData.party_details.organisation_flag = false; - component.accountData.debtor_type = 'Defendant'; - - component.navigateToConvertAccountPage(); + component.navigateToConvertAccountPage(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY); expect(routerSpy.navigate).toHaveBeenCalledWith( [ @@ -296,61 +293,16 @@ describe('FinesAccDefendantDetailsComponent', () => { it('should navigate to access-denied if user lacks permission for convert action', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(component['permissionsService'], 'hasBusinessUnitPermissionAccess').mockReturnValue(false); - component.navigateToConvertAccountPage(); + component.navigateToConvertAccountPage(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY); expect(routerSpy.navigate).toHaveBeenCalledWith(['/access-denied'], { relativeTo: component['activatedRoute'], }); }); - it('should show interactive convert-to-company action for an adult individual account with account maintenance permission', () => { - component.accountData.party_details.organisation_flag = false; - component.accountData.debtor_type = 'Defendant'; - component.accountData.is_youth = false; - - expect(component.convertAction).toEqual({ - interactive: true, - label: 'Convert to a company account', - partyType: FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, - }); - }); - - it('should show interactive convert-to-company action for a youth individual account with account maintenance permission', () => { - component.accountData.party_details.organisation_flag = false; - component.accountData.debtor_type = 'Defendant'; - component.accountData.is_youth = true; - - expect(component.convertAction).toEqual({ - interactive: true, - label: 'Convert to a company account', - partyType: FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, - }); - }); - - it('should hide convert action for an account with parent or guardian to pay', () => { - component.accountData.party_details.organisation_flag = false; - component.accountData.debtor_type = component.debtorTypes.parentGuardian; - - expect(component.convertAction).toBeNull(); - }); - - it('should show display-only convert-to-individual action for a company account', () => { - component.accountData.party_details.organisation_flag = true; - component.accountData.debtor_type = 'Defendant'; - - expect(component.convertAction).toEqual({ - interactive: true, - label: 'Convert to an individual account', - partyType: FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, - }); - }); - - it('should navigate to the individual convert page when interactive company convert is triggered', () => { + it('should navigate to the individual convert page when company convert is triggered', () => { routerSpy.navigate.mockClear(); - component.accountData.party_details.organisation_flag = true; - component.accountData.debtor_type = 'Defendant'; - - component.navigateToConvertAccountPage(); + component.navigateToConvertAccountPage(FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL); expect(routerSpy.navigate).toHaveBeenCalledWith( [ @@ -362,15 +314,6 @@ describe('FinesAccDefendantDetailsComponent', () => { ); }); - it('should hide convert action when account maintenance permission is not available', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vi.spyOn(component['permissionsService'], 'hasBusinessUnitPermissionAccess').mockReturnValue(false); - component.accountData.party_details.organisation_flag = false; - component.accountData.debtor_type = 'Defendant'; - - expect(component.convertAction).toBeNull(); - }); - it('should navigate to the change defendant payment terms access denied page if user does not have the relevant permission', () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.spyOn(component['permissionsService'], 'hasBusinessUnitPermissionAccess').mockReturnValue(false); diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts index 5a01a1c089..d5c8dd87b0 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details.component.ts @@ -55,8 +55,6 @@ import { FINES_ACCOUNT_TYPES } from '../../constants/fines-account-types.constan import { IOpalFinesResultRefData } from '@services/fines/opal-fines-service/interfaces/opal-fines-result-ref-data.interface'; import { FinesAccDefendantDetailsEnforcementTab } from './fines-acc-defendant-details-enforcement-tab/fines-acc-defendant-details-enforcement-tab.component'; import { FinesAccSummaryHeaderComponent } from '../fines-acc-summary-header/fines-acc-summary-header.component'; -import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-party-types.constant'; -import { IFinesAccDefendantDetailsConvertAction } from './interfaces/fines-acc-defendant-details-convert-action.interface'; @Component({ selector: 'app-fines-acc-defendant-details', @@ -395,30 +393,6 @@ export class FinesAccDefendantDetailsComponent extends AbstractTabData implement this.refreshFragment$.complete(); } - public get convertAction(): IFinesAccDefendantDetailsConvertAction | null { - if (!this.hasAccountMaintenancePermissionInBusinessUnit()) { - return null; - } - - if (this.accountData.debtor_type === this.debtorTypes.parentGuardian) { - return null; - } - - if (this.accountData.party_details.organisation_flag) { - return { - interactive: true, - label: 'Convert to an individual account', - partyType: FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.INDIVIDUAL, - }; - } - - return { - interactive: true, - label: 'Convert to a company account', - partyType: FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES.COMPANY, - }; - } - /** * Navigates to the amend party details page for the specified party type. * Or navigates to the access-denied page if the user lacks the required permission in this BU. @@ -436,10 +410,8 @@ export class FinesAccDefendantDetailsComponent extends AbstractTabData implement } } - public navigateToConvertAccountPage(): void { - const targetPartyType = this.convertAction?.partyType; - - if (this.hasAccountMaintenancePermissionInBusinessUnit() && this.convertAction?.interactive && targetPartyType) { + public navigateToConvertAccountPage(targetPartyType: string): void { + if (this.hasAccountMaintenancePermissionInBusinessUnit() && targetPartyType) { this['router'].navigate([`../${FINES_ACC_DEFENDANT_ROUTING_PATHS.children.convert}/${targetPartyType}`], { relativeTo: this.activatedRoute, }); From bae8a1d705c3b0284063b7641b3cc089fd5a2565 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 18 Mar 2026 09:46:14 +0000 Subject: [PATCH 18/28] feat: simplify account conversion logic by removing unused service and refactoring checks --- .../fines-acc-convert.component.spec.ts | 40 ------------------- .../fines-acc-convert.component.ts | 23 ++--------- 2 files changed, 4 insertions(+), 59 deletions(-) diff --git a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts index b7826fb6dd..4dd5006a74 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts @@ -19,9 +19,6 @@ import { authGuard } from '@hmcts/opal-frontend-common/guards/auth'; import { finesAccStateGuard } from '../routing/guards/fines-acc-state-guard/fines-acc-state.guard'; import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-modes.constant'; import { IOpalFinesAccountDefendantDetailsHeader } from '../fines-acc-defendant-details/interfaces/fines-acc-defendant-details-header.interface'; -import { OpalFines } from '../../services/opal-fines-service/opal-fines.service'; -import { of, throwError } from 'rxjs'; -import { OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK } from '../../services/opal-fines-service/mocks/opal-fines-account-defendant-account-party.mock'; describe('FinesAccConvertComponent', () => { let fixture: ComponentFixture; @@ -37,9 +34,6 @@ describe('FinesAccConvertComponent', () => { account_number: ReturnType; party_name: ReturnType; }; - let mockOpalFinesService: { - getDefendantAccountParty: ReturnType; - }; const defaultHeadingData: IOpalFinesAccountDefendantDetailsHeader = { ...structuredClone(FINES_ACC_DEFENDANT_DETAILS_HEADER_MOCK), @@ -112,9 +106,6 @@ describe('FinesAccConvertComponent', () => { }; mockAccountStore.account_number.mockReturnValue('06000427N'); mockAccountStore.party_name.mockReturnValue('Mr Terrence CONWAY-JOHNSON'); - mockOpalFinesService = { - getDefendantAccountParty: vi.fn().mockReturnValue(of(OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK)), - }; configureRoute(); @@ -125,7 +116,6 @@ describe('FinesAccConvertComponent', () => { { provide: ActivatedRoute, useValue: mockActivatedRoute }, { provide: FinesAccPayloadService, useValue: mockPayloadService }, { provide: FinesAccountStore, useValue: mockAccountStore }, - { provide: OpalFines, useValue: mockOpalFinesService }, ], }).compileComponents(); }); @@ -180,7 +170,6 @@ describe('FinesAccConvertComponent', () => { 'defendant', ); expect(mockAccountStore.setAccountState).toHaveBeenCalledWith(MOCK_FINES_ACCOUNT_STATE); - expect(mockOpalFinesService.getDefendantAccountParty).toHaveBeenCalledWith(123, defaultHeadingData.defendant_account_party_id); }); it('should render the caption, heading, warning text, and action buttons for company conversion', () => { @@ -302,33 +291,4 @@ describe('FinesAccConvertComponent', () => { }); }); - it('should redirect back to defendant details when the defendant is non-paying', () => { - mockOpalFinesService.getDefendantAccountParty.mockReturnValue( - of({ - ...OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK, - defendant_account_party: { - ...OPAL_FINES_ACCOUNT_DEFENDANT_ACCOUNT_PARTY_MOCK.defendant_account_party, - is_debtor: false, - }, - }), - ); - - createComponent(); - - expect(mockRouter.navigate).toHaveBeenCalledWith(['../../', FINES_ACC_DEFENDANT_ROUTING_PATHS.children.details], { - relativeTo: mockActivatedRoute, - fragment: 'defendant', - }); - }); - - it('should redirect back to defendant details when loading defendant party data fails', () => { - mockOpalFinesService.getDefendantAccountParty.mockReturnValue(throwError(() => new Error('API error'))); - - createComponent(); - - expect(mockRouter.navigate).toHaveBeenCalledWith(['../../', FINES_ACC_DEFENDANT_ROUTING_PATHS.children.details], { - relativeTo: mockActivatedRoute, - fragment: 'defendant', - }); - }); }); diff --git a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts index ce026a3dd0..3e198652bc 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts @@ -10,7 +10,6 @@ import { FINES_ACC_DEFENDANT_ROUTING_PATHS } from '../routing/constants/fines-ac import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-party-types.constant'; import { FINES_ACC_DEBTOR_TYPES } from '../constants/fines-acc-debtor-types.constant'; import { FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES } from '../fines-acc-party-add-amend-convert/constants/fines-acc-party-add-amend-convert-modes.constant'; -import { OpalFines } from '../../services/opal-fines-service/opal-fines.service'; @Component({ selector: 'app-fines-acc-convert', @@ -22,7 +21,6 @@ export class FinesAccConvertComponent implements OnInit { private readonly activatedRoute = inject(ActivatedRoute); private readonly router = inject(Router); private readonly payloadService = inject(FinesAccPayloadService); - private readonly opalFinesService = inject(OpalFines); public readonly accountStore = inject(FinesAccountStore); public readonly partyTypes = FINES_ACC_PARTY_ADD_AMEND_CONVERT_PARTY_TYPES; @@ -30,7 +28,6 @@ export class FinesAccConvertComponent implements OnInit { public readonly accountId = Number(this.activatedRoute.snapshot.paramMap.get('accountId')); public accountData!: IOpalFinesAccountDefendantDetailsHeader; - private isDebtor = false; private getHeaderDataFromRoute(): void { this.accountData = this.payloadService.transformPayload( @@ -47,10 +44,6 @@ export class FinesAccConvertComponent implements OnInit { } private get canConvertAccount(): boolean { - if (!this.isDebtor) { - return false; - } - if (this.routePartyType === this.partyTypes.COMPANY) { return !this.isSourceCompanyAccount && this.accountData.debtor_type !== FINES_ACC_DEBTOR_TYPES.parentGuardian; } @@ -84,18 +77,10 @@ export class FinesAccConvertComponent implements OnInit { public ngOnInit(): void { this.getHeaderDataFromRoute(); - this.opalFinesService - .getDefendantAccountParty(this.accountId, this.accountData.defendant_account_party_id) - .subscribe({ - next: (partyData) => { - this.isDebtor = partyData.defendant_account_party.is_debtor; - - if (!this.canConvertAccount) { - this.navigateBackToAccountSummary(); - } - }, - error: () => this.navigateBackToAccountSummary(), - }); + + if (!this.canConvertAccount) { + this.navigateBackToAccountSummary(); + } } public handleContinue(): void { From 9fc6fb3d5ce6fcf18101081c259e29bc8b122a98 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 18 Mar 2026 10:05:48 +0000 Subject: [PATCH 19/28] feat: add account conversion success messages --- ...-party-add-amend-convert.component.spec.ts | 36 +++++++++++++++++++ ...s-acc-party-add-amend-convert.component.ts | 21 +++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.spec.ts index 7e5c4078f9..3d6984f4ad 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.spec.ts @@ -221,6 +221,7 @@ describe('FinesAccPartyAddAmendConvert', () => { // Assert expect(mockOpalFinesService.clearCache).toHaveBeenCalledWith('defendantAccountPartyCache$'); + expect(mockFinesAccStore.setSuccessMessage).not.toHaveBeenCalled(); expect(mockRouter.navigate).toHaveBeenCalledWith(['details'], { relativeTo: undefined, fragment: 'defendant', @@ -249,12 +250,47 @@ describe('FinesAccPartyAddAmendConvert', () => { // Assert expect(mockOpalFinesService.clearCache).toHaveBeenCalledWith('defendantAccountPartyCache$'); + expect(mockFinesAccStore.setSuccessMessage).not.toHaveBeenCalled(); expect(mockRouter.navigate).toHaveBeenCalledWith(['details'], { relativeTo: undefined, fragment: 'parent-or-guardian', }); }); + it('should set a success message when converting to a company account', () => { + const mockFormData = { + formData: MOCK_EMPTY_FINES_ACC_PARTY_ADD_AMEND_CONVERT_FORM_DATA.formData, + nestedFlow: false, + }; + + Object.defineProperty(component, 'mode', { + value: FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.CONVERT, + writable: true, + }); + Object.defineProperty(component, 'partyType', { value: 'company', writable: true }); + + component.handleFormSubmit(mockFormData); + + expect(mockFinesAccStore.setSuccessMessage).toHaveBeenCalledWith('Converted to a company account.'); + }); + + it('should set a success message when converting to an individual account', () => { + const mockFormData = { + formData: MOCK_EMPTY_FINES_ACC_PARTY_ADD_AMEND_CONVERT_FORM_DATA.formData, + nestedFlow: false, + }; + + Object.defineProperty(component, 'mode', { + value: FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.CONVERT, + writable: true, + }); + Object.defineProperty(component, 'partyType', { value: 'individual', writable: true }); + + component.handleFormSubmit(mockFormData); + + expect(mockFinesAccStore.setSuccessMessage).toHaveBeenCalledWith('Converted to an individual account.'); + }); + it('should redirect to details page when required store values are missing', () => { const mockFormData = { formData: MOCK_EMPTY_FINES_ACC_PARTY_ADD_AMEND_CONVERT_FORM_DATA.formData, diff --git a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.ts b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.ts index 4a97415dda..32c5c0dea2 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-party-add-amend-convert/fines-acc-party-add-amend-convert.component.ts @@ -38,6 +38,22 @@ export class FinesAccPartyAddAmendConvert extends AbstractFormParentBaseComponen protected readonly isDebtor: boolean = this.partyPayload.defendant_account_party.is_debtor; protected readonly fragment = this.partyType === 'parentGuardian' ? 'parent-or-guardian' : 'defendant'; + private get successMessage(): string | null { + if (this.mode !== FINES_ACC_PARTY_ADD_AMEND_CONVERT_MODES.CONVERT) { + return null; + } + + if (this.partyType === 'company') { + return 'Converted to a company account.'; + } + + if (this.partyType === 'individual') { + return 'Converted to an individual account.'; + } + + return null; + } + /** * Handles the form submission event from the child form component. * @param formData - The form data submitted from the child component @@ -73,7 +89,12 @@ export class FinesAccPartyAddAmendConvert extends AbstractFormParentBaseComponen ) .subscribe({ next: () => { + const successMessage = this.successMessage; + this.opalFinesService.clearCache('defendantAccountPartyCache$'); + if (successMessage) { + this.finesAccStore.setSuccessMessage(successMessage); + } this.routerNavigate( this.finesDefendantRoutingPaths.children.details, false, From f1eae3f09374693cdbc62df3fff22d7a71c89211 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 18 Mar 2026 13:21:25 +0000 Subject: [PATCH 20/28] test: remove unnecessary blank line in FinesAccConvertComponent tests --- .../fines-acc-convert/fines-acc-convert.component.spec.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts index 4dd5006a74..586b7464eb 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.spec.ts @@ -290,5 +290,4 @@ describe('FinesAccConvertComponent', () => { fragment: 'defendant', }); }); - }); From 8370182c9d3cb103072e93f05a0e0e56fc30f6e1 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 18 Mar 2026 13:36:03 +0000 Subject: [PATCH 21/28] Revert "feat: add OPAL Ticket Context skill and agent configuration for TDIA markdown processing" This reverts commit 4fe4182f588a60ad427b211aea48bd01e9de85f1. --- .../opal-ticket-context/SKILL.md | 45 ------------------- .../opal-ticket-context/agents/openai.yaml | 4 -- 2 files changed, 49 deletions(-) delete mode 100644 .codex/skills/opal-frontend/opal-ticket-context/SKILL.md delete mode 100644 .codex/skills/opal-frontend/opal-ticket-context/agents/openai.yaml diff --git a/.codex/skills/opal-frontend/opal-ticket-context/SKILL.md b/.codex/skills/opal-frontend/opal-ticket-context/SKILL.md deleted file mode 100644 index 2c23b201f0..0000000000 --- a/.codex/skills/opal-frontend/opal-ticket-context/SKILL.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: opal-ticket-context -description: Read extracted OPAL TDIA markdown under `.codex-docs/tdia/.../extracted.md` and use it as pre-implementation context for a ticket. Use when a ticket already has a related TDIA folder in `.codex-docs/tdia/` and Codex needs to identify impacted frontend/backend/data areas, tests, toggles, APIs, assumptions, and NFRs before changing code. ---- - -# Opal Ticket Context - -## Use the extracted TDIA markdown first -- Treat `.codex-docs/tdia//extracted.md` as the primary design artifact for ticket preparation. -- Prefer the TDIA folder explicitly named by the user or linked from the ticket. -- If the correct TDIA folder is not obvious, stop and ask which folder under `.codex-docs/tdia/` applies. -- If no extracted TDIA exists yet, use `$opal-ticket-tdia` first to create it. - -## Build ticket context in this order -1. Read the ticket and identify the feature, scope, and likely affected journey. -2. Read the related `extracted.md`. -3. Pull the implementation-relevant TDIA sections for the ticket. -4. Inspect the codebase paths that match those sections. -5. Implement only after the ticket, TDIA, and codebase view are aligned. - -## Pull the right TDIA sections -- Always read `Overview and Scope`, `Assumptions`, `Tech Decisions`, and `Non-Functional Requirements` when they exist. -- For frontend tickets, read `Opal User Portal (FE)` and its relevant child sections such as `Pages`, `Global Components`, `Services (API Only)`, `Feature Toggles`, `Design Notes`, `Payload Generation`, validators, resolvers, state store, or guards if present. -- For backend tickets, read `Opal Services (BE)` and `REST API Endpoints`. -- For database-impacting tickets, read `Opal Database (DB)`, `Libra / GOB`, and any related `Stored Procedures`, `Data`, or design notes. -- For testing work, read `Test and QA` plus `Integration / Component Tests`, `E2E / Functional Tests`, `Non-Functional Tests`, and accessibility sections. -- For flows with diagrams, read `E2E Interactions` and inspect any images referenced from `images/`. - -## Convert the TDIA into implementation context -- Summarize the impacted routes, pages, APIs, services, feature toggles, data entities, and tests before touching code. -- Preserve TDIA wording and identifiers exactly when they materially affect implementation. -- Translate the TDIA into concrete repo targets: routes, components, services, validators, resolvers, state, tests, backend handlers, and DB integration points. -- Use the repo as the source of truth for current implementation details; the TDIA provides design intent, not guaranteed current state. - -## Handle mismatches explicitly -- If the ticket and TDIA disagree, call out the mismatch before implementing. -- If the codebase and TDIA disagree, call out the mismatch and favor code inspection for current behavior while preserving TDIA constraints where still applicable. -- If the TDIA is broader than the ticket, constrain the implementation to the ticket scope. -- Do not invent missing approvals, waivers, SYS-NFR IDs, routes, or API behavior. - -## Output expectations -- State which TDIA markdown file was used. -- Summarize the sections that informed the implementation. -- Identify the likely code areas to inspect before editing. -- Keep secrets, tokens, and PII out of notes, code, and tests. diff --git a/.codex/skills/opal-frontend/opal-ticket-context/agents/openai.yaml b/.codex/skills/opal-frontend/opal-ticket-context/agents/openai.yaml deleted file mode 100644 index 2f41d5d4ab..0000000000 --- a/.codex/skills/opal-frontend/opal-ticket-context/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "OPAL Ticket Context" - short_description: "Read TDIA markdown before tickets" - default_prompt: "Use $opal-ticket-context to read the related extracted TDIA markdown and prepare implementation context for this ticket." From 20821b45366017ed2f7b067a06b30562504f858e Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 18 Mar 2026 13:36:03 +0000 Subject: [PATCH 22/28] Revert "feat: enhance TDIA extraction script to support E2E interactions images and improved output structure" This reverts commit 1bd624114a0c23e6b19f526a8c69c3768c3a6838. --- .../opal-frontend/opal-ticket-tdia/SKILL.md | 11 +- .../opal-ticket-tdia/scripts/extract_tdia.py | 234 +----------------- 2 files changed, 14 insertions(+), 231 deletions(-) diff --git a/.codex/skills/opal-frontend/opal-ticket-tdia/SKILL.md b/.codex/skills/opal-frontend/opal-ticket-tdia/SKILL.md index 5f027bed48..c5674220a5 100644 --- a/.codex/skills/opal-frontend/opal-ticket-tdia/SKILL.md +++ b/.codex/skills/opal-frontend/opal-ticket-tdia/SKILL.md @@ -8,19 +8,16 @@ description: Read OPAL TDIA exports and extract implementation context before pl ## Use HTML as the primary source - Treat the TDIA as an input constraint, not as proof that the current code matches the design. - Prefer a local saved Confluence HTML file supplied by the user or stored under `.codex-docs/tdia/`. -- Preferred artifact: the browser-saved Confluence “View Source” page (`.html`) together with its companion `*_files/` folder, because that preserves headings, lists, links, tables, and embedded images. +- Preferred artifact: the browser-saved Confluence “View Source” page (`.html`), because it preserves headings, lists, links, and tables. - If the ticket only includes a Confluence URL, stop and ask for a saved HTML export. -- If the TDIA includes an `E2E Interactions` diagram, check the saved page’s companion `*_files/` folder first. Only ask for a separate exported image if the saved page assets do not contain it. - Read the extracted markdown, not the raw source artifact, whenever possible to keep context lean. - Default to a full extraction so no design detail is silently skipped. Narrow with `--section` only when the user clearly wants a subset. ## Extract the TDIA before coding - Run `python3 scripts/extract_tdia.py ` first. -- If the TDIA will be reused, prefer `--output-root .codex-docs/tdia` so the script creates `.codex-docs/tdia//source.html`, `.codex-docs/tdia//extracted.md`, and `images/` beside them when embedded images are found. +- If the TDIA will be reused, save the extraction with `--output .codex-docs/tdia/extracted/.md`. - The script accepts `.html` and `.htm`. - By default the script extracts the full TDIA, including the document preamble/title block and all discovered headings. -- The script automatically detects embedded images from the saved page’s sibling `*_files/` folder and copies `E2E Interactions` images into `images/`, then references them from the `E2E Interactions` section in `extracted.md`. -- If the saved page assets do not contain the E2E diagram, pass `--e2e-image ` to supply it explicitly. - If you only need a few sections, pass `--section` multiple times to narrow the output. Example: @@ -28,8 +25,7 @@ Example: ```bash python3 .codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py \ '/Users/maxholland/Downloads/View Source.html' \ - --output-root .codex-docs/tdia \ - --e2e-image '/Users/maxholland/Downloads/e2e-interactions.png' + --output .codex-docs/tdia/extracted/fae-convert-defendant-type.md ``` ## Pull implementation-relevant sections @@ -47,7 +43,6 @@ python3 .codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py \ ## Handle extraction limits explicitly - If the HTML extraction contains Confluence macro placeholders or saved-page noise, say so and call out the affected sections. -- If the `E2E Interactions` section refers to a diagram but no image was provided, ask for the exported image and keep a note in `extracted.md` until it is added. - If a section expected by the ticket is missing from the TDIA, continue with the codebase evidence but call out the gap. - If a section is duplicated because of a table of contents or repeated layout artifacts, rely on the extracted section body with the most substantive content. diff --git a/.codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py b/.codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py index d9ffc35320..974d130ae4 100644 --- a/.codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py +++ b/.codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py @@ -5,7 +5,6 @@ import argparse import re -import shutil import sys from collections import OrderedDict from dataclasses import dataclass @@ -125,13 +124,6 @@ class SectionBlock: position: int -@dataclass -class ImageRef: - section_key: str - source_path: str - title: str | None = None - - def normalize_inline(text: str) -> str: text = text.replace('\xa0', ' ') text = re.sub(r'[ \t\r\f\v]+', ' ', text) @@ -187,15 +179,7 @@ def match_requested_sections(sections: dict[str, SectionBlock], selector: str) - return matched -def build_output( - source_path: Path, - sections: dict[str, SectionBlock], - requested: list[str], - saved_from_url: str | None = None, - e2e_image_markdown: str | None = None, - e2e_image_missing_note: str | None = None, - section_image_markdown: dict[str, list[str]] | None = None, -) -> str: +def build_output(source_path: Path, sections: dict[str, SectionBlock], requested: list[str], saved_from_url: str | None = None) -> str: if requested: resolved: list[SectionBlock] = [] missing: list[str] = [] @@ -239,136 +223,11 @@ def build_output( lines.append(f'- Sections missing: {", ".join(missing)}') for block in unique_resolved: - content = sanitize_markdown(block.content) - image_markdown = [] - if section_image_markdown: - image_markdown = section_image_markdown.get(block.key, []) - - if block.key == 'E2E Interactions': - if image_markdown: - content = f'{content}\n\n### Diagram Images\n\n' + '\n\n'.join(image_markdown) - elif e2e_image_markdown: - content = f'{content}\n\n### Diagram Image\n\n{e2e_image_markdown}' - elif e2e_image_missing_note: - content = f'{content}\n\n{e2e_image_missing_note}' - lines.extend(['', f'## {block.key}', '', content]) + lines.extend(['', f'## {block.key}', '', sanitize_markdown(block.content)]) return '\n'.join(lines).rstrip() + '\n' -def derive_tdia_name(sections: dict[str, SectionBlock], fallback: str) -> str: - preamble = sections.get(DOCUMENT_PREAMBLE) - if preamble: - lines = [line.strip() for line in preamble.content.splitlines() if line.strip()] - for line in lines: - tdia_match = re.match(r'^TDIA:\s*(.+)$', line, re.IGNORECASE) - if tdia_match: - return tdia_match.group(1).strip() - - design_match = re.search(r'\bfor\s+(.+)$', line, re.IGNORECASE) - if 'tech design ia' in line.lower() and design_match: - return design_match.group(1).strip() - - return fallback - - -def slugify(value: str) -> str: - slug = value.strip().lower() - slug = re.sub(r'[^a-z0-9]+', '-', slug) - slug = re.sub(r'-{2,}', '-', slug).strip('-') - return slug or 'tdia' - - -def copy_e2e_image(target_dir: Path, image_path: Path) -> tuple[Path, str]: - image_dir = target_dir / 'images' - image_dir.mkdir(parents=True, exist_ok=True) - - safe_name = f'e2e-interactions{image_path.suffix.lower()}' - target_path = image_dir / safe_name - shutil.copy2(image_path, target_path) - markdown = f'![E2E Interactions](images/{target_path.name})' - return target_path, markdown - - -def resolve_companion_asset(html_path: Path, source_path: str) -> Path | None: - files_dir = html_path.with_name(f'{html_path.stem}_files') - if not files_dir.exists(): - return None - - cleaned = source_path.strip() - if cleaned.startswith('./'): - cleaned = cleaned[2:] - - candidate = (html_path.parent / cleaned).resolve() - if candidate.exists(): - return candidate - - fallback = files_dir / Path(cleaned).name - if fallback.exists(): - return fallback.resolve() - - return None - - -def copy_section_images( - target_dir: Path, - html_path: Path, - image_refs: list[ImageRef], - allowed_sections: set[str] | None = None, -) -> dict[str, list[str]]: - if not image_refs: - return {} - - image_dir = target_dir / 'images' - image_dir.mkdir(parents=True, exist_ok=True) - - by_section: dict[str, list[str]] = {} - seen_targets: set[tuple[str, str]] = set() - - for image_ref in image_refs: - if allowed_sections is not None and image_ref.section_key not in allowed_sections: - continue - - source_asset = resolve_companion_asset(html_path, image_ref.source_path) - if source_asset is None: - continue - - target_name = source_asset.name - target_path = image_dir / target_name - key = (image_ref.section_key, target_name) - if key not in seen_targets: - shutil.copy2(source_asset, target_path) - seen_targets.add(key) - - alt = image_ref.title or image_ref.section_key - by_section.setdefault(image_ref.section_key, []).append(f'![{alt}](images/{target_name})') - - return by_section - - -def write_output_bundle( - output_root: Path, - source_path: Path, - output_markdown: str, - sections: dict[str, SectionBlock], - e2e_image_path: Path | None = None, -) -> Path: - tdia_name = derive_tdia_name(sections, fallback=source_path.stem) - target_dir = output_root / slugify(tdia_name) - target_dir.mkdir(parents=True, exist_ok=True) - - source_target = target_dir / f'source{source_path.suffix.lower()}' - if source_path.resolve() != source_target.resolve(): - shutil.copy2(source_path, source_target) - - if e2e_image_path is not None: - copy_e2e_image(target_dir, e2e_image_path) - - markdown_target = target_dir / 'extracted.md' - markdown_target.write_text(output_markdown, encoding='utf-8') - return markdown_target - - def extract_saved_from_url(raw_html: str) -> str | None: match = re.search(r'', raw_html, re.IGNORECASE) if not match: @@ -380,7 +239,6 @@ class ConfluenceHtmlSectionParser(HTMLParser): def __init__(self) -> None: super().__init__(convert_charrefs=True) self.sections: OrderedDict[str, dict[str, object]] = OrderedDict() - self.section_images: list[ImageRef] = [] self.heading_stack: list[str] = [] self.section_counter = 0 self.in_body = False @@ -479,11 +337,6 @@ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None return if tag == 'img': - embedded_image = self.embedded_image_ref(attrs_dict) - if embedded_image is not None: - self.section_images.append(embedded_image) - return - macro_text = self.macro_text(attrs_dict) if macro_text: self.append_fragment(macro_text) @@ -601,17 +454,6 @@ def macro_text(self, attrs: dict[str, str]) -> str | None: return f'_Confluence {macro_name} macro omitted in saved HTML export._' - def embedded_image_ref(self, attrs: dict[str, str]) -> ImageRef | None: - if 'confluence-embedded-image' not in attrs.get('class', ''): - return None - - source_path = attrs.get('src', '').strip() - if not source_path: - return None - - title = attrs.get('data-element-title') or attrs.get('data-linked-resource-default-alias') or attrs.get('title') or None - return ImageRef(section_key=self.current_section_key(), source_path=source_path, title=title) - def append_fragment(self, text: str) -> None: if not text: return @@ -661,11 +503,6 @@ def current_section(self) -> dict[str, object]: key = build_section_key(self.heading_stack) return self.ensure_section(key, self.heading_stack[-1]) - def current_section_key(self) -> str: - if not self.heading_stack: - return DOCUMENT_PREAMBLE - return build_section_key(self.heading_stack) - def emit_block(self, text: str) -> None: block = normalize_block(text) if not block: @@ -713,7 +550,7 @@ def flush_table(self) -> None: self.emit_block(table_markdown) self.current_table = None - def finalize(self) -> tuple[dict[str, SectionBlock], list[ImageRef]]: + def finalize(self) -> dict[str, SectionBlock]: self.flush_orphan_fragments() self.flush_paragraph() self.flush_lists() @@ -730,7 +567,7 @@ def finalize(self) -> tuple[dict[str, SectionBlock], list[ImageRef]]: content=content, position=int(raw['position']), ) - return sections, self.section_images + return sections def render_table(rows: list[tuple[list[str], bool]]) -> str: @@ -767,13 +604,13 @@ def escape_table_cell(text: str) -> str: return text.replace('|', '\\|') -def parse_html_sections(html_path: Path) -> tuple[dict[str, SectionBlock], list[ImageRef], str | None]: +def parse_html_sections(html_path: Path) -> tuple[dict[str, SectionBlock], str | None]: raw_html = html_path.read_text(encoding='utf-8') parser = ConfluenceHtmlSectionParser() parser.feed(raw_html) parser.close() - sections, section_images = parser.finalize() - return sections, section_images, extract_saved_from_url(raw_html) + sections = parser.finalize() + return sections, extract_saved_from_url(raw_html) def parse_args() -> argparse.Namespace: @@ -785,16 +622,7 @@ def parse_args() -> argparse.Namespace: dest='sections', help='Repeat to limit output to specific headings or section paths. Defaults to extracting the full TDIA.', ) - output_group = parser.add_mutually_exclusive_group() - output_group.add_argument('--output', help='Write markdown output to a specific file') - output_group.add_argument( - '--output-root', - help='Create `.codex-docs/tdia//` style output under this root with `source.html` and `extracted.md`.', - ) - parser.add_argument( - '--e2e-image', - help='Optional path to the E2E interactions diagram image. When used with `--output-root`, the image is copied to `images/` beside `extracted.md`.', - ) + parser.add_argument('--output', help='Write markdown output to a file instead of stdout') return parser.parse_args() @@ -806,17 +634,6 @@ def main() -> int: print(f'[ERROR] Source file not found: {source_path}', file=sys.stderr) return 1 - if args.e2e_image and not args.output_root: - print('[ERROR] `--e2e-image` requires `--output-root` so the image can be copied beside `extracted.md`.', file=sys.stderr) - return 1 - - e2e_image_path: Path | None = None - if args.e2e_image: - e2e_image_path = Path(args.e2e_image).expanduser().resolve() - if not e2e_image_path.exists(): - print(f'[ERROR] E2E image not found: {e2e_image_path}', file=sys.stderr) - return 1 - suffix = source_path.suffix.lower() requested = resolve_requested_sections(args.sections) @@ -825,39 +642,10 @@ def main() -> int: print('[ERROR] Expected .html or .htm', file=sys.stderr) return 1 - sections, section_images, saved_from_url = parse_html_sections(source_path) - e2e_image_markdown = None - e2e_image_missing_note = None - section_image_markdown: dict[str, list[str]] | None = None - if e2e_image_path is not None: - e2e_image_markdown = f'![E2E Interactions](images/e2e-interactions{e2e_image_path.suffix.lower()})' - elif 'E2E Interactions' in sections and not any(ref.section_key == 'E2E Interactions' for ref in section_images): - e2e_image_missing_note = '_E2E interactions image not provided. Ask the user for the diagram export and store it under `images/` beside this file._' - - if args.output_root: - output_root = Path(args.output_root).expanduser().resolve() - tdia_name = derive_tdia_name(sections, fallback=source_path.stem) - target_dir = output_root / slugify(tdia_name) - section_image_markdown = copy_section_images( - target_dir=target_dir, - html_path=source_path, - image_refs=section_images, - allowed_sections={'E2E Interactions'}, - ) - - output = build_output( - source_path=source_path, - sections=sections, - requested=requested, - saved_from_url=saved_from_url, - e2e_image_markdown=e2e_image_markdown, - e2e_image_missing_note=e2e_image_missing_note, - section_image_markdown=section_image_markdown, - ) + sections, saved_from_url = parse_html_sections(source_path) + output = build_output(source_path=source_path, sections=sections, requested=requested, saved_from_url=saved_from_url) - if args.output_root: - write_output_bundle(output_root, source_path, output, sections, e2e_image_path=e2e_image_path) - elif args.output: + if args.output: output_path = Path(args.output).expanduser().resolve() output_path.parent.mkdir(parents=True, exist_ok=True) output_path.write_text(output, encoding='utf-8') From c9009190176238f73f03d63b5e9907c3fa11bcdb Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 18 Mar 2026 13:36:03 +0000 Subject: [PATCH 23/28] Revert "feat: add OPAL TDIA extraction skill and script for markdown conversion" This reverts commit 6ac3cf2279b490727ace1102b30474b58b7b11d4. --- .../opal-frontend/opal-ticket-tdia/SKILL.md | 52 -- .../opal-ticket-tdia/agents/openai.yaml | 4 - .../opal-ticket-tdia/scripts/extract_tdia.py | 659 ------------------ 3 files changed, 715 deletions(-) delete mode 100644 .codex/skills/opal-frontend/opal-ticket-tdia/SKILL.md delete mode 100644 .codex/skills/opal-frontend/opal-ticket-tdia/agents/openai.yaml delete mode 100644 .codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py diff --git a/.codex/skills/opal-frontend/opal-ticket-tdia/SKILL.md b/.codex/skills/opal-frontend/opal-ticket-tdia/SKILL.md deleted file mode 100644 index c5674220a5..0000000000 --- a/.codex/skills/opal-frontend/opal-ticket-tdia/SKILL.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -name: opal-ticket-tdia -description: Read OPAL TDIA exports and extract implementation context before planning, implementing, or reviewing a ticket. Use when a ticket has a saved Confluence HTML view-source TDIA and Codex needs scope, impacted frontend/backend/data areas, testing expectations, assumptions, tech decisions, or NFRs before touching code. ---- - -# Opal Ticket TDIA - -## Use HTML as the primary source -- Treat the TDIA as an input constraint, not as proof that the current code matches the design. -- Prefer a local saved Confluence HTML file supplied by the user or stored under `.codex-docs/tdia/`. -- Preferred artifact: the browser-saved Confluence “View Source” page (`.html`), because it preserves headings, lists, links, and tables. -- If the ticket only includes a Confluence URL, stop and ask for a saved HTML export. -- Read the extracted markdown, not the raw source artifact, whenever possible to keep context lean. -- Default to a full extraction so no design detail is silently skipped. Narrow with `--section` only when the user clearly wants a subset. - -## Extract the TDIA before coding -- Run `python3 scripts/extract_tdia.py ` first. -- If the TDIA will be reused, save the extraction with `--output .codex-docs/tdia/extracted/.md`. -- The script accepts `.html` and `.htm`. -- By default the script extracts the full TDIA, including the document preamble/title block and all discovered headings. -- If you only need a few sections, pass `--section` multiple times to narrow the output. - -Example: - -```bash -python3 .codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py \ - '/Users/maxholland/Downloads/View Source.html' \ - --output .codex-docs/tdia/extracted/fae-convert-defendant-type.md -``` - -## Pull implementation-relevant sections -- After extracting the full TDIA, always read `Overview and Scope`, `Assumptions`, and `Tech Decisions` when they exist. -- For frontend tickets, read `Opal User Portal (FE)` and any child sections such as `Pages`, `Global Components`, `Services (API Only)`, `Feature Toggles`, or feature-specific notes. -- Read backend or data sections only when the ticket touches them: `Opal Services (BE)`, `REST API Endpoints`, `Opal Database (DB)`, `Azure Infrastructure`, `Libra / GOB`, or `ETL`. -- Read the full testing section that applies to the ticket: `Integration / Component Tests`, `Frontend tests`, `Backend tests`, `E2E / Functional Tests`, `Non-Functional Tests`, `Automated Accessibility Tests`, `Manual Accessibility Tests`, and `Release-based Testing`. -- Read `Non-Functional Requirements`, `Response Time Targets`, `Specific NFRs`, and related NFR subsections whenever behavior, performance, accessibility, privacy, or resilience could be affected. - -## Implement against the codebase, not only the document -- After extracting the TDIA, inspect the existing code paths, routes, services, tests, and toggles in the repo. -- Verify that the ticket still matches the current implementation and highlight any mismatch between the ticket, TDIA, and codebase. -- Preserve TDIA terms and IDs exactly when quoting or summarizing them. -- Do not invent missing requirements, waivers, approvals, or SYS-NFR identifiers. - -## Handle extraction limits explicitly -- If the HTML extraction contains Confluence macro placeholders or saved-page noise, say so and call out the affected sections. -- If a section expected by the ticket is missing from the TDIA, continue with the codebase evidence but call out the gap. -- If a section is duplicated because of a table of contents or repeated layout artifacts, rely on the extracted section body with the most substantive content. - -## Output expectations -- Summarize the TDIA sections used before or alongside implementation work. -- Include the TDIA source path in the final summary when it materially influenced the change. -- Keep secrets, tokens, and PII out of extracted notes, code, and tests. diff --git a/.codex/skills/opal-frontend/opal-ticket-tdia/agents/openai.yaml b/.codex/skills/opal-frontend/opal-ticket-tdia/agents/openai.yaml deleted file mode 100644 index a6eb551e4d..0000000000 --- a/.codex/skills/opal-frontend/opal-ticket-tdia/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "OPAL TDIA" - short_description: "Read TDIA HTML before ticket work" - default_prompt: "Use $opal-ticket-tdia to extract context from the TDIA HTML and use it to implement the ticket." diff --git a/.codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py b/.codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py deleted file mode 100644 index 974d130ae4..0000000000 --- a/.codex/skills/opal-frontend/opal-ticket-tdia/scripts/extract_tdia.py +++ /dev/null @@ -1,659 +0,0 @@ -#!/usr/bin/env python3 -"""Extract OPAL TDIA content from saved Confluence HTML into markdown.""" - -from __future__ import annotations - -import argparse -import re -import sys -from collections import OrderedDict -from dataclasses import dataclass -from html.parser import HTMLParser -from pathlib import Path - -KNOWN_HEADINGS = [ - 'Status', - 'HMCTS Approvals', - 'Overview and Scope', - 'Design Sprint Tickets', - 'Design Collateral', - 'E2E Interactions', - 'Opal User Portal (FE)', - 'Pages', - 'Global Components', - 'Services (API Only)', - 'Feature Toggles', - 'Design Notes', - 'Payload Generation', - 'Opal Services (BE)', - 'REST API Endpoints', - 'Non-API Services', - 'Opal Database (DB)', - 'Tables', - 'Indexes', - 'Sequences', - 'Stored Procedures', - 'Data', - 'Azure Infrastructure', - 'Libra / GOB', - 'Stored Procedures and Gateway Actions', - 'ETL', - 'Test and QA', - 'Integration / Component Tests', - 'Frontend tests', - 'Backend tests', - 'E2E / Functional Tests', - 'Non-Functional Tests', - 'Automated Accessibility Tests', - 'Manual Accessibility Tests', - 'Release-based Testing', - 'Tech Concerns', - 'Tech Decisions', - 'Tech Debt', - 'Non-Functional Requirements', - 'Managed Data Set Configuration', - 'Response Time Targets', - 'Personal Data Processing Operations', - 'Non-Business Critical Applications / Components', - 'Specific NFRs', - 'Assumptions', -] - -HEADING_LEVELS = { - 'Status': 1, - 'HMCTS Approvals': 1, - 'Overview and Scope': 1, - 'Design Sprint Tickets': 1, - 'Design Collateral': 1, - 'E2E Interactions': 1, - 'Opal User Portal (FE)': 1, - 'Opal Services (BE)': 1, - 'Opal Database (DB)': 1, - 'Azure Infrastructure': 1, - 'Libra / GOB': 1, - 'ETL': 1, - 'Test and QA': 1, - 'Tech Concerns': 1, - 'Tech Decisions': 1, - 'Tech Debt': 1, - 'Non-Functional Requirements': 1, - 'Assumptions': 1, - 'Pages': 2, - 'Global Components': 2, - 'Services (API Only)': 2, - 'Feature Toggles': 2, - 'Design Notes': 2, - 'Payload Generation': 2, - 'REST API Endpoints': 2, - 'Non-API Services': 2, - 'Tables': 2, - 'Indexes': 2, - 'Sequences': 2, - 'Stored Procedures': 2, - 'Data': 2, - 'Stored Procedures and Gateway Actions': 2, - 'Integration / Component Tests': 2, - 'E2E / Functional Tests': 2, - 'Non-Functional Tests': 2, - 'Automated Accessibility Tests': 3, - 'Manual Accessibility Tests': 3, - 'Release-based Testing': 3, - 'Managed Data Set Configuration': 2, - 'Response Time Targets': 2, - 'Personal Data Processing Operations': 2, - 'Non-Business Critical Applications / Components': 2, - 'Specific NFRs': 2, - 'Convert Account Page': 3, - 'Frontend tests': 3, - 'Backend tests': 3, - 'Performance Testing': 4, - 'Security Testing': 4, - 'Operational Acceptance Testing': 4, -} - -DOCUMENT_PREAMBLE = 'Document Preamble' -VOID_TAGS = {'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr'} -IMPLICIT_HEADINGS = set(KNOWN_HEADINGS) | set(HEADING_LEVELS) - - -@dataclass -class SectionBlock: - key: str - title: str - content: str - position: int - - -def normalize_inline(text: str) -> str: - text = text.replace('\xa0', ' ') - text = re.sub(r'[ \t\r\f\v]+', ' ', text) - text = re.sub(r' *\n *', '\n', text) - return text - - -def normalize_block(text: str) -> str: - text = normalize_inline(text) - text = re.sub(r'\n{3,}', '\n\n', text) - return text.strip() - - -def normalize_cell(text: str) -> str: - return normalize_block(text).replace('\n', '
') - - -def heading_level(title: str) -> int: - return HEADING_LEVELS.get(title, 2) - - -def build_section_key(path_titles: list[str]) -> str: - return ' > '.join(path_titles) - - -def sanitize_markdown(text: str) -> str: - text = text.strip() - if not text: - return '_Section not found in extracted text._' - return text.replace('\n#', '\n\\#') - - -def resolve_requested_sections(requested: list[str] | None) -> list[str]: - if not requested: - return [] - - resolved: list[str] = [] - seen = set() - for item in requested: - if item in seen: - continue - seen.add(item) - resolved.append(item) - return resolved - - -def match_requested_sections(sections: dict[str, SectionBlock], selector: str) -> list[SectionBlock]: - if selector in sections: - return [sections[selector]] - - matched = [block for key, block in sections.items() if key.endswith(f' > {selector}') or block.title == selector] - matched.sort(key=lambda block: (block.position, block.key)) - return matched - - -def build_output(source_path: Path, sections: dict[str, SectionBlock], requested: list[str], saved_from_url: str | None = None) -> str: - if requested: - resolved: list[SectionBlock] = [] - missing: list[str] = [] - - for selector in requested: - matched = match_requested_sections(sections, selector) - if matched: - resolved.extend(matched) - else: - missing.append(selector) - else: - resolved = sorted(sections.values(), key=lambda block: (block.position, block.key)) - missing = [] - - unique_resolved: list[SectionBlock] = [] - seen_keys = set() - for block in resolved: - if block.key in seen_keys: - continue - seen_keys.add(block.key) - unique_resolved.append(block) - - lines = [ - f'# TDIA Extraction: {source_path.name}', - '', - f'- Source: `{source_path}`', - '- Input type: html', - ] - - if saved_from_url: - lines.append(f'- Saved from: {saved_from_url}') - - lines.extend( - [ - f'- Sections requested: {"all" if not requested else len(requested)}', - f'- Sections found: {len(unique_resolved)}', - ] - ) - - if missing: - lines.append(f'- Sections missing: {", ".join(missing)}') - - for block in unique_resolved: - lines.extend(['', f'## {block.key}', '', sanitize_markdown(block.content)]) - - return '\n'.join(lines).rstrip() + '\n' - - -def extract_saved_from_url(raw_html: str) -> str | None: - match = re.search(r'', raw_html, re.IGNORECASE) - if not match: - return None - return match.group(1).strip() or None - - -class ConfluenceHtmlSectionParser(HTMLParser): - def __init__(self) -> None: - super().__init__(convert_charrefs=True) - self.sections: OrderedDict[str, dict[str, object]] = OrderedDict() - self.heading_stack: list[str] = [] - self.section_counter = 0 - self.in_body = False - - self.current_heading_level: int | None = None - self.current_heading_text: list[str] = [] - self.orphan_fragments: list[str] = [] - - self.current_paragraph: list[str] | None = None - self.current_list_item: list[str] | None = None - self.list_stack: list[dict[str, object]] = [] - - self.current_table: list[tuple[list[str], bool]] | None = None - self.current_row: list[tuple[str, bool]] | None = None - self.current_cell: list[str] | None = None - self.current_cell_is_header = False - - self.active_link: dict[str, object] | None = None - - self.ignore_depth = 0 - self.skip_depth = 0 - - def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: - attrs_dict = {key: value or '' for key, value in attrs} - - if tag == 'body': - self.in_body = True - return - - if not self.in_body: - return - - if self.skip_depth: - if tag not in VOID_TAGS: - self.skip_depth += 1 - return - - if self.ignore_depth: - if tag not in VOID_TAGS: - self.ignore_depth += 1 - return - - if tag in {'script', 'style', 'template'}: - self.ignore_depth = 1 - return - - if self.should_skip_subtree(attrs_dict): - self.skip_depth = 1 - return - - if re.fullmatch(r'h[1-6]', tag): - self.flush_orphan_fragments() - self.flush_paragraph() - self.flush_lists() - self.flush_table() - self.current_heading_level = int(tag[1]) - self.current_heading_text = [] - return - - if tag == 'p' and self.current_table is None: - self.flush_orphan_fragments() - self.current_paragraph = [] - return - - if tag in {'ul', 'ol'} and self.current_table is None: - self.flush_orphan_fragments() - self.list_stack.append({'ordered': tag == 'ol', 'items': []}) - return - - if tag == 'li' and self.current_table is None: - self.current_list_item = [] - return - - if tag == 'table': - self.flush_orphan_fragments() - self.flush_paragraph() - self.flush_lists() - self.current_table = [] - return - - if tag == 'tr' and self.current_table is not None: - self.current_row = [] - return - - if tag in {'th', 'td'} and self.current_row is not None: - self.current_cell = [] - self.current_cell_is_header = tag == 'th' - return - - if tag == 'a': - self.active_link = {'href': attrs_dict.get('href', ''), 'text': []} - return - - if tag == 'br': - self.append_fragment('\n') - return - - if tag == 'img': - macro_text = self.macro_text(attrs_dict) - if macro_text: - self.append_fragment(macro_text) - - def handle_endtag(self, tag: str) -> None: - if tag == 'body': - self.flush_orphan_fragments() - self.in_body = False - return - - if not self.in_body: - return - - if self.skip_depth: - self.skip_depth -= 1 - return - - if self.ignore_depth: - self.ignore_depth -= 1 - return - - if tag == 'a' and self.active_link is not None: - href = str(self.active_link.get('href', '')).strip() - text = normalize_block(''.join(self.active_link.get('text', []))) - rendered = '' - if href and text: - if text == href: - rendered = href - else: - rendered = f'[{text}]({href})' - elif text: - rendered = text - elif href: - rendered = href - self.active_link = None - if rendered: - self.append_fragment(rendered) - return - - if re.fullmatch(r'h[1-6]', tag) and self.current_heading_level is not None: - title = normalize_block(''.join(self.current_heading_text)) - if title: - self.start_section(title, self.current_heading_level) - self.current_heading_level = None - self.current_heading_text = [] - return - - if tag == 'p' and self.current_table is None: - self.flush_paragraph() - return - - if tag == 'li' and self.current_list_item is not None: - item = normalize_block(''.join(self.current_list_item)) - if item and self.list_stack: - self.list_stack[-1]['items'].append(item) - self.current_list_item = None - return - - if tag in {'ul', 'ol'} and self.list_stack: - list_state = self.list_stack.pop() - items = list_state['items'] - if items: - ordered = bool(list_state['ordered']) - prefix = lambda index: f'{index}. ' if ordered else '- ' - block = '\n'.join(f'{prefix(index)}{item}' for index, item in enumerate(items, start=1)) - self.emit_block(block) - return - - if tag in {'th', 'td'} and self.current_cell is not None and self.current_row is not None: - cell_text = normalize_cell(''.join(self.current_cell)) - self.current_row.append((cell_text, self.current_cell_is_header)) - self.current_cell = None - self.current_cell_is_header = False - return - - if tag == 'tr' and self.current_row is not None and self.current_table is not None: - if any(cell for cell, _ in self.current_row): - row_cells = [cell for cell, _ in self.current_row] - is_header = any(is_header for _, is_header in self.current_row) - self.current_table.append((row_cells, is_header)) - self.current_row = None - return - - if tag == 'table': - self.flush_table() - - def handle_data(self, data: str) -> None: - if not self.in_body or self.skip_depth or self.ignore_depth: - return - self.append_fragment(data) - - def should_skip_subtree(self, attrs: dict[str, str]) -> bool: - element_id = attrs.get('id', '') - element_class = attrs.get('class', '') - return element_id.startswith('give-freely-root') or 'give-freely-root' in element_class - - def macro_text(self, attrs: dict[str, str]) -> str | None: - if 'editor-inline-macro' not in attrs.get('class', ''): - return None - - macro_name = attrs.get('data-macro-name', '').strip() - if macro_name == 'toc': - return None - - if macro_name == 'status': - params = attrs.get('data-macro-parameters', '') - match = re.search(r'(?:^|\|)title=([^|]+)', params) - if match: - return match.group(1).strip() - default_param = attrs.get('data-macro-default-parameter', '').strip() - return default_param or None - - if macro_name == 'jira': - return '_Confluence jira macro omitted in saved HTML export._' - - return f'_Confluence {macro_name} macro omitted in saved HTML export._' - - def append_fragment(self, text: str) -> None: - if not text: - return - - if self.active_link is not None: - self.active_link['text'].append(text) - return - - target = self.current_target() - if target is not None: - target.append(text) - else: - self.orphan_fragments.append(text) - - def current_target(self) -> list[str] | None: - if self.current_cell is not None: - return self.current_cell - if self.current_heading_level is not None: - return self.current_heading_text - if self.current_list_item is not None: - return self.current_list_item - if self.current_paragraph is not None: - return self.current_paragraph - return None - - def start_section(self, title: str, level: int) -> None: - while len(self.heading_stack) >= level: - self.heading_stack.pop() - self.heading_stack.append(title) - key = build_section_key(self.heading_stack) - self.ensure_section(key, title) - - def ensure_section(self, key: str, title: str) -> dict[str, object]: - if key not in self.sections: - self.sections[key] = { - 'title': title, - 'blocks': [], - 'position': self.section_counter, - } - self.section_counter += 1 - return self.sections[key] - - def current_section(self) -> dict[str, object]: - if not self.heading_stack: - return self.ensure_section(DOCUMENT_PREAMBLE, DOCUMENT_PREAMBLE) - - key = build_section_key(self.heading_stack) - return self.ensure_section(key, self.heading_stack[-1]) - - def emit_block(self, text: str) -> None: - block = normalize_block(text) - if not block: - return - section = self.current_section() - section['blocks'].append(block) - - def flush_orphan_fragments(self) -> None: - if not self.orphan_fragments: - return - - text = normalize_block(''.join(self.orphan_fragments)) - self.orphan_fragments = [] - if not text: - return - - if text in IMPLICIT_HEADINGS: - self.start_section(text, heading_level(text)) - return - - self.emit_block(text) - - def flush_paragraph(self) -> None: - if self.current_paragraph is None: - return - self.emit_block(''.join(self.current_paragraph)) - self.current_paragraph = None - - def flush_lists(self) -> None: - while self.list_stack: - list_state = self.list_stack.pop() - items = list_state['items'] - if not items: - continue - ordered = bool(list_state['ordered']) - prefix = lambda index: f'{index}. ' if ordered else '- ' - block = '\n'.join(f'{prefix(index)}{item}' for index, item in enumerate(items, start=1)) - self.emit_block(block) - - def flush_table(self) -> None: - if self.current_table is None: - return - table_markdown = render_table(self.current_table) - if table_markdown: - self.emit_block(table_markdown) - self.current_table = None - - def finalize(self) -> dict[str, SectionBlock]: - self.flush_orphan_fragments() - self.flush_paragraph() - self.flush_lists() - self.flush_table() - - sections: dict[str, SectionBlock] = {} - for key, raw in self.sections.items(): - title = str(raw['title']) - blocks = raw['blocks'] - content = '\n\n'.join(blocks) - sections[key] = SectionBlock( - key=key, - title=title, - content=content, - position=int(raw['position']), - ) - return sections - - -def render_table(rows: list[tuple[list[str], bool]]) -> str: - cleaned_rows = [(cells, is_header) for cells, is_header in rows if any(cell.strip() for cell in cells)] - if not cleaned_rows: - return '' - - widths = [len(cells) for cells, _ in cleaned_rows] - column_count = max(widths) - - normalized_rows: list[tuple[list[str], bool]] = [] - for cells, is_header in cleaned_rows: - padded = cells + [''] * (column_count - len(cells)) - normalized_rows.append((padded, is_header)) - - header_cells, header_is_header = normalized_rows[0] - if not header_is_header: - header_cells = [f'Column {index}' for index in range(1, column_count + 1)] - body_rows = [cells for cells, _ in normalized_rows] - else: - body_rows = [cells for cells, _ in normalized_rows[1:]] - - header = '| ' + ' | '.join(escape_table_cell(cell) for cell in header_cells) + ' |' - separator = '| ' + ' | '.join('---' for _ in range(column_count)) + ' |' - lines = [header, separator] - - for cells in body_rows: - lines.append('| ' + ' | '.join(escape_table_cell(cell) for cell in cells) + ' |') - - return '\n'.join(lines) - - -def escape_table_cell(text: str) -> str: - return text.replace('|', '\\|') - - -def parse_html_sections(html_path: Path) -> tuple[dict[str, SectionBlock], str | None]: - raw_html = html_path.read_text(encoding='utf-8') - parser = ConfluenceHtmlSectionParser() - parser.feed(raw_html) - parser.close() - sections = parser.finalize() - return sections, extract_saved_from_url(raw_html) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument('source_path', help='Path to a saved Confluence TDIA HTML file') - parser.add_argument( - '--section', - action='append', - dest='sections', - help='Repeat to limit output to specific headings or section paths. Defaults to extracting the full TDIA.', - ) - parser.add_argument('--output', help='Write markdown output to a file instead of stdout') - return parser.parse_args() - - -def main() -> int: - args = parse_args() - source_path = Path(args.source_path).expanduser().resolve() - - if not source_path.exists(): - print(f'[ERROR] Source file not found: {source_path}', file=sys.stderr) - return 1 - - suffix = source_path.suffix.lower() - requested = resolve_requested_sections(args.sections) - - if suffix not in {'.html', '.htm'}: - print(f'[ERROR] Unsupported file type: {source_path.suffix}', file=sys.stderr) - print('[ERROR] Expected .html or .htm', file=sys.stderr) - return 1 - - sections, saved_from_url = parse_html_sections(source_path) - output = build_output(source_path=source_path, sections=sections, requested=requested, saved_from_url=saved_from_url) - - if args.output: - output_path = Path(args.output).expanduser().resolve() - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(output, encoding='utf-8') - else: - sys.stdout.write(output) - - return 0 - - -if __name__ == '__main__': - raise SystemExit(main()) From ab13cddc211808086292338a224b57e97efb3724 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 18 Mar 2026 13:45:01 +0000 Subject: [PATCH 24/28] docs: add convert component method comments --- .../fines-acc-convert.component.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts index 3e198652bc..245502bd14 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts +++ b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.ts @@ -29,6 +29,9 @@ export class FinesAccConvertComponent implements OnInit { public accountData!: IOpalFinesAccountDefendantDetailsHeader; + /** + * Hydrates the account header data from the route resolver and syncs it into store state. + */ private getHeaderDataFromRoute(): void { this.accountData = this.payloadService.transformPayload( this.activatedRoute.snapshot.data['defendantAccountHeadingData'], @@ -39,10 +42,16 @@ export class FinesAccConvertComponent implements OnInit { ); } + /** + * Indicates whether the source account currently represents a company. + */ private get isSourceCompanyAccount(): boolean { return this.accountData.party_details.organisation_flag; } + /** + * Validates whether the requested target party type is a supported conversion from the source account. + */ private get canConvertAccount(): boolean { if (this.routePartyType === this.partyTypes.COMPANY) { return !this.isSourceCompanyAccount && this.accountData.debtor_type !== FINES_ACC_DEBTOR_TYPES.parentGuardian; @@ -55,10 +64,16 @@ export class FinesAccConvertComponent implements OnInit { return false; } + /** + * Builds the account number and party name caption shown above the confirmation prompt. + */ public get captionText(): string { return `${this.accountStore.account_number() ?? ''} - ${this.accountStore.party_name() ?? ''}`; } + /** + * Returns the confirmation heading for the requested conversion target. + */ public get headingText(): string { if (this.routePartyType === this.partyTypes.INDIVIDUAL) { return 'Are you sure you want to convert this account to an individual account?'; @@ -67,6 +82,9 @@ export class FinesAccConvertComponent implements OnInit { return 'Are you sure you want to convert this account to a company account?'; } + /** + * Returns the warning copy describing which source-specific fields will be removed. + */ public get warningText(): string { if (this.routePartyType === this.partyTypes.INDIVIDUAL) { return 'Some information specific to company accounts, such as company name, will be removed.'; @@ -75,6 +93,9 @@ export class FinesAccConvertComponent implements OnInit { return 'Certain data related to individual accounts, such as employment details, will be removed.'; } + /** + * Initializes the page state and redirects back to account details when the requested conversion is not valid. + */ public ngOnInit(): void { this.getHeaderDataFromRoute(); @@ -83,6 +104,9 @@ export class FinesAccConvertComponent implements OnInit { } } + /** + * Continues to the shared convert form for the selected target party type. + */ public handleContinue(): void { if (!this.canConvertAccount) { this.navigateBackToAccountSummary(); @@ -102,6 +126,9 @@ export class FinesAccConvertComponent implements OnInit { ); } + /** + * Returns the user to the defendant details tab from the conversion confirmation page. + */ public navigateBackToAccountSummary(): void { this.router.navigate(['../../', FINES_ACC_DEFENDANT_ROUTING_PATHS.children.details], { relativeTo: this.activatedRoute, From f41b67115d5647b125e3d5a0553ed9cb392b8237 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 18 Mar 2026 14:39:30 +0000 Subject: [PATCH 25/28] chore: drop registry config changes from convert PR --- Dockerfile | 2 +- charts/opal-frontend/Chart.yaml | 8 ++++---- charts/opal-frontend/values.dev.template.yaml | 2 +- charts/opal-frontend/values.yaml | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Dockerfile b/Dockerfile index a0ef7e4bd9..79fcc0589e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM hmctsprod.azurecr.io/base/node:20-alpine AS base +FROM hmctspublic.azurecr.io/base/node:20-alpine AS base COPY --chown=hmcts:hmcts . . diff --git a/charts/opal-frontend/Chart.yaml b/charts/opal-frontend/Chart.yaml index 84198e9004..9e202ca518 100644 --- a/charts/opal-frontend/Chart.yaml +++ b/charts/opal-frontend/Chart.yaml @@ -3,13 +3,13 @@ appVersion: '1.0' description: A Helm chart for opal-frontend name: opal-frontend home: https://github.com/hmcts/opal-frontend/ -version: 0.0.305 +version: 0.0.304 maintainers: - name: HMCTS Opal team dependencies: - name: nodejs version: 3.2.0 - repository: 'oci://hmctsprod.azurecr.io/helm' + repository: 'oci://hmctspublic.azurecr.io/helm' - name: redis version: 25.3.2 repository: 'oci://registry-1.docker.io/bitnamicharts' @@ -20,10 +20,10 @@ dependencies: condition: postgresql.enabled - name: opal-fines-service version: 0.0.76 - repository: 'oci://sdshmctsprod.azurecr.io/helm' + repository: 'oci://sdshmctspublic.azurecr.io/helm' condition: opal-fines-service.enabled - name: servicebus version: 1.2.1 - repository: 'oci://hmctsprod.azurecr.io/helm' + repository: 'oci://hmctspublic.azurecr.io/helm' condition: opal-fines-service.enabled diff --git a/charts/opal-frontend/values.dev.template.yaml b/charts/opal-frontend/values.dev.template.yaml index 5158f5a423..1e4c0a0b22 100644 --- a/charts/opal-frontend/values.dev.template.yaml +++ b/charts/opal-frontend/values.dev.template.yaml @@ -21,7 +21,7 @@ opal-fines-service: enabled: ${DEV_ENABLE_OPAL_FINES_SERVICE} java: releaseNameOverride: ${SERVICE_NAME}-fines-service - image: 'sdshmctsprod.azurecr.io/opal/fines-service:${DEV_OPAL_FINES_SERVICE_IMAGE_SUFFIX}' + image: 'sdshmctspublic.azurecr.io/opal/fines-service:${DEV_OPAL_FINES_SERVICE_IMAGE_SUFFIX}' ingressHost: "opal-frontend-pr-${CHANGE_ID}-fines-service.dev.platform.hmcts.net" imagePullPolicy: Always devmemoryRequests: "1Gi" diff --git a/charts/opal-frontend/values.yaml b/charts/opal-frontend/values.yaml index ba6256b413..144c827fa1 100644 --- a/charts/opal-frontend/values.yaml +++ b/charts/opal-frontend/values.yaml @@ -2,7 +2,7 @@ nodejs: applicationPort: 4000 aadIdentityName: opal ingressHost: opal-frontend.{{ .Values.global.environment }}.platform.hmcts.net - image: 'sdshmctsprod.azurecr.io/opal/frontend:latest' + image: 'sdshmctspublic.azurecr.io/opal/frontend:latest' keyVaults: opal: secrets: @@ -35,7 +35,7 @@ nodejs: redis: enabled: false image: - registry: hmctsprod.azurecr.io + registry: hmctspublic.azurecr.io repository: imported/bitnami/redis opal-fines-service: @@ -44,7 +44,7 @@ opal-fines-service: postgresql: enabled: false image: - registry: hmctsprod.azurecr.io + registry: hmctspublic.azurecr.io repository: imported/bitnami/postgresql tag: '17.5.0' From 3c082ef326eb7711dd1a0efa1ef4da42d43e87ed Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 18 Mar 2026 14:52:07 +0000 Subject: [PATCH 26/28] test: use stable hooks for convert flow selectors --- .../account-details/account.convert.locators.ts | 10 +++++----- .../account.defendant.details.locators.ts | 6 +++--- .../fines-acc-convert.component.html | 13 +++++++++++-- ...c-defendant-details-defendant-tab.component.html | 7 ++++--- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/cypress/shared/selectors/account-details/account.convert.locators.ts b/cypress/shared/selectors/account-details/account.convert.locators.ts index ff8e27f2b8..c8347ca447 100644 --- a/cypress/shared/selectors/account-details/account.convert.locators.ts +++ b/cypress/shared/selectors/account-details/account.convert.locators.ts @@ -5,10 +5,10 @@ export const AccountConvertLocators = { page: { root: 'main[role="main"]', - header: 'main[role="main"] h1.govuk-heading-l', - caption: 'main[role="main"] h1.govuk-heading-l .govuk-caption-l', - warningText: 'main[role="main"] p.govuk-body', - confirmButton: 'main[role="main"] button.govuk-button:contains("Yes - continue")', - cancelLink: 'main[role="main"] a.govuk-link:contains("No - cancel")', + heading: '#account-convert-heading', + caption: '#account-convert-heading .govuk-caption-l', + warningText: '#account-convert-warning', + confirmButton: '#account-convert-confirm', + cancelLink: '#account-convert-cancel a.govuk-link', }, } as const; diff --git a/cypress/shared/selectors/account-details/account.defendant.details.locators.ts b/cypress/shared/selectors/account-details/account.defendant.details.locators.ts index 2aae45e269..e1ae798c32 100644 --- a/cypress/shared/selectors/account-details/account.defendant.details.locators.ts +++ b/cypress/shared/selectors/account-details/account.defendant.details.locators.ts @@ -167,12 +167,12 @@ export const AccountDefendantDetailsLocators = { // ────────────────────────────── actions: { /** Container for the right column actions within Defendant tab. */ - sideColumn: 'app-fines-acc-defendant-details-defendant-tab .govuk-grid-column-one-third', + sideColumn: '#defendant-convert-actions', /** Convert action text within the Defendant tab actions column. */ - convertAction: 'app-fines-acc-defendant-details-defendant-tab .govuk-grid-column-one-third p', + convertAction: '#defendant-convert-action', /** Interactive convert action link, when present. */ - convertActionLink: 'app-fines-acc-defendant-details-defendant-tab .govuk-grid-column-one-third p > a', + convertActionLink: '#defendant-convert-action-link', }, }; diff --git a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.html b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.html index 5cf1ceb6af..a84d690bf4 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.html +++ b/src/app/flows/fines/fines-acc/fines-acc-convert/fines-acc-convert.component.html @@ -1,15 +1,24 @@
-

{{ warningText }}

+

{{ warningText }}

- + diff --git a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html index 3df36a87ee..a58ff5773d 100644 --- a/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html +++ b/src/app/flows/fines/fines-acc/fines-acc-defendant-details/fines-acc-defendant-details-defendant-tab/fines-acc-defendant-details-defendant-tab.component.html @@ -30,12 +30,13 @@

Defendant Details

>
@if (convertAction) { -
+

Actions

-

+

@if (convertAction.interactive) { Actions >{{ convertAction.label }} } @else { - {{ convertAction.label }} + {{ convertAction.label }} }

From b8c28a4ff94524056a0e042c255c506252b62c38 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 18 Mar 2026 16:36:22 +0000 Subject: [PATCH 27/28] feat: add assertions and conversion methods for account details in the flow --- .../details.defendant.actions.ts | 23 ++++++ .../account-details/details.nav.actions.ts | 14 ++++ .../edit.company-details.actions.ts | 16 ++++ .../edit.defendant-details.actions.ts | 47 ++++++++++++ .../AccountEnquiriesViewDetails.feature | 20 ++++- .../opal/flows/account-enquiry.flow.ts | 76 +++++++++++++++++++ .../account.nav.details.locators.ts | 6 ++ .../searchForAccount/account-enquiry.steps.ts | 55 ++++++++++++++ 8 files changed, 255 insertions(+), 2 deletions(-) diff --git a/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts b/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts index 7a8d512311..f4044ac9e9 100644 --- a/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts +++ b/cypress/e2e/functional/opal/actions/account-details/details.defendant.actions.ts @@ -57,6 +57,29 @@ export class AccountDetailsDefendantActions { cy.get(L.defendant.fields.name, this.common.getTimeoutOptions()).should('contain.text', expected); } + /** + * Asserts the defendant summary card is rendered in the Defendant tab. + */ + assertDefendantSummaryVisible(): void { + cy.get(L.defendant.card, this.common.getTimeoutOptions()).should('be.visible'); + } + + /** + * Asserts the defendant summary card is not rendered in the Defendant tab. + */ + assertDefendantSummaryNotPresent(): void { + cy.get(L.defendant.card, this.common.getTimeoutOptions()).should('not.exist'); + } + + /** + * Asserts the primary email address shown in the contact summary contains the expected value. + * + * @param expected - Expected text within the primary email field. + */ + assertPrimaryEmailContains(expected: string): void { + cy.get(L.contact.fields.primaryEmail, this.common.getTimeoutOptions()).should('contain.text', expected); + } + /** * Asserts that the convert-to-company action is visible in the Defendant tab. */ diff --git a/cypress/e2e/functional/opal/actions/account-details/details.nav.actions.ts b/cypress/e2e/functional/opal/actions/account-details/details.nav.actions.ts index 0e68ccd31c..5a364fd0d6 100644 --- a/cypress/e2e/functional/opal/actions/account-details/details.nav.actions.ts +++ b/cypress/e2e/functional/opal/actions/account-details/details.nav.actions.ts @@ -181,4 +181,18 @@ export class AccountDetailsNavActions { .and('have.attr', 'aria-current', 'page') .and('contain.text', 'Payment terms'); } + + /** + * Asserts the account details success banner shows the expected message. + * + * @param expected - Expected success banner text. + */ + assertSuccessBannerText(expected: string): void { + log('assert', 'Asserting account details success banner text', { expected }); + + cy.get(N.banners.success, { timeout: 10_000 }) + .should('be.visible') + .find(N.banners.successText) + .should('contain.text', expected); + } } diff --git a/cypress/e2e/functional/opal/actions/account-details/edit.company-details.actions.ts b/cypress/e2e/functional/opal/actions/account-details/edit.company-details.actions.ts index aa8a3cc5a1..110aac1b1a 100644 --- a/cypress/e2e/functional/opal/actions/account-details/edit.company-details.actions.ts +++ b/cypress/e2e/functional/opal/actions/account-details/edit.company-details.actions.ts @@ -129,6 +129,22 @@ export class EditCompanyDetailsActions { log('done', `Verified company name contains "${expected}"`); } + /** + * Asserts the company summary card is rendered in the Defendant tab. + */ + public assertCompanySummaryVisible(): void { + log('assert', 'Asserting company summary card is visible'); + cy.get(SummaryL.card, this.common.getTimeoutOptions()).should('be.visible'); + } + + /** + * Asserts the company summary card is not rendered in the Defendant tab. + */ + public assertCompanySummaryNotPresent(): void { + log('assert', 'Asserting company summary card is absent'); + cy.get(SummaryL.card, this.common.getTimeoutOptions()).should('not.exist'); + } + /** * Asserts Company details form fields are pre-populated with the expected values. * diff --git a/cypress/e2e/functional/opal/actions/account-details/edit.defendant-details.actions.ts b/cypress/e2e/functional/opal/actions/account-details/edit.defendant-details.actions.ts index 1d5ce34a93..90b6048674 100644 --- a/cypress/e2e/functional/opal/actions/account-details/edit.defendant-details.actions.ts +++ b/cypress/e2e/functional/opal/actions/account-details/edit.defendant-details.actions.ts @@ -65,6 +65,22 @@ export class EditDefendantDetailsActions { }); } + /** + * Selects the title on the defendant details form. + * + * @param value - Title option text to select. + * @param opts Optional configuration. + * @param opts.timeout Max time to wait for the form/field visibility. + */ + public selectTitle(value: string, opts?: { timeout?: number }): void { + const timeout = opts?.timeout ?? 10_000; + + log('method', `Selecting Title value: "${value}"`); + cy.get(L.form.selector, { timeout }).should('be.visible'); + cy.get(L.titleSelect.selector, { timeout }).should('be.visible').select(value); + cy.get(L.titleSelect.selector, { timeout }).find('option:selected').should('contain.text', value); + } + /** * Updates the "First names" field on the edit form. * @@ -99,6 +115,37 @@ export class EditDefendantDetailsActions { } } + /** + * Updates the "Last name" field on the edit form. + * + * @param value - The new last name to enter. + * @param opts Optional configuration. + * @param opts.timeout Max time to wait for elements (default 10_000ms). + * @param opts.assert Whether to assert the value after typing (default true). + */ + public updateSurname(value: string, opts?: { timeout?: number; assert?: boolean }): void { + const timeout = opts?.timeout ?? 10_000; + + log('method', `Updating Last Name field to: "${value}"`); + + cy.get(L.form.selector, { timeout }).should('be.visible'); + + log('action', 'Typing into Last Name field'); + cy.get(L.surnameInput.selector, { timeout }) + .should('be.visible') + .and('be.enabled') + .scrollIntoView() + .clear({ force: true }) + .type(value) + .blur(); + + if (opts?.assert !== false) { + log('assert', `Verifying Last Name field value equals "${value}"`); + cy.get(L.surnameInput.selector).should('have.value', value.toUpperCase()); + log('done', 'Last Name field value updated successfully'); + } + } + /** * Asserts that the "First names" input value matches the expected text. * diff --git a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature index b1f96819c3..42cc3660e1 100644 --- a/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature +++ b/cypress/e2e/functional/opal/features/fineAccountEnquiry/accountEnquiry/AccountEnquiriesViewDetails.feature @@ -67,7 +67,7 @@ Feature: Account Enquiries – View Account Details And I verify no amendments were created via API @PO-1942 @PO-1943 - Scenario: Convert to company confirmation continues to Company details with shared fields pre-populated + Scenario: Convert to company saves and shows the converted company account details When I start converting the account to a company account Then I should see the convert to company confirmation screen for defendant "Mr John ACCDETAILSURNAME{uniqUpper}" When I continue converting the account to a company account @@ -75,6 +75,14 @@ Feature: Account Enquiries – View Account Details Then the Company details form should be pre-populated with: | Primary email address | John.AccDetailSurname{uniq}@test.com | | Home telephone number | 02078259314 | + When I complete converting the account to a company with company name "Accdetail converted comp{uniq}" + Then I should return to the account details page Defendant tab + And I should see the account conversion success message "Converted to a company account." + When I go to the Defendant details section and the header is "Company details" + Then I should see the company summary card + And I should not see the defendant summary card + And I should see the company name contains "Accdetail converted comp{uniq}" + And I should see the primary email address contains "John.AccDetailSurname{uniq}@test.com" @PO-1943 Scenario: Convert to company confirmation cancel returns to Defendant details with no changes made @@ -139,7 +147,7 @@ Feature: Account Enquiries – View Account Details And I verify no amendments were created via API for company details @PO-1956 - Scenario: Convert to individual confirmation continues to Defendant details with shared fields pre-populated + Scenario: Convert to individual saves and shows the converted defendant account details When I start converting the account to an individual account Then I should see the convert to individual confirmation screen for company "Accdetail comp{uniq}" When I continue converting the account to an individual account @@ -147,6 +155,14 @@ Feature: Account Enquiries – View Account Details And the Defendant details form should be pre-populated with: | Postcode | AB23 4RN | | Primary email address | Accdetailcomp{uniq}@test.com | + When I complete converting the account to an individual with title "Miss", first name "Jamie", and last name "Converted{uniq}" + Then I should return to the account details page Defendant tab + And I should see the account conversion success message "Converted to an individual account." + When I go to the Defendant details section and the header is "Defendant details" + Then I should see the defendant summary card + And I should not see the company summary card + And I should see the defendant name contains "Jamie" + And I should see the primary email address contains "Accdetailcomp{uniq}@test.com" @PO-1956 Scenario: Convert to individual confirmation cancel returns to Defendant details with no changes made diff --git a/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts b/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts index a67d0dc9d1..c4e85ae1b0 100644 --- a/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts +++ b/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts @@ -353,6 +353,82 @@ export class AccountEnquiryFlow { this.editDefendantDetailsActions.assertPrefilledFieldValues(expectedFieldValues); } + /** + * Completes the convert-to-company form by providing a company name and saving the form. + * + * @param companyName - Company name to use for the converted account. + */ + public completeConvertToCompany(companyName: string): void { + logAE('method', 'completeConvertToCompany()', { companyName }); + this.editCompanyDetailsActions.editCompanyName(companyName); + this.editCompanyDetailsActions.saveChanges(); + } + + /** + * Completes the convert-to-individual form by filling the required personal details and saving. + * + * @param details - Title and name fields required for the converted individual account. + */ + public completeConvertToIndividual(details: { title: string; firstName: string; lastName: string }): void { + logAE('method', 'completeConvertToIndividual()', details); + this.editDefendantDetailsActions.selectTitle(details.title); + this.editDefendantDetailsActions.updateFirstName(details.firstName); + this.editDefendantDetailsActions.updateSurname(details.lastName); + this.editDefendantDetailsActions.saveChanges(); + } + + /** + * Asserts the account details success banner contains the expected conversion message. + * + * @param expected - Expected banner text. + */ + public assertAccountConversionSuccessMessage(expected: string): void { + logAE('method', 'assertAccountConversionSuccessMessage()', { expected }); + this.detailsNav.assertSuccessBannerText(expected); + } + + /** + * Asserts the company summary card is visible in the Defendant tab. + */ + public assertCompanySummaryVisible(): void { + logAE('method', 'assertCompanySummaryVisible()'); + this.editCompanyDetailsActions.assertCompanySummaryVisible(); + } + + /** + * Asserts the company summary card is not visible in the Defendant tab. + */ + public assertCompanySummaryNotPresent(): void { + logAE('method', 'assertCompanySummaryNotPresent()'); + this.editCompanyDetailsActions.assertCompanySummaryNotPresent(); + } + + /** + * Asserts the defendant summary card is visible in the Defendant tab. + */ + public assertDefendantSummaryVisible(): void { + logAE('method', 'assertDefendantSummaryVisible()'); + this.defendantDetails.assertDefendantSummaryVisible(); + } + + /** + * Asserts the defendant summary card is not visible in the Defendant tab. + */ + public assertDefendantSummaryNotPresent(): void { + logAE('method', 'assertDefendantSummaryNotPresent()'); + this.defendantDetails.assertDefendantSummaryNotPresent(); + } + + /** + * Asserts the primary email address shown in the contact card contains the expected value. + * + * @param expected - Expected email text. + */ + public assertPrimaryEmailContains(expected: string): void { + logAE('method', 'assertPrimaryEmailContains()', { expected }); + this.defendantDetails.assertPrimaryEmailContains(expected); + } + /** * Navigates to the Parent/Guardian tab and asserts a specific section header. * diff --git a/cypress/shared/selectors/account-details/account.nav.details.locators.ts b/cypress/shared/selectors/account-details/account.nav.details.locators.ts index e4f30d5cb6..aac658d196 100644 --- a/cypress/shared/selectors/account-details/account.nav.details.locators.ts +++ b/cypress/shared/selectors/account-details/account.nav.details.locators.ts @@ -125,6 +125,12 @@ export const AccountNavDetailsLocators = { addCommentsLink: 'a.govuk-link[href*="comments"], a.govuk-link:contains("Add comments")', }, + /** Page-level banner messages rendered above the account details header. */ + banners: { + success: 'opal-lib-moj-alert[type="success"]', + successText: 'opal-lib-moj-alert-content-text', + }, + // ────────────────────────────── // Shell-level widgets // ────────────────────────────── diff --git a/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts b/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts index bb1a65801f..4e92bd62b0 100644 --- a/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts +++ b/cypress/support/step_definitions/searchForAccount/account-enquiry.steps.ts @@ -212,6 +212,61 @@ Then('the Defendant details form should be pre-populated with:', (table: DataTab flow().assertDefendantDetailsPrefilledValues(expectedFieldValues); }); +When('I complete converting the account to a company with company name {string}', (companyName: string) => { + const companyNameWithUniq = applyUniqPlaceholder(companyName); + log('step', 'Complete converting account to company', { companyName: companyNameWithUniq }); + flow().completeConvertToCompany(companyNameWithUniq); +}); + +When( + 'I complete converting the account to an individual with title {string}, first name {string}, and last name {string}', + (title: string, firstName: string, lastName: string) => { + const firstNameWithUniq = applyUniqPlaceholder(firstName); + const lastNameWithUniq = applyUniqPlaceholder(lastName); + log('step', 'Complete converting account to individual', { + title, + firstName: firstNameWithUniq, + lastName: lastNameWithUniq, + }); + flow().completeConvertToIndividual({ + title, + firstName: firstNameWithUniq, + lastName: lastNameWithUniq, + }); + }, +); + +Then('I should see the account conversion success message {string}', (expected: string) => { + log('assert', 'Account conversion success message is visible', { expected }); + flow().assertAccountConversionSuccessMessage(expected); +}); + +Then('I should see the company summary card', () => { + log('assert', 'Company summary card is visible'); + flow().assertCompanySummaryVisible(); +}); + +Then('I should not see the company summary card', () => { + log('assert', 'Company summary card is absent'); + flow().assertCompanySummaryNotPresent(); +}); + +Then('I should see the defendant summary card', () => { + log('assert', 'Defendant summary card is visible'); + flow().assertDefendantSummaryVisible(); +}); + +Then('I should not see the defendant summary card', () => { + log('assert', 'Defendant summary card is absent'); + flow().assertDefendantSummaryNotPresent(); +}); + +Then('I should see the primary email address contains {string}', (expected: string) => { + const expectedWithUniq = applyUniqPlaceholder(expected); + log('assert', 'Primary email address contains', { expected: expectedWithUniq }); + flow().assertPrimaryEmailContains(expectedWithUniq); +}); + /** * @step Navigates to the Parent or guardian details section and validates the header text. * From 8fcf547a2d7744ec47c4863b815dd4efe54e6fd0 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 19 Mar 2026 11:40:07 +0000 Subject: [PATCH 28/28] feat: enhance completeConvertToIndividual method with detailed parameter descriptions --- cypress/e2e/functional/opal/flows/account-enquiry.flow.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts b/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts index c4e85ae1b0..7844ddc980 100644 --- a/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts +++ b/cypress/e2e/functional/opal/flows/account-enquiry.flow.ts @@ -368,6 +368,9 @@ export class AccountEnquiryFlow { * Completes the convert-to-individual form by filling the required personal details and saving. * * @param details - Title and name fields required for the converted individual account. + * @param details.title - Title to select for the converted individual account. + * @param details.firstName - First name to enter for the converted individual account. + * @param details.lastName - Last name to enter for the converted individual account. */ public completeConvertToIndividual(details: { title: string; firstName: string; lastName: string }): void { logAE('method', 'completeConvertToIndividual()', details);