diff --git a/CHANGELOG.md b/CHANGELOG.md index 5951ee6..c53e473 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to this project will be documented in this file. +## 2.17.4 - 2026-10-23 + +### Added + +- API regions can sort array results by a selected field in ascending or descending order, independently per region. +- API placeholders can reference array entries by zero-based index, such as `{{0.name}}` and `{{1.name}}`; plain field placeholders continue to work with single-object responses. +- API result previews show the first item as an expandable JSON tree. + +### Changed + +- Image dimension guides appear in the small editor preview but not in the popup preview. + ## 2.17.3 - 2026-10-23 ### Fixed @@ -13,7 +25,7 @@ All notable changes to this project will be documented in this file. ### Added -- Added provider-specific weather normalization for Open-Meteo and Pirate Weather, giving current, hourly, and daily forecasts a shared format across previews, playback, and thumbnails, including cached responses, condition icons, timezone-aware dates, and independently selected display units. +- Provider-specific weather normalization for Open-Meteo and Pirate Weather, giving current, hourly, and daily forecasts a shared format across previews, playback, and thumbnails, including cached responses, condition icons, timezone-aware dates, and independently selected display units. ### Fixed @@ -30,9 +42,9 @@ All notable changes to this project will be documented in this file. ### Added -- Added read-only user previews from the Users page, controlled by Impersonate > Allow, with an expiring session, a persistent exit banner, and audit logging. -- Added a responsive changelog page linked from About, with collapsible release history and the latest release expanded by default. -- Added a password-only screen for required password changes, keeping profile and session controls unavailable until completion. +- Read-only user previews from the Users page, controlled by Impersonate > Allow, with an expiring session, a persistent exit banner, and audit logging. +- A responsive changelog page linked from About, with collapsible release history and the latest release expanded by default. +- A password-only screen for required password changes, keeping profile and session controls unavailable until completion. - Dashboard-wide reload, pause, and blackout actions use Connected clients > Allow, the same permission as per-client commands. - Signed-in users without Dashboard access now land on their first permitted section; users without assigned sections get a no-access page with Account and Sign out options. - Role permission checkboxes show each permission's description on hover. @@ -41,7 +53,7 @@ All notable changes to this project will be documented in this file. ### Added -- Added a responsive changelog page linked from About, with collapsible release history and the latest release expanded by default. +- A responsive changelog page linked from About, with collapsible release history and the latest release expanded by default. ## 2.16.7 - 2026-10-22 @@ -88,7 +100,7 @@ All notable changes to this project will be documented in this file. ### Added -- Added an API source Enable/Disable action in the Connection card that pauses all request refreshes while preserving each request's individual enabled setting and unsaved form changes. +- An API source Enable/Disable action in the Connection card that pauses all request refreshes while preserving each request's individual enabled setting and unsaved form changes. ### Fixed @@ -101,19 +113,19 @@ All notable changes to this project will be documented in this file. ### Added -- Added API source groups with shared authentication and cached tokens, plus support for unauthenticated public APIs. Existing request IDs and slide bindings are preserved. -- Added individually named request cards with independent URLs, enabled states, refresh schedules, and full-width response trees with Expand all/Collapse all controls. The Connection card provides source actions and Add request, with confirmation before removing a request. -- Added group-level authentication testing and per-request diagnostics using unsaved settings, with dismissible results below the tested section. Diagnostic reports redact credentials and populated tokens while preserving OAuth metadata and null or empty token fields. +- API source groups with shared authentication and cached tokens, plus support for unauthenticated public APIs. Existing request IDs and slide bindings are preserved. +- Individually named request cards with independent URLs, enabled states, refresh schedules, and full-width response trees with Expand all/Collapse all controls. The Connection card provides source actions and Add request, with confirmation before removing a request. +- Group-level authentication testing and per-request diagnostics using unsaved settings, with dismissible results below the tested section. Diagnostic reports redact credentials and populated tokens while preserving OAuth metadata and null or empty token fields. ## 2.16.0 - 2026-10-20 ### Added -- Added a slide layout editor with drag/resize, pixel position and size, z-index, ratio locking, keyboard nudging, undo/redo, and reset-to-template controls. Its wide modal leaves the sidebar visible and matches the template editor's region outlines; both editors provide alignment guides and snapping, with the template snap toggle in the Designer header. -- Added slide-specific region geometry overrides that flow through previews, playback, and thumbnails without changing sibling slides. Users with template-edit permission can apply the layout to this slide or save it to the shared template; shared saves confirm the affected-slide count, preserve region IDs and animations, clear only the current slide's geometry overrides, and reject stale templates. -- Added a separate slide animation editor with the template's presets and advanced timing controls, plus intro/outro/attention previews on the actual slide canvas. Changes can be applied to this slide or saved to the shared template; per-region and all-region reset actions are available, and shared saves preserve slide content and sibling overrides. -- Added consistent +/- sizing controls to HTML region editors, matching WYSIWYG regions, with the sandboxed iframe note alongside the controls. -- Added an API source connection test using unsaved form values, with separate authentication, data request, and JSON/items checks, sanitised response previews, available field paths, and a copyable diagnostic report in a dismissible results card without changing stored responses. +- A slide layout editor with drag/resize, pixel position and size, z-index, ratio locking, keyboard nudging, undo/redo, and reset-to-template controls. Its wide modal leaves the sidebar visible and matches the template editor's region outlines; both editors provide alignment guides and snapping, with the template snap toggle in the Designer header. +- Slide-specific region geometry overrides that flow through previews, playback, and thumbnails without changing sibling slides. Users with template-edit permission can apply the layout to this slide or save it to the shared template; shared saves confirm the affected-slide count, preserve region IDs and animations, clear only the current slide's geometry overrides, and reject stale templates. +- A separate slide animation editor with the template's presets and advanced timing controls, plus intro/outro/attention previews on the actual slide canvas. Changes can be applied to this slide or saved to the shared template; per-region and all-region reset actions are available, and shared saves preserve slide content and sibling overrides. +- Consistent +/- sizing controls to HTML region editors, matching WYSIWYG regions, with the sandboxed iframe note alongside the controls. +- An API source connection test using unsaved form values, with separate authentication, data request, and JSON/items checks, sanitised response previews, available field paths, and a copyable diagnostic report in a dismissible results card without changing stored responses. ### Changed @@ -123,8 +135,8 @@ All notable changes to this project will be documented in this file. ### Fixed -- Fixed region dragging and resizing when template animation metadata is null. -- Fixed template and slide region outlines so they follow sizes below 12px, including scaled one-pixel regions, while keeping resize handles usable. +- Region dragging and resizing when template animation metadata is null. +- Template and slide region outlines so they follow sizes below 12px, including scaled one-pixel regions, while keeping resize handles usable. ## 2.15.7 - 2026-10-19 @@ -142,8 +154,8 @@ All notable changes to this project will be documented in this file. ### Added -- Added an All timetable display mode that includes past items while respecting date filters and the item limit. -- Added filename renaming in Media Library image and video previews without changing stored media paths or slide references. +- An All timetable display mode that includes past items while respecting date filters and the item limit. +- Filename renaming in Media Library image and video previews without changing stored media paths or slide references. ### Changed @@ -165,8 +177,8 @@ All notable changes to this project will be documented in this file. ### Added -- Added configurable audit events for media-library assets, managed fonts, and weather icon sets. -- Added All / Between Dates / Before Date / After Date filtering to timetable regions, with optional inclusive start-time boundaries shared by previews and playback. +- Configurable audit events for media-library assets, managed fonts, and weather icon sets. +- All / Between Dates / Before Date / After Date filtering to timetable regions, with optional inclusive start-time boundaries shared by previews and playback. ### Changed @@ -197,13 +209,13 @@ All notable changes to this project will be documented in this file. ### Added -- Added Pick / Repeat group modes beside the group title. Repeat plays the entire eligible group in order for the configured number of passes, independently of Pick and shuffle settings. +- Pick / Repeat group modes beside the group title. Repeat plays the entire eligible group in order for the configured number of passes, independently of Pick and shuffle settings. ## 2.15.1 - 2026-10-15 ### Added -- Added an optional shuffle toggle for playlist groups; ordered rotation is now the default. +- An optional shuffle toggle for playlist groups; ordered rotation is now the default. ### Changed @@ -214,7 +226,7 @@ All notable changes to this project will be documented in this file. ### Added -- Added playlist groups with configurable per-pass selection counts and independent multi-rule display schedules. +- Playlist groups with configurable per-pass selection counts and independent multi-rule display schedules. ## 2.14.3 - 2026-10-13 @@ -226,7 +238,7 @@ All notable changes to this project will be documented in this file. ### Added -- Added managed weather icon sets under Settings > Assets, with day and optional night variants, PNG/WebP/SVG uploads, per-region selection, and player synchronization. +- Managed weather icon sets under Settings > Assets, with day and optional night variants, PNG/WebP/SVG uploads, per-region selection, and player synchronization. ### Changed @@ -236,7 +248,7 @@ All notable changes to this project will be documented in this file. ### Added -- Added a configurable text size for chart axis labels, legends, and tooltips. +- A configurable text size for chart axis labels, legends, and tooltips. ### Changed @@ -259,10 +271,10 @@ All notable changes to this project will be documented in this file. ### Added -- Added configurable chart data sources, multi-series line charts, chart type and source options, value ordering, grid line colors, and text colors across editor, popout preview, and player rendering. -- Added player playlist and playback recovery for cached snapshots, offline states, stalled media, failed image/video loads, and service-worker cache failures. -- Added deterministic image derivatives for large uploads while preserving the original file and preventing generated variants from exceeding the original dimensions or file size. -- Added immediate template background uploads with shared media validation, variant generation, cleanup of abandoned uploads, and permission checks. +- Configurable chart data sources, multi-series line charts, chart type and source options, value ordering, grid line colors, and text colors across editor, popout preview, and player rendering. +- Player playlist and playback recovery for cached snapshots, offline states, stalled media, failed image/video loads, and service-worker cache failures. +- Deterministic image derivatives for large uploads while preserving the original file and preventing generated variants from exceeding the original dimensions or file size. +- Immediate template background uploads with shared media validation, variant generation, cleanup of abandoned uploads, and permission checks. ### Changed @@ -280,13 +292,13 @@ All notable changes to this project will be documented in this file. ### Fixed -- Fixed the template background image media picker Upload media button so it opens the file selector. -- Fixed the template designer empty background message moving when the background image is removed. -- Fixed cached API and RSS images not being served by the player after being downloaded. -- Fixed blank time/date regions displaying the default clock format on the player. -- Fixed the time/date placeholder popup omitting text and math transform help. -- Fixed empty region previews displaying type labels instead of remaining blank. -- Fixed empty weather regions displaying the default weather summary. +- The template background image media picker Upload media button so it opens the file selector. +- The template designer empty background message moving when the background image is removed. +- Cached API and RSS images not being served by the player after being downloaded. +- Blank time/date regions displaying the default clock format on the player. +- The time/date placeholder popup omitting text and math transform help. +- Empty region previews displaying type labels instead of remaining blank. +- Empty weather regions displaying the default weather summary. ## 2.13.2 - 2026-09-11 @@ -298,7 +310,7 @@ All notable changes to this project will be documented in this file. ### Added -- Added the shared Media Library to slide region controls and template editors, preserving existing media paths while providing safe private uploads, responsive four-row server-side batching, and card-only scrolling with Load more pagination. +- The shared Media Library to slide region controls and template editors, preserving existing media paths while providing safe private uploads, responsive four-row server-side batching, and card-only scrolling with Load more pagination. ### Changed @@ -310,9 +322,9 @@ All notable changes to this project will be documented in this file. ### Added -- Added offline-capable Local Control for central users with `clients.allow`, scoped to each player and its connected clients, with responsive client actions, current slide titles, and live WebSocket updates for pause and blackout state changes. -- Added player-specific HTTP-only sessions, minimized cached authentication data with hashed usernames, immediate session and socket revocation when authorization changes, and login throttling after repeated failures. -- Added web-owned authorization synchronization: connected remote players refresh every fifteen minutes and receive the current authorization state immediately after reconnecting, while the configured local player is excluded from remote fanout. +- Offline-capable Local Control for central users with `clients.allow`, scoped to each player and its connected clients, with responsive client actions, current slide titles, and live WebSocket updates for pause and blackout state changes. +- Player-specific HTTP-only sessions, minimized cached authentication data with hashed usernames, immediate session and socket revocation when authorization changes, and login throttling after repeated failures. +- Web-owned authorization synchronization: connected remote players refresh every fifteen minutes and receive the current authorization state immediately after reconnecting, while the configured local player is excluded from remote fanout. ## 2.11.3 - 2026-09-08 @@ -382,7 +394,7 @@ All notable changes to this project will be documented in this file. ### Fixed -- Fixed client screen moves from mobile cards so the next move disables the client’s current screen group rather than its previous one. +- Client screen moves from mobile cards so the next move disables the client’s current screen group rather than its previous one. - Removed unused Bootstrap Icons font preloads that triggered browser warnings on pages where icons were not rendered. ## 2.10.6 - 2026-08-29 @@ -398,23 +410,23 @@ All notable changes to this project will be documented in this file. ### Fixed -- Fixed remote player commands and media synchronization so they route through the bridge using the physical player device identity, including announcement and playlist refresh notifications. -- Fixed player media handling for remote bridge uploads and deletes by using the bridge endpoint with explicit player-device authentication. -- Fixed player service-worker caching so ranged media requests bypass stale cached responses and video playback remains reliable. +- Remote player commands and media synchronization so they route through the bridge using the physical player device identity, including announcement and playlist refresh notifications. +- Player media handling for remote bridge uploads and deletes by using the bridge endpoint with explicit player-device authentication. +- Player service-worker caching so ranged media requests bypass stale cached responses and video playback remains reliable. ## 2.10.5 - 2026-08-29 ### Fixed -- Fixed intermittent pairing failures while player registration and pairing sessions propagate through the bridge by retrying transient resolution and completion requests. -- Fixed the pairing page so transient submission failures retry automatically and pairing progress uses a single spinner without dimming the form. -- Fixed the player onboarding page so it continues polling until the pairing code and QR code are available. +- Intermittent pairing failures while player registration and pairing sessions propagate through the bridge by retrying transient resolution and completion requests. +- The pairing page so transient submission failures retry automatically and pairing progress uses a single spinner without dimming the form. +- The player onboarding page so it continues polling until the pairing code and QR code are available. ## 2.10.4 - 2026-08-29 ### Fixed -- Fixed remote client move commands so they route through the player bridge to the correct browser tab using the physical player identity and connection ID. +- Remote client move commands so they route through the player bridge to the correct browser tab using the physical player identity and connection ID. - Removed the obsolete `PLAYER_BASE_URL` configuration fallback; command routing uses `PLAYER_INTERNAL_URL` locally or the player bridge remotely, while `PLAYER_PUBLIC_URL` remains available for kiosk launcher and direct player access. - Removed the player URL list from screen-group add/edit pages and expanded the remaining form card to full width. @@ -427,19 +439,19 @@ All notable changes to this project will be documented in this file. ### Fixed -- Fixed the weather edit preview so the daily forecast is shown initially, the 24-hour forecast is hidden until selected, and the redundant hourly heading is removed. -- Fixed onboarding cleanup so incomplete pairings are retained for the 15-minute pairing-code lifetime while inactive completed bindings are pruned after 24 hours. -- Fixed client-name handling so offline names can be reused, active collisions receive numeric suffixes, and reconnecting players resolve duplicate names consistently. -- Fixed an internal server error when renaming clients by forwarding the available-name resolver to the client command routes. -- Fixed remote client moves to resolve the paired browser client binding before changing its target screen. -- Fixed remote player heartbeats to update the central onboarding bindings for active browser clients without treating the physical player registry ID as a client binding. -- Fixed targeted commands after browser reconnects by falling back to the stable client ID when a transient connection ID is stale. -- Fixed stale browser command connections remaining active indefinitely when the physical player heartbeat was still healthy. -- Fixed media synchronization so generated player caches are excluded while remote image caches remain available to players. -- Fixed weather screen notifications so changes in the fetched data or the current forecast hour trigger a refresh. -- Fixed player runtime script loading and slide rendering so region modules, transitions, and cached playlists initialize consistently. -- Fixed command delivery reporting to require acknowledgement from the receiving browser tab. -- Fixed playlist refresh notifications to target the physical player hosting each screen instead of always targeting the local player. +- The weather edit preview so the daily forecast is shown initially, the 24-hour forecast is hidden until selected, and the redundant hourly heading is removed. +- Onboarding cleanup so incomplete pairings are retained for the 15-minute pairing-code lifetime while inactive completed bindings are pruned after 24 hours. +- Client-name handling so offline names can be reused, active collisions receive numeric suffixes, and reconnecting players resolve duplicate names consistently. +- An internal server error when renaming clients by forwarding the available-name resolver to the client command routes. +- Remote client moves to resolve the paired browser client binding before changing its target screen. +- Remote player heartbeats to update the central onboarding bindings for active browser clients without treating the physical player registry ID as a client binding. +- Targeted commands after browser reconnects by falling back to the stable client ID when a transient connection ID is stale. +- Stale browser command connections remaining active indefinitely when the physical player heartbeat was still healthy. +- Media synchronization so generated player caches are excluded while remote image caches remain available to players. +- Weather screen notifications so changes in the fetched data or the current forecast hour trigger a refresh. +- Player runtime script loading and slide rendering so region modules, transitions, and cached playlists initialize consistently. +- Command delivery reporting to require acknowledgement from the receiving browser tab. +- Playlist refresh notifications to target the physical player hosting each screen instead of always targeting the local player. ## 2.10.2 - 2026-08-28 @@ -449,7 +461,7 @@ All notable changes to this project will be documented in this file. ### Fixed -- Fixed the weather forecast preview to show only its first-fetch message until a successful forecast is available. +- The weather forecast preview to show only its first-fetch message until a successful forecast is available. ## 2.10.1 - 2026-08-28 @@ -527,7 +539,7 @@ All notable changes to this project will be documented in this file. ### Fixed -- Fixed thumbnail capture timing so video assets are ready before the preview canvas is captured. +- Thumbnail capture timing so video assets are ready before the preview canvas is captured. - Replaced unavailable webpage thumbnails with a subdued placeholder while keeping live webpage previews intact. ## 2.8.4 - 2026-08-17 @@ -539,9 +551,9 @@ All notable changes to this project will be documented in this file. ### Fixed -- Fixed popup preview authentication for background thumbnail capture. -- Fixed thumbnails so they use the same popup-preview canvas and resolve API/RSS placeholders, fonts, styles, and cached images correctly. -- Fixed manual scheduled-task run notifications so they use task-neutral wording. +- Popup preview authentication for background thumbnail capture. +- Thumbnails so they use the same popup-preview canvas and resolve API/RSS placeholders, fonts, styles, and cached images correctly. +- Manual scheduled-task run notifications so they use task-neutral wording. ## 2.8.3 - 2026-08-17 @@ -1217,7 +1229,7 @@ All notable changes to this project will be documented in this file. ### Fixed - Removed several onboarding and runtime code paths that were still inserting or refreshing `d_players` rows during normal player playback. -- Fixed the screen-binding flow so it no longer writes a device UUID into `d_screens.player_id`, which avoided foreign key errors under the shared-player model. +- The screen-binding flow so it no longer writes a device UUID into `d_screens.player_id`, which avoided foreign key errors under the shared-player model. ## 2.0.0 - 2026-07-28 @@ -1356,7 +1368,7 @@ All notable changes to this project will be documented in this file. ### Fixed -- Fixed a migration gap that could leave `slides.thumbnail_path` missing on upgraded databases. +- A migration gap that could leave `slides.thumbnail_path` missing on upgraded databases. ## 1.5.4 - 2026-07-25 @@ -1393,13 +1405,13 @@ All notable changes to this project will be documented in this file. ### Fixed -- Fixed player media upload sync so signed upload requests are verified after the request body has been parsed. +- Player media upload sync so signed upload requests are verified after the request body has been parsed. ## 1.5.1 - 2026-07-25 ### Fixed -- Fixed the account page so it can save changes again. +- The account page so it can save changes again. ## 1.5.0 - 2026-07-25 @@ -1452,13 +1464,13 @@ All notable changes to this project will be documented in this file. ### Fixed -- Fixed RBAC client action visibility. +- RBAC client action visibility. ## 1.4.2 - 2026-07-21 ### Fixed -- Fixed duplicate permissions cleanup. +- Duplicate permissions cleanup. ## 1.4.1 - 2026-07-21 @@ -1478,7 +1490,7 @@ All notable changes to this project will be documented in this file. ### Fixed -- Fixed duplicate onboarding client names. +- Duplicate onboarding client names. ## 1.3.3 - 2026-07-21 @@ -1514,20 +1526,20 @@ All notable changes to this project will be documented in this file. ### Fixed -- Fixed screen playlist reassignment so changing a screen's playlist now triggers a refresh. -- Fixed single-slide playlist refresh behavior so updates do not get stuck behind the current slide. +- Screen playlist reassignment so changing a screen's playlist now triggers a refresh. +- Single-slide playlist refresh behavior so updates do not get stuck behind the current slide. ## 1.1.5 - 2026-07-15 ### Fixed -- Fixed text pasting in the admin UI. +- Text pasting in the admin UI. ## 1.1.4 - 2026-07-15 ### Fixed -- Fixed the admin table and playlist reorder UI. +- The admin table and playlist reorder UI. ## 1.1.3 - 2026-07-15 diff --git a/build/package.player.json b/build/package.player.json index 60f836e..3531d50 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.17.3", + "version": "2.17.4", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 0270d03..a7ceec9 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.17.3", + "version": "2.17.4", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index 180c914..93c4322 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.17.3", + "version": "2.17.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.17.3", + "version": "2.17.4", "dependencies": { "animate.css": "^4.1.1", "bootstrap-icons": "1.13.1", diff --git a/package.json b/package.json index 8cf7455..f035f1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.17.3", + "version": "2.17.4", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/data/slides.js b/src/data/slides.js index ff43ec8..a8f6e94 100644 --- a/src/data/slides.js +++ b/src/data/slides.js @@ -466,6 +466,9 @@ async function buildTemplateContent(pool, template, body, filesByField, existing const sourceId = body[`region_api_source_id_${region.id}`]; const itemNumber = body[`region_api_item_number_${region.id}`]; const itemsPath = body[`region_api_items_path_${region.id}`]; + const sortMode = body[`region_api_sort_mode_${region.id}`]; + const sortBy = body[`region_api_sort_by_${region.id}`]; + const sortDirection = body[`region_api_sort_direction_${region.id}`]; const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1)); content[region.region_key] = { type: 'api', @@ -473,6 +476,8 @@ async function buildTemplateContent(pool, template, body, filesByField, existing source_id: sourceId === undefined || sourceId === null ? (current.source_id || null) : (sourceId === '' ? null : Number(sourceId)), item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1, items_path: itemsPath === undefined || itemsPath === null ? (current.items_path === undefined || current.items_path === null ? '' : String(current.items_path)) : String(itemsPath || '').trim(), + sort_by: sortMode === undefined ? String(current.sort_by || '') : (sortMode === 'field' ? String(sortBy || '').trim() : ''), + sort_direction: sortDirection === undefined ? (String(current.sort_direction || '').toLowerCase() === 'desc' ? 'desc' : 'asc') : (sortDirection === 'desc' ? 'desc' : 'asc'), variable_name: 'item', font_family: style.font_family, font_size: style.font_size, diff --git a/src/player/playlist.js b/src/player/playlist.js index 921c15c..39cda48 100644 --- a/src/player/playlist.js +++ b/src/player/playlist.js @@ -5,7 +5,7 @@ const { convertWeatherSnapshot } = require('#src/data/weather-units'); const fs = require('fs'); const path = require('path'); const { createStyledQrCodeDataUrl } = require('#src/data/qr-code'); -const { resolveApiItems } = require('#src/web/public/js/shared/placeholder-utils'); +const { resolveApiItems, sortApiItems } = require('#src/web/public/js/shared/placeholder-utils'); const IMAGE_VARIANT_DIMENSIONS = [2048, 4096, 8192, 16384]; @@ -157,8 +157,14 @@ function createPlayerPlaylistService(options) { const itemNumber = Math.max(1, Number(region.item_number || 1)) - 1; if (String(region.type || '').toLowerCase() === 'api') { const source = (apiSources || []).find(function (entry) { return Number(entry.id) === Number(region.source_id); }); - const items = source ? resolveApiItems(source.responseJson, region.items_path === undefined || region.items_path === null ? source.items_path : region.items_path) : []; - await cacheSource(source, items[itemNumber], expressions); + const items = source ? sortApiItems(resolveApiItems(source.responseJson, region.items_path === undefined || region.items_path === null ? source.items_path : region.items_path), region.sort_by, region.sort_direction) : []; + for (const expression of expressions) { + const indexedMatch = String(expression || '').match(/^\s*(\d+)\.(.+)$/); + const resultIndex = indexedMatch ? Number(indexedMatch[1]) : 0; + const targetItem = indexedMatch ? items[resultIndex] : items[itemNumber]; + const targetExpression = indexedMatch ? indexedMatch[2] : expression; + await cacheSource(source, targetItem, [targetExpression]); + } } if (String(region.type || '').toLowerCase() === 'rss') { const feed = (rssFeeds || []).find(function (entry) { return Number(entry.id) === Number(region.feed_id); }); diff --git a/src/player/regions/api.js b/src/player/regions/api.js index a787ea1..9db93b2 100644 --- a/src/player/regions/api.js +++ b/src/player/regions/api.js @@ -19,24 +19,29 @@ function getApiSourceItemsPath(source, overridePath) { return String(overridePath || '').trim(); } -function getApiSourceItems(sourceId, itemsPathOverride) { +function getApiSourceItems(sourceId, itemsPathOverride, sortBy, sortDirection) { var source = getApiSourceById(sourceId); var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null; var itemsPath = getApiSourceItemsPath(source, itemsPathOverride); - return placeholderUtils.resolveApiItems(responseJson, itemsPath); + var items = placeholderUtils.resolveApiItems(responseJson, itemsPath); + return typeof placeholderUtils.sortApiItems === 'function' ? placeholderUtils.sortApiItems(items, sortBy, sortDirection) : items; } -function getApiItem(sourceId, itemNumber, itemsPathOverride) { - var items = getApiSourceItems(sourceId, itemsPathOverride); +function getApiItem(sourceId, itemNumber, itemsPathOverride, sortBy, sortDirection) { + var items = getApiSourceItems(sourceId, itemsPathOverride, sortBy, sortDirection); var parsedItemNumber = Math.max(1, Number(itemNumber || 1)); var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0; return items[index] || null; } -function substituteApiVariables(html, item, sourceId) { +function substituteApiVariables(html, item, sourceId, items) { var source = String(html || ''); return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) { - if (!item || typeof item !== 'object') { + var indexedMatch = String(expression || '').match(/^\s*(\d+)\.(.+)$/); + var resultIndex = indexedMatch ? Number(indexedMatch[1]) : 0; + var expressionItem = indexedMatch ? (Array.isArray(items) ? items[resultIndex] : null) : item; + var resolvedExpression = indexedMatch ? indexedMatch[2] : expression; + if (!expressionItem || typeof expressionItem !== 'object') { return ''; } @@ -44,18 +49,18 @@ function substituteApiVariables(html, item, sourceId) { return ''; } - var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression); - if (typeof placeholderUtils.isProgressPlaceholderExpression === 'function' && placeholderUtils.isProgressPlaceholderExpression(expression)) { - return placeholderUtils.renderProgressPlaceholder(item, expression); + var resolved = placeholderUtils.resolvePlaceholderExpression(expressionItem, resolvedExpression); + if (typeof placeholderUtils.isProgressPlaceholderExpression === 'function' && placeholderUtils.isProgressPlaceholderExpression(resolvedExpression)) { + return placeholderUtils.renderProgressPlaceholder(expressionItem, resolvedExpression); } - if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) { + if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(resolvedExpression)) { var imageSource = placeholderUtils.formatPlaceholderValue(resolved); var source = getApiSourceById(sourceId); imageSource = source && source.imageCache && source.imageCache[imageSource] ? source.imageCache[imageSource] : imageSource; if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) { return ''; } - var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {}; + var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(resolvedExpression) : {}; var hasImageBounds = imageConfig.width && imageConfig.height; var imageStyle = hasImageBounds ? 'display:block;width:100%;height:100%;object-fit:contain;' @@ -102,12 +107,17 @@ function renderApiRegion(region, regionContent) { var sourceId = regionContent && regionContent.source_id !== undefined ? regionContent.source_id : null; var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1; var itemsPath = regionContent && regionContent.items_path !== undefined ? regionContent.items_path : ''; + var sortBy = regionContent && regionContent.sort_by !== undefined ? regionContent.sort_by : ''; + var sortDirection = regionContent && regionContent.sort_direction !== undefined ? regionContent.sort_direction : 'asc'; var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily); var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize); var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor); - var item = getApiItem(sourceId, itemNumber, itemsPath); + var items = getApiSourceItems(sourceId, itemsPath, sortBy, sortDirection); + var parsedItemNumber = Math.max(1, Number(itemNumber || 1)); + var itemIndex = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0; + var item = items[itemIndex] || null; var hasSource = String(sourceId === undefined || sourceId === null ? '' : sourceId).trim() !== ''; - var body = hasSource ? (item ? substituteApiVariables(content, item, sourceId) : '') : content; + var body = hasSource ? (item || items.length ? substituteApiVariables(content, item, sourceId, items) : '') : content; var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';'; if (!body) { return ''; diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index 5649843..5f0d785 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -3716,47 +3716,81 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection { .api-region-placeholder-section { display: grid; - gap: 0.5rem; - padding-top: 0.25rem; - padding-bottom: 0.5rem; + gap: 0.4rem; + padding-top: 0.125rem; + padding-bottom: 0.25rem; margin-bottom: 0; } .api-region-placeholder-title { - color: var(--bs-primary); - font-size: 0.8rem; - font-weight: 800; - letter-spacing: 0.06em; - text-transform: uppercase; + color: var(--bs-secondary-color); + font-size: 0.78rem; + font-weight: 600; } .api-region-placeholder-title-help { - font-size: 0.9rem; - font-weight: 800; + font-size: 0.8rem; + font-weight: 600; letter-spacing: normal; text-transform: none; } -.api-region-sample-accordion { - padding: 0.85rem 1rem 1rem; - margin-bottom: 0.75rem; +.api-region-placeholder-section .chip { + padding: 0.2rem 0.45rem; border: 1px solid var(--bs-border-color); - border-radius: 0.85rem; - background: var(--bs-secondary-bg); + border-radius: 0.3rem; + background: transparent; + color: var(--bs-secondary-color); + font-family: var(--bs-font-monospace); + font-size: 0.75rem; } -.api-region-sample-accordion > summary { +.api-region-placeholder-section .chip:hover { + border-color: var(--bs-secondary-color); + background: var(--bs-tertiary-bg); +} + +.api-region-placeholder-section .chip:focus-visible { + outline: 2px solid var(--bs-primary); + outline-offset: 2px; +} + +.api-region-sample-accordion { + margin-bottom: 0.75rem; +} + +.api-region-sample-header { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.api-region-sample-toggle { + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + box-sizing: border-box; + height: 2.5rem; + padding: 0.375rem 0.75rem; cursor: pointer; - list-style: none; - color: var(--bs-primary); - font-size: 0.8rem; + font-size: 0.875rem; font-weight: 800; - letter-spacing: 0.06em; - text-transform: uppercase; + text-align: left; } -.api-region-sample-accordion > summary::-webkit-details-marker { - display: none; +.api-region-sample-toggle:focus-visible { + outline: 2px solid var(--bs-primary); + outline-offset: 2px; +} + +.api-region-sample-chevron { + transition: transform 120ms ease; +} + +.api-region-sample-accordion.is-open .api-region-sample-chevron { + transform: rotate(180deg); } .api-region-sample-body { @@ -3765,19 +3799,21 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection { margin-top: 0.75rem; } +.api-region-sample-body[hidden] { + display: none; +} + .api-region-sample-meta { color: var(--bs-secondary-color); font-size: 0.78rem; } .api-region-sample-preview { - max-height: 12rem; - overflow: auto; margin: 0; padding: 0.75rem; - border-radius: 0.65rem; - background: var(--bs-body-bg); - border: 1px solid var(--bs-border-color-translucent); + border: 0; + border-radius: 0.375rem; + background: var(--bs-secondary-bg); white-space: pre-wrap; word-break: break-word; font-size: 0.78rem; diff --git a/src/web/public/js/regions/type/api.js b/src/web/public/js/regions/type/api.js index 7281435..cdfe735 100644 --- a/src/web/public/js/regions/type/api.js +++ b/src/web/public/js/regions/type/api.js @@ -47,15 +47,16 @@ return String(overridePath || '').trim(); } - function getItems(sourceId, sources, itemsPathOverride) { + function getItems(sourceId, sources, itemsPathOverride, sortBy, sortDirection) { var source = getSourceById(sourceId, sources); var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null; var itemsPath = getSourceItemsPath(source, itemsPathOverride); - return placeholderUtils.resolveApiItems(responseJson, itemsPath); + var items = placeholderUtils.resolveApiItems(responseJson, itemsPath); + return typeof placeholderUtils.sortApiItems === 'function' ? placeholderUtils.sortApiItems(items, sortBy, sortDirection) : items; } - function getItemCount(sourceId, sources, itemsPathOverride) { - return getItems(sourceId, sources, itemsPathOverride).length; + function getItemCount(sourceId, sources, itemsPathOverride, sortBy, sortDirection) { + return getItems(sourceId, sources, itemsPathOverride, sortBy, sortDirection).length; } function getCurrentConfig(region, existingContent, sources) { @@ -66,15 +67,21 @@ source_id: current.source_id === undefined || current.source_id === null || current.source_id === '' ? '' : Number(current.source_id), item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1, items_path: Object.prototype.hasOwnProperty.call(current, 'items_path') ? String(current.items_path === undefined || current.items_path === null ? '' : current.items_path) : undefined, + sort_by: String(current.sort_by || ''), + sort_direction: String(current.sort_direction || 'asc').toLowerCase() === 'desc' ? 'desc' : 'asc', default_items_path: String(source && (source.items_path || source.itemsPath) || '').trim(), value: current.value !== undefined ? current.value : '' }; } - function substituteVariables(html, item, options) { + function substituteVariables(html, item, options, items) { var source = String(html || ''); return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) { - if (!item || typeof item !== 'object') { + var indexedMatch = String(expression || '').match(/^\s*(\d+)\.(.+)$/); + var resultIndex = indexedMatch ? Number(indexedMatch[1]) : 0; + var expressionItem = indexedMatch ? (Array.isArray(items) ? items[resultIndex] : null) : item; + var resolvedExpression = indexedMatch ? indexedMatch[2] : expression; + if (!expressionItem || typeof expressionItem !== 'object') { return ''; } @@ -82,16 +89,16 @@ return ''; } - var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression); - if (typeof placeholderUtils.isProgressPlaceholderExpression === 'function' && placeholderUtils.isProgressPlaceholderExpression(expression)) { - return placeholderUtils.renderProgressPlaceholder(item, expression); + var resolved = placeholderUtils.resolvePlaceholderExpression(expressionItem, resolvedExpression); + if (typeof placeholderUtils.isProgressPlaceholderExpression === 'function' && placeholderUtils.isProgressPlaceholderExpression(resolvedExpression)) { + return placeholderUtils.renderProgressPlaceholder(expressionItem, resolvedExpression); } - if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(expression)) { + if (typeof placeholderUtils.isImagePlaceholderExpression === 'function' && placeholderUtils.isImagePlaceholderExpression(resolvedExpression)) { var imageSource = placeholderUtils.formatPlaceholderValue(resolved); if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) { return ''; } - var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(expression) : {}; + var imageConfig = typeof placeholderUtils.getImagePlaceholderConfig === 'function' ? placeholderUtils.getImagePlaceholderConfig(resolvedExpression) : {}; var imageStyle = options && options.preview && imageConfig.width && imageConfig.height ? 'display:block;width:100%;height:100%;object-fit:contain;' : 'display:block;width:auto;height:auto;' + (imageConfig.width ? 'max-width:' + imageConfig.width + 'px;' : '') + (imageConfig.height ? 'max-height:' + imageConfig.height + 'px;' : ''); @@ -99,13 +106,14 @@ var hasPreviewBounds = options && options.preview && imageConfig.width && imageConfig.height; if (hasPreviewBounds || imageConfig.verticalAlign) { var wrapperStyle = 'display:inline-flex;align-items:center;justify-content:center;'; + var wrapperClass = hasPreviewBounds ? ' class="api-image-placeholder-bounds"' : ''; if (hasPreviewBounds) { wrapperStyle += 'box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);'; } if (imageConfig.verticalAlign) { wrapperStyle += 'vertical-align:' + imageConfig.verticalAlign + ';'; } - return '' + image + ''; + return '' + image + ''; } return image; } @@ -113,8 +121,8 @@ }); } - function getItem(sourceId, itemNumber, sources, itemsPathOverride) { - var items = getItems(sourceId, sources, itemsPathOverride); + function getItem(sourceId, itemNumber, sources, itemsPathOverride, sortBy, sortDirection) { + var items = getItems(sourceId, sources, itemsPathOverride, sortBy, sortDirection); var parsedItemNumber = Math.max(1, Number(itemNumber || 1)); var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0; return items[index] || null; @@ -149,8 +157,8 @@ '

API placeholder transforms

' + '' + '' + - '
' + - '

Placeholders read values from the selected API item. Nested fields use dots.

' + + '
' + + '

Use placeholders such as {{name}} for the selected result, or {{0.name}} and {{1.name}} to read sorted results by index. Indices start at 0. Nested fields use dots.

' + (window.placeholderInfo ? window.placeholderInfo.renderTextTransforms() : '') + (window.placeholderInfo ? window.placeholderInfo.renderMathTransforms() : '') + (window.placeholderInfo ? window.placeholderInfo.renderDateFormatTokens() : '') + @@ -189,14 +197,15 @@ var sourceId = regionContent && regionContent.source_id !== undefined ? regionContent.source_id : config.source_id; var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : config.item_number; var itemsPath = regionContent && regionContent.items_path !== undefined ? regionContent.items_path : (config.items_path !== undefined ? config.items_path : config.default_items_path); - var itemCount = getItemCount(sourceId, sources, itemsPath); - var itemNumberMax = Math.max(1, Number(itemCount || 1)); var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily); var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize); var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor); - var item = getItem(sourceId, itemNumber, sources, itemsPath); + var items = getItems(sourceId, sources, itemsPath, regionContent.sort_by, regionContent.sort_direction); + var parsedItemNumber = Math.max(1, Number(itemNumber || 1)); + var itemIndex = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0; + var item = items[itemIndex] || null; var hasSource = String(sourceId === undefined || sourceId === null ? '' : sourceId).trim() !== ''; - var body = hasSource ? (item ? substituteVariables(content, item, { preview: true }) : '') : content; + var body = hasSource ? (item || items.length ? substituteVariables(content, item, { preview: true }, items) : '') : content; body = String(body || '') .replace(/]*class="[^"]*api-region-sample-preview[^"]*"[^>]*>[\s\S]*?<\/pre>/gi, '') @@ -239,17 +248,13 @@ '' + '
' + '
' + - '
' + + '
' + '' + '' + '
' + - '
' + - '' + - '' + - '
' + '
' + '' + '
' + @@ -257,17 +262,36 @@ '' + '
' + '
' + + '
' + + '' + + '
' + + '
' + + '' + '' + '' + '
' + - '
Available placeholders ' + renderTransformInfo(region.id) + '
' + + '
Available fields ' + renderTransformInfo(region.id) + '
' + '
' + ((placeholderFields.length && window.placeholderChips && typeof window.placeholderChips.renderChips === 'function') ? window.placeholderChips.renderChips(placeholderFields) : placeholderChips || 'No JSON fields available.') + '
' + '
' + - '
' + - 'Data' + - '
' + sampleDataPreview + '
' + - '
' + + '
' + + '
' + + '' + + '
' + + '' + + '
' + '
' + '
'; } @@ -314,7 +338,9 @@ }())), source_id: card.querySelector('select[name="region_api_source_id_' + region.id + '"]') ? card.querySelector('select[name="region_api_source_id_' + region.id + '"]').value : current.source_id, item_number: (card.querySelector('input[name="region_api_item_number_' + region.id + '"]') || {}).value || current.item_number, - items_path: card.querySelector('input[name="region_api_items_path_' + region.id + '"]') ? (card.querySelector('input[name="region_api_items_path_' + region.id + '"]') || {}).value : current.items_path + items_path: card.querySelector('input[name="region_api_items_path_' + region.id + '"]') ? (card.querySelector('input[name="region_api_items_path_' + region.id + '"]') || {}).value : current.items_path, + sort_by: card.querySelector('select[name="region_api_sort_mode_' + region.id + '"]') && card.querySelector('select[name="region_api_sort_mode_' + region.id + '"]').value === 'field' ? (card.querySelector('input[name="region_api_sort_by_' + region.id + '"]') || {}).value || '' : '', + sort_direction: (card.querySelector('select[name="region_api_sort_direction_' + region.id + '"]') || {}).value || current.sort_direction } : current; return { @@ -322,6 +348,8 @@ source_id: content.source_id, item_number: content.item_number, items_path: content.items_path, + sort_by: content.sort_by, + sort_direction: content.sort_direction, existingContent: existingContent || {}, sources: Array.isArray(sources) ? sources : [] }; diff --git a/src/web/public/js/shared/placeholder-utils.js b/src/web/public/js/shared/placeholder-utils.js index cec4586..e0e47f9 100644 --- a/src/web/public/js/shared/placeholder-utils.js +++ b/src/web/public/js/shared/placeholder-utils.js @@ -67,6 +67,44 @@ return value && typeof value === 'object' ? [value] : []; } + function sortApiItems(items, sortBy, direction) { + var path = String(sortBy || '').trim().replace(/\[([0-9]+)\]/g, '.$1'); + if (!Array.isArray(items)) { + return []; + } + if (!path) { + return items.slice(); + } + + var descending = String(direction || '').toLowerCase() === 'desc'; + function valueFor(item) { + return resolvePath(item, path); + } + function isMissing(value) { + return value === undefined || value === null || value === ''; + } + function compare(left, right) { + if (isMissing(left)) return isMissing(right) ? 0 : 1; + if (isMissing(right)) return -1; + var leftNumber = typeof left === 'number' ? left : (typeof left === 'string' && left.trim() !== '' ? Number(left) : NaN); + var rightNumber = typeof right === 'number' ? right : (typeof right === 'string' && right.trim() !== '' ? Number(right) : NaN); + if (Number.isFinite(leftNumber) && Number.isFinite(rightNumber)) { + return leftNumber < rightNumber ? -1 : leftNumber > rightNumber ? 1 : 0; + } + return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' }); + } + + return items.map(function (item, index) { + return { item: item, index: index, value: valueFor(item) }; + }).sort(function (left, right) { + var result = compare(left.value, right.value); + if (result && descending && !isMissing(left.value) && !isMissing(right.value)) return -result; + return result || left.index - right.index; + }).map(function (entry) { + return entry.item; + }); + } + function parsePlaceholderExpression(expression) { var raw = String(expression || '').trim(); var segments = raw ? splitExpressionSegments(raw) : []; @@ -604,6 +642,7 @@ return { resolvePath: resolvePath, resolveApiItems: resolveApiItems, + sortApiItems: sortApiItems, parsePlaceholderExpression: parsePlaceholderExpression, resolvePlaceholderExpression: resolvePlaceholderExpression, isImagePlaceholderExpression: isImagePlaceholderExpression, diff --git a/src/web/public/js/slides/slide-form-regions.js b/src/web/public/js/slides/slide-form-regions.js index 7f17123..710330e 100644 --- a/src/web/public/js/slides/slide-form-regions.js +++ b/src/web/public/js/slides/slide-form-regions.js @@ -178,18 +178,39 @@ export function createSlideFormRegionHelpers(options) { return text.slice(0, limit).replace(/\s+$/, '') + '\n...'; } - function buildApiSampleDataMarkup(sourceId, itemNumber, itemsPathOverride) { + function buildApiSampleDataMarkup(sourceId, itemsPathOverride, sortBy, sortDirection) { var source = getApiSourceById(sourceId); if (!source) { return '
Select an API source to preview sample data.
'; } - var item = getApiSourceItems(sourceId, itemsPathOverride)[Math.max(0, Math.max(1, Number(itemNumber || 1)) - 1)] || null; - if (!item) { + var items = getApiSourceItems(sourceId, itemsPathOverride, sortBy, sortDirection); + if (!items.length) { return '
No sample item is available for the current selection.
'; } - return '
' + escapeHtml(truncateJsonPreview(item, 1100)) + '
'; + var firstItemJson = ''; + try { + firstItemJson = JSON.stringify(items[0], null, 2); + } catch (_error) { + firstItemJson = String(items[0] === undefined || items[0] === null ? '' : items[0]); + } + var label = apiItemsPathIsArray(source, itemsPathOverride) ? 'Example result (first item)' : 'Example result'; + var jsonSource = JSON.stringify(firstItemJson).replace(//g, '\\u003e').replace(/&/g, '\\u0026'); + return '' + + '
' + + '
' + + '
' + label + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '' + + '
' + escapeHtml(firstItemJson) + '
' + + '
' + + '
'; } function updatePlaceholderChipList(card, fieldList, emptyLabel) { @@ -356,25 +377,31 @@ export function createSlideFormRegionHelpers(options) { return String(overridePath || '').trim(); } - function getApiSourceItems(sourceId, itemsPathOverride) { + function getApiSourceItems(sourceId, itemsPathOverride, sortBy, sortDirection) { var source = getApiSourceById(sourceId); var module = getRegionTypeModule('api'); if (module && typeof module.getItems === 'function') { - return module.getItems(sourceId, apiSources, itemsPathOverride); + return module.getItems(sourceId, apiSources, itemsPathOverride, sortBy, sortDirection); } var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null; var itemsPath = getApiSourceItemsPath(source, itemsPathOverride); - return window.placeholderUtils.resolveApiItems(responseJson, itemsPath); + var items = window.placeholderUtils.resolveApiItems(responseJson, itemsPath); + return typeof window.placeholderUtils.sortApiItems === 'function' ? window.placeholderUtils.sortApiItems(items, sortBy, sortDirection) : items; } - function getApiItemCount(sourceId, itemsPathOverride) { - var module = getRegionTypeModule('api'); - if (module && typeof module.getItemCount === 'function') { - return module.getItemCount(sourceId, apiSources, itemsPathOverride); + function apiItemsPathIsArray(source, itemsPathOverride) { + var response = source && source.responseJson; + var itemsPath = getApiSourceItemsPath(source, itemsPathOverride); + if (itemsPath) { + return Array.isArray(window.placeholderUtils.resolvePath(response, itemsPath)); } - - return getApiSourceItems(sourceId, itemsPathOverride).length; + if (Array.isArray(response)) { + return true; + } + return Boolean(response && ['items', 'results', 'data'].some(function (key) { + return Array.isArray(response[key]); + })); } function getApiFieldList(sourceId, itemsPathOverride) { @@ -399,7 +426,18 @@ export function createSlideFormRegionHelpers(options) { container.innerHTML = buildLimitedPlaceholderChipMarkup(getApiFieldList(sourceId, itemsPathOverride), 'No JSON fields available.', false); } - function updateApiSampleDataPanel(regionId, sourceId, itemNumber, itemsPathOverride) { + function updateApiItemsPathReset(regionId, sourceId) { + var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null; + var button = card ? card.querySelector('[data-api-items-path-reset]') : null; + if (!button) { + return; + } + + var source = getApiSourceById(sourceId); + button.dataset.apiDefaultItemsPath = String(source && (source.items_path || source.itemsPath) || '').trim(); + } + + function updateApiSampleDataPanel(regionId, sourceId, itemsPathOverride, sortBy, sortDirection) { var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null; if (!card) { return; @@ -410,44 +448,10 @@ export function createSlideFormRegionHelpers(options) { return; } - container.innerHTML = buildApiSampleDataMarkup(sourceId, itemNumber, itemsPathOverride); - } - - function updateApiItemNumberLimit(regionId, sourceId, itemsPathOverride) { - var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null; - if (!card) { - return; + container.innerHTML = buildApiSampleDataMarkup(sourceId, itemsPathOverride, sortBy, sortDirection); + if (typeof window.initJsonTogglePanels === 'function') { + window.initJsonTogglePanels(container); } - - var input = card.querySelector('input[name="region_api_item_number_' + regionId + '"]'); - if (!input) { - return; - } - - var itemCount = Math.max(1, Number(getApiItemCount(sourceId, itemsPathOverride) || 1)); - input.setAttribute('max', String(itemCount)); - if (Number(input.value || 0) > itemCount) { - input.value = String(itemCount); - } - } - - function clampApiItemNumber(regionId, sourceId, itemNumber, itemsPathOverride) { - var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null; - if (!card) { - return ''; - } - - var input = card.querySelector('input[name="region_api_item_number_' + regionId + '"]'); - if (!input) { - return ''; - } - - var itemCount = Math.max(1, Number(getApiItemCount(sourceId, itemsPathOverride) || 1)); - var parsedValue = Math.max(1, Number(itemNumber || 1)); - var clampedValue = Math.min(itemCount, Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : 1); - input.setAttribute('max', String(itemCount)); - input.value = String(clampedValue); - return String(clampedValue); } function getCurrentApiConfig(region) { @@ -546,7 +550,7 @@ export function createSlideFormRegionHelpers(options) { : region.region_type === 'rss' ? getRssFieldList(getCurrentRssConfig(region).feed_id) : [], - sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path), + sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path, getCurrentApiConfig(region).sort_by, getCurrentApiConfig(region).sort_direction), timetableGroups: timetableGroups, weatherLocations: weatherLocations, mediaAssets: mediaAssets @@ -589,7 +593,7 @@ export function createSlideFormRegionHelpers(options) { : region.region_type === 'rss' ? getRssFieldList(rssConfig.feed_id) : [], - sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath), + sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiItemsPath, apiConfig.sort_by, apiConfig.sort_direction), timetableGroups: timetableGroups, weatherLocations: weatherLocations, mediaAssets: mediaAssets @@ -754,7 +758,6 @@ export function createSlideFormRegionHelpers(options) { var rssFeedInput = card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]'); var rssItemInput = card.querySelector('input[name="region_rss_item_number_' + region.id + '"]'); var apiSourceInput = card.querySelector('select[name="region_api_source_id_' + region.id + '"]'); - var apiItemInput = card.querySelector('input[name="region_api_item_number_' + region.id + '"]'); var apiItemsPathInput = card.querySelector('input[name="region_api_items_path_' + region.id + '"]'); if (region.region_type === 'image') { @@ -779,8 +782,12 @@ export function createSlideFormRegionHelpers(options) { } else if (region.region_type === 'api') { content.value = hiddenInput ? hiddenInput.value : (content.value || ''); content.source_id = apiSourceInput ? apiSourceInput.value : content.source_id; - content.item_number = apiItemInput ? apiItemInput.value : content.item_number; content.items_path = apiItemsPathInput ? apiItemsPathInput.value : content.items_path; + var apiSortModeInput = card.querySelector('select[name="region_api_sort_mode_' + region.id + '"]'); + var apiSortByInput = card.querySelector('input[name="region_api_sort_by_' + region.id + '"]'); + var apiSortDirectionInput = card.querySelector('select[name="region_api_sort_direction_' + region.id + '"]'); + content.sort_by = apiSortModeInput && apiSortModeInput.value === 'field' && apiSortByInput ? apiSortByInput.value : ''; + content.sort_direction = apiSortDirectionInput ? apiSortDirectionInput.value : content.sort_direction; } else if (region.region_type === 'timetable') { var timetableGroupInput = card.querySelector('select[name="region_timetable_group_id_' + region.id + '"]'); var timetableDisplayModeInput = card.querySelector('select[name="region_timetable_display_mode_' + region.id + '"]'); @@ -835,9 +842,10 @@ export function createSlideFormRegionHelpers(options) { updateRssPlaceholderChips: updateRssPlaceholderChips, getCurrentRssConfig: getCurrentRssConfig, getApiFieldList: getApiFieldList, + buildApiSampleDataMarkup: buildApiSampleDataMarkup, updateApiPlaceholderChips: updateApiPlaceholderChips, + updateApiItemsPathReset: updateApiItemsPathReset, updateApiSampleDataPanel: updateApiSampleDataPanel, - updateApiItemNumberLimit: updateApiItemNumberLimit, updateRssItemNumberLimit: updateRssItemNumberLimit, getCurrentApiConfig: getCurrentApiConfig, reduceAspectRatio: reduceAspectRatio, diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js index 6568578..7ec5ddc 100644 --- a/src/web/public/js/slides/slide-form.js +++ b/src/web/public/js/slides/slide-form.js @@ -748,8 +748,8 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed var getCurrentRssConfig = slideRegionHelpers.getCurrentRssConfig; var getApiFieldList = slideRegionHelpers.getApiFieldList; var updateApiPlaceholderChips = slideRegionHelpers.updateApiPlaceholderChips; + var updateApiItemsPathReset = slideRegionHelpers.updateApiItemsPathReset; var updateApiSampleDataPanel = slideRegionHelpers.updateApiSampleDataPanel; - var updateApiItemNumberLimit = slideRegionHelpers.updateApiItemNumberLimit; var getCurrentApiConfig = slideRegionHelpers.getCurrentApiConfig; var updateRssItemNumberLimit = slideRegionHelpers.updateRssItemNumberLimit; @@ -768,6 +768,9 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed templateFields.innerHTML = template.regions.length ? template.regions.map(function (region) { return getRegionEditorCardHtml(region) || '
Region editor unavailable.
'; }).join('') : '
This template has no regions.
'; + if (typeof window.initJsonTogglePanels === 'function') { + window.initJsonTogglePanels(templateFields); + } if (window.adminlte && typeof window.adminlte.CardWidget === 'function') { templateFields.querySelectorAll('.card-widget[data-region-id]').forEach(function (card) { @@ -787,14 +790,6 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed window.requestAnimationFrame(function () { bindRegionCards(); }); - templateFields.querySelectorAll('input[name^="region_api_item_number_"]').forEach(function (input) { - var regionId = input.name.replace(/^region_api_item_number_/, ''); - var sourceInput = templateFields.querySelector('select[name="region_api_source_id_' + regionId + '"]'); - var pathInput = templateFields.querySelector('input[name="region_api_items_path_' + regionId + '"]'); - if (sourceInput) { - updateApiItemNumberLimit(regionId, sourceInput.value, pathInput ? pathInput.value : ''); - } - }); templateFields.querySelectorAll('input[name^="region_rss_item_number_"]').forEach(function (input) { var regionId = input.name.replace(/^region_rss_item_number_/, ''); var feedInput = templateFields.querySelector('select[name="region_rss_feed_id_' + regionId + '"]'); @@ -852,39 +847,59 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed } }); } - templateFields.querySelectorAll('select[name^="region_rss_feed_id_"], input[name^="region_rss_item_number_"], select[name^="region_api_source_id_"], input[name^="region_api_item_number_"], input[name^="region_api_items_path_"]').forEach(function (input) { + templateFields.querySelectorAll('select[name^="region_rss_feed_id_"], input[name^="region_rss_item_number_"], select[name^="region_api_source_id_"], input[name^="region_api_items_path_"], select[name^="region_api_sort_mode_"], input[name^="region_api_sort_by_"], select[name^="region_api_sort_direction_"]').forEach(function (input) { input.addEventListener('input', function () { - var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number|api_items_path)_/, ''); + var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_items_path|api_sort_mode|api_sort_by|api_sort_direction)_/, ''); if (input.name.indexOf('region_rss_') === 0) { var rssFeed = input.name.indexOf('region_rss_feed_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_rss_feed_id_' + regionId + '"]') || {}).value; updateRssPlaceholderChips(regionId, rssFeed); updateRssItemNumberLimit(regionId, rssFeed); } if (input.name.indexOf('region_api_') === 0) { + var apiSortMode = templateFields.querySelector('select[name="region_api_sort_mode_' + regionId + '"]'); + var apiSortByInput = templateFields.querySelector('input[name="region_api_sort_by_' + regionId + '"]'); + var apiSortFieldWrap = apiSortByInput && apiSortByInput.closest('[data-api-sort-field-wrap]'); + var apiSortDirection = templateFields.querySelector('select[name="region_api_sort_direction_' + regionId + '"]'); + var apiSortDirectionWrap = apiSortDirection && apiSortDirection.closest('[data-api-sort-direction-wrap]'); + var apiSortEnabled = apiSortMode && apiSortMode.value === 'field'; + if (apiSortFieldWrap) apiSortFieldWrap.hidden = !apiSortEnabled; + if (apiSortDirectionWrap) apiSortDirectionWrap.hidden = !apiSortEnabled; + if (apiSortMode && !apiSortEnabled && apiSortByInput) apiSortByInput.value = ''; var apiSource = input.name.indexOf('region_api_source_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_api_source_id_' + regionId + '"]') || {}).value; - var apiItemNumber = input.name.indexOf('region_api_item_number_') === 0 ? input.value : (templateFields.querySelector('input[name="region_api_item_number_' + regionId + '"]') || {}).value; var apiItemsPath = input.name.indexOf('region_api_items_path_') === 0 ? input.value : (templateFields.querySelector('input[name="region_api_items_path_' + regionId + '"]') || {}).value; - updateApiItemNumberLimit(regionId, apiSource, apiItemsPath); + var apiSortBy = apiSortEnabled && apiSortByInput ? apiSortByInput.value : ''; + var apiSortDirectionValue = apiSortDirection ? apiSortDirection.value : 'asc'; + updateApiItemsPathReset(regionId, apiSource); updateApiPlaceholderChips(regionId, apiSource, apiItemsPath); - updateApiSampleDataPanel(regionId, apiSource, apiItemNumber, apiItemsPath); + updateApiSampleDataPanel(regionId, apiSource, apiItemsPath, apiSortBy, apiSortDirectionValue); } templateSelectorLock.markEdited(); requestPreviewRender(); }); input.addEventListener('change', function () { - var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number|api_items_path)_/, ''); + var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_items_path|api_sort_mode|api_sort_by|api_sort_direction)_/, ''); if (input.name.indexOf('region_rss_') === 0) { var rssFeed = input.name.indexOf('region_rss_feed_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_rss_feed_id_' + regionId + '"]') || {}).value; updateRssPlaceholderChips(regionId, rssFeed); updateRssItemNumberLimit(regionId, rssFeed); } if (input.name.indexOf('region_api_') === 0) { + var apiSortMode = templateFields.querySelector('select[name="region_api_sort_mode_' + regionId + '"]'); + var apiSortByInput = templateFields.querySelector('input[name="region_api_sort_by_' + regionId + '"]'); + var apiSortFieldWrap = apiSortByInput && apiSortByInput.closest('[data-api-sort-field-wrap]'); + var apiSortDirection = templateFields.querySelector('select[name="region_api_sort_direction_' + regionId + '"]'); + var apiSortDirectionWrap = apiSortDirection && apiSortDirection.closest('[data-api-sort-direction-wrap]'); + var apiSortEnabled = apiSortMode && apiSortMode.value === 'field'; + if (apiSortFieldWrap) apiSortFieldWrap.hidden = !apiSortEnabled; + if (apiSortDirectionWrap) apiSortDirectionWrap.hidden = !apiSortEnabled; + if (apiSortMode && !apiSortEnabled && apiSortByInput) apiSortByInput.value = ''; var apiSource = input.name.indexOf('region_api_source_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_api_source_id_' + regionId + '"]') || {}).value; - var apiItemNumber = input.name.indexOf('region_api_item_number_') === 0 ? input.value : (templateFields.querySelector('input[name="region_api_item_number_' + regionId + '"]') || {}).value; var apiItemsPath = input.name.indexOf('region_api_items_path_') === 0 ? input.value : (templateFields.querySelector('input[name="region_api_items_path_' + regionId + '"]') || {}).value; - updateApiItemNumberLimit(regionId, apiSource, apiItemsPath); + var apiSortBy = apiSortEnabled && apiSortByInput ? apiSortByInput.value : ''; + var apiSortDirectionValue = apiSortDirection ? apiSortDirection.value : 'asc'; + updateApiItemsPathReset(regionId, apiSource); updateApiPlaceholderChips(regionId, apiSource, apiItemsPath); - updateApiSampleDataPanel(regionId, apiSource, apiItemNumber, apiItemsPath); + updateApiSampleDataPanel(regionId, apiSource, apiItemsPath, apiSortBy, apiSortDirectionValue); } templateSelectorLock.markEdited(); requestPreviewRender(); @@ -989,6 +1004,30 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed requestPreviewRender(); }); templateFields.addEventListener('click', function (event) { + var advancedToggle = event.target && typeof event.target.closest === 'function' ? event.target.closest('[data-api-advanced-toggle]') : null; + if (advancedToggle) { + var advancedPanel = templateFields.querySelector('#' + advancedToggle.getAttribute('aria-controls')); + if (advancedPanel) { + var isExpanded = advancedToggle.getAttribute('aria-expanded') === 'true'; + advancedToggle.setAttribute('aria-expanded', isExpanded ? 'false' : 'true'); + advancedPanel.hidden = isExpanded; + advancedToggle.classList.toggle('btn-outline-secondary', isExpanded); + advancedToggle.classList.toggle('btn-secondary', !isExpanded); + } + return; + } + var sampleToggle = event.target && typeof event.target.closest === 'function' ? event.target.closest('[data-api-sample-toggle]') : null; + if (sampleToggle) { + var sampleAccordion = sampleToggle.closest('[data-api-sample-data-accordion]'); + var samplePanel = sampleAccordion && sampleAccordion.querySelector('[data-api-sample-data-panel]'); + if (sampleAccordion && samplePanel) { + var isExpanded = sampleToggle.getAttribute('aria-expanded') === 'true'; + sampleToggle.setAttribute('aria-expanded', isExpanded ? 'false' : 'true'); + sampleAccordion.classList.toggle('is-open', !isExpanded); + samplePanel.hidden = isExpanded; + } + return; + } var button = event.target && typeof event.target.closest === 'function' ? event.target.closest('[data-api-items-path-reset]') : null; var webpageUpdateButton = event.target && typeof event.target.closest === 'function' ? event.target.closest('[data-webpage-preview-update]') : null; if (!button) { diff --git a/src/web/routes/data-sources/api-sources/form-view-model.js b/src/web/routes/data-sources/api-sources/form-view-model.js index a0cb99e..77515c1 100644 --- a/src/web/routes/data-sources/api-sources/form-view-model.js +++ b/src/web/routes/data-sources/api-sources/form-view-model.js @@ -73,7 +73,7 @@ function buildApiSourceFormViewModel(apiSource, message, currentUser, options, i showSaveSecondaryActions: true, deleteDisabled: !isEdit || Boolean(viewApiSource.inUse), formAction: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id : '/data-sources/api-sources', - formAttrs: 'data-async-save data-async-save-close-url="/data-sources/api-sources" data-async-save-new-url="/data-sources/api-sources/new"', + formAttrs: 'data-async-save data-async-save-close-url="/data-sources/api-sources" data-async-save-new-url="/data-sources/api-sources/new"' + (isEdit ? '' : ' data-async-save-new-redirect="response-url"'), cancelUrl: '/data-sources/api-sources', deleteUrl: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id + '/delete' : '' }; diff --git a/src/web/views/signage/slides/popup-preview.hbs b/src/web/views/signage/slides/popup-preview.hbs index dd73b7d..5c1f7e8 100644 --- a/src/web/views/signage/slides/popup-preview.hbs +++ b/src/web/views/signage/slides/popup-preview.hbs @@ -44,6 +44,10 @@ background: transparent; } + #popup-preview-canvas .api-image-placeholder-bounds { + border: 0 !important; + } + #popup-preview-canvas .template-region.chart, #popup-preview-canvas .slide-preview-chart-region { background: transparent !important; diff --git a/test/api-region-sort.test.js b/test/api-region-sort.test.js new file mode 100644 index 0000000..6754a9e --- /dev/null +++ b/test/api-region-sort.test.js @@ -0,0 +1,274 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { buildSlidePayload } = require('../src/data/slides'); + +function loadApiRenderer(apiSources) { + let renderer; + const window = { + placeholderUtils: {}, + pulsePlayerRegionTypes: { + register: function (_type, module) { + renderer = module; + } + }, + pulsePlayerRenderingShared: { + normalizeRegionValue: function (value) { + return String(value || ''); + } + } + }; + const context = { + window, + initialData: { apiSources: apiSources }, + escapeHtml: String, + sanitizeFontFamily: String, + sanitizeFontSize: function (value) { return Number(value) || 16; }, + sanitizeTextColor: String, + sanitizeRichText: String, + renderEditorJsContent: String + }; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), context); + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/player/regions/api.js'), 'utf8'), context); + return renderer; +} + +function loadApiEditor() { + let editor; + const window = { + pulseRegionTypes: { + register: function (_type, module) { + editor = module; + } + }, + pulseRegionUtils: { + escapeHtml: String, + sanitizePreviewHtml: String, + sanitizeFontFamily: String, + sanitizeTextColor: String + } + }; + const context = { window }; + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/shared/placeholder-utils.js'), 'utf8'), context); + vm.runInNewContext(fs.readFileSync(path.join(__dirname, '../src/web/public/js/regions/type/api.js'), 'utf8'), context); + return editor; +} + +test('API region sort settings persist in slide content', async () => { + const pool = { + async query(sql) { + if (sql.includes('FROM c_templates st')) { + return [[{ id: 8, name: 'Template 8', canvas_size_id: 1, canvas_size_width: 1920, canvas_size_height: 1080 }]]; + } + if (sql.includes('FROM c_template_regions')) { + return [[{ id: 23, template_id: 8, region_key: 'apiRegion', region_type: 'api', label: 'API' }]]; + } + return [[]]; + } + }; + + const payload = await buildSlidePayload(pool, { + body: { + title: 'API Slide', + template_id: '8', + region_api_source_id_23: '11', + region_api_items_path_23: 'results', + region_api_sort_mode_23: 'field', + region_api_sort_by_23: 'stats.kills', + region_api_sort_direction_23: 'desc', + region_text_23: '{{name}}' + }, + files: [] + }, null); + + const content = JSON.parse(payload.contentJson).apiRegion; + assert.equal(content.sort_by, 'stats.kills'); + assert.equal(content.sort_direction, 'desc'); + assert.equal(content.items_path, 'results'); + assert.equal(content.item_number, 1); +}); + +test('API field sorting defaults to ascending', async () => { + const pool = { + async query(sql) { + if (sql.includes('FROM c_templates st')) { + return [[{ id: 8, name: 'Template 8', canvas_size_id: 1, canvas_size_width: 1920, canvas_size_height: 1080 }]]; + } + if (sql.includes('FROM c_template_regions')) { + return [[{ id: 23, template_id: 8, region_key: 'apiRegion', region_type: 'api', label: 'API' }]]; + } + return [[]]; + } + }; + + const payload = await buildSlidePayload(pool, { + body: { + title: 'API Slide', + template_id: '8', + region_api_source_id_23: '11', + region_api_sort_mode_23: 'field', + region_api_sort_by_23: 'stats.kills', + region_text_23: '{{name}}' + }, + files: [] + }, null); + + assert.equal(JSON.parse(payload.contentJson).apiRegion.sort_direction, 'asc'); +}); + +test('player API region selects the requested item after sorting', () => { + const renderer = loadApiRenderer([{ + id: 11, + responseJson: { + results: [ + { name: 'Ari', stats: { kills: 8 } }, + { name: 'Bea', stats: { kills: 12 } }, + { name: 'Cam', stats: { kills: 4 } } + ] + } + }]); + const rendered = renderer.renderRegion({ pixelWidth: 400, pixelHeight: 100, canvasScale: 1, baseStyle: '' }, { + type: 'api', + source_id: 11, + item_number: 2, + items_path: 'results', + sort_by: 'stats.kills', + sort_direction: 'desc', + value: '{{0.name}} / {{1.name}} / {{name}}' + }); + + assert.match(rendered, /Bea \/ Ari \/ Ari/); + assert.doesNotMatch(rendered, /Cam/); +}); + +test('API editor preview resolves zero-based indexed placeholders from sorted results', () => { + const editor = loadApiEditor(); + const preview = editor.renderPreview({ id: 23, region_key: 'apiRegion', fontFamily: 'Arial', fontSize: 20, fontColor: '#000000' }, { + source_id: 11, + item_number: 2, + items_path: 'results', + sort_by: 'stats.kills', + sort_direction: 'desc', + value: '{{0.name}} / {{1.name}} / {{name}}' + }, { + sources: [{ + id: 11, + responseJson: { + results: [ + { name: 'Ari', stats: { kills: 8 } }, + { name: 'Bea', stats: { kills: 12 } }, + { name: 'Cam', stats: { kills: 4 } } + ] + } + }], + existingContent: {} + }); + + assert.match(preview, /Bea \/ Ari \/ Ari/); + assert.doesNotMatch(preview, /Cam/); +}); + +test('API image bounds guide is limited to the small preview and hidden in the popup', () => { + const editor = loadApiEditor(); + const preview = editor.renderPreview({ id: 23, region_key: 'apiRegion', fontFamily: 'Arial', fontSize: 20, fontColor: '#000000' }, { + source_id: 11, + items_path: 'results', + value: '{{image.image(120,80)}}' + }, { + sources: [{ id: 11, responseJson: { results: [{ image: 'https://example.com/image.png' }] } }], + existingContent: {} + }); + const popupStyles = fs.readFileSync(path.join(__dirname, '../src/web/views/signage/slides/popup-preview.hbs'), 'utf8'); + + assert.match(preview, /class="api-image-placeholder-bounds"/); + assert.match(preview, /border:1px dashed/); + assert.match(popupStyles, /#popup-preview-canvas \.api-image-placeholder-bounds\s*\{\s*border:\s*0 !important;/); +}); + +test('API region plain placeholders work with a single object response', () => { + const renderer = loadApiRenderer([{ + id: 11, + responseJson: { name: 'Pikachu', type: 'Electric' } + }]); + const rendered = renderer.renderRegion({ pixelWidth: 400, pixelHeight: 100, canvasScale: 1, baseStyle: '' }, { + type: 'api', + source_id: 11, + value: '{{name}} - {{type}}' + }); + + assert.match(rendered, /Pikachu - Electric/); +}); + +test('API region results panel exposes its open control without resize buttons', () => { + const editor = loadApiEditor(); + const markup = editor.renderEditorCard({ + region: { id: 23 }, + current: { value: '{{name}}' }, + config: { source_id: 11, item_number: 1, items_path: '', sort_by: '', sort_direction: 'asc', default_items_path: '' }, + sourceOptions: '', + placeholderFields: [], + placeholderChips: '', + sampleDataPreview: '[]', + fontSize: 32 + }); + + assert.match(markup, /data-api-sample-toggle[^>]*aria-expanded="false"/); + assert.doesNotMatch(markup, /data-api-sample-resize/); + assert.match(markup, /data-api-sample-data-panel hidden/); + assert.match(markup, /btn-outline-secondary w-100" data-api-advanced-toggle aria-expanded="false"/); + assert.match(markup, /data-api-advanced-options hidden/); + assert.match(markup, />Order results]*hidden/); + assert.match(expandedMarkup, /name="region_api_sort_by_24"[^>]*value="stats\.kills"/); +}); + +test('API results preview always includes the first result completely', () => { + let source = fs.readFileSync(path.join(__dirname, '../src/web/public/js/slides/slide-form-regions.js'), 'utf8'); + source = source.replace("import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-preview.js';", '').replace('export function createSlideFormRegionHelpers', 'function createSlideFormRegionHelpers'); + const context = { + window: { + placeholderUtils: require('../src/web/public/js/shared/placeholder-utils'), + placeholderChips: { renderChip: function (value) { return String(value); } } + }, + createSlideFormPreviewHelpers: function () { return {}; } + }; + vm.runInNewContext(source + '\nthis.createSlideFormRegionHelpers = createSlideFormRegionHelpers;', context); + const longDescription = 'x'.repeat(1500); + const helpers = context.createSlideFormRegionHelpers({ + apiSources: [ + { id: 11, responseJson: { results: [{ name: 'Long result', products: [{ description: longDescription }] }, { name: 'Second result' }] } }, + { id: 12, responseJson: { name: 'Single object' } } + ], + getRegionTypeModule: function () { return null; } + }); + + const markup = helpers.buildApiSampleDataMarkup(11, 'results', '', 'asc'); + const firstItemMarkup = markup.match(/data-api-sample-data-output>([\s\S]*?)<\/div>/); + assert.ok(firstItemMarkup); + assert.ok(firstItemMarkup[1].includes(longDescription)); + assert.match(firstItemMarkup[1], /\}\s*$/); + assert.equal((markup.match(/class="api-region-sample-preview(?:\s|\")/g) || []).length, 1); + assert.match(markup, /Example result \(first item\)/); + assert.match(markup, /data-json-toggle-collapse-all/); + assert.match(markup, /data-json-toggle-expand-all/); + assert.match(markup, /data-json-toggle-panel/); + assert.doesNotMatch(markup, /Second result|Remaining results/); + + const objectMarkup = helpers.buildApiSampleDataMarkup(12, '', '', 'asc'); + assert.match(objectMarkup, /Example resultManage shared authentication, request URLs, and latest responses\.<\/p>/); assert.match(add, /data-api-source-test-url="\/data-sources\/api-sources\/test"/); assert.match(edit, /data-api-source-test-url="\/data-sources\/api-sources\/42\/test"/); + assert.match(add, /data-async-save-new-redirect="response-url"/); + assert.doesNotMatch(edit, /data-async-save-new-redirect|data-async-save-refresh-page/); for (const html of [add, edit]) { assert.match(html, /