diff --git a/docs/changelog/assets/css/app.css b/docs/changelog/assets/css/app.css
index 796ec2d6a..6f66a1f69 100644
--- a/docs/changelog/assets/css/app.css
+++ b/docs/changelog/assets/css/app.css
@@ -1,3 +1,32 @@
+/*
+ * CSS Variables for consistent theming
+ *
+ * Usage in CSS: background-color: var(--color-webex-blue);
+ * Usage in JS: element.style.backgroundColor = 'var(--color-success)';
+ *
+ * Available variables:
+ * --color-success: Success state (green)
+ * --color-success-hover: Success hover state
+ * --color-danger: Error/danger state (red)
+ * --color-danger-hover: Danger hover state
+ * --color-error-bg: Error message background (light red)
+ * --color-error-text: Error message text color
+ * --color-webex-blue: Primary brand color
+ * --color-webex-blue-hover: Primary brand hover color
+ * --color-background: Page background color (light gray)
+ */
+ :root {
+ --color-success: #28a745;
+ --color-success-hover: #218838;
+ --color-danger: #dc3545;
+ --color-danger-hover: #c82333;
+ --color-error-bg: #fee;
+ --color-error-text: #721c24;
+ --color-webex-blue: #00bceb;
+ --color-webex-blue-hover: #0099ba;
+ --color-background: #f5f5f5;
+}
+
* {
box-sizing: border-box;
}
@@ -15,7 +44,7 @@
body {
font-family: 'Roboto', sans-serif;
- background-color: #f5f5f5;
+ background-color: var(--color-background);
margin: 0;
display: flex;
flex-direction: column;
@@ -29,7 +58,7 @@ body {
#header {
position: sticky;
top: 0;
- background-color: #00bceb; /* Webex blue */
+ background-color: var(--color-webex-blue);
color: white;
padding: 20px;
margin: 0;
@@ -45,14 +74,14 @@ body {
}
#footer {
- background-color: #f5f5f5;
+ background-color: var(--color-background);
text-align: center;
padding: 10px;
box-shadow: 0 -2px 5px rgba(0, 0, 0, 0.2);
}
#footer a {
- color: #00bceb; /* Webex blue */
+ color: var(--color-webex-blue);
text-decoration: none;
font-weight: bold;
}
@@ -84,7 +113,7 @@ select {
}
button {
- background-color: #00bceb; /* Webex blue */
+ background-color: var(--color-webex-blue);
color: white;
padding: 10px 20px;
border: none;
@@ -95,7 +124,7 @@ button {
}
button:hover {
- background-color: #0099ba; /* Darker Webex blue */
+ background-color: var(--color-webex-blue-hover);
}
table {
@@ -113,7 +142,7 @@ th, td {
}
th {
- background-color: #f5f5f5;
+ background-color: var(--color-background);
}
.search-container {
@@ -264,7 +293,7 @@ footer .copyright {
.alert-info {
background-color: #f0f9ff;
- border-left: 6px solid #00bceb;
+ border-left: 6px solid var(--color-webex-blue);
color: #333;
padding: 10px;
margin-bottom: 20px;
@@ -272,11 +301,167 @@ footer .copyright {
.alert-info p.note {
font-weight: bold;
- color: #00bceb;
+ color: var(--color-webex-blue);
}
.alert-info p.warning {
font-weight: bold;
color: #ff7f0e;
display: inline;
+}
+
+/* ============================================
+ VERSION COMPARISON STYLES
+ ============================================ */
+
+/* Mode Toggle Buttons */
+.mode-toggle {
+ display: flex;
+ gap: 10px;
+ margin-bottom: 20px;
+}
+
+.mode-toggle button {
+ flex: 1;
+ padding: 12px 20px;
+ border: 2px solid var(--color-webex-blue);
+ background-color: white;
+ color: var(--color-webex-blue);
+ cursor: pointer;
+ transition: all 0.3s;
+ border-radius: 4px;
+ font-weight: 500;
+}
+
+.mode-toggle button:hover {
+ background-color: #e6f7fb;
+}
+
+.mode-toggle button.active {
+ background-color: var(--color-webex-blue);
+ color: white;
+}
+
+/* Hide utility class */
+.hide {
+ display: none !important;
+}
+
+/* Comparison Form */
+#comparison-form {
+ margin-top: 20px;
+}
+
+/* Comparison Results Table */
+#comparison-results .table-wrapper {
+ max-height: 500px;
+ overflow-y: auto;
+ overflow-x: auto;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ margin-top: 10px;
+ position: relative;
+}
+
+.comparison-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: 0;
+ font-size: 14px;
+}
+
+.comparison-table th,
+.comparison-table td {
+ padding: 12px;
+ text-align: left;
+ border: 1px solid #ddd;
+}
+
+.comparison-table th {
+ background-color: var(--color-webex-blue);
+ color: white;
+ font-weight: bold;
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+
+.comparison-table tbody tr {
+ transition: background-color 0.2s;
+}
+
+.comparison-table tbody tr:hover {
+ background-color: var(--color-background) !important;
+}
+
+/* Color Coding for Changes */
+.comparison-table tr.version-changed {
+ background-color: #fff3cd; /* Yellow - version changed */
+ border-left: 4px solid #ffc107;
+}
+
+.comparison-table tr.only-in-a {
+ background-color: #f8d7da; /* Red - removed in B */
+ border-left: 4px solid #dc3545;
+}
+
+.comparison-table tr.only-in-b {
+ background-color: #d4edda; /* Green - added in B */
+ border-left: 4px solid #28a745;
+}
+
+.comparison-table tr.unchanged {
+ background-color: #ffffff; /* White - no change */
+}
+
+/* Comparison Summary */
+.comparison-summary {
+ background: linear-gradient(135deg, #e7f3ff 0%, #f0f9ff 100%);
+ padding: 25px;
+ border-radius: 8px;
+ margin-bottom: 20px;
+ border-left: 5px solid var(--color-webex-blue);
+}
+
+.comparison-summary h3 {
+ margin-top: 0;
+ margin-bottom: 15px;
+ color: var(--color-webex-blue);
+ font-size: 1.5em;
+}
+
+.summary-stats {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 15px;
+ margin-top: 15px;
+}
+
+.stat-item {
+ padding: 10px 20px;
+ background-color: white;
+ border-radius: 5px;
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ font-size: 14px;
+}
+
+.stat-item.changed {
+ border-left: 4px solid #ffc107;
+}
+
+.stat-item.unchanged {
+ border-left: 4px solid #6c757d;
+}
+
+.stat-item.added {
+ border-left: 4px solid #28a745;
+}
+
+.stat-item.removed {
+ border-left: 4px solid #dc3545;
+}
+
+.stat-item strong {
+ font-size: 1.2em;
+ color: #333;
}
\ No newline at end of file
diff --git a/docs/changelog/assets/js/app.js b/docs/changelog/assets/js/app.js
index 184b291e1..40656c077 100644
--- a/docs/changelog/assets/js/app.js
+++ b/docs/changelog/assets/js/app.js
@@ -1,7 +1,7 @@
// Global variable to store the current changelog and version paths
let currentChangelog;
const versionPaths = {};
-const github_base_url = 'https://github.com/webex/widgets/';
+const github_base_url = "https://github.com/webex/widgets/";
// DOM elements
const versionSelectDropdown = document.getElementById('version-select');
@@ -17,443 +17,1946 @@ const commitHashGroup = document.getElementById('commit-hash-group');
const searchForm = document.getElementById('search-form');
const searchButton = document.getElementById('search-button');
const searchResults = document.getElementById('search-results');
+
+// DOM elements - Comparison Mode
+const comparisonResults = document.getElementById('comparison-results');
+const comparisonTemplateElement = document.getElementById('comparison-template');
+const comparisonForm = document.getElementById('comparison-form');
+const singleViewBtn = document.getElementById('single-view-btn');
+const comparisonViewBtn = document.getElementById('comparison-view-btn');
+const versionASelect = document.getElementById('version-a-select');
+const versionBSelect = document.getElementById('version-b-select');
+const comparisonPackageSelect = document.getElementById('comparison-package-select');
+const comparisonPackageRow = document.getElementById('comparison-package-row');
+const versionAPrereleaseSelect = document.getElementById('version-a-prerelease-select');
+const versionBPrereleaseSelect = document.getElementById('version-b-prerelease-select');
+const prereleaseRow = document.getElementById('comparison-prerelease-row');
+const compareButton = document.getElementById('compare-button');
+const clearComparisonButton = document.getElementById('clear-comparison-button');
+const copyComparisonLinkBtn = document.getElementById('copy-comparison-link');
+const comparisonHelper = document.getElementById('comparison-helper');
+
+// DOM elements - Shared
+const helperSection = document.getElementById('helper-section');
+const packageLevelSection = document.getElementById('package-level-comparison-section');
+
+// Initialize UI state
searchResults.classList.add('hide');
+// Ensure helper section is visible on page load (single search mode default)
+if (helperSection) helperSection.classList.remove('hide');
// Templates and Helpers - Handlebar
const changelogItemTemplate = document.getElementById('changelog-item-template');
var changelogUI = Handlebars.compile(changelogItemTemplate.innerHTML);
-Handlebars.registerHelper('forIn', function (object) {
- let returnArray = [];
- for (let prop in object) {
- returnArray.push({key: prop, value: object[prop]});
- }
- return returnArray;
+Handlebars.registerHelper("forIn", function(object) {
+ let returnArray = [];
+ for(let prop in object){
+ returnArray.push({key: prop, value: object[prop]});
+ }
+ return returnArray;
});
-Handlebars.registerHelper('json', function (context, package, version) {
- const copyElem = {
- ...context,
- [package]: version,
- };
- return JSON.stringify(copyElem);
+Handlebars.registerHelper('json', function(context, package, version) {
+ const copyElem = {
+ ...context,
+ [package]: version
+ }
+ return JSON.stringify(copyElem);
});
-Handlebars.registerHelper('github_linking', function (string, type) {
- switch (type) {
- case 'hash':
- return `${string}`;
- case 'message':
- // if commit message has a pr number, replace that pr number with pr anchor link and send back the transformed commit message
- return string.replace(/#(\d+)/g, `#$1`);
- }
+Handlebars.registerHelper('github_linking', function(string, type) {
+ switch(type){
+ case 'hash':
+ return `${string}`;
+ case 'message':
+ // if commit message has a pr number, replace that pr number with pr anchor link and send back the transformed commit message
+ return string.replace(/#(\d+)/g, `#$1`);
+ }
});
-Handlebars.registerHelper('convertDate', function (timestamp) {
- return `${new Date(timestamp).toDateString()} ${new Date(timestamp).toTimeString()}`;
+Handlebars.registerHelper('convertDate', function(timestamp) {
+ return `${new Date(timestamp).toDateString()} ${new Date(timestamp).toTimeString()}`;
});
// Util Methods
const populateFormFieldsFromURL = async () => {
- const queryParams = new URLSearchParams(window.location.search);
- const searchParams = {
- stable_version: queryParams.get('stable_version'),
- package: queryParams.get('package'),
- version: queryParams.get('version'),
- commitMessage: queryParams.get('commitMessage'),
- commitHash: queryParams.get('commitHash'),
- };
-
- let hasAtleastOneParam = false;
-
- if (searchParams.stable_version) {
- versionSelectDropdown.value = searchParams.stable_version;
- await doStableVersionChange({
- stable_version: searchParams.stable_version,
- });
- }
+ const queryParams = new URLSearchParams(window.location.search);
+
+ // Skip single-view URL handling if comparison parameters are present
+ if (queryParams.has('compare') || queryParams.has('compareStableA') || (queryParams.has('versionA') && queryParams.has('versionB'))) {
+ return; // Comparison mode will handle these parameters
+ }
+
+ const searchParams = {
+ stable_version: queryParams.get('stable_version'),
+ package: queryParams.get('package'),
+ version: queryParams.get('version'),
+ commitMessage: queryParams.get('commitMessage'),
+ commitHash: queryParams.get('commitHash')
+ };
- if (searchParams.package) {
- if (!packageNameInputDropdown.disabled) {
- packageNameInputDropdown.value = searchParams.package;
- packageNameInputDropdown.dispatchEvent(new Event('change'));
+ let hasAtleastOneParam = false;
+
+ if (searchParams.stable_version) {
+ versionSelectDropdown.value = searchParams.stable_version;
+ await doStableVersionChange({
+ stable_version: searchParams.stable_version
+ });
+ }
+
+ if (searchParams.package) {
+ if (!packageNameInputDropdown.disabled) {
+ packageNameInputDropdown.value = searchParams.package;
+ packageNameInputDropdown.dispatchEvent(new Event('change'));
+ hasAtleastOneParam = true;
+ }
+ }
+
+ if (searchParams.version) {
+ versionInput.value = searchParams.version;
+ hasAtleastOneParam = true;
+ validateVersionInput({version: searchParams.version});
+ }
+
+ if (searchParams.commitMessage) {
+ commitMessageInput.value = searchParams.commitMessage;
+ hasAtleastOneParam = true;
+ }
+
+ if (searchParams.commitHash) {
+ commitHashInput.value = searchParams.commitHash;
hasAtleastOneParam = true;
}
- }
- if (searchParams.version) {
- versionInput.value = searchParams.version;
- hasAtleastOneParam = true;
- validateVersionInput({version: searchParams.version});
- }
+ updateFormState(searchParams);
- if (searchParams.commitMessage) {
- commitMessageInput.value = searchParams.commitMessage;
- hasAtleastOneParam = true;
- }
+ if(hasAtleastOneParam){
+ doSearch(searchParams);
+ }
+};
+
+const populateVersions = async () => {
+ try {
+ const response = await fetch('logs/main.json');
+ const data = await response.json();
+ let optionsHtml = ''; // Placeholder option
- if (searchParams.commitHash) {
- commitHashInput.value = searchParams.commitHash;
- hasAtleastOneParam = true;
- }
+ Object.entries(data).forEach(([version, path]) => {
+ versionPaths[version] = path;
+ optionsHtml += ``;
+ });
- updateFormState(searchParams);
+ versionSelectDropdown.innerHTML = optionsHtml; // Set all options at once
- if (hasAtleastOneParam) {
- doSearch(searchParams);
- }
+ // Call populateFormFieldsFromURL on page load to populate fields based on URL parameters
+ populateFormFieldsFromURL();
+ } catch (error) {
+ console.error('Error fetching version data:', error);
+ }
+};
+const fetchChangelog = async (versionPath) => {
+ try {
+ const response = await fetch(versionPath);
+ currentChangelog = await response.json();
+ } catch (error) {
+ console.error('Error fetching changelog:', error);
+ }
};
-const populateVersions = async () => {
- try {
- const response = await fetch('logs/main.json');
- const data = await response.json();
- let optionsHtml = ''; // Placeholder option
-
- Object.entries(data).forEach(([version, path]) => {
- versionPaths[version] = path;
- optionsHtml += ``;
+const populatePackageNames = (changelog) => {
+ let specialPackages = ['@webex/widgets', '@webex/cc-widgets'];
+
+ // Get all packages that actually exist in this version's changelog
+ let allPackages = Object.keys(changelog);
+
+ // Filter special packages that ACTUALLY EXIST in this version
+ let existingSpecialPackages = specialPackages.filter(pkg => allPackages.includes(pkg));
+
+ // Get remaining packages (excluding special ones)
+ let otherPackages = allPackages.filter(pkg => !specialPackages.includes(pkg));
+
+ // Sort the remaining packages alphabetically
+ otherPackages.sort();
+
+ // Build the sorted list - only add separator if special packages exist
+ let sortedPackages;
+ if (existingSpecialPackages.length > 0) {
+ sortedPackages = ['separator', ...existingSpecialPackages, 'separator', ...otherPackages];
+ } else {
+ // No special packages exist, just show others
+ sortedPackages = otherPackages;
+ }
+
+ let optionsHtml = '';
+
+ sortedPackages.forEach((packageName) => {
+ if (packageName === 'separator') {
+ optionsHtml += ``;
+ return;
+ }
+ optionsHtml += ``;
});
+
+ // Set default intelligently:
+ // 1. First existing special package
+ // 2. Or first available package
+ // 3. Or empty if no packages
+ if (existingSpecialPackages.length > 0) {
+ packageNameInputDropdown.value = existingSpecialPackages[0];
+ } else if (otherPackages.length > 0) {
+ packageNameInputDropdown.value = otherPackages[0];
+ } else {
+ packageNameInputDropdown.value = "";
+ }
+
+ packageNameInputDropdown.innerHTML = optionsHtml;
+};
- versionSelectDropdown.innerHTML = optionsHtml; // Set all options at once
+const doStableVersionChange = async ({stable_version}) => {
+ if (stable_version && versionPaths[stable_version]) {
+ // Enable the package-name-input dropdown
+ packageNameInputDropdown.disabled = false;
+ // Fetch the changelog and populate package names
+ await fetchChangelog(versionPaths[stable_version]);
+ populatePackageNames(currentChangelog);
+
+ updateFormState();
+ if(versionInput.value.trim() !== ''){
+ validateVersionInput({version: versionInput.value});
+ }
+ } else {
+ // Disable all other form elements if no version is selected
+ updateFormState();
+ }
+};
+
+// Search Form Utils
+const validateVersionInput = ({version}) => {
+ const stableVersion = versionSelectDropdown.value;
+ const expectedPattern = new RegExp(`^${stableVersion}-([a-z\-]*\\.)?\\d+$`, 'i');
+
+ if (version !== "" && !expectedPattern.test(version) && stableVersion !== version) {
+ versionInputError.innerText = `Version can be empty or should start with ${stableVersion} and match ${stableVersion}-{tag}.patch_version. Eg: ${stableVersion}-next.1`;
+ versionInput.focus();
+ searchButton.disabled = true;
+ }
+ else{
+ versionInputError.innerText = ``;
+ searchButton.disabled = false;
+ }
+}
+
+const updateFormState = (formParams) => {
+ // If the stable version is empty, show no more fields and disable the search button
+ // If the package name is empty, hide version input and show commit options
+ // If the package name is not empty, show all options
+ // If one of the commit search options is not empty, hide version input and show commit search options
+ // If the version field is not empty, hide the commit search options
+ if(formParams === undefined){
+ formParams = {
+ stable_version: versionSelectDropdown.value,
+ package: packageNameInputDropdown.value,
+ version: versionInput.value,
+ commitMessage: commitMessageInput.value,
+ commitHash: commitHashInput.value
+ };
+ }
+
+ const disable = {
+ package: false,
+ version: false,
+ commitMessage: false,
+ commitHash: false,
+ searchButton: true
+ };
+
+ if(formParams.stable_version === null || formParams.stable_version.trim() === ''){
+ disable.package = true;
+ disable.version = true;
+ disable.commitMessage = true;
+ disable.commitHash = true;
+ disable.searchButton = true;
+ }
+ else{
+ disable.package = false;
+ disable.commitMessage = false;
+ disable.commitHash = false;
+ }
+ //If the package name is empty, disable the version input
+ if(formParams.package === null || formParams.package.trim() === ''){
+ disable.version = true;
+ }
+ else{
+ disable.searchButton = false;
+ }
+// If version filled → disable commit fields
+// If commit fields filled → disable version input
+ if(formParams.version && formParams.version.trim() !== ''){
+ disable.version = false;
+ disable.commitMessage = true;
+ disable.commitHash = true;
+ disable.searchButton = false;
+ }
+ else if((formParams.commitMessage && formParams.commitMessage.trim() !== '') || (formParams.commitHash && formParams.commitHash.trim() !== '')){
+ disable.version = true;
+ disable.searchButton = false;
+ }
- // Call populateFormFieldsFromURL on page load to populate fields based on URL parameters
- populateFormFieldsFromURL();
- } catch (error) {
- console.error('Error fetching version data:', error);
- }
+ for(let key in disable){
+ switch(key){
+ case 'package':
+ if(disable[key]){
+ packageNameInputDropdown.disabled = true;
+ packageNameInputDropdown.value = "";
+ packageInputGroup.classList.add('hide');
+ formParams.package = null;
+ }
+ else{
+ packageNameInputDropdown.disabled = false;
+ packageInputGroup.classList.remove('hide');
+ }
+ break;
+ case 'version':
+ if(disable[key]){
+ versionInput.disabled = true;
+ versionInput.value = "";
+ versionInputGroup.classList.add('hide');
+ formParams.version = null;
+ }
+ else{
+ versionInput.disabled = false;
+ versionInputGroup.classList.remove('hide');
+ }
+ break;
+ case 'commitMessage':
+ if(disable[key]){
+ commitMessageInput.disabled = true;
+ commitMessageInput.value = "";
+ commitMessageGroup.classList.add('hide');
+ formParams.commitMessage = null;
+ }
+ else{
+ commitMessageInput.disabled = false;
+ commitMessageGroup.classList.remove('hide');
+ }
+ break;
+ case 'commitHash':
+ if(disable[key]){
+ commitHashInput.disabled = true;
+ commitHashInput.value = "";
+ commitHashGroup.classList.add('hide');
+ formParams.commitHash = null;
+ }
+ else{
+ commitHashInput.disabled = false;
+ commitHashGroup.classList.remove('hide');
+ }
+ break;
+ case 'searchButton':
+ searchButton.disabled = disable[key];
+ break;
+ }
+ }
};
+// Search changelog by commit message or hash.(A single commit can appear in multiple package versions.)
+const doSearch_commit = (searchParams, drill_down) => {
+ let resulting_versions = new Set(),
+ resulting_commit_messages = new Set(),
+ resulting_commit_hash = new Set(),
+ search_results = [];
+ for(let package in drill_down){
+ const thisPackage = drill_down[package];
+ for(let version in thisPackage){
+ const thisVersion = thisPackage[version];
+ let allHashes = new Set(), discontinueSearch = false;
+ for(let hash in thisVersion.commits){
+ const thisCommit = thisVersion.commits[hash];
+ if(discontinueSearch){
+ resulting_versions.add(`${package}-${version}`);
+ resulting_commit_messages.add(thisCommit);
+ allHashes.forEach(h => resulting_commit_hash.add(h));
+ }
+ else{
+ allHashes.add(hash);
+ if(!resulting_versions.has(`${package}-${version}`) &&
+ !resulting_commit_messages.has(thisCommit) &&
+ !resulting_commit_hash.has(hash)
+ ){
+ if(
+ (
+ searchParams.commitMessage && searchParams.commitMessage.trim() !== "" &&
+ thisCommit.includes(searchParams.commitMessage.trim())
+ ) ||
+ (
+ searchParams.commitHash && (hash.includes(searchParams.commitHash) || searchParams.commitHash.startsWith(hash))
+ )
+ ){
+ resulting_versions.add(`${package}-${version}`);
+ resulting_commit_messages.add(thisCommit);
+ allHashes.forEach(h => resulting_commit_hash.add(h));
+ allHashes = new Set();
+ discontinueSearch = true;
+ search_results.push({
+ package,
+ version,
+ published_date: thisVersion.published_date,
+ commits: thisVersion.commits,
+ alongWith: thisVersion.alongWith,
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+ return search_results;
+}
-const fetchChangelog = async (versionPath) => {
- try {
- const response = await fetch(versionPath);
- currentChangelog = await response.json();
- } catch (error) {
- console.error('Error fetching changelog:', error);
- }
+const doSearch = (searchParams) => {
+ const { package, version } = searchParams;
+ let drill_down = {...currentChangelog}, shouldTransform = true, search_results = [];
+// If package selected → filter to that package
+ if(package !== null && package?.trim() !== ""){
+ drill_down = {
+ [package]: drill_down[package]
+ };
+ }
+// If version selected → filter to that version
+ if(version !== null && version?.trim() !== ""){
+ drill_down = drill_down[package][version] ? {
+ [package]: {
+ [version]: drill_down[package][version]
+ }
+ } : {};
+ }
+ else if(// If searching by commit → call doSearch_commit()
+ searchParams.commitMessage !== null && searchParams.commitMessage?.trim() !== "" ||
+ searchParams.commitHash !== null && searchParams.commitHash?.trim() !== ""
+ ){
+ search_results = doSearch_commit(searchParams, drill_down);
+ shouldTransform = false;
+ }
+
+ if(shouldTransform){
+ Object.keys(drill_down).forEach((package) => {
+ Object.keys(drill_down[package]).forEach((version) => {
+ search_results.push({
+ package,
+ version,
+ published_date: drill_down[package][version].published_date,
+ commits: drill_down[package][version].commits,
+ alongWith: drill_down[package][version].alongWith,
+ });
+ });
+ });
+ }
+
+ // sort search results based on published date which will be in Unit timestamp
+ search_results.sort((a, b) => b.published_date - a.published_date);
+
+ const searchResultsHtml = changelogUI({data: {
+ search_results,
+ stable_version: searchParams.stable_version,
+ }});
+
+ searchResults.innerHTML = searchResultsHtml;
+ searchResults.classList.remove('hide');
};
-const populatePackageNames = (changelog) => {
- let specialPackages = ['webex', '@webex/calling'];
- let filteredPackages = Object.keys(changelog).filter((pkg) => !specialPackages.includes(pkg));
+// Event listeners
+versionSelectDropdown.addEventListener('change', (event) => doStableVersionChange({stable_version: event.target.value}));
- // Sort the remaining packages alphabetically
- filteredPackages.sort();
+[
+ versionInput,
+ commitHashInput,
+ commitMessageInput
+].forEach((element) => {
+ element.addEventListener('keyup', () => updateFormState());
+});
- // Add 'webex' and '@webex/calling' back to the beginning of the array
- // let sortedPackages = ['separator', ...specialPackages, 'separator', ...filteredPackages];
- let optionsHtml = ''; // Placeholder option
+packageNameInputDropdown.addEventListener('change', () => updateFormState());
+
+versionInput.addEventListener('keyup', (event) => validateVersionInput({version: event.target.value}));
+
+searchForm.addEventListener('submit', (event) => {
+ // Prevent the default form submission
+ event.preventDefault();
- // sortedPackages.forEach((packageName) => {
- filteredPackages.forEach((packageName) => {
- if (packageName === 'separator') {
- optionsHtml += ``;
- return;
+ // Construct the query string only with non-empty values
+ const queryParams = new URLSearchParams();
+ if (versionSelectDropdown.value) {
+ queryParams.set('stable_version', versionSelectDropdown.value);
+ }
+ if (packageNameInputDropdown.value) {
+ queryParams.set('package', packageNameInputDropdown.value);
+ }
+ if (versionInput.value) {
+ queryParams.set('version', versionInput.value);
+ }
+ if (commitMessageInput.value) {
+ queryParams.set('commitMessage', commitMessageInput.value);
}
- optionsHtml += ``;
- });
+ if (commitHashInput.value) {
+ queryParams.set('commitHash', commitHashInput.value);
+ }
+
+ // Redirect to the same page with the query string
+ window.history.pushState({}, 'Cisco Webex Widgets', `${window.location.pathname}?${queryParams.toString()}`);
+ populateVersions();
+});
+
+const copyToClipboard = (copyButton) => {
+ navigator.clipboard.writeText(JSON.stringify(JSON.parse(copyButton.dataset.alongWith), null, 4));
+ const copyText = copyButton.querySelector('span');
+ copyText.textContent = 'Copied!';
+ setTimeout(() => {
+ copyText.textContent = 'Copy';
+ },2000);
+}
+
+/**
+ * Copy comparison link to clipboard
+ * Global function that can be called from HTML or JS
+ */
+const copyComparisonLink = () => {
+ const currentURL = window.location.href;
+
+ // Try modern clipboard API first
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(currentURL)
+ .then(() => {
+ showCopySuccess(copyComparisonLinkBtn);
+ })
+ .catch(err => {
+ console.error('Clipboard API failed:', err);
+ fallbackCopyToClipboard(currentURL, copyComparisonLinkBtn);
+ });
+ } else {
+ fallbackCopyToClipboard(currentURL, copyComparisonLinkBtn);
+ }
+}
+
+/**
+ * Show success feedback on copy button
+ */
+const showCopySuccess = (button) => {
+ if (!button) return;
+
+ const originalText = button.innerHTML;
+ button.innerHTML = '✓ Link Copied!';
+ button.style.backgroundColor = 'var(--color-success)';
+ button.style.borderColor = 'var(--color-success)';
+
+ setTimeout(() => {
+ button.innerHTML = originalText;
+ button.style.backgroundColor = '';
+ button.style.borderColor = '';
+ }, 2000);
+}
- // packageNameInputDropdown.value = 'webex';
- packageNameInputDropdown.innerHTML = optionsHtml; // Set all options at once
+/**
+ * Fallback copy method for browsers without Clipboard API (Older browsers don't support navigator.clipboard)
+ */
+const fallbackCopyToClipboard = (text, button) => {
+ // Create temporary input element
+ const tempInput = document.createElement('input');
+ tempInput.style.position = 'fixed';
+ tempInput.style.opacity = '0';
+ tempInput.value = text;
+ document.body.appendChild(tempInput);
+
+ // Select and copy
+ tempInput.select();
+ tempInput.setSelectionRange(0, 99999); // For mobile devices
+
+ try {
+ const successful = document.execCommand('copy');
+ if (successful) {
+ showCopySuccess(button);
+ } else {
+ console.error('execCommand copy failed');
+ showCopyError(button);
+ }
+ } catch (err) {
+ console.error('Fallback copy failed:', err);
+ showCopyError(button);
+ }
+
+ // Remove temporary input
+ document.body.removeChild(tempInput);
+}
+
+/**
+ * Show error feedback
+ */
+const showCopyError = (button) => {
+ if (!button) {
+ alert('Could not copy link. Please copy manually from the address bar.');
+ return;
+ }
+
+ const originalText = button.innerHTML;
+ button.innerHTML = '❌ Copy Failed';
+ button.style.backgroundColor = 'var(--color-danger)';
+ button.style.borderColor = 'var(--color-danger)';
+
+ setTimeout(() => {
+ button.innerHTML = originalText;
+ button.style.backgroundColor = '';
+ button.style.borderColor = '';
+ }, 2000);
+
+ // Also show alert with instructions
+ setTimeout(() => {
+ alert('Could not copy link automatically.\n\nPlease copy manually from the address bar:\n' + window.location.href);
+ }, 100);
+}
+
+window.onhashchange = () => {
+ populateVersions();
};
-const doStableVersionChange = async ({stable_version}) => {
- if (stable_version && versionPaths[stable_version]) {
- // Enable the package-name-input dropdown
- packageNameInputDropdown.disabled = false;
- // Fetch the changelog and populate package names
- await fetchChangelog(versionPaths[stable_version]);
- populatePackageNames(currentChangelog);
- updateFormState();
- if (versionInput.value.trim() !== '') {
- validateVersionInput({version: versionInput.value});
- }
- } else {
- // Disable all other form elements if no version is selected
- updateFormState();
- }
+populateVersions();
+
+/**
+ * Populate package dropdown for comparison
+ * @param {string} selectId - ID of the select element
+ */
+/* ============================================
+ VERSION COMPARISON FUNCTIONALITY
+ ============================================ */
+
+// Global state for comparison mode
+let comparisonMode = false;
+
+/**
+ * Extract all packages from a version changelog
+ * @param {Object} changelog - The changelog JSON for a version
+ * @param {Object} specificVersions - Optional map of {packageName: specificVersion}
+ * @returns {Object} - Map of {packageName: version}
+ */
+const extractPackagesFromVersion = (changelog, specificVersions = null) => {
+ const packageMap = {};
+
+ for (const packageName of Object.keys(changelog)) {
+
+ const packageVersions = changelog[packageName];
+ console.log('packageVersions', packageVersions);
+
+ // Safety check: ensure packageVersions is an object
+ if (!packageVersions || typeof packageVersions !== 'object') continue;
+
+ const versionKeys = Object.keys(packageVersions);
+ console.log('versionKeys', versionKeys);
+
+ if (versionKeys.length === 0) continue;
+
+ let selectedVersion = null;
+
+ // Check if user specified a specific version for this package
+ if (specificVersions && specificVersions[packageName]) {
+ const requestedVersion = specificVersions[packageName];
+ if (packageVersions[requestedVersion]) {
+ selectedVersion = requestedVersion;
+ }
+ }
+
+ // If no specific version requested or not found, use earliest (first) version
+ if (!selectedVersion) {
+ let earliestVersion = versionKeys[0];
+ let earliestDate = packageVersions[earliestVersion]?.published_date || Infinity;
+
+ for (const version of versionKeys) {
+ const publishedDate = packageVersions[version]?.published_date || Infinity;
+ if (publishedDate < earliestDate) {
+ earliestDate = publishedDate;
+ earliestVersion = version;
+ }
+ }
+
+ selectedVersion = earliestVersion;
+ }
+
+ packageMap[packageName] = selectedVersion;
+ }
+
+ return packageMap;
};
-// Search Form Utils
-const validateVersionInput = ({version}) => {
- const stableVersion = versionSelectDropdown.value;
- const expectedPattern = new RegExp(`^${stableVersion}-([a-z\-]*\\.)?\\d+$`, 'i');
+/**
+ * Compare packages between two versions
+ * @param {Object} packagesA - {packageName: version} for version A
+ * @param {Object} packagesB - {packageName: version} for version B
+ * @param {Object} changelogA - Full changelog data for version A
+ * @param {Object} changelogB - Full changelog data for version B
+ * @returns {Object} - Comparison results with statistics
+ */
+const comparePackages = (packagesA, packagesB, changelogA, changelogB, stableVersionA, stableVersionB) => {
+ // Get ALL package names from both changelogs (entire changelog, not just specific versions)
+ const allPackageNames = new Set([
+ ...Object.keys(changelogA),//ALL packages in changelog A
+ ...Object.keys(changelogB)//ALL packages in changelog B
+ ]);
+
+ const packages = [];
+ let changedCount = 0;
+ let unchangedCount = 0;
+ let onlyInACount = 0;
+ let onlyInBCount = 0;
+
+ // Helper function to find earliest version by published date (for full version comparison)
+ const findEarliestVersion = (changelog, packageName) => {
+ if (!changelog[packageName]) return null;
+
+ const versions = Object.keys(changelog[packageName]);
+ if (versions.length === 0) return null;
+
+ // Find version with earliest published_date
+ let earliestVersion = versions[0];
+ let earliestDate = changelog[packageName][versions[0]]?.published_date || Infinity;
+
+ for (const version of versions) {
+ const publishedDate = changelog[packageName][version]?.published_date || Infinity;
+ if (publishedDate < earliestDate) {
+ earliestDate = publishedDate;
+ earliestVersion = version;
+ }
+ }
+
+ // Debug logging
+ console.log(`[${packageName}] Found earliest version: ${earliestVersion} (date: ${new Date(earliestDate).toISOString()})`);
+ console.log(`[${packageName}] Total versions available: ${versions.length}`);
+
+ return earliestVersion;
+ };
+
+ // Helper function to find stable version first, then highest pre-release version
+const findStableVersion = (changelog, packageName, stableVersion) => {
+ if (!changelog[packageName]) return null;
+
+ const versions = Object.keys(changelog[packageName]);
+ if (versions.length === 0) return null;
+
+ // Escape dots in version string for regex (3.4.0 -> 3\.4\.0)
+ const escapedVersion = stableVersion.replace(/\./g, '\\.');
+
+ // Priority 1: Find exact stable version (e.g., "3.4.0" only, no suffixes)
+ const exactStablePattern = new RegExp(`^${escapedVersion}$`);
+ const exactStableVersion = versions.find(ver => exactStablePattern.test(ver));
+
+ if (exactStableVersion) {
+ return exactStableVersion;
+ }
+
+ // Priority 2: Find highest pre-release version (any tag: next, alpha, beta, rc, etc.)
+ // Pattern: 3.4.0-{tag}.{number} -> captures tag and number
+ const prereleasePattern = new RegExp(`^${escapedVersion}-([a-z]+)\\.(\\d+)$`, 'i');
+
+ const prereleaseVersions = versions
+ .filter(ver => prereleasePattern.test(ver))
+ .sort((a, b) => {
+ const matchA = a.match(prereleasePattern);
+ const matchB = b.match(prereleasePattern);
+ if (!matchA || !matchB) return 0;
+
+ const numA = parseInt(matchA[2], 10);
+ const numB = parseInt(matchB[2], 10);
+ return numB - numA; // Sort descending (highest first)
+ });
+
+ // Return highest pre-release version, or fallback to first available
+ return prereleaseVersions[0] || versions[0];
+};
+
+ allPackageNames.forEach(packageName => {
+ // Find the earliest version by published_date for full version comparison
+ const versionA = findEarliestVersion(changelogA, packageName);
+ const versionB = findEarliestVersion(changelogB, packageName);
+
+ let status, changeClass;//Declare variables for status label and CSS class
+
+ if (versionA && versionB) {//checks if package is in both changelogs
+ if (versionA === versionB) {//if versionA is the same as versionB, then it is unchanged
+ status = 'Unchanged';
+ changeClass = 'unchanged';
+ unchangedCount++;
+ } else {
+ status = 'Version Changed';
+ changeClass = 'version-changed';
+ changedCount++;
+ }
+ } else if (versionA && !versionB) {
+ status = 'Removed';
+ changeClass = 'only-in-a';
+ onlyInACount++;
+ } else if (!versionA && versionB) {
+ status = 'Added';
+ changeClass = 'only-in-b';
+ onlyInBCount++;
+ }
+
+ packages.push({
+ packageName,
+ versionA: versionA || 'N/A',
+ versionB: versionB || 'N/A',
+ status,
+ changeClass
+ });
+ });
+
+ // Sort packages alphabetically
+ packages.sort((a, b) => a.packageName.localeCompare(b.packageName));
+
+ return {
+ packages,
+ totalPackages: allPackageNames.size,
+ changedCount,
+ unchangedCount,
+ onlyInACount,
+ onlyInBCount
+ };
+};
- if (version !== '' && !expectedPattern.test(version) && stableVersion !== version) {
- versionInputError.innerText = `Version can be empty or should start with ${stableVersion} and match ${stableVersion}-{tag}.patch_version. Eg: ${stableVersion}-next.1`;
- versionInput.focus();
- searchButton.disabled = true;
- } else {
- versionInputError.innerText = ``;
- searchButton.disabled = false;
- }
+/*
+ Populate package dropdowns for comparison mode when version is selected
+ @param {string} versionSelectId - ID of the version select element
+ @param {string} packageSelectId - ID of the package select element
+ */
+
+/* ============================================
+ UI HELPER FUNCTIONS
+ ============================================ */
+
+/**
+ * Show loading state for comparison
+ */
+const showComparisonLoading = () => {
+ if (!comparisonResults) return;
+ comparisonResults.innerHTML = '
Loading comparison...
';
+ comparisonResults.classList.remove('hide');
};
-const updateFormState = (formParams) => {
- // If the stable version is empty, show no more fields and disable the search button
- // If the package name is empty, hide version input and show commit options
- // If the package name is not empty, show all options
- // If one of the commit search options is not empty, hide version input and show commit search options
- // If the version field is not empty, hide the commit search options
- if (formParams === undefined) {
- formParams = {
- stable_version: versionSelectDropdown.value,
- package: packageNameInputDropdown.value,
- version: versionInput.value,
- commitMessage: commitMessageInput.value,
- commitHash: commitHashInput.value,
+/**
+ * Show error state for comparison
+ * @param {Error} error - The error object
+ */
+const showComparisonError = (error) => {
+ if (!comparisonResults) return;
+
+ console.error('Error performing version comparison:', error);
+ console.error('Error stack:', error.stack);
+
+ comparisonResults.innerHTML =
+ `
+ Error: Failed to compare versions. ${error.message}
+
Check browser console for details (F12)
+
`;
+};
+
+/* ============================================
+ DATA LAYER FUNCTIONS
+ ============================================ */
+
+/**
+ * DATA LAYER: Fetch and compare versions (Pure data logic, no DOM manipulation)
+ * @param {string} versionA - Base version
+ * @param {string} versionB - Target version
+ * @returns {Promise