diff --git a/blocks/fragment-list/_fragment-list.json b/blocks/fragment-list/_fragment-list.json new file mode 100644 index 0000000..a3519c9 --- /dev/null +++ b/blocks/fragment-list/_fragment-list.json @@ -0,0 +1,178 @@ +{ + "definitions": [ + { + "title": "Fragment List", + "id": "fragment-list", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/block/v1/block", + "template": { + "name": "Fragment List", + "model": "fragment-list", + "key-value": true + } + } + } + } + } + ], + "models": [ + { + "id": "fragment-list", + "fields": [ + { + "component": "text", + "valueType": "string", + "name": "title", + "label": "Title", + "value": "" + }, + { + "component": "text", + "valueType": "string", + "name": "subtitle", + "label": "Subtitle", + "value": "" + }, + { + "component": "select", + "name": "layout", + "label": "Layout Style", + "valueType": "string", + "value": "grid", + "options": [ + { "name": "Grid", "value": "grid" }, + { "name": "List", "value": "list" }, + { "name": "Compact", "value": "compact" }, + { "name": "Masonry", "value": "masonry" }, + { "name": "Carousel", "value": "carousel" }, + { "name": "Articles", "value": "articles" } + ] + }, + { + "component": "select", + "name": "dataSourceType", + "label": "Data Source Type", + "valueType": "string", + "value": "content-fragments", + "options": [ + { "name": "Content Fragments", "value": "content-fragments" }, + { "name": "External API", "value": "api" } + ] + }, + { + "component": "aem-content", + "name": "contentFragmentFolder", + "label": "Content Fragment Folder", + "description": "Select folder containing Content Fragments", + "valueType": "string", + "validation": { "rootPath": "/content/dam" }, + "condition": { "==": [{ "var": "dataSourceType" }, "content-fragments"] } + }, + { + "component": "text", + "valueType": "string", + "name": "modelName", + "label": "Content Fragment Model Name", + "description": "Target CF model name to filter by (e.g. content-card)", + "condition": { "==": [{ "var": "dataSourceType" }, "content-fragments"] } + }, + { + "component": "text", + "valueType": "string", + "name": "apiUrl", + "label": "API URL", + "description": "External API endpoint for data", + "condition": { "==": [{ "var": "dataSourceType" }, "api"] } + }, + { + "component": "boolean", + "name": "enableFilters", + "label": "Enable Filters", + "description": "Show the search and filter panel", + "value": false, + "valueType": "boolean" + }, + { + "component": "boolean", + "name": "enableTextSearch", + "label": "Enable Text Search", + "description": "Show full-text search input", + "value": true, + "valueType": "boolean", + "condition": { "==": [{ "var": "enableFilters" }, true] } + }, + { + "component": "boolean", + "name": "enableTagFilter", + "label": "Enable Tag Filter", + "description": "Show tag-based filter dropdown", + "value": true, + "valueType": "boolean", + "condition": { "==": [{ "var": "enableFilters" }, true] } + }, + { + "component": "text", + "valueType": "string", + "name": "searchPlaceholder", + "label": "Search Placeholder", + "value": "Search...", + "description": "Placeholder text for the search input", + "condition": { "==": [{ "var": "enableFilters" }, true] } + }, + { + "component": "boolean", + "name": "showTags", + "label": "Show Tags", + "description": "Show content fragment tags on each card", + "value": false, + "valueType": "boolean", + "condition": { "==": [{ "var": "dataSourceType" }, "content-fragments"] } + }, + { + "component": "select", + "name": "ctastyle", + "label": "CTA Style", + "valueType": "string", + "value": "button", + "options": [ + { + "name": "Primary Button", + "value": "button" + }, + { + "name": "Secondary Button", + "value": "button-secondary" + }, + { + "name": "Dark Button", + "value": "button-dark" + }, + { + "name": "Link", + "value": "link" + } + ] + }, + { + "component": "text", + "valueType": "string", + "name": "noResultsMessage", + "label": "No Results Message", + "value": "No items found", + "description": "Message displayed when no content fragments match" + }, + { + "component": "text", + "name": "custom-class", + "label": "Custom class", + "value": "", + "valueType": "string", + "description": "Space-separated class names to add to the block" + } + ] + } + ], + "filters": [] +} \ No newline at end of file diff --git a/blocks/fragment-list/fragment-list.css b/blocks/fragment-list/fragment-list.css new file mode 100644 index 0000000..d03e6f8 --- /dev/null +++ b/blocks/fragment-list/fragment-list.css @@ -0,0 +1,547 @@ +/* ===== Fragment List Block ===== */ +.fragment-list { + max-width: 1200px; + margin: 0 auto; + padding: 2rem 1rem; + font-family: var(--body-font-family, system-ui, sans-serif); +} + +/* Header */ +.fragment-list-header { + text-align: center; + margin-bottom: 2rem; +} + +.fragment-list-title { + font-size: 2.5rem; + font-weight: 700; + color: var(--text-color, #333); + margin: 0 0 0.5rem 0; +} + +.fragment-list-subtitle { + font-size: 1.2rem; + color: var(--text-color-secondary, #666); + margin: 0; +} + +/* Filter Panel */ +.fragment-list-filters { + background: var(--background-color-secondary, #f8f9fa); + border-radius: 12px; + padding: 2rem; + margin-bottom: 2rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.filter-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 1.5rem; + align-items: end; +} + +.filter-group { + display: flex; + flex-direction: column; +} + +.filter-group label { + font-weight: 600; + margin-bottom: 0.5rem; + color: var(--text-color, #333); + font-size: 0.9rem; +} + +.filter-group input, +.filter-group select { + padding: 0.75rem; + border: 2px solid var(--border-color, #e0e0e0); + border-radius: 8px; + font-size: 1rem; + transition: border-color 0.3s ease; + background: var(--background-color, #fff); +} + +.filter-group input:focus, +.filter-group select:focus { + outline: none; + border-color: var(--link-color, #007bff); + box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1); +} + +/* Results Container */ +.fragment-list-results { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; +} + +/* Card */ +.fragment-card { + background: var(--background-color, #fff); + border-radius: 12px; + overflow: hidden; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.fragment-card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); +} + +/* Card Image */ +.fragment-card-image { + position: relative; + height: 200px; + overflow: hidden; +} + +.fragment-card-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Card Content */ +.fragment-card-content { + padding: 1.5rem; +} + +.fragment-card-title { + font-size: 1.4rem; + font-weight: 700; + color: var(--text-color, #333); + margin: 0 0 0.5rem 0; +} + +.fragment-card-subtext { + font-size: 0.95rem; + color: var(--text-color-secondary, #666); + margin: 0 0 1rem 0; + line-height: 1.5; +} + +/* Tag Pills */ +.fragment-card-tags { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.fragment-card-tag { + display: inline-block; + padding: 0.25rem 0.75rem; + border-radius: 20px; + font-size: 0.8rem; + font-weight: 500; + background: var(--background-color-secondary, #f0f0f0); + color: var(--text-color-secondary, #555); + border: 1px solid var(--border-color, #e0e0e0); +} + +/* CTA Buttons (theme-based) */ +.fragment-card .button-container { + margin: 0; +} + +.fragment-card .button-container a.button { + margin: 0; +} + +/* Static CTA (no link) — base */ +.fragment-card-cta-static .button { + display: inline-block; + padding: 12px 24px; + border-radius: 4px; + border: 2px solid var(--border-color, #ddd); + color: var(--text-color-secondary, #666); + background: var(--background-color, #fff); + cursor: default; +} + +/* Static CTA — Primary variant */ +.fragment-card .cta-button.fragment-card-cta-static .button { + background-color: var(--main-accent-color); + color: var(--text-light, #fff); + border-color: var(--main-accent-color); +} + +/* Static CTA — Secondary variant */ +.fragment-card .cta-button-secondary.fragment-card-cta-static .button { + background-color: transparent; + color: var(--main-accent-color); + border-color: var(--main-accent-color); +} + +/* Static CTA — Dark variant */ +.fragment-card .cta-button-dark.fragment-card-cta-static .button { + background-color: var(--text-light, #fff); + color: var(--dark-color, #131313); + border-color: var(--dark-color, #131313); +} + +/* Static CTA — Link variant */ +.fragment-card .cta-link.fragment-card-cta-static .button { + background: transparent; + border: none; + padding: 0; + color: var(--link-color, var(--main-accent-color)); + cursor: default; +} + +.fragment-card .cta-link.fragment-card-cta-static .button::after { + content: none; +} + +/* CTA Link variant — inline link style within cards */ +.fragment-card .button-container.cta-link a.button { + background: transparent; + border: none; + padding: 0; + box-shadow: none; + font-size: 16px; + font-weight: 500; +} + +.fragment-card .button-container.cta-link a.button:hover { + background: transparent; + color: var(--main-accent-color); + box-shadow: none; + transform: none; +} + +/* No Results */ +.no-results { + grid-column: 1 / -1; + text-align: center; + padding: 3rem 1rem; + color: var(--text-color-secondary, #666); +} + +.no-results svg { + width: 64px; + height: 64px; + margin-bottom: 1rem; + opacity: 0.5; +} + +.no-results h3 { + font-size: 1.5rem; + margin: 0 0 0.5rem 0; +} + +.no-results p { + margin: 0; +} + +/* ===== Layout: Grid (default) ===== */ +.fragment-list.grid .fragment-list-results { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.5rem; +} + +/* ===== Layout: List ===== */ +.fragment-list.list .fragment-list-results { + grid-template-columns: 1fr; + gap: 1rem; +} + +.fragment-list.list .fragment-card { + display: flex; + flex-direction: row; + min-height: 180px; +} + +.fragment-list.list .fragment-card-image { + width: 220px; + height: auto; + flex-shrink: 0; +} + +.fragment-list.list .fragment-card-content { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + padding: 1.25rem 1.5rem; +} + +.fragment-list.list .fragment-card-title { + font-size: 1.2rem; + margin-bottom: 0.25rem; +} + +.fragment-list.list .fragment-card .button-container { + align-self: flex-start; + margin-top: auto; +} + +/* ===== Layout: Compact ===== */ +.fragment-list.compact .fragment-list-results { + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 1rem; +} + +.fragment-list.compact .fragment-card-image { + height: 140px; +} + +.fragment-list.compact .fragment-card-content { + padding: 1rem; +} + +.fragment-list.compact .fragment-card-title { + font-size: 1.1rem; + margin-bottom: 0.25rem; +} + +.fragment-list.compact .fragment-card-subtext { + font-size: 0.85rem; + margin-bottom: 0.5rem; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.fragment-list.compact .fragment-card .button-container a.button { + padding: 8px 14px !important; + font-size: 0.9rem; +} + +/* ===== Layout: Articles ===== */ +.fragment-list.articles .fragment-list-results { + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1.25rem; +} + +.fragment-list.articles .fragment-card { + border-radius: 0; + background: #efefef; + box-shadow: none; + display: flex; + flex-direction: column; +} + +.fragment-list.articles .fragment-card:hover { + transform: none; + box-shadow: none; +} + +.fragment-list.articles .fragment-card-image { + height: 210px; +} + +.fragment-list.articles .fragment-card-content { + display: flex; + flex-direction: column; + flex: 1; + padding: 1.25rem 1.5rem 1.5rem; +} + +.fragment-list.articles .fragment-card-title { + color: var(--main-accent-color, #c2410c); + font-size: 1.95rem; + font-weight: 300; + line-height: 1.2; + margin-bottom: 0.65rem; +} + +.fragment-list.articles .fragment-card-subtext { + font-size: 1.2rem; + line-height: 1.5; + color: var(--text-color-secondary, #5a5a5a); + margin-bottom: 1.25rem; +} + +.fragment-list.articles .fragment-card .button-container { + margin-top: auto; + align-self: flex-end; +} + +.fragment-list.articles .fragment-card .button-container a.button { + font-size: 1rem; + font-weight: 400; +} + +.fragment-list.articles .fragment-card .button-container a.button::after { + font-size: 1rem; +} + +/* ===== Layout: Masonry ===== */ +.fragment-list.masonry .fragment-list-results { + display: block; + columns: 3; + column-gap: 1.5rem; +} + +.fragment-list.masonry .fragment-card { + break-inside: avoid; + margin-bottom: 1.5rem; +} + +.fragment-list.masonry .fragment-card-image { + height: auto; +} + +.fragment-list.masonry .fragment-card-image img { + height: auto; +} + +/* ===== Layout: Carousel ===== */ +.fragment-list.carousel .fragment-list-results { + display: flex; + overflow-x: auto; + scroll-snap-type: x mandatory; + gap: 1.5rem; + padding-bottom: 1rem; + -webkit-overflow-scrolling: touch; +} + +.fragment-list.carousel .fragment-list-results::-webkit-scrollbar { + height: 6px; +} + +.fragment-list.carousel .fragment-list-results::-webkit-scrollbar-track { + background: var(--background-color-secondary, #f0f0f0); + border-radius: 3px; +} + +.fragment-list.carousel .fragment-list-results::-webkit-scrollbar-thumb { + background: var(--border-color, #ccc); + border-radius: 3px; +} + +.fragment-list.carousel .fragment-card { + flex: 0 0 300px; + scroll-snap-align: start; +} + +.carousel-nav { + display: flex; + justify-content: center; + gap: 0.75rem; + margin-top: 1rem; +} + +.carousel-nav-btn { + width: 40px; + height: 40px; + border-radius: 50%; + border: 2px solid var(--border-color, #e0e0e0); + background: var(--background-color, #fff); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.1rem; + transition: border-color 0.3s ease, background-color 0.3s ease; +} + +.carousel-nav-btn:hover { + border-color: var(--link-color, #007bff); + background: var(--background-color-secondary, #f8f9fa); +} + +/* ===== Loading & Error States ===== */ +.loading-state { + text-align: center; + padding: 2rem; + color: var(--text-color-secondary, #666); + font-size: 1.1rem; + grid-column: 1 / -1; +} + +.error-state { + text-align: center; + padding: 2rem; + color: var(--error-color, #dc3545); + background: var(--error-background, #f8d7da); + border: 1px solid var(--error-border, #f5c6cb); + border-radius: 8px; + margin: 1rem 0; + grid-column: 1 / -1; +} + +/* ===== Responsive ===== */ +@media (max-width: 768px) { + .fragment-list { + padding: 1rem; + } + + .fragment-list-title { + font-size: 2rem; + } + + .filter-row { + grid-template-columns: 1fr; + gap: 1rem; + } + + .fragment-list-results { + grid-template-columns: 1fr; + } + + /* List layout stacks on mobile */ + .fragment-list.list .fragment-card { + flex-direction: column; + } + + .fragment-list.list .fragment-card-image { + width: 100%; + height: 180px; + } + + /* Masonry falls to single column */ + .fragment-list.masonry .fragment-list-results { + columns: 1; + } + + /* Carousel keeps horizontal scroll */ + .fragment-list.carousel .fragment-card { + flex: 0 0 260px; + } + + .fragment-list.articles .fragment-card-image { + height: 180px; + } + + .fragment-list.articles .fragment-card-content { + padding: 1rem; + } + + .fragment-list.articles .fragment-card-title { + font-size: 1.5rem; + } + + .fragment-list.articles .fragment-card-subtext { + font-size: 1rem; + margin-bottom: 1rem; + } + + .fragment-list.articles .fragment-card .button-container a.button { + font-size: 1rem; + } + + .fragment-list.articles .fragment-card .button-container a.button::after { + font-size: 1rem; + } +} + +@media (max-width: 480px) { + .fragment-list-filters { + padding: 1rem; + } + + .fragment-card-content { + padding: 1rem; + } + + .fragment-list.carousel .fragment-card { + flex: 0 0 85vw; + } +} \ No newline at end of file diff --git a/blocks/fragment-list/fragment-list.js b/blocks/fragment-list/fragment-list.js new file mode 100644 index 0000000..654a132 --- /dev/null +++ b/blocks/fragment-list/fragment-list.js @@ -0,0 +1,628 @@ +import { getMetadata } from '../../scripts/aem.js'; +import { isAuthorEnvironment } from '../../scripts/scripts.js'; +import { getHostname, resolveImageUrl } from '../../scripts/utils.js'; + +// --- Constants --- +const GRAPHQL_QUERY_PATH = '/graphql/execute.json/ref-demo-eds/GetContentCardListFromFolder'; +const OPEN_API_CF_PATH = '/adobe/contentFragments'; +const OPEN_API_CFM_PATH = '/adobe/contentFragments/models'; +const CONFIG = { + WRAPPER_SERVICE_URL: 'https://675172-referencedemopartner-stage.adobeioruntime.net/api/v1/web/ref-demo-api-gateway/fetch-cf', +}; + +// --- Utilities --- +function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + clearTimeout(timeout); + timeout = setTimeout(() => func(...args), wait); + }; +} + +function createElement(tag, className, content) { + const element = document.createElement(tag); + if (className) element.className = className; + if (content) element.innerHTML = content; + return element; +} + +function toTitleCase(text) { + if (!text) return ''; + return text.replace(/[-_]/g, ' ').replace(/\b\w/g, (m) => m.toUpperCase()); +} + +function extractTagLabel(tagId) { + if (!tagId || typeof tagId !== 'string') return ''; + const last = tagId.split('/').pop(); + return toTitleCase(last); +} + +function getUniqueTags(cards) { + const tags = new Set(); + cards.forEach((card) => { + if (Array.isArray(card.tags)) { + card.tags.forEach((tag) => { + if (tag && tag.trim()) tags.add(tag.trim()); + }); + } + }); + return Array.from(tags).sort(); +} + +function getCtaVariant(config) { + if (config.ctaStyle) { + const styleMap = { + button: 'cta-button', + 'button-secondary': 'cta-button-secondary', + 'button-dark': 'cta-button-dark', + link: 'cta-link', + }; + return styleMap[config.ctaStyle] || 'cta-button'; + } + return config.layout === 'articles' ? 'cta-button-secondary' : 'cta-button'; +} + +// --- Data Fetching --- + +/** + * Primary method: Content Fragment Open API + */ +async function fetchViaOpenAPI(folderPath, modelName) { + try { + const decodedFolderPath = decodeURIComponent(folderPath); + const hostnameFromPlaceholders = await getHostname(); + const hostname = hostnameFromPlaceholders || getMetadata('hostname'); + const publishUrl = hostname?.replace('author', 'publish')?.replace(/\/$/, '') || ''; + + const cfUrl = `${publishUrl}${OPEN_API_CF_PATH}?path=${encodeURIComponent(decodedFolderPath)}`; + const cfResponse = await fetch(cfUrl); + + if (!cfResponse.ok) { + // Non-200 means Open API is not enabled on this environment + console.log(`Open API not available (${cfResponse.status}), will fallback to GraphQL`); + return null; + } + + const cfData = await cfResponse.json(); + const items = cfData?.items || cfData?.results || (Array.isArray(cfData) ? cfData : []); + + if (!items.length) { + console.log('Open API returned empty results for folder:', folderPath); + return []; + } + + // If modelName is provided, fetch CFM list to filter by model + let filteredItems = items; + if (modelName) { + const modelsUrl = `${publishUrl}${OPEN_API_CFM_PATH}`; + const modelsResponse = await fetch(modelsUrl); + + if (modelsResponse.ok) { + const modelsData = await modelsResponse.json(); + const modelsList = modelsData?.items || modelsData?.results || (Array.isArray(modelsData) ? modelsData : []); + + // Build modelId → modelName map + const modelMap = new Map(); + modelsList.forEach((m) => { + const mName = m.name || m.title || ''; + const mId = m.id || m._id || m.modelId || ''; + if (mId) modelMap.set(mId, mName.toLowerCase()); + }); + + // Filter items by matching model name + filteredItems = items.filter((item) => { + const itemModelId = item.modelId || item.model?.id || item._modelId || ''; + const resolvedName = modelMap.get(itemModelId) || ''; + return resolvedName === modelName.toLowerCase(); + }); + } + } + + const isAuthor = isAuthorEnvironment(); + return filteredItems.map((item) => transformOpenAPIItem(item, isAuthor)); + } catch (error) { + console.error('Error in Open API fetch:', error); + return null; + } +} + +/** + * Fallback method: GraphQL persisted query + */ +async function fetchViaGraphQL(folderPath) { + try { + const decodedFolderPath = decodeURIComponent(folderPath); + const hostnameFromPlaceholders = await getHostname(); + const hostname = hostnameFromPlaceholders || getMetadata('hostname'); + const aemauthorurl = getMetadata('authorurl') || ''; + const aempublishurl = hostname?.replace('author', 'publish')?.replace(/\/$/, '') || ''; + const isAuthor = isAuthorEnvironment(); + + const requestConfig = isAuthor + ? { + url: `${aemauthorurl}${GRAPHQL_QUERY_PATH};path=${decodedFolderPath};ts=${Date.now()}`, + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + } + : { + url: CONFIG.WRAPPER_SERVICE_URL, + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + graphQLPath: `${aempublishurl}${GRAPHQL_QUERY_PATH}`, + cfPath: decodedFolderPath, + variation: `main;ts=${Date.now()}`, + }), + }; + + const response = await fetch(requestConfig.url, { + method: requestConfig.method, + headers: requestConfig.headers, + ...(requestConfig.body && { body: requestConfig.body }), + }); + + if (!response.ok) { + throw new Error(`GraphQL request failed: ${response.status}`); + } + + const payload = await response.json(); + const items = payload?.data?.contentCardList?.items || []; + return items.map((item) => transformGraphQLItem(item, isAuthor)); + } catch (error) { + console.error('Error in GraphQL fetch:', error); + return []; + } +} + +/** + * External API fetch + */ +async function fetchFromExternalAPI(apiUrl) { + try { + const response = await fetch(apiUrl); + if (!response.ok) throw new Error(`API request failed: ${response.status}`); + const data = await response.json(); + const rawItems = Array.isArray(data) ? data : data.items || data.results || []; + return rawItems.map((item) => transformExternalAPIItem(item)); + } catch (error) { + console.error('Error fetching from external API:', error); + return []; + } +} + +/** + * Orchestrator: decides which fetch method to use based on config + */ +async function fetchFragmentData(config) { + const { + dataSourceType, contentFragmentFolder, apiUrl, modelName, + } = config; + + if (dataSourceType === 'api') { + if (!apiUrl) { + console.warn('External API selected but no URL provided'); + return []; + } + return fetchFromExternalAPI(apiUrl); + } + + // Content Fragments: try Open API first, fallback to GraphQL + if (!contentFragmentFolder) { + console.warn('Content Fragment folder not configured'); + return []; + } + + const openAPIResult = await fetchViaOpenAPI(contentFragmentFolder, modelName); + if (openAPIResult !== null) { + // Open API responded (200) — use its result (could be empty array) + return openAPIResult; + } + + // Fallback to GraphQL + console.log('Falling back to GraphQL approach'); + return fetchViaGraphQL(contentFragmentFolder); +} + +// --- Transformers --- + +function transformOpenAPIItem(item, isAuthorEnv) { + const imageUrl = resolveImageUrl(item?.images, isAuthorEnv); + + const tags = Array.isArray(item?.tags) + ? item.tags.map(extractTagLabel).filter(Boolean) + : []; + + const subtext = item?.subtext?.plaintext || item?.subtext?.html || item?.subtext || ''; + const subtextHtml = item?.subtext?.html || ''; + + return { + // eslint-disable-next-line no-underscore-dangle + id: item?._path || item?.id || Math.random().toString(36).slice(2), + title: item?.title || '', + subtext, + subtextHtml, + tags, + image: imageUrl, + enabled: item?.enabled !== false && item?.enabled !== 'false', + ctaText: item?.ctaText || '', + ctaLink: item?.ctaLink || '', + }; +} + +function transformGraphQLItem(item, isAuthorEnv) { + const imageUrl = resolveImageUrl(item?.images, isAuthorEnv); + + const tags = Array.isArray(item?.tags) + ? item.tags.map(extractTagLabel).filter(Boolean) + : []; + + const subtext = item?.subtext?.plaintext || item?.subtext || ''; + const subtextHtml = item?.subtext?.html || ''; + + return { + id: item?._path || Math.random().toString(36).slice(2), + title: item?.title || '', + subtext, + subtextHtml, + tags, + image: imageUrl, + enabled: item?.enabled !== false && item?.enabled !== 'false', + ctaText: item?.ctaText || '', + ctaLink: item?.ctaLink || '', + }; +} + +function transformExternalAPIItem(item) { + const tags = Array.isArray(item?.tags) + ? item.tags.map((t) => (typeof t === 'string' ? extractTagLabel(t) : t)).filter(Boolean) + : []; + + return { + id: item?.id || Math.random().toString(36).slice(2), + title: item?.title || item?.name || '', + subtext: item?.subtext || item?.description || '', + subtextHtml: item?.subtextHtml || '', + tags, + image: item?.images || item?.imageUrl || '', + enabled: item?.enabled !== false && item?.enabled !== 'false', + ctaText: item?.ctaText || item?.ctaLabel || '', + ctaLink: item?.ctaLink || item?.ctaUrl || item?.url || '', + }; +} + +// --- UI Rendering --- + +function createFragmentCard(card, config) { + const cardEl = createElement('div', 'fragment-card'); + + let html = ''; + + // Image + if (card.image) { + html += ` +
+ ${card.title} +
`; + } + + // Content + html += '
'; + + // Title + if (card.title) { + html += `

${card.title}

`; + } + + // Subtext + if (card.subtextHtml) { + html += `
${card.subtextHtml}
`; + } else if (card.subtext) { + html += `

${card.subtext}

`; + } + + // Tags + if (config.showTags && card.tags && card.tags.length > 0) { + html += '
'; + card.tags.forEach((tag) => { + html += `${tag}`; + }); + html += '
'; + } + + // CTA Button + const ctaLabel = card.ctaText || config.ctaButtonLabel || 'Learn More'; + const ctaVariant = getCtaVariant(config); + if (card.ctaLink) { + html += `

${ctaLabel}

`; + } else { + html += `

${ctaLabel}

`; + } + + html += '
'; + cardEl.innerHTML = html; + return cardEl; +} + +function renderResults(cards, container, config) { + container.innerHTML = ''; + + if (cards.length === 0) { + const noResults = createElement('div', 'no-results'); + const message = config.noResultsMessage || 'No items found'; + noResults.innerHTML = ` + + + +

${message}

+

Try adjusting your search criteria.

+ `; + container.appendChild(noResults); + return; + } + + cards.forEach((card) => { + container.appendChild(createFragmentCard(card, config)); + }); +} + +function filterCards(cards, filters) { + return cards.filter((card) => { + // Full-text search + if (filters.textSearch && filters.textSearch.length >= 2) { + const query = filters.textSearch.toLowerCase(); + const searchable = [ + card.title, + card.subtext, + ...(card.tags || []), + ].join(' ').toLowerCase(); + if (!searchable.includes(query)) return false; + } + + // Tag filter + if (filters.tag && filters.tag !== '') { + const hasTag = (card.tags || []).some( + (t) => t.toLowerCase() === filters.tag.toLowerCase(), + ); + if (!hasTag) return false; + } + + return true; + }); +} + +function createFilterPanel(config, cards) { + const panel = createElement('div', 'fragment-list-filters'); + const row = createElement('div', 'filter-row'); + + // Text search + if (config.enableTextSearch) { + const group = createElement('div', 'filter-group'); + const label = createElement('label', '', 'Search'); + const input = createElement('input'); + input.type = 'text'; + input.className = 'text-search-input'; + input.placeholder = config.searchPlaceholder || 'Search...'; + group.appendChild(label); + group.appendChild(input); + row.appendChild(group); + } + + // Tag filter + if (config.enableTagFilter) { + const group = createElement('div', 'filter-group'); + const label = createElement('label', '', 'Filter by Tag'); + const select = createElement('select', 'tag-filter-select'); + + const defaultOpt = createElement('option'); + defaultOpt.value = ''; + defaultOpt.textContent = 'All Tags'; + select.appendChild(defaultOpt); + + const uniqueTags = getUniqueTags(cards); + uniqueTags.forEach((tag) => { + const opt = createElement('option'); + opt.value = tag.toLowerCase(); + opt.textContent = tag; + select.appendChild(opt); + }); + + group.appendChild(label); + group.appendChild(select); + row.appendChild(group); + } + + panel.appendChild(row); + return panel; +} + +// --- Carousel Navigation --- +function addCarouselNav(block, resultsContainer) { + const nav = createElement('div', 'carousel-nav'); + const prevBtn = createElement('button', 'carousel-nav-btn', '←'); + prevBtn.setAttribute('aria-label', 'Previous'); + prevBtn.type = 'button'; + const nextBtn = createElement('button', 'carousel-nav-btn', '→'); + nextBtn.setAttribute('aria-label', 'Next'); + nextBtn.type = 'button'; + + prevBtn.addEventListener('click', () => { + resultsContainer.scrollBy({ left: -320, behavior: 'smooth' }); + }); + nextBtn.addEventListener('click', () => { + resultsContainer.scrollBy({ left: 320, behavior: 'smooth' }); + }); + + nav.appendChild(prevBtn); + nav.appendChild(nextBtn); + block.appendChild(nav); +} + +// --- Main Decorate Function --- +export default async function decorate(block) { + // Parse config from block key-value structure + let title = ''; + let subtitle = ''; + let layout = 'grid'; + let dataSourceType = 'content-fragments'; + let contentFragmentFolder = ''; + let modelName = ''; + let apiUrl = ''; + let enableFilters = false; + let enableTextSearch = true; + let enableTagFilter = true; + let searchPlaceholder = 'Search...'; + let showTags = false; + let ctaButtonLabel = ''; + let ctaStyle = ''; + let noResultsMessage = ''; + let customClass = ''; + + const rows = Array.from(block.querySelectorAll(':scope > div')); + rows.forEach((row) => { + const cells = row.querySelectorAll(':scope > div'); + if (cells.length < 2) return; + + const key = cells[0].textContent?.trim()?.toLowerCase(); + const valueCell = cells[1]; + const link = valueCell.querySelector('a'); + const value = (link?.getAttribute('title') || link?.textContent || valueCell.textContent || '').trim(); + + if (!key || !value) return; + + switch (key) { + case 'title': title = value; break; + case 'subtitle': subtitle = value; break; + case 'layout': + case 'layout style': layout = value; break; + case 'data source type': + case 'datasourcetype': dataSourceType = value; break; + case 'content fragment folder': + case 'contentfragmentfolder': contentFragmentFolder = value; break; + case 'content fragment model name': + case 'modelname': modelName = value; break; + case 'api url': + case 'apiurl': apiUrl = value; break; + case 'enable filters': + case 'enablefilters': enableFilters = value === 'true'; break; + case 'enable text search': + case 'enabletextsearch': enableTextSearch = value !== 'false'; break; + case 'enable tag filter': + case 'enabletagfilter': enableTagFilter = value !== 'false'; break; + case 'search placeholder': + case 'searchplaceholder': searchPlaceholder = value; break; + case 'show tags': + case 'showtags': showTags = value === 'true'; break; + case 'cta button label': + case 'ctabuttonlabel': ctaButtonLabel = value; break; + case 'cta style': + case 'ctastyle': ctaStyle = value; break; + case 'no results message': + case 'noresultsmessage': noResultsMessage = value; break; + case 'custom class': + case 'custom-class': customClass = value; break; + default: break; + } + }); + + // Hide config rows + Array.from(block.children).forEach((row) => { row.style.display = 'none'; }); + + // Apply classes + block.classList.add('fragment-list'); + if (layout) block.classList.add(layout); + if (customClass) { + customClass.split(/\s+/).filter(Boolean).forEach((cls) => block.classList.add(cls)); + } + + // Build config object + const config = { + title, + subtitle, + layout, + dataSourceType, + contentFragmentFolder, + modelName, + apiUrl, + enableFilters, + enableTextSearch, + enableTagFilter, + searchPlaceholder, + showTags, + ctaButtonLabel, + ctaStyle, + noResultsMessage, + }; + + // --- Build UI --- + // Header + const header = createElement('div', 'fragment-list-header'); + header.innerHTML = ` +

${title}

+ ${subtitle ? `

${subtitle}

` : ''} + `; + block.appendChild(header); + + // Results container + const resultsContainer = createElement('div', 'fragment-list-results'); + resultsContainer.innerHTML = '
Loading...
'; + block.appendChild(resultsContainer); + + // Fetch data + let cards = await fetchFragmentData(config); + + // Filter out disabled items + cards = cards.filter((card) => card.enabled !== false); + + // Filter panel (if enabled) + if (enableFilters && (enableTextSearch || enableTagFilter)) { + const filterPanel = createFilterPanel(config, cards); + block.insertBefore(filterPanel, resultsContainer); + + // Wire up filter listeners + const filters = { textSearch: '', tag: '' }; + const performFilter = debounce(() => { + const filtered = filterCards(cards, filters); + renderResults(filtered, resultsContainer, config); + }, 300); + + const textInput = block.querySelector('.text-search-input'); + if (textInput) { + textInput.addEventListener('input', (e) => { + filters.textSearch = e.target.value; + performFilter(); + }); + } + + const tagSelect = block.querySelector('.tag-filter-select'); + if (tagSelect) { + tagSelect.addEventListener('change', (e) => { + filters.tag = e.target.value; + performFilter(); + }); + } + } + + // Render cards + renderResults(cards, resultsContainer, config); + + // Add carousel navigation if layout is carousel + if (layout === 'carousel') { + addCarouselNav(block, resultsContainer); + } + + // Universal Editor auto-reload support + const blockResource = block.getAttribute('data-aue-resource'); + if (blockResource) { + const handleUEEvent = (event) => { + const eventResource = event.detail?.request?.target?.resource; + if (eventResource === blockResource) { + setTimeout(() => window.location.reload(), 1000); + } + }; + if (!block._ueListenerAdded) { + document.querySelector('main')?.addEventListener('aue:content-patch', handleUEEvent); + document.querySelector('main')?.addEventListener('aue:content-update', handleUEEvent); + block._ueListenerAdded = true; + } + } +} \ No newline at end of file diff --git a/component-definition.json b/component-definition.json index 8f4edd3..6fccd28 100644 --- a/component-definition.json +++ b/component-definition.json @@ -962,6 +962,22 @@ } } }, + { + "title": "Fragment List", + "id": "fragment-list", + "plugins": { + "xwalk": { + "page": { + "resourceType": "core/franklin/components/block/v1/block", + "template": { + "name": "Fragment List", + "model": "fragment-list", + "key-value": true + } + } + } + } + }, { "...": "../blocks/*/_*.json#/definitions" } diff --git a/component-filters.json b/component-filters.json index e877ffa..13802ba 100644 --- a/component-filters.json +++ b/component-filters.json @@ -35,7 +35,8 @@ "form", "forex", "embed-adaptive-form", - "find-a-doctor" + "find-a-doctor", + "fragment-list" ] }, { diff --git a/component-models.json b/component-models.json index 724eb16..151f42b 100644 --- a/component-models.json +++ b/component-models.json @@ -7078,5 +7078,160 @@ "condition": { "==": [{ "var": "enableSubmitAction" }, true] } } ] + }, + { + "id": "fragment-list", + "fields": [ + { + "component": "text", + "valueType": "string", + "name": "title", + "label": "Title", + "value": "" + }, + { + "component": "text", + "valueType": "string", + "name": "subtitle", + "label": "Subtitle", + "value": "" + }, + { + "component": "select", + "name": "layout", + "label": "Layout Style", + "valueType": "string", + "value": "grid", + "options": [ + { "name": "Grid", "value": "grid" }, + { "name": "List", "value": "list" }, + { "name": "Compact", "value": "compact" }, + { "name": "Masonry", "value": "masonry" }, + { "name": "Carousel", "value": "carousel" }, + { "name": "Articles", "value": "articles" } + ] + }, + { + "component": "select", + "name": "dataSourceType", + "label": "Data Source Type", + "valueType": "string", + "value": "content-fragments", + "options": [ + { "name": "Content Fragments", "value": "content-fragments" }, + { "name": "External API", "value": "api" } + ] + }, + { + "component": "aem-content", + "name": "contentFragmentFolder", + "label": "Content Fragment Folder", + "description": "Select folder containing Content Fragments", + "valueType": "string", + "validation": { "rootPath": "/content/dam" }, + "condition": { "==": [{ "var": "dataSourceType" }, "content-fragments"] } + }, + { + "component": "text", + "valueType": "string", + "name": "modelName", + "label": "Content Fragment Model Name", + "description": "Target CF model name to filter by (e.g. content-card)", + "condition": { "==": [{ "var": "dataSourceType" }, "content-fragments"] } + }, + { + "component": "text", + "valueType": "string", + "name": "apiUrl", + "label": "API URL", + "description": "External API endpoint for data", + "condition": { "==": [{ "var": "dataSourceType" }, "api"] } + }, + { + "component": "boolean", + "name": "enableFilters", + "label": "Enable Filters", + "description": "Show the search and filter panel", + "value": false, + "valueType": "boolean" + }, + { + "component": "boolean", + "name": "enableTextSearch", + "label": "Enable Text Search", + "description": "Show full-text search input", + "value": true, + "valueType": "boolean", + "condition": { "==": [{ "var": "enableFilters" }, true] } + }, + { + "component": "boolean", + "name": "enableTagFilter", + "label": "Enable Tag Filter", + "description": "Show tag-based filter dropdown", + "value": true, + "valueType": "boolean", + "condition": { "==": [{ "var": "enableFilters" }, true] } + }, + { + "component": "text", + "valueType": "string", + "name": "searchPlaceholder", + "label": "Search Placeholder", + "value": "Search...", + "description": "Placeholder text for the search input", + "condition": { "==": [{ "var": "enableFilters" }, true] } + }, + { + "component": "boolean", + "name": "showTags", + "label": "Show Tags", + "description": "Show content fragment tags on each card", + "value": false, + "valueType": "boolean", + "condition": { "==": [{ "var": "dataSourceType" }, "content-fragments"] } + }, + { + "component": "select", + "name": "ctastyle", + "label": "CTA Style", + "valueType": "string", + "value": "button", + "options": [ + { + "name": "Primary Button", + "value": "button" + }, + { + "name": "Secondary Button", + "value": "button-secondary" + }, + { + "name": "Dark Button", + "value": "button-dark" + }, + { + "name": "Link", + "value": "link" + } + ] + }, + { + "component": "text", + "valueType": "string", + "name": "noResultsMessage", + "label": "No Results Message", + "value": "No items found", + "description": "Message displayed when no content fragments match" + }, + { + "component": "text", + "name": "custom-class", + "label": "Custom class", + "value": "", + "valueType": "string", + "description": "Space-separated class names to add to the block" + } + ] } ] diff --git a/models/_component-filters.json b/models/_component-filters.json index 7e23a69..f05f931 100644 --- a/models/_component-filters.json +++ b/models/_component-filters.json @@ -35,7 +35,8 @@ "text", "title", "title-block", - "video" + "video", + "fragment-list" ] }, { diff --git a/models/_component-models.json b/models/_component-models.json index 31459e8..27efbb5 100644 --- a/models/_component-models.json +++ b/models/_component-models.json @@ -266,6 +266,161 @@ } ] }, + { + "id": "fragment-list", + "fields": [ + { + "component": "text", + "valueType": "string", + "name": "title", + "label": "Title", + "value": "" + }, + { + "component": "text", + "valueType": "string", + "name": "subtitle", + "label": "Subtitle", + "value": "" + }, + { + "component": "select", + "name": "layout", + "label": "Layout Style", + "valueType": "string", + "value": "grid", + "options": [ + { "name": "Grid", "value": "grid" }, + { "name": "List", "value": "list" }, + { "name": "Compact", "value": "compact" }, + { "name": "Masonry", "value": "masonry" }, + { "name": "Carousel", "value": "carousel" }, + { "name": "Articles", "value": "articles" } + ] + }, + { + "component": "select", + "name": "dataSourceType", + "label": "Data Source Type", + "valueType": "string", + "value": "content-fragments", + "options": [ + { "name": "Content Fragments", "value": "content-fragments" }, + { "name": "External API", "value": "api" } + ] + }, + { + "component": "aem-content", + "name": "contentFragmentFolder", + "label": "Content Fragment Folder", + "description": "Select folder containing Content Fragments", + "valueType": "string", + "validation": { "rootPath": "/content/dam" }, + "condition": { "==": [{ "var": "dataSourceType" }, "content-fragments"] } + }, + { + "component": "text", + "valueType": "string", + "name": "modelName", + "label": "Content Fragment Model Name", + "description": "Target CF model name to filter by (e.g. content-card)", + "condition": { "==": [{ "var": "dataSourceType" }, "content-fragments"] } + }, + { + "component": "text", + "valueType": "string", + "name": "apiUrl", + "label": "API URL", + "description": "External API endpoint for data", + "condition": { "==": [{ "var": "dataSourceType" }, "api"] } + }, + { + "component": "boolean", + "name": "enableFilters", + "label": "Enable Filters", + "description": "Show the search and filter panel", + "value": false, + "valueType": "boolean" + }, + { + "component": "boolean", + "name": "enableTextSearch", + "label": "Enable Text Search", + "description": "Show full-text search input", + "value": true, + "valueType": "boolean", + "condition": { "==": [{ "var": "enableFilters" }, true] } + }, + { + "component": "boolean", + "name": "enableTagFilter", + "label": "Enable Tag Filter", + "description": "Show tag-based filter dropdown", + "value": true, + "valueType": "boolean", + "condition": { "==": [{ "var": "enableFilters" }, true] } + }, + { + "component": "text", + "valueType": "string", + "name": "searchPlaceholder", + "label": "Search Placeholder", + "value": "Search...", + "description": "Placeholder text for the search input", + "condition": { "==": [{ "var": "enableFilters" }, true] } + }, + { + "component": "boolean", + "name": "showTags", + "label": "Show Tags", + "description": "Show content fragment tags on each card", + "value": false, + "valueType": "boolean", + "condition": { "==": [{ "var": "dataSourceType" }, "content-fragments"] } + }, + { + "component": "select", + "name": "ctastyle", + "label": "CTA Style", + "valueType": "string", + "value": "button", + "options": [ + { + "name": "Primary Button", + "value": "button" + }, + { + "name": "Secondary Button", + "value": "button-secondary" + }, + { + "name": "Dark Button", + "value": "button-dark" + }, + { + "name": "Link", + "value": "link" + } + ] + }, + { + "component": "text", + "valueType": "string", + "name": "noResultsMessage", + "label": "No Results Message", + "value": "No items found", + "description": "Message displayed when no content fragments match" + }, + { + "component": "text", + "name": "custom-class", + "label": "Custom class", + "value": "", + "valueType": "string", + "description": "Space-separated class names to add to the block" + } + ] + }, { "...": "../blocks/*/_*.json#/models" } diff --git a/scripts/utils.js b/scripts/utils.js index 3ba8e56..f736a69 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -476,3 +476,76 @@ export function dynamicMediaAssetProcess(pictureElement, qParam) { }); } } +/** + * Resolves an image URL from a Content Fragment image field. + * Handles: + * - Plain string URLs (passthrough) + * - RemoteRef (connected Assets / Polaris DAM) → DM delivery URL + * - ImageRef (local AEM DAM) → _dynamicUrl / _publishUrl / _authorUrl + * - Open API responses (no __typename, field-presence fallback) + * + * @param {string|object} imageField - The image field from CF response + * @param {boolean} [isAuthorEnv] - Whether running on author environment + * @returns {string} Resolved image URL or empty string + */ +export function resolveImageUrl(imageField, isAuthorEnv = false) { + if (!imageField) return ''; + if (typeof imageField === 'string') return imageField; + + // eslint-disable-next-line no-underscore-dangle + const typename = imageField.__typename; + + // Primary: __typename-based routing (GraphQL responses) + switch (typename) { + case 'RemoteRef': { + const repositoryId = (imageField.repositoryId || '').trim(); + const assetId = (imageField.assetId || '').trim(); + const fileName = 'remoteRefAsset.png'; + + if (!repositoryId || !assetId || !fileName) return ''; + + const host = repositoryId.startsWith('http://') || repositoryId.startsWith('https://') + ? repositoryId.replace(/\/$/, '') + : `https://${repositoryId}`; + + return `${host}/adobe/dynamicmedia/deliver/${assetId}/${fileName}`; + } + case 'ImageRef': { + if (isAuthorEnv) { + // eslint-disable-next-line no-underscore-dangle + return imageField._authorUrl || imageField._publishUrl || imageField._dynamicUrl || ''; + } + // eslint-disable-next-line no-underscore-dangle + return imageField._dynamicUrl || imageField._publishUrl || imageField._authorUrl || ''; + } + default: + break; + } + + // Fallback: field-presence detection (Open API / legacy responses without __typename) + if (imageField.repositoryId && imageField.assetId) { + const repositoryId = (imageField.repositoryId || '').trim(); + const assetId = (imageField.assetId || '').trim(); + const fileName = 'asset.png'; + + if (!repositoryId || !assetId || !fileName) return ''; + + const host = repositoryId.startsWith('http://') || repositoryId.startsWith('https://') + ? repositoryId.replace(/\/$/, '') + : `https://${repositoryId}`; + + return `${host}/adobe/dynamicmedia/deliver/${assetId}/${fileName}`; + } + + // eslint-disable-next-line no-underscore-dangle + if (imageField._dynamicUrl || imageField._publishUrl || imageField._authorUrl) { + if (isAuthorEnv) { + // eslint-disable-next-line no-underscore-dangle + return imageField._authorUrl || imageField._publishUrl || imageField._dynamicUrl || ''; + } + // eslint-disable-next-line no-underscore-dangle + return imageField._dynamicUrl || imageField._publishUrl || imageField._authorUrl || ''; + } + + return ''; +} \ No newline at end of file