diff --git a/.github/scripts/test_update_dependency_changes.py b/.github/scripts/test_update_dependency_changes.py new file mode 100644 index 0000000000..f8cf0100f3 --- /dev/null +++ b/.github/scripts/test_update_dependency_changes.py @@ -0,0 +1,406 @@ +#!/usr/bin/env python3 +""" +Comprehensive test suite for update_dependency_changes.py + +Tests cover: +- Basic update/add/remove scenarios +- Version revert scenarios +- Complex multi-step change sequences +- Edge cases and duplicate operations +- Document format validation +""" + +import sys +import os +sys.path.insert(0, os.path.dirname(__file__)) + +from update_dependency_changes import merge_changes, render_section + + +def test_update_then_revert(): + """Test: PR1 updates A->B, PR2 reverts B->A. Should be removed.""" + print("Test 1: Update then revert") + existing = ( + {"PackageA": ("1.0.0", "2.0.0", "#1")}, # updated + {}, # added + {} # removed + ) + new = ( + {"PackageA": ("2.0.0", "1.0.0", "#2")}, # updated back + {}, + {} + ) + updated, added, removed = merge_changes(existing, new) + assert "PackageA" not in updated, f"Expected PackageA removed, got: {updated}" + assert len(added) == 0 and len(removed) == 0 + print("✓ Passed: Package correctly removed from updates\n") + + +def test_add_then_remove_same_version(): + """Test: PR1 adds v1.0, PR2 removes v1.0. Should be completely removed.""" + print("Test 2: Add then remove same version") + existing = ( + {}, + {"PackageB": ("1.0.0", "#1")}, # added + {} + ) + new = ( + {}, + {}, + {"PackageB": ("1.0.0", "#2")} # removed + ) + updated, added, removed = merge_changes(existing, new) + assert "PackageB" not in added, f"Expected PackageB removed from added, got: {added}" + assert "PackageB" not in removed, f"Expected PackageB removed from removed, got: {removed}" + assert "PackageB" not in updated + print("✓ Passed: Package correctly removed from all sections\n") + + +def test_remove_then_add_same_version(): + """Test: PR1 removes v1.0, PR2 adds v1.0. Should be removed.""" + print("Test 3: Remove then add same version") + existing = ( + {}, + {}, + {"PackageC": ("1.0.0", "#1")} # removed + ) + new = ( + {}, + {"PackageC": ("1.0.0", "#2")}, # added back + {} + ) + updated, added, removed = merge_changes(existing, new) + assert "PackageC" not in updated, f"Expected PackageC removed from updated, got: {updated}" + assert "PackageC" not in added, f"Expected PackageC removed from added, got: {added}" + assert "PackageC" not in removed, f"Expected PackageC removed from removed, got: {removed}" + print("✓ Passed: Package correctly removed from all sections\n") + + +def test_add_then_remove_different_version(): + """Test: PR1 adds v1.0, PR2 removes v2.0. Should show as removed v2.0.""" + print("Test 4: Add then remove different version") + existing = ( + {}, + {"PackageD": ("1.0.0", "#1")}, # added + {} + ) + new = ( + {}, + {}, + {"PackageD": ("2.0.0", "#2")} # removed different version + ) + updated, added, removed = merge_changes(existing, new) + assert "PackageD" not in added, f"Expected PackageD removed from added, got: {added}" + assert "PackageD" in removed, f"Expected PackageD in removed, got: {removed}" + assert removed["PackageD"][0] == "2.0.0", f"Expected version 2.0.0, got: {removed['PackageD']}" + print(f"✓ Passed: Package correctly tracked as removed with version {removed['PackageD'][0]}\n") + + +def test_update_in_added(): + """Test: PR1 adds v1.0, PR2 updates to v2.0. Should show as updated 1.0->2.0.""" + print("Test 5: Update a package that was added") + existing = ( + {}, + {"PackageE": ("1.0.0", "#1")}, # added + {} + ) + new = ( + {"PackageE": ("1.0.0", "2.0.0", "#2")}, # updated + {}, + {} + ) + updated, added, removed = merge_changes(existing, new) + assert "PackageE" not in added, f"Expected PackageE removed from added, got: {added}" + assert "PackageE" in updated, f"Expected PackageE in updated, got: {updated}" + assert updated["PackageE"] == ("1.0.0", "2.0.0", "#1, #2"), \ + f"Expected ('1.0.0', '2.0.0', '#1, #2'), got: {updated['PackageE']}" + print(f"✓ Passed: Package correctly converted to updated: {updated['PackageE']}\n") + + +def test_multiple_updates(): + """Test: PR1 updates A->B, PR2 updates B->C. Should show A->C.""" + print("Test 6: Multiple updates") + existing = ( + {"PackageF": ("1.0.0", "2.0.0", "#1")}, # updated + {}, + {} + ) + new = ( + {"PackageF": ("2.0.0", "3.0.0", "#2")}, # updated again + {}, + {} + ) + updated, added, removed = merge_changes(existing, new) + assert "PackageF" in updated + assert updated["PackageF"] == ("1.0.0", "3.0.0", "#1, #2"), \ + f"Expected ('1.0.0', '3.0.0', '#1, #2'), got: {updated['PackageF']}" + print(f"✓ Passed: Package correctly shows full range: {updated['PackageF']}\n") + + +def test_multiple_updates_back_to_original(): + """Test: PR1 updates 1->2, PR2 updates 2->3, PR3 updates 3->1. Should be removed.""" + print("Test 7: Multiple updates ending back at original version") + # Simulate PR1 and PR2 already merged + existing = ( + {"PackageG": ("1.0.0", "3.0.0", "#1, #2")}, # updated through PR1 and PR2 + {}, + {} + ) + # PR3 changes back to 1.0.0 + new = ( + {"PackageG": ("3.0.0", "1.0.0", "#3")}, # updated back to original + {}, + {} + ) + updated, added, removed = merge_changes(existing, new) + assert "PackageG" not in updated, f"Expected PackageG removed, got: {updated}" + assert len(added) == 0 and len(removed) == 0 + print("✓ Passed: Package correctly removed (version returned to original)\n") + + +def test_update_remove_add_same_version(): + """Test: PR1 updates 1->2, PR2 updates 2->3, PR3 removes, PR4 adds v3. Should show updated 1->3.""" + print("Test 8: Update-Update-Remove-Add same version") + # After PR1, PR2, PR3 + existing = ( + {}, + {}, + {"PackageH": ("1.0.0", "#1, #2, #3")} # removed (original was 1.0.0) + ) + # PR4 adds back the same version that was removed + new = ( + {}, + {"PackageH": ("3.0.0", "#4")}, # added + {} + ) + updated, added, removed = merge_changes(existing, new) + assert "PackageH" in updated, f"Expected PackageH in updated, got: updated={updated}, added={added}, removed={removed}" + assert updated["PackageH"] == ("1.0.0", "3.0.0", "#1, #2, #3, #4"), \ + f"Expected ('1.0.0', '3.0.0', '#1, #2, #3, #4'), got: {updated['PackageH']}" + print(f"✓ Passed: Package correctly shows as updated: {updated['PackageH']}\n") + + +def test_update_remove_add_original_version(): + """Test: PR1 updates 1->2, PR2 updates 2->3, PR3 removes, PR4 adds v1. Should be removed.""" + print("Test 9: Update-Update-Remove-Add original version") + # After PR1, PR2, PR3 + existing = ( + {}, + {}, + {"PackageI": ("1.0.0", "#1, #2, #3")} # removed (original was 1.0.0) + ) + # PR4 adds back the original version + new = ( + {}, + {"PackageI": ("1.0.0", "#4")}, # added back to original + {} + ) + updated, added, removed = merge_changes(existing, new) + assert "PackageI" not in updated, f"Expected PackageI removed, got: updated={updated}" + assert "PackageI" not in added, f"Expected PackageI removed, got: added={added}" + assert "PackageI" not in removed, f"Expected PackageI removed, got: removed={removed}" + print("✓ Passed: Package correctly removed (added back to original version)\n") + + +def test_update_remove_add_different_version(): + """Test: PR1 updates 1->2, PR2 updates 2->3, PR3 removes, PR4 adds v4. Should show updated 1->4.""" + print("Test 10: Update-Update-Remove-Add different version") + # After PR1, PR2, PR3 + existing = ( + {}, + {}, + {"PackageJ": ("1.0.0", "#1, #2, #3")} # removed (original was 1.0.0) + ) + # PR4 adds a completely different version + new = ( + {}, + {"PackageJ": ("4.0.0", "#4")}, # added new version + {} + ) + updated, added, removed = merge_changes(existing, new) + assert "PackageJ" in updated, f"Expected PackageJ in updated, got: updated={updated}, added={added}, removed={removed}" + assert updated["PackageJ"] == ("1.0.0", "4.0.0", "#1, #2, #3, #4"), \ + f"Expected ('1.0.0', '4.0.0', '#1, #2, #3, #4'), got: {updated['PackageJ']}" + print(f"✓ Passed: Package correctly shows as updated: {updated['PackageJ']}\n") + + +def test_add_update_remove(): + """Test: PR1 adds v1, PR2 updates to v2, PR3 removes v2. Should be completely removed.""" + print("Test 11: Add-Update-Remove") + # After PR1 and PR2 + existing = ( + {"PackageK": ("1.0.0", "2.0.0", "#1, #2")}, # updated (was added in PR1, updated in PR2) + {}, + {} + ) + # PR3 removes v2 + new = ( + {}, + {}, + {"PackageK": ("2.0.0", "#3")} # removed + ) + updated, added, removed = merge_changes(existing, new) + assert "PackageK" not in updated, f"Expected PackageK removed from updated, got: {updated}" + assert "PackageK" not in added, f"Expected PackageK removed from added, got: {added}" + assert "PackageK" in removed, f"Expected PackageK in removed, got: {removed}" + # The removed should track from the original first version + assert removed["PackageK"][0] == "1.0.0", f"Expected removed from 1.0.0, got: {removed['PackageK']}" + print(f"✓ Passed: Package correctly shows as removed from original: {removed['PackageK']}\n") + + +def test_add_remove_add_same_version(): + """Test: PR1 adds v1, PR2 removes v1, PR3 adds v1 again. Should show as added v1.""" + print("Test 12: Add-Remove-Add same version") + # After PR1 and PR2 (added then removed) + existing = ( + {}, + {}, + {} # Completely removed after PR2 + ) + # PR3 adds v1 again + new = ( + {}, + {"PackageL": ("1.0.0", "#3")}, # added + {} + ) + updated, added, removed = merge_changes(existing, new) + assert "PackageL" in added, f"Expected PackageL in added, got: added={added}" + assert added["PackageL"] == ("1.0.0", "#3"), f"Expected ('1.0.0', '#3'), got: {added['PackageL']}" + print(f"✓ Passed: Package correctly shows as added: {added['PackageL']}\n") + + +def test_update_remove_remove(): + """Test: PR1 updates 1->2, PR2 removes v2, PR3 tries to remove again. Should show removed from v1.""" + print("Test 13: Update-Remove (duplicate remove)") + # After PR1 and PR2 + existing = ( + {}, + {}, + {"PackageM": ("1.0.0", "#1, #2")} # removed (original was 1.0.0) + ) + # PR3 tries to remove again (edge case, might not happen in practice) + new = ( + {}, + {}, + {"PackageM": ("1.0.0", "#3")} # removed again + ) + updated, added, removed = merge_changes(existing, new) + assert "PackageM" in removed, f"Expected PackageM in removed, got: {removed}" + # Should keep the original information + assert removed["PackageM"][0] == "1.0.0", f"Expected removed from 1.0.0, got: {removed['PackageM']}" + print(f"✓ Passed: Package correctly maintains removed state: {removed['PackageM']}\n") + + +def test_add_add(): + """Test: PR1 adds v1, PR2 adds v2 (version changed externally). Should show added v2.""" + print("Test 14: Add-Add (version changed between PRs)") + # After PR1 + existing = ( + {}, + {"PackageN": ("1.0.0", "#1")}, # added + {} + ) + # PR2 adds different version (edge case) + new = ( + {}, + {"PackageN": ("2.0.0", "#2")}, # added different version + {} + ) + updated, added, removed = merge_changes(existing, new) + assert "PackageN" in added, f"Expected PackageN in added, got: {added}" + assert added["PackageN"][0] == "2.0.0", f"Expected version 2.0.0, got: {added['PackageN']}" + print(f"✓ Passed: Package correctly shows latest added version: {added['PackageN']}\n") + + +def test_complex_chain_ending_in_original(): + """Test: Complex chain - Add v1, Update to v2, Remove, Add v2, Update to v1. Should be removed.""" + print("Test 15: Complex chain ending at nothing changed") + # After PR1 (add), PR2 (update), PR3 (remove), PR4 (add back) + existing = ( + {"PackageO": ("1.0.0", "2.0.0", "#1, #2, #3, #4")}, # Complex history + {}, + {} + ) + # PR5 updates back to v1 (original from perspective of first state) + new = ( + {"PackageO": ("2.0.0", "1.0.0", "#5")}, # back to start + {}, + {} + ) + updated, added, removed = merge_changes(existing, new) + assert "PackageO" not in updated, f"Expected PackageO removed, got: {updated}" + print(f"✓ Passed: Complex chain correctly removed when ending at original\n") + + +def test_document_format(): + """Test: Verify the document rendering format.""" + print("Test 16: Document format validation") + + updated = { + "Microsoft.Extensions.Logging": ("8.0.0", "8.0.1", "#123"), + "Newtonsoft.Json": ("13.0.1", "13.0.3", "#456, #789"), + } + + added = { + "Azure.Identity": ("1.10.0", "#567"), + } + + removed = { + "System.Text.Json": ("7.0.0", "#890"), + } + + document = render_section("9.0.0", updated, added, removed) + + # Verify document structure + assert "## 9.0.0" in document, "Version header missing" + assert "| Package | Old Version | New Version | PR |" in document, "Updated table header missing" + assert "Microsoft.Extensions.Logging" in document, "Updated package missing" + assert "**Added:**" in document, "Added section missing" + assert "Azure.Identity" in document, "Added package missing" + assert "**Removed:**" in document, "Removed section missing" + assert "System.Text.Json" in document, "Removed package missing" + + print("✓ Passed: Document format is correct") + print("\nSample output:") + print("-" * 60) + print(document) + print("-" * 60 + "\n") + + +def run_all_tests(): + """Run all test cases.""" + print("=" * 70) + print("Testing update_dependency_changes.py") + print("=" * 70 + "\n") + + test_update_then_revert() + test_add_then_remove_same_version() + test_remove_then_add_same_version() + test_add_then_remove_different_version() + test_update_in_added() + test_multiple_updates() + test_multiple_updates_back_to_original() + test_update_remove_add_same_version() + test_update_remove_add_original_version() + test_update_remove_add_different_version() + test_add_update_remove() + test_add_remove_add_same_version() + test_update_remove_remove() + test_add_add() + test_complex_chain_ending_in_original() + test_document_format() + + print("=" * 70) + print("All 16 tests passed! ✓") + print("=" * 70) + print("\nTest coverage summary:") + print(" ✓ Basic scenarios (update, add, remove)") + print(" ✓ Version revert handling") + print(" ✓ Complex multi-step sequences") + print(" ✓ Edge cases and duplicates") + print(" ✓ Document format validation") + print("=" * 70) + + +if __name__ == "__main__": + run_all_tests() diff --git a/.github/scripts/update_dependency_changes.py b/.github/scripts/update_dependency_changes.py new file mode 100644 index 0000000000..05e0019470 --- /dev/null +++ b/.github/scripts/update_dependency_changes.py @@ -0,0 +1,331 @@ +import subprocess +import re +import os +import sys +import xml.etree.ElementTree as ET + + +HEADER = "# Package Version Changes\n" +DOC_PATH = os.environ.get("DOC_PATH", "docs/en/package-version-changes.md") + + +def get_version(): + """Read the current version from common.props.""" + try: + tree = ET.parse("common.props") + root = tree.getroot() + version_elem = root.find(".//Version") + if version_elem is not None: + return version_elem.text + except FileNotFoundError: + print("Error: 'common.props' file not found.", file=sys.stderr) + except ET.ParseError as ex: + print(f"Error: Failed to parse 'common.props': {ex}", file=sys.stderr) + return None + + +def get_diff(base_ref): + """Get diff of Directory.Packages.props against the base branch.""" + result = subprocess.run( + ["git", "diff", f"origin/{base_ref}", "--", "Directory.Packages.props"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError( + f"Failed to get diff for base ref 'origin/{base_ref}': {result.stderr}" + ) + return result.stdout + + +def get_existing_doc_from_base(base_ref): + """Read the existing document from the base branch.""" + result = subprocess.run( + ["git", "show", f"origin/{base_ref}:{DOC_PATH}"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + return result.stdout + return "" + + +def parse_diff_packages(lines, prefix): + """Parse package versions from diff lines with the given prefix (+ or -).""" + packages = {} + # Use separate patterns to handle different attribute orders + include_pattern = re.compile(r'Include="([^"]+)"') + version_pattern = re.compile(r'Version="([^"]+)"') + for line in lines: + if line.startswith(prefix) and "PackageVersion" in line and not line.startswith(prefix * 3): + include_match = include_pattern.search(line) + version_match = version_pattern.search(line) + if include_match and version_match: + packages[include_match.group(1)] = version_match.group(1) + return packages + + +def classify_changes(old_packages, new_packages, pr_number): + """Classify diff into updated, added, and removed with PR attribution.""" + updated = {} + added = {} + removed = {} + + all_packages = sorted(set(list(old_packages.keys()) + list(new_packages.keys()))) + + for pkg in all_packages: + if pkg in old_packages and pkg in new_packages: + if old_packages[pkg] != new_packages[pkg]: + updated[pkg] = (old_packages[pkg], new_packages[pkg], pr_number) + elif pkg in new_packages: + added[pkg] = (new_packages[pkg], pr_number) + else: + removed[pkg] = (old_packages[pkg], pr_number) + + return updated, added, removed + + +def parse_existing_section(section_text): + """Parse an existing markdown section to extract package records with PR info.""" + updated = {} + added = {} + removed = {} + + mode = "updated" + for line in section_text.split("\n"): + if "**Added:**" in line: + mode = "added" + continue + if "**Removed:**" in line: + mode = "removed" + continue + if not line.startswith("|") or line.startswith("| Package") or line.startswith("|---"): + continue + + parts = [p.strip() for p in line.split("|")[1:-1]] + if mode == "updated" and len(parts) >= 3: + pr = parts[3] if len(parts) >= 4 else "" + updated[parts[0]] = (parts[1], parts[2], pr) + elif len(parts) >= 2: + pr = parts[2] if len(parts) >= 3 else "" + if mode == "added": + added[parts[0]] = (parts[1], pr) + else: + removed[parts[0]] = (parts[1], pr) + + return updated, added, removed + + +def merge_prs(existing_pr, new_pr): + """Merge PR numbers, avoiding duplicates.""" + if not existing_pr or not existing_pr.strip(): + return new_pr + if not new_pr or not new_pr.strip(): + return existing_pr + + # Parse existing PRs + existing_prs = [p.strip() for p in existing_pr.split(",") if p.strip()] + # Add new PR if not already present + if new_pr not in existing_prs: + existing_prs.append(new_pr) + return ", ".join(existing_prs) + + +def merge_changes(existing, new): + """Merge new changes into existing records for the same version.""" + ex_updated, ex_added, ex_removed = existing + new_updated, new_added, new_removed = new + + merged_updated = dict(ex_updated) + merged_added = dict(ex_added) + merged_removed = dict(ex_removed) + + for pkg, (old_ver, new_ver, pr) in new_updated.items(): + if pkg in merged_updated: + existing_old_ver, existing_new_ver, existing_pr = merged_updated[pkg] + merged_pr = merge_prs(existing_pr, pr) + merged_updated[pkg] = (existing_old_ver, new_ver, merged_pr) + elif pkg in merged_added: + existing_ver, existing_pr = merged_added[pkg] + merged_pr = merge_prs(existing_pr, pr) + # Convert added to updated since the version changed again + del merged_added[pkg] + merged_updated[pkg] = (existing_ver, new_ver, merged_pr) + else: + merged_updated[pkg] = (old_ver, new_ver, pr) + + for pkg, (ver, pr) in new_added.items(): + if pkg in merged_removed: + removed_ver, removed_pr = merged_removed.pop(pkg) + merged_pr = merge_prs(removed_pr, pr) + merged_updated[pkg] = (removed_ver, ver, merged_pr) + elif pkg in merged_added: + existing_ver, existing_pr = merged_added[pkg] + merged_pr = merge_prs(existing_pr, pr) + merged_added[pkg] = (ver, merged_pr) + else: + merged_added[pkg] = (ver, pr) + + for pkg, (ver, pr) in new_removed.items(): + if pkg in merged_added: + existing_ver, existing_pr = merged_added[pkg] + # Only delete if versions match (added then removed the same version) + if existing_ver == ver: + del merged_added[pkg] + else: + # Version changed between add and remove, convert to updated then removed + del merged_added[pkg] + merged_removed[pkg] = (ver, merge_prs(existing_pr, pr)) + elif pkg in merged_updated: + old_ver, new_ver, existing_pr = merged_updated.pop(pkg) + merged_pr = merge_prs(existing_pr, pr) + # Only keep as removed if the final state is different from original + merged_removed[pkg] = (old_ver, merged_pr) + else: + merged_removed[pkg] = (ver, pr) + + # Remove updated entries where old and new versions are the same + merged_updated = {k: v for k, v in merged_updated.items() if v[0] != v[1]} + + # Remove added entries that are also in removed with the same version + for pkg in list(merged_added.keys()): + if pkg in merged_removed: + added_ver, added_pr = merged_added[pkg] + removed_ver, removed_pr = merged_removed[pkg] + if added_ver == removed_ver: + # Package was added and removed at the same version, cancel out + del merged_added[pkg] + del merged_removed[pkg] + + return merged_updated, merged_added, merged_removed + + +def render_section(version, updated, added, removed): + """Render a version section as markdown.""" + lines = [f"## {version}\n"] + + if updated: + lines.append("| Package | Old Version | New Version | PR |") + lines.append("|---------|-------------|-------------|-----|") + for pkg in sorted(updated): + old_ver, new_ver, pr = updated[pkg] + lines.append(f"| {pkg} | {old_ver} | {new_ver} | {pr} |") + lines.append("") + + if added: + lines.append("**Added:**\n") + lines.append("| Package | Version | PR |") + lines.append("|---------|---------|-----|") + for pkg in sorted(added): + ver, pr = added[pkg] + lines.append(f"| {pkg} | {ver} | {pr} |") + lines.append("") + + if removed: + lines.append("**Removed:**\n") + lines.append("| Package | Version | PR |") + lines.append("|---------|---------|-----|") + for pkg in sorted(removed): + ver, pr = removed[pkg] + lines.append(f"| {pkg} | {ver} | {pr} |") + lines.append("") + + return "\n".join(lines) + + +def parse_document(content): + """Split document into a list of (version, section_text) tuples.""" + sections = [] + current_version = None + current_lines = [] + + for line in content.split("\n"): + match = re.match(r"^## (.+)$", line) + if match: + if current_version: + sections.append((current_version, "\n".join(current_lines))) + current_version = match.group(1).strip() + current_lines = [line] + elif current_version: + current_lines.append(line) + + if current_version: + sections.append((current_version, "\n".join(current_lines))) + + return sections + + +def main(): + if len(sys.argv) < 3: + print("Usage: update_dependency_changes.py ") + sys.exit(1) + + base_ref = sys.argv[1] + pr_arg = sys.argv[2] + + # Validate PR number is numeric + if not re.fullmatch(r"\d+", pr_arg): + print("Invalid PR number; must be numeric.") + sys.exit(1) + + # Validate base_ref doesn't contain dangerous characters + if not re.fullmatch(r"[a-zA-Z0-9/_.-]+", base_ref): + print("Invalid base ref; contains invalid characters.") + sys.exit(1) + + pr_number = f"#{pr_arg}" + + version = get_version() + if not version: + print("Could not read version from common.props.") + sys.exit(1) + + diff = get_diff(base_ref) + if not diff: + print("No diff found for Directory.Packages.props.") + sys.exit(0) + + diff_lines = diff.split("\n") + old_packages = parse_diff_packages(diff_lines, "-") + new_packages = parse_diff_packages(diff_lines, "+") + + new_updated, new_added, new_removed = classify_changes(old_packages, new_packages, pr_number) + + if not new_updated and not new_added and not new_removed: + print("No package version changes detected.") + sys.exit(0) + + # Load existing document from the base branch + existing_content = get_existing_doc_from_base(base_ref) + sections = parse_document(existing_content) if existing_content else [] + + # Find existing section for this version + version_index = None + for i, (v, _) in enumerate(sections): + if v == version: + version_index = i + break + + if version_index is not None: + existing = parse_existing_section(sections[version_index][1]) + merged = merge_changes(existing, (new_updated, new_added, new_removed)) + section_text = render_section(version, *merged) + sections[version_index] = (version, section_text) + else: + section_text = render_section(version, new_updated, new_added, new_removed) + sections.insert(0, (version, section_text)) + + # Write document + doc_dir = os.path.dirname(DOC_PATH) + if doc_dir: + os.makedirs(doc_dir, exist_ok=True) + with open(DOC_PATH, "w") as f: + f.write(HEADER + "\n") + for _, text in sections: + f.write(text.rstrip("\n") + "\n\n") + + print(f"Updated {DOC_PATH} for version {version}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/nuget-packages-version-change-detector.yml b/.github/workflows/nuget-packages-version-change-detector.yml new file mode 100644 index 0000000000..75b0404929 --- /dev/null +++ b/.github/workflows/nuget-packages-version-change-detector.yml @@ -0,0 +1,71 @@ +# Automatically detects and documents NuGet package version changes in PRs. +# Triggers on changes to Directory.Packages.props and: +# - Adds 'dependency-change' label to the PR +# - Updates docs/en/package-version-changes.md with version changes +# - Commits the documentation back to the PR branch +# Note: Only runs for PRs from the same repository (not forks) to ensure write permissions. +name: Nuget Packages Version Change Detector + +on: + pull_request: + paths: + - 'Directory.Packages.props' + types: + - opened + - synchronize + - reopened + - ready_for_review + +permissions: + contents: read + +concurrency: + group: dependency-changes-${{ github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + label: + if: ${{ !github.event.pull_request.draft && !startsWith(github.head_ref, 'auto-merge/') && github.event.pull_request.head.repo.full_name == github.repository && !contains(github.event.head_commit.message, '[skip ci]') }} + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + env: + DOC_PATH: docs/en/package-version-changes.md + steps: + - run: gh pr edit "$PR_NUMBER" --add-label "dependency-change" + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ secrets.BOT_SECRET }} + GH_REPO: ${{ github.repository }} + + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 1 + + - name: Fetch base branch + run: git fetch origin ${{ github.event.pull_request.base.ref }}:refs/remotes/origin/${{ github.event.pull_request.base.ref }} --depth=1 + + - uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - run: python .github/scripts/update_dependency_changes.py ${{ github.event.pull_request.base.ref }} ${{ github.event.pull_request.number }} + + - name: Commit changes + run: | + set -e + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add "$DOC_PATH" + if git diff --staged --quiet; then + echo "No changes to commit." + else + git commit -m "docs: update package version changes [skip ci]" + if ! git push; then + echo "Error: Failed to push changes. This may be due to conflicts or permission issues." + exit 1 + fi + echo "Successfully committed and pushed documentation changes." + fi diff --git a/.github/workflows/update-studio-docs.yml b/.github/workflows/update-studio-docs.yml new file mode 100644 index 0000000000..490da685d5 --- /dev/null +++ b/.github/workflows/update-studio-docs.yml @@ -0,0 +1,261 @@ +name: Update ABP Studio Docs + +on: + repository_dispatch: + types: [update_studio_docs] + +jobs: + update-docs: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + models: read + + steps: + # ------------------------------------------------- + # Validate payload (safe & strict) + # ------------------------------------------------- + - name: Validate payload + run: | + required_keys=(version name notes url target_branch) + for key in "${required_keys[@]}"; do + value="$(jq -r --arg k "$key" '.client_payload[$k] // ""' "$GITHUB_EVENT_PATH")" + if [ -z "$value" ] || [ "$value" = "null" ]; then + echo "Missing payload field: $key" + exit 1 + fi + done + + # ------------------------------------------------- + # Checkout target branch + # ------------------------------------------------- + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.client_payload.target_branch }} + fetch-depth: 0 + + - name: Configure git + run: | + git config user.name "docs-bot" + git config user.email "docs-bot@users.noreply.github.com" + + # ------------------------------------------------- + # Create working branch + # ------------------------------------------------- + - name: Create branch + run: | + VERSION="${{ github.event.client_payload.version }}" + BRANCH="docs/studio-${VERSION}" + git checkout -B "$BRANCH" + echo "BRANCH=$BRANCH" >> $GITHUB_ENV + + # ------------------------------------------------- + # Save raw release notes + # ------------------------------------------------- + - name: Save raw release notes + run: | + mkdir -p .tmp + jq -r '.client_payload.notes' "$GITHUB_EVENT_PATH" > .tmp/raw-notes.txt + + # ------------------------------------------------- + # Try AI formatting (OPTIONAL) + # ------------------------------------------------- + - name: Generate release notes with AI (optional) + id: ai + continue-on-error: true + uses: actions/ai-inference@v1 + with: + model: openai/gpt-4.1 + prompt: | + You are a technical writer. + + Convert the following release notes into concise, user-facing bullet points. + Rules: + - Use "-" bullets + - Keep it short and clear + - Skip internal or low-level changes + + Release notes: + ${{ github.event.client_payload.notes }} + + # ------------------------------------------------- + # Decide final release notes (AI or fallback) + # ------------------------------------------------- + - name: Decide final release notes + run: | + if [ -n "${{ steps.ai.outputs.response }}" ]; then + echo "✅ Using AI formatted notes" + echo "${{ steps.ai.outputs.response }}" > .tmp/final-notes.txt + else + echo "⚠️ AI unavailable – using raw notes" + sed 's/^/- /' .tmp/raw-notes.txt > .tmp/final-notes.txt + fi + + # ------------------------------------------------- + # Update release-notes.md (UNDER Latest) + # ------------------------------------------------- + - name: Update release-notes.md + run: | + FILE="docs/en/studio/release-notes.md" + VERSION="${{ github.event.client_payload.version }}" + NAME="${{ github.event.client_payload.name }}" + URL="${{ github.event.client_payload.url }}" + DATE="$(date +%Y-%m-%d)" + + mkdir -p docs/en/studio + touch "$FILE" + + if grep -q "## $VERSION" "$FILE"; then + echo "Release notes already contain $VERSION" + exit 0 + fi + + ENTRY=$(cat < "$FILE.new" + + mv "$FILE.new" "$FILE" + + # ------------------------------------------------- + # Update version-mapping.md (INSIDE table) + # ------------------------------------------------- + - name: Update version-mapping.md (smart) + run: | + FILE="docs/en/studio/version-mapping.md" + STUDIO_VERSION="${{ github.event.client_payload.version }}" + ABP_VERSION="dev" # gerekiyorsa payload’dan alabilirsin + + mkdir -p docs/en/studio + + if [ ! -f "$FILE" ]; then + echo "| ABP Studio Version | ABP Version |" > "$FILE" + echo "|-------------------|-------------|" >> "$FILE" + echo "| $STUDIO_VERSION | $ABP_VERSION |" >> "$FILE" + exit 0 + fi + + python3 <<'EOF' + import re + from packaging.version import Version + + file_path = "docs/en/studio/version-mapping.md" + studio = Version("${STUDIO_VERSION}") + abp = "${ABP_VERSION}" + + with open(file_path) as f: + lines = f.readlines() + + header = lines[:2] + rows = lines[2:] + + new_rows = [] + handled = False + + for row in rows: + m = re.match(r"\|\s*(.+?)\s*\|\s*(.+?)\s*\|", row) + if not m: + new_rows.append(row) + continue + + studio_range, abp_version = m.groups() + + if abp_version != abp: + new_rows.append(row) + continue + + # range cases + if "-" in studio_range: + start, end = [Version(v.strip()) for v in studio_range.split("-")] + if start <= studio <= end: + handled = True + new_rows.append(row) # already covered + elif studio == end.next_patch(): + handled = True + new_rows.append(f"| {start} - {studio} | {abp} |\n") + else: + new_rows.append(row) + else: + v = Version(studio_range) + if studio == v: + handled = True + new_rows.append(row) + elif studio == v.next_patch(): + handled = True + new_rows.append(f"| {v} - {studio} | {abp} |\n") + else: + new_rows.append(row) + + if not handled: + new_rows.insert(0, f"| {studio} | {abp} |\n") + + with open(file_path, "w") as f: + f.writelines(header + new_rows) + EOF + + # ------------------------------------------------- + # Check for changes + # ------------------------------------------------- + - name: Check for changes + id: changes + run: | + git add docs/en/studio + if git diff --cached --quiet; then + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + fi + + # ------------------------------------------------- + # Commit & push + # ------------------------------------------------- + - name: Commit & push + if: steps.changes.outputs.has_changes == 'true' + run: | + git commit -m "docs(studio): release ${{ github.event.client_payload.version }}" + git push -u origin "$BRANCH" + + # ------------------------------------------------- + # Create PR + # ------------------------------------------------- + - name: Create PR + if: steps.changes.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.BOT_SECRET }} + run: | + PR_URL=$(gh pr create \ + --title "docs(studio): release ${{ github.event.client_payload.version }}" \ + --body "Automated documentation update for ABP Studio." \ + --base "${{ github.event.client_payload.target_branch }}" \ + --head "$BRANCH") + + echo "PR_URL=$PR_URL" >> $GITHUB_ENV + + # ------------------------------------------------- + # Enable auto-merge (branch protection safe) + # ------------------------------------------------- + - name: Enable auto-merge + if: steps.changes.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.BOT_SECRET }} + run: | + gh pr merge "$PR_URL" --squash --auto diff --git a/docs/en/modules/ai-management/index.md b/docs/en/modules/ai-management/index.md index e50253c22c..16e777186b 100644 --- a/docs/en/modules/ai-management/index.md +++ b/docs/en/modules/ai-management/index.md @@ -9,14 +9,11 @@ > You must have an ABP Team or a higher license to use this module. -> **⚠️ Important Notice** -> The **AI Management Module** is currently in **preview**. The documentation and implementation are subject to change. - -This module implements AI (Artificial Intelligence) management capabilities on top of the [Artificial Intelligence Workspaces](../../framework/infrastructure/artificial-intelligence/index.md) feature of the ABP Framework and allows to manage workspaces dynamically from the application including UI components and API endpoints. +This module implements AI (Artificial Intelligence) management capabilities on top of the [Artificial Intelligence Workspaces](../../framework/infrastructure/artificial-intelligence/index.md) feature of the ABP Framework and allows managing workspaces dynamically from the application, including UI components and API endpoints. ## How to Install -AI Management module is not pre-installed in [the startup templates](../solution-templates/layered-web-application). You can install it using the ABP CLI or ABP Studio. +The **AI Management Module** is not included in [the startup templates](../solution-templates/layered-web-application) by default. However, when creating a new application with [ABP Studio](../../tools/abp-studio/index.md), you can easily enable it during setup via the *AI Integration* step in the project creation wizard. Alternatively, you can install it using the ABP CLI or ABP Studio: **Using ABP CLI:** @@ -40,7 +37,7 @@ AI Management module packages are designed for various usage scenarios. Packages ### Menu Items -AI Management module adds the following items to the "Main" menu: +The **AI Management Module** adds the following items to the "Main" menu: * **AI Management**: Root menu item for AI Management module. (`AIManagement`) * **Workspaces**: Workspace management page. (`AIManagement.Workspaces`) diff --git a/docs/en/studio/version-mapping.md b/docs/en/studio/version-mapping.md index f399c97390..d3fe945967 100644 --- a/docs/en/studio/version-mapping.md +++ b/docs/en/studio/version-mapping.md @@ -11,7 +11,8 @@ This document provides a general overview of the relationship between various ve | **ABP Studio Version** | **ABP Version of Startup Template** | |------------------------|---------------------------| -| 2.1.0 - 2.1.3 | 10.0.1 | +| 2.1.5 - 2.1.9 | 10.0.2 | +| 2.1.0 - 2.1.4 | 10.0.1 | | 2.0.0 to 2.0.2 | 10.0.0 | | 1.4.2 | 9.3.6 | | 1.3.3 to 1.4.1 | 9.3.5 | diff --git a/framework/src/Volo.Abp.Swashbuckle/Microsoft/Extensions/DependencyInjection/AbpSwaggerUIOptionsExtensions.cs b/framework/src/Volo.Abp.Swashbuckle/Microsoft/Extensions/DependencyInjection/AbpSwaggerUIOptionsExtensions.cs new file mode 100644 index 0000000000..d307bc7e34 --- /dev/null +++ b/framework/src/Volo.Abp.Swashbuckle/Microsoft/Extensions/DependencyInjection/AbpSwaggerUIOptionsExtensions.cs @@ -0,0 +1,47 @@ +using System; +using System.Text; +using System.Text.Json; +using JetBrains.Annotations; +using Swashbuckle.AspNetCore.SwaggerUI; +using Volo.Abp; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class AbpSwaggerUIOptionsExtensions +{ + /// + /// Sets the abp.appPath used by the Swagger UI scripts. + /// + /// The Swagger UI options. + /// The application base path. + public static void AbpAppPath([NotNull] this SwaggerUIOptions options, [NotNull] string appPath) + { + Check.NotNull(options, nameof(options)); + Check.NotNull(appPath, nameof(appPath)); + + var normalizedAppPath = NormalizeAppPath(appPath); + options.HeadContent = BuildAppPathScript(normalizedAppPath, options.HeadContent ?? string.Empty); + } + + private static string NormalizeAppPath(string appPath) + { + return string.IsNullOrWhiteSpace(appPath) + ? "/" + : appPath.Trim().EnsureStartsWith('/').EnsureEndsWith('/'); + } + + private static string BuildAppPathScript(string normalizedAppPath, string headContent) + { + var builder = new StringBuilder(headContent); + if (builder.Length > 0) + { + builder.AppendLine(); + } + + builder.AppendLine(""); + return builder.ToString(); + } +} diff --git a/framework/src/Volo.Abp.Swashbuckle/wwwroot/swagger/ui/abp.js b/framework/src/Volo.Abp.Swashbuckle/wwwroot/swagger/ui/abp.js index 11cbe56803..da996ea6f5 100644 --- a/framework/src/Volo.Abp.Swashbuckle/wwwroot/swagger/ui/abp.js +++ b/framework/src/Volo.Abp.Swashbuckle/wwwroot/swagger/ui/abp.js @@ -2,11 +2,7 @@ var abp = abp || {}; (function () { /* Application paths *****************************************/ - - //Current application root path (including virtual directory if exists). - var baseElement = document.querySelector('base'); - var baseHref = baseElement ? baseElement.getAttribute('href') : null; - abp.appPath = baseHref || abp.appPath || '/'; + abp.appPath = abp.appPath || '/'; /* UTILS ***************************************************/ diff --git a/framework/src/Volo.Abp.Swashbuckle/wwwroot/swagger/ui/abp.swagger.js b/framework/src/Volo.Abp.Swashbuckle/wwwroot/swagger/ui/abp.swagger.js index db054056d1..e961f6bc2d 100644 --- a/framework/src/Volo.Abp.Swashbuckle/wwwroot/swagger/ui/abp.swagger.js +++ b/framework/src/Volo.Abp.Swashbuckle/wwwroot/swagger/ui/abp.swagger.js @@ -11,7 +11,7 @@ var abp = abp || {}; var oidcSupportedScopes = configObject.oidcSupportedScopes || []; var oidcDiscoveryEndpoint = configObject.oidcDiscoveryEndpoint || []; var tenantPlaceHolders = ["{{tenantId}}", "{{tenantName}}", "{0}"] - abp.appPath = configObject.baseUrl || abp.appPath; + abp.appPath = abp.appPath || "/"; var requestInterceptor = configObject.requestInterceptor; var responseInterceptor = configObject.responseInterceptor; diff --git a/npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.ts b/npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.ts index 0955a448e5..aaa213775c 100644 --- a/npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.ts +++ b/npm/ng-packs/apps/dev-app/src/app/dynamic-form-page/dynamic-form-page.component.ts @@ -1,4 +1,4 @@ -import { Component, inject, OnInit, ViewChild } from '@angular/core'; +import { Component, inject, OnInit, viewChild } from '@angular/core'; import { DynamicFormComponent, FormFieldConfig } from '@abp/ng.components/dynamic-form'; import { FormConfigService } from './form-config.service'; @@ -8,7 +8,7 @@ import { FormConfigService } from './form-config.service'; imports: [DynamicFormComponent], }) export class DynamicFormPageComponent implements OnInit { - @ViewChild(DynamicFormComponent, { static: false }) dynamicFormComponent: DynamicFormComponent; + readonly dynamicFormComponent = viewChild(DynamicFormComponent); protected readonly formConfigService = inject(FormConfigService); formFields: FormFieldConfig[] = []; @@ -27,12 +27,12 @@ export class DynamicFormPageComponent implements OnInit { alert('✅ Form submitted successfully! Check the console for details.'); // Reset form after submission - this.dynamicFormComponent.resetForm(); + this.dynamicFormComponent().resetForm(); } cancel() { console.log('❌ Form Cancelled'); alert('Form cancelled'); - this.dynamicFormComponent.resetForm(); + this.dynamicFormComponent().resetForm(); } } diff --git a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field-host.component.ts b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field-host.component.ts index 56f32f6e0d..d711b2030a 100644 --- a/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field-host.component.ts +++ b/npm/ng-packs/packages/components/dynamic-form/src/dynamic-form-field/dynamic-form-field-host.component.ts @@ -1,6 +1,5 @@ import { Component, - ViewChild, ViewContainerRef, ChangeDetectionStrategy, forwardRef, @@ -9,6 +8,7 @@ import { DestroyRef, inject, input, + viewChild } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, FormControl, ReactiveFormsModule @@ -34,7 +34,7 @@ export class DynamicFieldHostComponent implements ControlValueAccessor { component = input>(); inputs = input>({}); - @ViewChild('vcRef', { read: ViewContainerRef, static: true }) viewContainerRef!: ViewContainerRef; + readonly viewContainerRef = viewChild.required('vcRef', { read: ViewContainerRef }); private componentRef?: any; private value: any; @@ -55,10 +55,10 @@ export class DynamicFieldHostComponent implements ControlValueAccessor { } private createChild() { - this.viewContainerRef.clear(); + this.viewContainerRef().clear(); if (!this.component()) return; - this.componentRef = this.viewContainerRef.createComponent(this.component()); + this.componentRef = this.viewContainerRef().createComponent(this.component()); this.applyInputs(); const instance: any = this.componentRef.instance as controlValueAccessorLike & acceptsFormControl; diff --git a/npm/ng-packs/packages/components/extensible/src/lib/components/date-time-picker/extensible-date-time-picker.component.ts b/npm/ng-packs/packages/components/extensible/src/lib/components/date-time-picker/extensible-date-time-picker.component.ts index 907118283e..05fad7a63d 100644 --- a/npm/ng-packs/packages/components/extensible/src/lib/components/date-time-picker/extensible-date-time-picker.component.ts +++ b/npm/ng-packs/packages/components/extensible/src/lib/components/date-time-picker/extensible-date-time-picker.component.ts @@ -6,7 +6,7 @@ import { input, Optional, SkipSelf, - ViewChild, + viewChild } from '@angular/core'; import { ControlContainer, ReactiveFormsModule } from '@angular/forms'; import { @@ -76,14 +76,14 @@ export class ExtensibleDateTimePickerComponent { meridian = input(false); placement = input('bottom-left'); - @ViewChild(NgbInputDatepicker) date!: NgbInputDatepicker; - @ViewChild(NgbTimepicker) time!: NgbTimepicker; + readonly date = viewChild.required(NgbInputDatepicker); + readonly time = viewChild.required(NgbTimepicker); setDate(dateStr: string) { - this.date.writeValue(dateStr); + this.date().writeValue(dateStr); } setTime(dateStr: string) { - this.time.writeValue(dateStr); + this.time().writeValue(dateStr); } } diff --git a/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form-prop.component.ts b/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form-prop.component.ts index 09a29a98a9..db7c0636b8 100644 --- a/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form-prop.component.ts +++ b/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form-prop.component.ts @@ -19,9 +19,7 @@ import { Optional, SimpleChanges, SkipSelf, - ViewChild, - signal, - effect, + viewChild, } from '@angular/core'; import { ControlContainer, @@ -72,8 +70,8 @@ import { ExtensibleFormMultiselectComponent } from '../multi-select/extensible-f AsyncPipe, NgComponentOutlet, NgTemplateOutlet, - FormsModule -], + FormsModule, + ], changeDetection: ChangeDetectionStrategy.OnPush, providers: [ExtensibleFormPropService], viewProviders: [ @@ -98,7 +96,7 @@ export class ExtensibleFormPropComponent implements OnChanges, AfterViewInit { @Input() prop!: FormProp; @Input() first?: boolean; @Input() isFirstGroup?: boolean; - @ViewChild('field') private fieldRef!: ElementRef; + private readonly fieldRef = viewChild.required>('field'); injectorForCustomComponent?: Injector; asterisk = ''; @@ -158,9 +156,9 @@ export class ExtensibleFormPropComponent implements OnChanges, AfterViewInit { } ngAfterViewInit() { - if (this.isFirstGroup && this.first && this.fieldRef) { + if (this.isFirstGroup && this.first && this.fieldRef()) { requestAnimationFrame(() => { - this.fieldRef.nativeElement.focus(); + this.fieldRef().nativeElement.focus(); }); } } diff --git a/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form.component.ts b/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form.component.ts index 8b5829754d..4da4a21468 100644 --- a/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form.component.ts +++ b/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-form/extensible-form.component.ts @@ -6,9 +6,8 @@ import { inject, Input, Optional, - QueryList, SkipSelf, - ViewChildren, + viewChildren } from '@angular/core'; import { ControlContainer, ReactiveFormsModule, UntypedFormGroup } from '@angular/forms'; import { EXTRA_PROPERTIES_KEY } from '../../constants/extra-properties'; @@ -41,8 +40,7 @@ export class ExtensibleFormComponent { private readonly extensions = inject(ExtensionsService); private readonly identifier = inject(EXTENSIONS_IDENTIFIER); - @ViewChildren(ExtensibleFormPropComponent) - formProps!: QueryList; + readonly formProps = viewChildren(ExtensibleFormPropComponent); @Input() set selectedRecord(record: R) { diff --git a/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.ts b/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.ts index 75253b1f02..8669982c9c 100644 --- a/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.ts +++ b/npm/ng-packs/packages/components/extensible/src/lib/components/extensible-table/extensible-table.component.ts @@ -4,7 +4,6 @@ import { ChangeDetectorRef, Component, computed, - ContentChild, EventEmitter, inject, Injector, @@ -18,7 +17,8 @@ import { SimpleChanges, TemplateRef, TrackByFunction, - ViewChild, + contentChild, + viewChild } from '@angular/core'; import { AsyncPipe, isPlatformBrowser, NgComponentOutlet, NgTemplateOutlet } from '@angular/common'; @@ -141,17 +141,16 @@ export class ExtensibleTableComponent implements OnChanges, AfterViewIn @Input() rowDetailHeight: string | number = '100%'; @Output() rowDetailToggle = new EventEmitter(); - @ContentChild(ExtensibleTableRowDetailComponent) - rowDetailComponent?: ExtensibleTableRowDetailComponent; + readonly rowDetailComponent = contentChild(ExtensibleTableRowDetailComponent); - @ViewChild('table', { static: false }) table!: DatatableComponent; + readonly table = viewChild.required('table'); protected get effectiveRowDetailTemplate(): TemplateRef> | undefined { - return this.rowDetailComponent?.template() ?? this.rowDetailTemplate; + return this.rowDetailComponent()?.template() ?? this.rowDetailTemplate; } protected get effectiveRowDetailHeight(): string | number { - return this.rowDetailComponent?.rowHeight() ?? this.rowDetailHeight; + return this.rowDetailComponent()?.rowHeight() ?? this.rowDetailHeight; } hasAtLeastOnePermittedAction: boolean; @@ -318,8 +317,9 @@ export class ExtensibleTableComponent implements OnChanges, AfterViewIn } toggleExpandRow(row: R): void { - if (this.table && this.table.rowDetail) { - this.table.rowDetail.toggleExpandRow(row); + const table = this.table(); + if (table && table.rowDetail) { + table.rowDetail.toggleExpandRow(row); } this.rowDetailToggle.emit(row); } diff --git a/npm/ng-packs/packages/components/page/src/page.component.html b/npm/ng-packs/packages/components/page/src/page.component.html index ee32f2e5e2..1f33a841f4 100644 --- a/npm/ng-packs/packages/components/page/src/page.component.html +++ b/npm/ng-packs/packages/components/page/src/page.component.html @@ -1,6 +1,6 @@ @if (shouldRenderRow) {
- @if (customTitle) { + @if (customTitle()) { } @else { @if (title) { @@ -12,7 +12,7 @@ } } - @if (customBreadcrumb) { + @if (customBreadcrumb()) { } @else { @if (breadcrumb) { @@ -22,7 +22,7 @@ } } - @if (customToolbar) { + @if (customToolbar()) { } @else { @if (toolbarVisible) { diff --git a/npm/ng-packs/packages/components/page/src/page.component.ts b/npm/ng-packs/packages/components/page/src/page.component.ts index bd5242651f..da81504e79 100644 --- a/npm/ng-packs/packages/components/page/src/page.component.ts +++ b/npm/ng-packs/packages/components/page/src/page.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, ViewEncapsulation, ContentChild } from '@angular/core'; +import { Component, Input, ViewEncapsulation, contentChild } from '@angular/core'; import { PageTitleContainerComponent, PageBreadcrumbContainerComponent, @@ -37,19 +37,18 @@ export class PageComponent { toolbar: PageParts.toolbar, }; - @ContentChild(PageTitleContainerComponent) customTitle?: PageTitleContainerComponent; - @ContentChild(PageBreadcrumbContainerComponent) - customBreadcrumb?: PageBreadcrumbContainerComponent; - @ContentChild(PageToolbarContainerComponent) customToolbar?: PageToolbarContainerComponent; + readonly customTitle = contentChild(PageTitleContainerComponent); + readonly customBreadcrumb = contentChild(PageBreadcrumbContainerComponent); + readonly customToolbar = contentChild(PageToolbarContainerComponent); get shouldRenderRow() { return !!( this.title || this.toolbarVisible || this.breadcrumb || - this.customTitle || - this.customBreadcrumb || - this.customToolbar || + this.customTitle() || + this.customBreadcrumb() || + this.customToolbar() || this.pageParts ); } diff --git a/npm/ng-packs/packages/components/tree/src/lib/components/tree.component.html b/npm/ng-packs/packages/components/tree/src/lib/components/tree.component.html index 8cd2058699..2dbe164d01 100644 --- a/npm/ng-packs/packages/components/tree/src/lib/components/tree.component.html +++ b/npm/ng-packs/packages/components/tree/src/lib/components/tree.component.html @@ -7,7 +7,7 @@ [nzData]="nodes" [nzTreeTemplate]="treeTemplate" [nzExpandedKeys]="expandedKeys" - [nzExpandedIcon]="expandedIconTemplate?.template || defaultIconTemplate" + [nzExpandedIcon]="expandedIconTemplate()?.template || defaultIconTemplate" (nzExpandChange)="onExpandedKeysChange($event)" (nzCheckboxChange)="onCheckboxChange($event)" (nzOnDrop)="onDrop($event)" @@ -26,13 +26,13 @@
- @if (menu) { + @if (menu()) { } diff --git a/npm/ng-packs/packages/components/tree/src/lib/components/tree.component.ts b/npm/ng-packs/packages/components/tree/src/lib/components/tree.component.ts index 5c320a539b..97e0f305d6 100644 --- a/npm/ng-packs/packages/components/tree/src/lib/components/tree.component.ts +++ b/npm/ng-packs/packages/components/tree/src/lib/components/tree.component.ts @@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, - ContentChild, + contentChild, EventEmitter, inject, Input, @@ -60,9 +60,9 @@ export class TreeComponent implements OnInit { dropdowns = {} as { [key: string]: NgbDropdown }; - @ContentChild('menu') menu: TemplateRef; - @ContentChild(TreeNodeTemplateDirective) customNodeTemplate: TreeNodeTemplateDirective; - @ContentChild(ExpandedIconTemplateDirective) expandedIconTemplate: ExpandedIconTemplateDirective; + readonly menu = contentChild>('menu'); + readonly customNodeTemplate = contentChild(TreeNodeTemplateDirective); + readonly expandedIconTemplate = contentChild(ExpandedIconTemplateDirective); @Output() readonly checkedKeysChange = new EventEmitter(); @Output() readonly expandedKeysChange = new EventEmitter(); @Output() readonly selectedNodeChange = new EventEmitter(); diff --git a/npm/ng-packs/packages/identity/src/lib/components/users/users.component.ts b/npm/ng-packs/packages/identity/src/lib/components/users/users.component.ts index 7ff04cf679..ada580059d 100644 --- a/npm/ng-packs/packages/identity/src/lib/components/users/users.component.ts +++ b/npm/ng-packs/packages/identity/src/lib/components/users/users.component.ts @@ -39,7 +39,7 @@ import { OnInit, TemplateRef, TrackByFunction, - ViewChild, + viewChild } from '@angular/core'; import { AbstractControl, @@ -99,8 +99,7 @@ export class UsersComponent implements OnInit { data: PagedResultDto = { items: [], totalCount: 0 }; - @ViewChild('modalContent', { static: false }) - modalContent!: TemplateRef; + readonly modalContent = viewChild.required>('modalContent'); form!: UntypedFormGroup; diff --git a/npm/ng-packs/packages/permission-management/src/lib/components/permission-management.component.ts b/npm/ng-packs/packages/permission-management/src/lib/components/permission-management.component.ts index b2612e9aaf..635f5ed86b 100644 --- a/npm/ng-packs/packages/permission-management/src/lib/components/permission-management.component.ts +++ b/npm/ng-packs/packages/permission-management/src/lib/components/permission-management.component.ts @@ -15,21 +15,22 @@ import { UpdatePermissionDto, } from '@abp/ng.permission-management/proxy'; import { + afterNextRender, Component, computed, DOCUMENT, ElementRef, EventEmitter, inject, + Injector, Input, Output, - QueryList, signal, TrackByFunction, - ViewChildren, + viewChildren } from '@angular/core'; -import { concat, of } from 'rxjs'; -import { finalize, switchMap, take, tap } from 'rxjs/operators'; +import { of } from 'rxjs'; +import { finalize, switchMap, tap } from 'rxjs/operators'; import { PermissionManagement } from '../models'; import { FormsModule } from '@angular/forms'; @@ -116,6 +117,7 @@ export class PermissionManagementComponent protected readonly service = inject(PermissionsService); protected readonly configState = inject(ConfigStateService); protected readonly toasterService = inject(ToasterService); + private readonly injector = inject(Injector); private document = inject(DOCUMENT); @Input() @@ -146,11 +148,9 @@ export class PermissionManagementComponent this.openModal().subscribe(() => { this._visible = true; this.visibleChange.emit(true); - concat(this.selectAllInAllTabsRef.changes, this.selectAllInThisTabsRef.changes) - .pipe(take(1)) - .subscribe(() => { - this.initModal(); - }); + afterNextRender(() => { + this.initModal(); + }, { injector: this.injector }); }); } else { this.setSelectedGroup(null); @@ -162,10 +162,8 @@ export class PermissionManagementComponent @Output() readonly visibleChange = new EventEmitter(); - @ViewChildren('selectAllInThisTabsRef') - selectAllInThisTabsRef!: QueryList>; - @ViewChildren('selectAllInAllTabsRef') - selectAllInAllTabsRef!: QueryList>; + selectAllInThisTabsRef = viewChildren>('selectAllInThisTabsRef'); + selectAllInAllTabsRef = viewChildren>('selectAllInAllTabsRef'); data: GetPermissionListResultDto = { groups: [], entityDisplayName: '' }; diff --git a/npm/ng-packs/packages/theme-basic/src/lib/components/routes/routes.component.ts b/npm/ng-packs/packages/theme-basic/src/lib/components/routes/routes.component.ts index a6afd37b12..40782d113c 100644 --- a/npm/ng-packs/packages/theme-basic/src/lib/components/routes/routes.component.ts +++ b/npm/ng-packs/packages/theme-basic/src/lib/components/routes/routes.component.ts @@ -11,10 +11,9 @@ import { ElementRef, inject, Input, - QueryList, Renderer2, TrackByFunction, - ViewChildren, + viewChildren } from '@angular/core'; import { NgTemplateOutlet, AsyncPipe } from '@angular/common'; import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'; @@ -41,7 +40,7 @@ export class RoutesComponent { @Input() smallScreen?: boolean; - @ViewChildren('childrenContainer') childrenContainers!: QueryList>; + readonly childrenContainers = viewChildren>('childrenContainer'); rootDropdownExpand = {} as { [key: string]: boolean }; @@ -52,7 +51,7 @@ export class RoutesComponent { } closeDropdown() { - this.childrenContainers.forEach(({ nativeElement }) => { + this.childrenContainers().forEach(({ nativeElement }) => { this.renderer.addClass(nativeElement, 'd-none'); setTimeout(() => this.renderer.removeClass(nativeElement, 'd-none'), 0); }); diff --git a/npm/ng-packs/packages/theme-shared/src/lib/components/button/button.component.ts b/npm/ng-packs/packages/theme-shared/src/lib/components/button/button.component.ts index 47272992fd..cd717d1a2d 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/components/button/button.component.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/components/button/button.component.ts @@ -7,8 +7,8 @@ import { OnInit, Output, Renderer2, - ViewChild, inject, + viewChild } from '@angular/core'; import { ABP, StopPropagationDirective } from '@abp/ng.core'; @@ -70,8 +70,7 @@ export class ButtonComponent implements OnInit { @Output() readonly abpBlur = new EventEmitter(); - @ViewChild('button', { static: true }) - buttonRef!: ElementRef; + readonly buttonRef = viewChild.required>('button'); get icon(): string { return `${this.loading ? 'fa fa-spinner fa-spin' : this.iconClass || 'd-none'}`; @@ -81,7 +80,7 @@ export class ButtonComponent implements OnInit { if (this.attributes) { Object.keys(this.attributes).forEach(key => { if (this.attributes?.[key]) { - this.renderer.setAttribute(this.buttonRef.nativeElement, key, this.attributes[key]); + this.renderer.setAttribute(this.buttonRef().nativeElement, key, this.attributes[key]); } }); } diff --git a/npm/ng-packs/packages/theme-shared/src/lib/components/http-error-wrapper/http-error-wrapper.component.ts b/npm/ng-packs/packages/theme-shared/src/lib/components/http-error-wrapper/http-error-wrapper.component.ts index 24e7fae1d6..c24728b886 100644 --- a/npm/ng-packs/packages/theme-shared/src/lib/components/http-error-wrapper/http-error-wrapper.component.ts +++ b/npm/ng-packs/packages/theme-shared/src/lib/components/http-error-wrapper/http-error-wrapper.component.ts @@ -6,12 +6,12 @@ import { ElementRef, EmbeddedViewRef, Type, - ViewChild, AfterViewInit, OnDestroy, createComponent, EnvironmentInjector, DestroyRef, + viewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { DOCUMENT } from '@angular/common'; @@ -53,8 +53,7 @@ export class HttpErrorWrapperComponent implements OnInit, AfterViewInit, OnDestr isHomeShow = true; - @ViewChild('container', { static: false }) - containerRef?: ElementRef; + readonly containerRef = viewChild>('container'); get statusText(): string { return this.status ? `[${this.status}]` : ''; @@ -86,8 +85,9 @@ export class HttpErrorWrapperComponent implements OnInit, AfterViewInit, OnDestr this.appRef.attachView(customComponentRef.hostView); - if (this.containerRef) { - this.containerRef.nativeElement.appendChild( + const containerRef = this.containerRef(); + if (containerRef) { + containerRef.nativeElement.appendChild( (customComponentRef.hostView as EmbeddedViewRef).rootNodes[0], ); }