kaxil commented on code in PR #62261: URL: https://github.com/apache/airflow/pull/62261#discussion_r2835342302
########## registry/.eleventy.js: ########## @@ -0,0 +1,108 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +module.exports = function(eleventyConfig) { + // Copy static assets + eleventyConfig.addPassthroughCopy("src/assets"); + eleventyConfig.addPassthroughCopy("src/css"); + eleventyConfig.addPassthroughCopy("src/js"); + + // Copy public directory contents to root of output + eleventyConfig.addPassthroughCopy({ "public": "/" }); + + // Watch CSS and JS for changes + eleventyConfig.addWatchTarget("src/css/"); + eleventyConfig.addWatchTarget("src/js/"); + + // Add filters + eleventyConfig.addFilter("slice", (array, start, end) => { + return array.slice(start, end); + }); + + eleventyConfig.addFilter("formatDownloads", (num) => { + if (!num) return "0"; + + // For billions + if (num >= 1_000_000_000) { + const billions = num / 1_000_000_000; + return billions >= 10 ? `${Math.round(billions)}B` : `${billions.toFixed(1)}B`; + } + + // For millions + if (num >= 1_000_000) { + const millions = num / 1_000_000; + return millions >= 10 ? `${Math.round(millions)}M` : `${millions.toFixed(1)}M`; + } + + // For thousands + if (num >= 1_000) { + const thousands = num / 1_000; + return thousands >= 10 ? `${Math.round(thousands)}K` : `${thousands.toFixed(1)}K`; + } + + return num.toString(); + }); + + eleventyConfig.addFilter("formatLargeNumber", (num) => { + if (!num) return "0"; + if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)}B`; + if (num >= 1_000_000) return `${Math.round(num / 1_000_000)}M`; + if (num >= 1_000) return `${(num / 1_000).toFixed(1)}K`; + return num.toLocaleString(); + }); Review Comment: Intentional. `formatDownloads` is used on provider cards where compact rounding at 10+ makes sense (e.g. "12M" not "12.3M"). `formatLargeNumber` is used in summary stats where consistent decimal precision is preferred. Different contexts, different formatting. ########## registry/src/js/search.js: ########## @@ -0,0 +1,310 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +(function() { + let pagefind = null; + let currentFilter = 'all'; + let selectedIndex = 0; + let currentResults = []; + let searchId = 0; + + const typeLabels = { + operator: 'Operator', + hook: 'Hook', + sensor: 'Sensor', + trigger: 'Trigger', + transfer: 'Transfer', + bundle: 'Bundle', + notifier: 'Notifier', + secret: 'Secrets Backend', + logging: 'Log Handler', + executor: 'Executor', + decorator: 'Decorator', + }; + + function escapeHtml(str) { + const div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } + + const modal = document.getElementById('search-modal'); + const input = document.getElementById('search-input'); + const resultsContainer = document.getElementById('search-results'); + const closeButton = document.getElementById('search-close'); + const filterTabs = document.querySelectorAll('#search-modal nav button'); + + async function initPagefind() { + if (pagefind === null) { + const base = window.__REGISTRY_BASE__ || '/'; + pagefind = await import(base + 'pagefind/pagefind.js'); + } + return pagefind; + } + + async function performSearch(query) { + const pf = await initPagefind(); + + if (!query.trim()) { + return []; + } + + let search; + if (currentFilter !== 'all') { + search = await pf.search(query, { filters: { type: [currentFilter] } }); + } else { + search = await pf.search(query); + } + + const results = await Promise.all(search.results.map(r => r.data())); + + return results; + } + + function renderResults(results, resetSelection) { + currentResults = results; + if (resetSelection) { + selectedIndex = 0; + } + + if (results.length === 0) { + resultsContainer.innerHTML = ` + <div class="search-empty"> + <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> + </svg> + <p>No results found</p> + </div> + `; + updateCountsFromResults([]); + const statusEl = document.getElementById('search-status'); + if (statusEl) { + statusEl.textContent = 'No results found'; + } + return; + } + + const html = results.map((result, index) => { + const type = result.meta.type || 'provider'; + const name = result.meta.className || result.meta.name || result.meta.title || 'Unknown'; + const providerName = result.meta.providerName || ''; + const description = result.meta.description || result.excerpt; + const moduleType = result.meta.moduleType || ''; + const icon = type === 'provider' ? 'P' : (moduleType ? moduleType[0].toUpperCase() : 'M'); + const resultType = type === 'provider' ? 'provider' : moduleType; + + return ` + <a href="${escapeHtml(result.url)}" class="${escapeHtml(resultType)}${index === selectedIndex ? ' selected' : ''}" data-index="${index}"> + <span>${icon}</span> + <div> + <div> + ${escapeHtml(name)} + ${moduleType ? `<span class="badge ${escapeHtml(moduleType)}">${escapeHtml(typeLabels[moduleType] || moduleType)}</span>` : ''} + </div> + <div> + ${providerName ? `<span><svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> ${escapeHtml(providerName)}</span>` : ''} + ${description ? `<span>${escapeHtml(description)}</span>` : ''} + </div> + </div> + ${index === selectedIndex ? '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>' : ''} + </a> + `; + }).join(''); + + resultsContainer.innerHTML = html; + updateCountsFromResults(results); + const statusEl = document.getElementById('search-status'); + if (statusEl) { + statusEl.textContent = results.length + ' result' + (results.length !== 1 ? 's' : '') + ' found'; + } + + const selected = resultsContainer.querySelector('a.selected'); + if (selected) { + selected.scrollIntoView({ block: 'nearest' }); + } + } + + function updateCountsFromResults(results) { + let providerCount = 0; + let moduleCount = 0; + + results.forEach(result => { + const type = result.meta.type || 'provider'; + if (type === 'provider') { + providerCount++; + } else { + moduleCount++; + } + }); + + document.getElementById('provider-count').textContent = providerCount; + document.getElementById('module-count').textContent = moduleCount; + } + + async function initializeCounts() { + const pf = await initPagefind(); + const providerSearch = await pf.search('', { filters: { type: ['provider'] } }); + const moduleSearch = await pf.search('', { filters: { type: ['module'] } }); + document.getElementById('provider-count').textContent = providerSearch.results.length; + document.getElementById('module-count').textContent = moduleSearch.results.length; + } + + input.addEventListener('input', async (e) => { + const query = e.target.value; + const thisSearchId = ++searchId; + + if (!query.trim()) { + resultsContainer.innerHTML = ` + <div class="search-empty"> + <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> + </svg> + <p>Start typing to search...</p> + </div> + `; + currentResults = []; + const statusEl = document.getElementById('search-status'); + if (statusEl) { + statusEl.textContent = ''; + } + return; + } + + const pf = await initPagefind(); + + let search; + if (currentFilter !== 'all') { + search = await pf.debouncedSearch(query, { filters: { type: [currentFilter] } }, 150); + } else { + search = await pf.debouncedSearch(query, {}, 150); + } + + if (search !== null && thisSearchId === searchId) { + const results = await Promise.all(search.results.map(r => r.data())); + + if (thisSearchId === searchId) { + renderResults(results, true); + } + } + }); Review Comment: The `searchId` counter is not debouncing — it's a race condition guard. Pagefind's `debouncedSearch` prevents rapid API calls, but `searchId` ensures that if a slower earlier search completes after a faster newer one, its stale results are discarded (line 198/201 checks `thisSearchId === searchId`). They solve different problems. ########## registry/src/js/search.js: ########## @@ -0,0 +1,310 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +(function() { + let pagefind = null; + let currentFilter = 'all'; + let selectedIndex = 0; + let currentResults = []; + let searchId = 0; + + const typeLabels = { + operator: 'Operator', + hook: 'Hook', + sensor: 'Sensor', + trigger: 'Trigger', + transfer: 'Transfer', + bundle: 'Bundle', + notifier: 'Notifier', + secret: 'Secrets Backend', + logging: 'Log Handler', + executor: 'Executor', + decorator: 'Decorator', + }; + + function escapeHtml(str) { + const div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } + + const modal = document.getElementById('search-modal'); + const input = document.getElementById('search-input'); + const resultsContainer = document.getElementById('search-results'); + const closeButton = document.getElementById('search-close'); + const filterTabs = document.querySelectorAll('#search-modal nav button'); + + async function initPagefind() { + if (pagefind === null) { + const base = window.__REGISTRY_BASE__ || '/'; + pagefind = await import(base + 'pagefind/pagefind.js'); + } + return pagefind; + } + + async function performSearch(query) { + const pf = await initPagefind(); + + if (!query.trim()) { + return []; + } + + let search; + if (currentFilter !== 'all') { + search = await pf.search(query, { filters: { type: [currentFilter] } }); + } else { + search = await pf.search(query); + } + + const results = await Promise.all(search.results.map(r => r.data())); + + return results; + } + + function renderResults(results, resetSelection) { + currentResults = results; + if (resetSelection) { + selectedIndex = 0; + } + + if (results.length === 0) { + resultsContainer.innerHTML = ` + <div class="search-empty"> + <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> + </svg> + <p>No results found</p> + </div> + `; + updateCountsFromResults([]); + const statusEl = document.getElementById('search-status'); + if (statusEl) { + statusEl.textContent = 'No results found'; + } + return; + } + + const html = results.map((result, index) => { + const type = result.meta.type || 'provider'; + const name = result.meta.className || result.meta.name || result.meta.title || 'Unknown'; + const providerName = result.meta.providerName || ''; + const description = result.meta.description || result.excerpt; + const moduleType = result.meta.moduleType || ''; + const icon = type === 'provider' ? 'P' : (moduleType ? moduleType[0].toUpperCase() : 'M'); + const resultType = type === 'provider' ? 'provider' : moduleType; + + return ` + <a href="${escapeHtml(result.url)}" class="${escapeHtml(resultType)}${index === selectedIndex ? ' selected' : ''}" data-index="${index}"> + <span>${icon}</span> + <div> + <div> + ${escapeHtml(name)} + ${moduleType ? `<span class="badge ${escapeHtml(moduleType)}">${escapeHtml(typeLabels[moduleType] || moduleType)}</span>` : ''} + </div> + <div> + ${providerName ? `<span><svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> ${escapeHtml(providerName)}</span>` : ''} + ${description ? `<span>${escapeHtml(description)}</span>` : ''} + </div> + </div> + ${index === selectedIndex ? '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>' : ''} + </a> + `; + }).join(''); + + resultsContainer.innerHTML = html; + updateCountsFromResults(results); + const statusEl = document.getElementById('search-status'); + if (statusEl) { + statusEl.textContent = results.length + ' result' + (results.length !== 1 ? 's' : '') + ' found'; + } + + const selected = resultsContainer.querySelector('a.selected'); + if (selected) { + selected.scrollIntoView({ block: 'nearest' }); + } + } + + function updateCountsFromResults(results) { + let providerCount = 0; + let moduleCount = 0; + + results.forEach(result => { + const type = result.meta.type || 'provider'; + if (type === 'provider') { + providerCount++; + } else { + moduleCount++; + } + }); + + document.getElementById('provider-count').textContent = providerCount; + document.getElementById('module-count').textContent = moduleCount; + } + + async function initializeCounts() { + const pf = await initPagefind(); + const providerSearch = await pf.search('', { filters: { type: ['provider'] } }); + const moduleSearch = await pf.search('', { filters: { type: ['module'] } }); + document.getElementById('provider-count').textContent = providerSearch.results.length; + document.getElementById('module-count').textContent = moduleSearch.results.length; + } + + input.addEventListener('input', async (e) => { + const query = e.target.value; + const thisSearchId = ++searchId; + + if (!query.trim()) { + resultsContainer.innerHTML = ` + <div class="search-empty"> + <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> + </svg> + <p>Start typing to search...</p> + </div> + `; + currentResults = []; + const statusEl = document.getElementById('search-status'); + if (statusEl) { + statusEl.textContent = ''; + } + return; + } + + const pf = await initPagefind(); + + let search; + if (currentFilter !== 'all') { + search = await pf.debouncedSearch(query, { filters: { type: [currentFilter] } }, 150); + } else { + search = await pf.debouncedSearch(query, {}, 150); + } + + if (search !== null && thisSearchId === searchId) { + const results = await Promise.all(search.results.map(r => r.data())); + + if (thisSearchId === searchId) { + renderResults(results, true); + } + } + }); + + filterTabs.forEach(tab => { + tab.addEventListener('click', async () => { + filterTabs.forEach(t => t.classList.remove('active')); + tab.classList.add('active'); + currentFilter = tab.dataset.filter; + + const query = input.value; + if (query.trim()) { + const results = await performSearch(query); + renderResults(results, true); + } + }); + }); + + function openModal() { + modal.classList.add('active'); + input.value = ''; + input.focus(); + currentResults = []; + selectedIndex = 0; + resultsContainer.innerHTML = ` + <div class="search-empty"> + <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> + </svg> + <p>Start typing to search...</p> + </div> + `; + + initializeCounts(); + } + + function closeModal() { + modal.classList.remove('active'); + input.value = ''; + currentResults = []; + selectedIndex = 0; + } + + document.addEventListener('keydown', (e) => { + if (!modal.classList.contains('active')) return; + + if (e.key === 'Escape') { + e.preventDefault(); + closeModal(); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + selectedIndex = Math.min(selectedIndex + 1, currentResults.length - 1); + renderResults(currentResults); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + selectedIndex = Math.max(selectedIndex - 1, 0); + renderResults(currentResults); + } else if (e.key === 'Tab') { + const focusable = modal.querySelectorAll('input, button, a[href]'); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + } else if (e.key === 'Enter' && currentResults.length > 0) { + e.preventDefault(); + const selected = currentResults[selectedIndex]; + if (selected) { + closeModal(); + window.location.href = selected.url; + } + } + }); Review Comment: Already handled — lines 253 and 257 both call `e.preventDefault()` for ArrowDown and ArrowUp respectively. ########## registry/src/js/connection-builder.js: ########## @@ -0,0 +1,417 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Connection Builder - chip grid with shared expansion panel +(function () { + var dataEl = document.getElementById("connections-data"); + if (!dataEl) return; + + var connectionTypes; + try { + connectionTypes = JSON.parse(dataEl.textContent || "[]"); + } catch (e) { + return; + } + if (!connectionTypes || !connectionTypes.length) return; + + // Index connection data by connection_type + var connDataMap = {}; + connectionTypes.forEach(function (ct) { + connDataMap[ct.connection_type] = ct; + }); + + var panel = document.getElementById("conn-builder-panel"); + var formContainer = document.getElementById("conn-builder-form"); + var titleEl = document.getElementById("conn-builder-title"); + var docsLink = document.getElementById("conn-builder-docs"); + var closeBtn = document.getElementById("conn-builder-close"); + var chips = document.querySelectorAll(".conn-chip"); + + if (!panel || !formContainer || !titleEl) return; + + var activeConnType = null; + var renderedForms = {}; // Cache rendered HTML per conn type + var debounceTimers = {}; + + // Chip click: select/deselect connection type + chips.forEach(function (chip) { + chip.addEventListener("click", function () { + var connType = chip.dataset.connType; + + // Toggle off if already active + if (activeConnType === connType) { + closePanel(); + return; + } + + // Deactivate previous chip + chips.forEach(function (c) { c.classList.remove("active"); }); + chip.classList.add("active"); + activeConnType = connType; + + // Set title + titleEl.textContent = connType; + + // Show/hide docs link (derive per-connection-type URL) + if (docsLink) { + var baseDocsUrl = chip.dataset.docsUrl; + if (baseDocsUrl) { + var perTypeUrl = baseDocsUrl.replace(/index\.html$/, connType + ".html"); + docsLink.href = perTypeUrl; + docsLink.hidden = false; + } else { + docsLink.hidden = true; + } + } + + // Render form (or restore cached) + var data = connDataMap[connType]; + if (!data) return; + + if (renderedForms[connType]) { + formContainer.innerHTML = renderedForms[connType]; + attachFormListeners(formContainer, connType, data); + } else { + renderConnectionForm(formContainer, connType, data); + renderedForms[connType] = formContainer.innerHTML; + } + + // Show panel + panel.hidden = false; + }); + }); + + // Close button + if (closeBtn) { + closeBtn.addEventListener("click", closePanel); + } + + function closePanel() { + // Save current form state before closing + if (activeConnType) { + renderedForms[activeConnType] = formContainer.innerHTML; + } + chips.forEach(function (c) { c.classList.remove("active"); }); + panel.hidden = true; + activeConnType = null; + } + + function renderConnectionForm(container, connType, data) { + var connIdDefault = connType.replace(/[^a-zA-Z0-9]/g, "_"); + var html = '<div class="conn-field">'; + html += '<label for="conn-id-' + connType + '">Connection ID</label>'; + html += + '<input type="text" id="conn-id-' + connType + + '" data-field="conn_id" value="' + escapeAttr(connIdDefault) + + '" placeholder="my_conn_id">'; + html += "</div>"; + + // Standard fields + var standardOrder = ["host", "port", "login", "password", "schema", "extra", "description"]; + var stdFields = data.standard_fields || {}; + standardOrder.forEach(function (key) { + var field = stdFields[key]; + if (!field || !field.visible) return; + html += renderField(connType, key, field, false); + }); + + // Custom fields + var customFields = data.custom_fields || {}; + Object.keys(customFields).forEach(function (key) { + html += renderField(connType, key, customFields[key], true); + }); + + // Export panel + html += renderExportPanel(connType); + + container.innerHTML = html; + attachFormListeners(container, connType, data); + updateExports(connType, container, data); + } + + function attachFormListeners(container, connType, data) { + // Input listeners for live export updates + container.querySelectorAll("input, textarea, select").forEach(function (input) { + input.addEventListener("input", function () { + debouncedUpdate(connType, container, data); + }); + input.addEventListener("change", function () { + debouncedUpdate(connType, container, data); + }); + }); + + // Tab switching + container.querySelectorAll(".conn-export-tab").forEach(function (tab) { + tab.addEventListener("click", function () { + container.querySelectorAll(".conn-export-tab").forEach(function (t) { + t.classList.remove("active"); + }); + tab.classList.add("active"); + var format = tab.dataset.format; + container.querySelectorAll(".conn-export-content").forEach(function (c) { + c.hidden = c.dataset.format !== format; + }); + }); + }); + + // Copy buttons + container.querySelectorAll(".conn-copy-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + var targetId = btn.dataset.copyTarget; + var target = document.getElementById(targetId); + if (!target || !navigator.clipboard) return; + navigator.clipboard.writeText(target.textContent || "").then( + function () { + var origHTML = btn.innerHTML; + btn.innerHTML = + '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" style="color:var(--color-green-400)"></path></svg>'; + setTimeout(function () { btn.innerHTML = origHTML; }, 2000); + }, + function () {} + ); + }); + }); + + // Update exports on attach (handles restored forms) + updateExports(connType, container, data); + } + + function renderField(connType, key, field, isCustom) { + var label = field.label || key; + var id = "conn-" + connType + "-" + key; + var isSensitive = + key === "password" || field.is_sensitive || field.format === "password"; + var fieldType = field.type || "string"; + var html = '<div class="conn-field">'; + html += '<label for="' + id + '">' + escapeHTML(label); + if (isSensitive) { + html += ' <span class="field-sensitive">sensitive</span>'; + } + html += "</label>"; + + if (field.enum && field.enum.length) { + html += '<select id="' + id + '" data-field="' + key + '"'; + if (isCustom) html += ' data-custom="true"'; + html += ">"; + html += '<option value="">-- Select --</option>'; + field.enum.forEach(function (opt) { + var selected = field.default === opt ? " selected" : ""; + html += '<option value="' + escapeAttr(opt) + '"' + selected + ">" + escapeHTML(opt) + "</option>"; + }); + html += "</select>"; + } else if (fieldType === "boolean") { + var checked = field.default === true || field.default === "true" ? " checked" : ""; + html += '<label class="conn-checkbox"><input type="checkbox" id="' + id + '" data-field="' + key + '"'; + if (isCustom) html += ' data-custom="true"'; + html += ' data-type="boolean"' + checked + ">"; + html += " " + escapeHTML(label) + "</label>"; + } else if (key === "extra" || fieldType === "object") { + html += '<textarea id="' + id + '" data-field="' + key + '" rows="3"'; + if (isCustom) html += ' data-custom="true"'; + if (field.placeholder) html += ' placeholder="' + escapeAttr(field.placeholder) + '"'; + html += "></textarea>"; + } else if (fieldType === "integer" || fieldType === "number") { + html += '<input type="number" id="' + id + '" data-field="' + key + '"'; + if (isCustom) html += ' data-custom="true"'; + if (field.default != null) html += ' value="' + escapeAttr(String(field.default)) + '"'; + if (field.minimum != null) html += ' min="' + escapeAttr(String(field.minimum)) + '"'; + if (field.placeholder) html += ' placeholder="' + escapeAttr(field.placeholder) + '"'; + html += ">"; + } else { + var inputType = isSensitive ? "password" : "text"; + html += '<input type="' + inputType + '" id="' + id + '" data-field="' + key + '"'; + if (isCustom) html += ' data-custom="true"'; + if (field.default != null && fieldType !== "boolean") html += ' value="' + escapeAttr(String(field.default)) + '"'; + if (field.placeholder) html += ' placeholder="' + escapeAttr(field.placeholder) + '"'; + html += ">"; + } + + if (field.description) { + html += '<span class="field-help">' + escapeHTML(field.description) + "</span>"; + } + html += "</div>"; + return html; + } + + function renderExportPanel(connType) { + var uid = connType.replace(/[^a-zA-Z0-9]/g, "_"); + var html = '<div class="conn-export">'; + html += '<div class="conn-export-tabs">'; + html += '<button class="conn-export-tab active" data-format="uri" type="button">URI</button>'; + html += '<button class="conn-export-tab" data-format="json" type="button">JSON</button>'; + html += '<button class="conn-export-tab" data-format="env" type="button">Env Var</button>'; + html += "</div>"; + + ["uri", "json", "env"].forEach(function (fmt) { + var hidden = fmt !== "uri" ? " hidden" : ""; + html += '<div class="conn-export-content" data-format="' + fmt + '"' + hidden + ">"; + html += '<div class="conn-export-output">'; + html += '<pre id="conn-export-' + uid + "-" + fmt + '"></pre>'; + html += '<button class="conn-copy-btn" data-copy-target="conn-export-' + uid + "-" + fmt + '" type="button" title="Copy">'; + html += '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>'; + html += "</button></div></div>"; + }); + + html += "</div>"; + return html; + } + + function debouncedUpdate(connType, container, data) { + clearTimeout(debounceTimers[connType]); + debounceTimers[connType] = setTimeout(function () { + updateExports(connType, container, data); + }, 100); + } + + function collectFieldValues(container) { + var values = { standard: {}, custom: {}, conn_id: "" }; + container.querySelectorAll("[data-field]").forEach(function (el) { + var key = el.dataset.field; + var val = el.type === "checkbox" ? el.checked : el.value; + if (key === "conn_id") { + values.conn_id = val; + } else if (el.dataset.custom === "true") { + values.custom[key] = val; + } else { + values.standard[key] = val; + } + }); + return values; + } + + function updateExports(connType, container, data) { + var values = collectFieldValues(container); + var uid = connType.replace(/[^a-zA-Z0-9]/g, "_"); + + var uriEl = document.getElementById("conn-export-" + uid + "-uri"); + var jsonEl = document.getElementById("conn-export-" + uid + "-json"); + var envEl = document.getElementById("conn-export-" + uid + "-env"); + + if (uriEl) uriEl.textContent = generateURI(connType, values); + if (jsonEl) jsonEl.textContent = generateJSON(connType, values); + if (envEl) envEl.textContent = generateEnvVar(connType, values); + } + + function generateURI(connType, values) { + var s = values.standard; + var login = s.login || ""; + var password = s.password || ""; + var host = s.host || ""; + var port = s.port || ""; + var schema = s.schema || ""; + var extra = s.extra || ""; + + var extraObj = {}; + if (extra) { + try { extraObj = JSON.parse(extra); } + catch (e) { extraObj = null; } + } + + var customEntries = Object.keys(values.custom).filter(function (k) { + var v = values.custom[k]; + return v !== "" && v !== false && v != null; + }); + + var uri = connType + "://"; + + if (login || password) { + if (login) uri += encodeURIComponent(login); + if (password) uri += ":" + encodeURIComponent(password); + uri += "@"; + } + + if (host) uri += host; + if (port) uri += ":" + port; + + if (schema) uri += "/" + encodeURIComponent(schema); + else if (extra || customEntries.length) uri += "/"; + + var params = []; + if (extraObj && typeof extraObj === "object") { + Object.keys(extraObj).forEach(function (k) { + var v = extraObj[k]; + if (typeof v === "object") { + params.push(encodeURIComponent(k) + "=" + encodeURIComponent(JSON.stringify(v))); + } else { + params.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(v))); + } + }); + } else if (extra) { + params.push("__extra__=" + encodeURIComponent(extra)); + } + + customEntries.forEach(function (k) { + params.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(values.custom[k]))); + }); + + if (params.length) uri += "?" + params.join("&"); + return uri; + } + + function generateJSON(connType, values) { + var obj = { conn_type: connType }; + var s = values.standard; + + if (s.host) obj.host = s.host; + if (s.port) obj.port = isNaN(Number(s.port)) ? s.port : Number(s.port); + if (s.login) obj.login = s.login; + if (s.password) obj.password = s.password; + if (s.schema) obj.schema = s.schema; + if (s.description) obj.description = s.description; + + var extraObj = {}; + var hasExtra = false; + if (s.extra) { + try { extraObj = JSON.parse(s.extra); hasExtra = true; } + catch (e) { obj.extra = s.extra; hasExtra = true; } + } + + Object.keys(values.custom).forEach(function (k) { + var v = values.custom[k]; + if (v === "" || v == null) return; + if (v === false) { extraObj[k] = false; } + else if (v === true) { extraObj[k] = true; } + else if (!isNaN(Number(v)) && v !== "") { extraObj[k] = Number(v); } + else { extraObj[k] = v; } + hasExtra = true; + }); + + if (hasExtra && typeof obj.extra !== "string") { + if (Object.keys(extraObj).length) obj.extra = extraObj; + } + + return JSON.stringify(obj, null, 2); + } Review Comment: `JSON.stringify` handles all necessary escaping. The output is rendered into a `<pre>` block or copied to clipboard — it's never passed to `eval` or inserted as raw HTML. No injection risk here. ########## registry/src/js/search.js: ########## @@ -0,0 +1,310 @@ +/*! + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +(function() { + let pagefind = null; + let currentFilter = 'all'; + let selectedIndex = 0; + let currentResults = []; + let searchId = 0; + + const typeLabels = { + operator: 'Operator', + hook: 'Hook', + sensor: 'Sensor', + trigger: 'Trigger', + transfer: 'Transfer', + bundle: 'Bundle', + notifier: 'Notifier', + secret: 'Secrets Backend', + logging: 'Log Handler', + executor: 'Executor', + decorator: 'Decorator', + }; + + function escapeHtml(str) { + const div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } + + const modal = document.getElementById('search-modal'); + const input = document.getElementById('search-input'); + const resultsContainer = document.getElementById('search-results'); + const closeButton = document.getElementById('search-close'); + const filterTabs = document.querySelectorAll('#search-modal nav button'); + + async function initPagefind() { + if (pagefind === null) { + const base = window.__REGISTRY_BASE__ || '/'; + pagefind = await import(base + 'pagefind/pagefind.js'); + } + return pagefind; + } + + async function performSearch(query) { + const pf = await initPagefind(); + + if (!query.trim()) { + return []; + } + + let search; + if (currentFilter !== 'all') { + search = await pf.search(query, { filters: { type: [currentFilter] } }); + } else { + search = await pf.search(query); + } + + const results = await Promise.all(search.results.map(r => r.data())); + + return results; + } + + function renderResults(results, resetSelection) { + currentResults = results; + if (resetSelection) { + selectedIndex = 0; + } + + if (results.length === 0) { + resultsContainer.innerHTML = ` + <div class="search-empty"> + <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> + </svg> + <p>No results found</p> + </div> + `; + updateCountsFromResults([]); + const statusEl = document.getElementById('search-status'); + if (statusEl) { + statusEl.textContent = 'No results found'; + } + return; + } + + const html = results.map((result, index) => { + const type = result.meta.type || 'provider'; + const name = result.meta.className || result.meta.name || result.meta.title || 'Unknown'; + const providerName = result.meta.providerName || ''; + const description = result.meta.description || result.excerpt; + const moduleType = result.meta.moduleType || ''; + const icon = type === 'provider' ? 'P' : (moduleType ? moduleType[0].toUpperCase() : 'M'); + const resultType = type === 'provider' ? 'provider' : moduleType; + + return ` + <a href="${escapeHtml(result.url)}" class="${escapeHtml(resultType)}${index === selectedIndex ? ' selected' : ''}" data-index="${index}"> + <span>${icon}</span> + <div> + <div> + ${escapeHtml(name)} + ${moduleType ? `<span class="badge ${escapeHtml(moduleType)}">${escapeHtml(typeLabels[moduleType] || moduleType)}</span>` : ''} + </div> + <div> + ${providerName ? `<span><svg width="12" height="12" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg> ${escapeHtml(providerName)}</span>` : ''} + ${description ? `<span>${escapeHtml(description)}</span>` : ''} + </div> + </div> + ${index === selectedIndex ? '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>' : ''} + </a> + `; + }).join(''); + + resultsContainer.innerHTML = html; + updateCountsFromResults(results); + const statusEl = document.getElementById('search-status'); + if (statusEl) { + statusEl.textContent = results.length + ' result' + (results.length !== 1 ? 's' : '') + ' found'; + } + + const selected = resultsContainer.querySelector('a.selected'); + if (selected) { + selected.scrollIntoView({ block: 'nearest' }); + } + } Review Comment: There's already a `search-status` live region that announces result counts (lines 96-99, 132-135). Adding `aria-activedescendant` for per-item keyboard navigation announcements is a reasonable follow-up but out of scope for this PR. ########## dev/breeze/src/airflow_breeze/commands/registry_commands.py: ########## @@ -0,0 +1,72 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +from __future__ import annotations + +import sys +import uuid + +import click + +from airflow_breeze.commands.ci_image_commands import rebuild_or_pull_ci_image_if_needed +from airflow_breeze.commands.common_options import option_dry_run, option_python, option_verbose +from airflow_breeze.params.shell_params import ShellParams +from airflow_breeze.utils.ci_group import ci_group +from airflow_breeze.utils.click_utils import BreezeGroup +from airflow_breeze.utils.docker_command_utils import execute_command_in_shell, fix_ownership_using_docker + + [email protected](cls=BreezeGroup, name="registry", help="Tools for the Airflow Provider Registry") +def registry_group(): + pass + + +@registry_group.command( + name="extract-data", + help="Extract provider metadata, parameters, and connection types for the registry.", +) +@option_python +@option_verbose +@option_dry_run +def extract_data(python: str): + unique_project_name = f"breeze-registry-{uuid.uuid4().hex[:8]}" + + shell_params = ShellParams( + python=python, + project_name=unique_project_name, + quiet=True, + skip_environment_initialization=True, + extra_args=(), + ) + + rebuild_or_pull_ci_image_if_needed(command_params=shell_params) + + command = ( + "python dev/registry/extract_metadata.py && " + "python dev/registry/extract_parameters.py && " + "python dev/registry/extract_connections.py" + ) + + with ci_group("Extracting registry data"): + result = execute_command_in_shell( + shell_params=shell_params, + project_name=unique_project_name, + command=command, + preserve_backend=True, + ) + + fix_ownership_using_docker() + sys.exit(result.returncode) Review Comment: `option_dry_run` is defined with `expose_value=False` in breeze's common_options — it sets a global flag checked by the underlying `run_command` utilities, not a function parameter. This is the standard pattern used by every breeze command (`ui compile-assets`, `build-docs`, etc.). -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
