Release v2.17.4: add API region sorting and indexed placeholders
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m17s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 1m10s

This commit is contained in:
lzstealth committed 2026-10-23 20:54:15 +01:00
1 parent bc8075379a
commit 46636939de
18 files changed
+708 -231

No files matched your search

+89 -77
View File
@@ -2,6 +2,18 @@
All notable changes to this project will be documented in this file. 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 ## 2.17.3 - 2026-10-23
### Fixed ### Fixed
@@ -13,7 +25,7 @@ All notable changes to this project will be documented in this file.
### Added ### 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 ### Fixed
@@ -30,9 +42,9 @@ All notable changes to this project will be documented in this file.
### Added ### 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. - 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. - 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. - 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. - 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. - 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. - 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
- 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 ## 2.16.7 - 2026-10-22
@@ -88,7 +100,7 @@ All notable changes to this project will be documented in this file.
### Added ### 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 ### Fixed
@@ -101,19 +113,19 @@ All notable changes to this project will be documented in this file.
### Added ### 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. - 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. - 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. - 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 ## 2.16.0 - 2026-10-20
### Added ### 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. - 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. - 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. - 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. - 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. - 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 ### Changed
@@ -123,8 +135,8 @@ All notable changes to this project will be documented in this file.
### Fixed ### Fixed
- Fixed region dragging and resizing when template animation metadata is null. - 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. - 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 ## 2.15.7 - 2026-10-19
@@ -142,8 +154,8 @@ All notable changes to this project will be documented in this file.
### Added ### Added
- Added an All timetable display mode that includes past items while respecting date filters and the item limit. - 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. - Filename renaming in Media Library image and video previews without changing stored media paths or slide references.
### Changed ### Changed
@@ -165,8 +177,8 @@ All notable changes to this project will be documented in this file.
### Added ### Added
- Added configurable audit events for media-library assets, managed fonts, and weather icon sets. - 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. - All / Between Dates / Before Date / After Date filtering to timetable regions, with optional inclusive start-time boundaries shared by previews and playback.
### Changed ### Changed
@@ -197,13 +209,13 @@ All notable changes to this project will be documented in this file.
### Added ### 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 ## 2.15.1 - 2026-10-15
### Added ### 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 ### Changed
@@ -214,7 +226,7 @@ All notable changes to this project will be documented in this file.
### Added ### 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 ## 2.14.3 - 2026-10-13
@@ -226,7 +238,7 @@ All notable changes to this project will be documented in this file.
### Added ### 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 ### Changed
@@ -236,7 +248,7 @@ All notable changes to this project will be documented in this file.
### Added ### 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 ### Changed
@@ -259,10 +271,10 @@ All notable changes to this project will be documented in this file.
### Added ### 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. - 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. - 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. - 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. - Immediate template background uploads with shared media validation, variant generation, cleanup of abandoned uploads, and permission checks.
### Changed ### Changed
@@ -280,13 +292,13 @@ All notable changes to this project will be documented in this file.
### Fixed ### Fixed
- Fixed the template background image media picker Upload media button so it opens the file selector. - 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. - 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. - 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. - Blank time/date regions displaying the default clock format on the player.
- Fixed the time/date placeholder popup omitting text and math transform help. - The time/date placeholder popup omitting text and math transform help.
- Fixed empty region previews displaying type labels instead of remaining blank. - Empty region previews displaying type labels instead of remaining blank.
- Fixed empty weather regions displaying the default weather summary. - Empty weather regions displaying the default weather summary.
## 2.13.2 - 2026-09-11 ## 2.13.2 - 2026-09-11
@@ -298,7 +310,7 @@ All notable changes to this project will be documented in this file.
### Added ### 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 ### Changed
@@ -310,9 +322,9 @@ All notable changes to this project will be documented in this file.
### Added ### 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. - 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. - 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. - 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 ## 2.11.3 - 2026-09-08
@@ -382,7 +394,7 @@ All notable changes to this project will be documented in this file.
### Fixed ### 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. - Removed unused Bootstrap Icons font preloads that triggered browser warnings on pages where icons were not rendered.
## 2.10.6 - 2026-08-29 ## 2.10.6 - 2026-08-29
@@ -398,23 +410,23 @@ All notable changes to this project will be documented in this file.
### Fixed ### 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. - 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. - 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. - Player service-worker caching so ranged media requests bypass stale cached responses and video playback remains reliable.
## 2.10.5 - 2026-08-29 ## 2.10.5 - 2026-08-29
### Fixed ### Fixed
- Fixed intermittent pairing failures while player registration and pairing sessions propagate through the bridge by retrying transient resolution and completion requests. - 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. - 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. - The player onboarding page so it continues polling until the pairing code and QR code are available.
## 2.10.4 - 2026-08-29 ## 2.10.4 - 2026-08-29
### Fixed ### 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 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. - 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
- 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. - 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. - 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. - 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. - 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. - 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. - 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. - 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. - 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. - 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. - 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. - 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. - 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. - Playlist refresh notifications to target the physical player hosting each screen instead of always targeting the local player.
## 2.10.2 - 2026-08-28 ## 2.10.2 - 2026-08-28
@@ -449,7 +461,7 @@ All notable changes to this project will be documented in this file.
### Fixed ### 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 ## 2.10.1 - 2026-08-28
@@ -527,7 +539,7 @@ All notable changes to this project will be documented in this file.
### Fixed ### 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. - Replaced unavailable webpage thumbnails with a subdued placeholder while keeping live webpage previews intact.
## 2.8.4 - 2026-08-17 ## 2.8.4 - 2026-08-17
@@ -539,9 +551,9 @@ All notable changes to this project will be documented in this file.
### Fixed ### Fixed
- Fixed popup preview authentication for background thumbnail capture. - 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. - 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. - Manual scheduled-task run notifications so they use task-neutral wording.
## 2.8.3 - 2026-08-17 ## 2.8.3 - 2026-08-17
@@ -1217,7 +1229,7 @@ All notable changes to this project will be documented in this file.
### Fixed ### Fixed
- Removed several onboarding and runtime code paths that were still inserting or refreshing `d_players` rows during normal player playback. - 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 ## 2.0.0 - 2026-07-28
@@ -1356,7 +1368,7 @@ All notable changes to this project will be documented in this file.
### Fixed ### 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 ## 1.5.4 - 2026-07-25
@@ -1393,13 +1405,13 @@ All notable changes to this project will be documented in this file.
### Fixed ### 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 ## 1.5.1 - 2026-07-25
### Fixed ### 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 ## 1.5.0 - 2026-07-25
@@ -1452,13 +1464,13 @@ All notable changes to this project will be documented in this file.
### Fixed ### Fixed
- Fixed RBAC client action visibility. - RBAC client action visibility.
## 1.4.2 - 2026-07-21 ## 1.4.2 - 2026-07-21
### Fixed ### Fixed
- Fixed duplicate permissions cleanup. - Duplicate permissions cleanup.
## 1.4.1 - 2026-07-21 ## 1.4.1 - 2026-07-21
@@ -1478,7 +1490,7 @@ All notable changes to this project will be documented in this file.
### Fixed ### Fixed
- Fixed duplicate onboarding client names. - Duplicate onboarding client names.
## 1.3.3 - 2026-07-21 ## 1.3.3 - 2026-07-21
@@ -1514,20 +1526,20 @@ All notable changes to this project will be documented in this file.
### Fixed ### Fixed
- Fixed screen playlist reassignment so changing a screen's playlist now triggers a refresh. - 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. - Single-slide playlist refresh behavior so updates do not get stuck behind the current slide.
## 1.1.5 - 2026-07-15 ## 1.1.5 - 2026-07-15
### Fixed ### Fixed
- Fixed text pasting in the admin UI. - Text pasting in the admin UI.
## 1.1.4 - 2026-07-15 ## 1.1.4 - 2026-07-15
### Fixed ### Fixed
- Fixed the admin table and playlist reorder UI. - The admin table and playlist reorder UI.
## 1.1.3 - 2026-07-15 ## 1.1.3 - 2026-07-15
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.17.3", "version": "2.17.4",
"private": false, "private": false,
"description": "Pulse Signage player application bundle", "description": "Pulse Signage player application bundle",
"engines": { "engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-web", "name": "pulse-signage-web",
"version": "2.17.3", "version": "2.17.4",
"private": false, "private": false,
"description": "Pulse Signage web and bridge application bundle", "description": "Pulse Signage web and bridge application bundle",
"engines": { "engines": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.17.3", "version": "2.17.4",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.17.3", "version": "2.17.4",
"dependencies": { "dependencies": {
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
"bootstrap-icons": "1.13.1", "bootstrap-icons": "1.13.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.17.3", "version": "2.17.4",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"engines": { "engines": {
+5
View File
@@ -466,6 +466,9 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
const sourceId = body[`region_api_source_id_${region.id}`]; const sourceId = body[`region_api_source_id_${region.id}`];
const itemNumber = body[`region_api_item_number_${region.id}`]; const itemNumber = body[`region_api_item_number_${region.id}`];
const itemsPath = body[`region_api_items_path_${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)); const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1));
content[region.region_key] = { content[region.region_key] = {
type: 'api', 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)), source_id: sourceId === undefined || sourceId === null ? (current.source_id || null) : (sourceId === '' ? null : Number(sourceId)),
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1, 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(), 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', variable_name: 'item',
font_family: style.font_family, font_family: style.font_family,
font_size: style.font_size, font_size: style.font_size,
+9 -3
View File
@@ -5,7 +5,7 @@ const { convertWeatherSnapshot } = require('#src/data/weather-units');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { createStyledQrCodeDataUrl } = require('#src/data/qr-code'); 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]; 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; const itemNumber = Math.max(1, Number(region.item_number || 1)) - 1;
if (String(region.type || '').toLowerCase() === 'api') { if (String(region.type || '').toLowerCase() === 'api') {
const source = (apiSources || []).find(function (entry) { return Number(entry.id) === Number(region.source_id); }); 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) : []; 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) : [];
await cacheSource(source, items[itemNumber], expressions); 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') { if (String(region.type || '').toLowerCase() === 'rss') {
const feed = (rssFeeds || []).find(function (entry) { return Number(entry.id) === Number(region.feed_id); }); const feed = (rssFeeds || []).find(function (entry) { return Number(entry.id) === Number(region.feed_id); });
+23 -13
View File
@@ -19,24 +19,29 @@ function getApiSourceItemsPath(source, overridePath) {
return String(overridePath || '').trim(); return String(overridePath || '').trim();
} }
function getApiSourceItems(sourceId, itemsPathOverride) { function getApiSourceItems(sourceId, itemsPathOverride, sortBy, sortDirection) {
var source = getApiSourceById(sourceId); var source = getApiSourceById(sourceId);
var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null; var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null;
var itemsPath = getApiSourceItemsPath(source, itemsPathOverride); 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) { function getApiItem(sourceId, itemNumber, itemsPathOverride, sortBy, sortDirection) {
var items = getApiSourceItems(sourceId, itemsPathOverride); var items = getApiSourceItems(sourceId, itemsPathOverride, sortBy, sortDirection);
var parsedItemNumber = Math.max(1, Number(itemNumber || 1)); var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0; var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
return items[index] || null; return items[index] || null;
} }
function substituteApiVariables(html, item, sourceId) { function substituteApiVariables(html, item, sourceId, items) {
var source = String(html || ''); var source = String(html || '');
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) { 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 ''; return '';
} }
@@ -44,18 +49,18 @@ function substituteApiVariables(html, item, sourceId) {
return ''; return '';
} }
var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression); var resolved = placeholderUtils.resolvePlaceholderExpression(expressionItem, resolvedExpression);
if (typeof placeholderUtils.isProgressPlaceholderExpression === 'function' && placeholderUtils.isProgressPlaceholderExpression(expression)) { if (typeof placeholderUtils.isProgressPlaceholderExpression === 'function' && placeholderUtils.isProgressPlaceholderExpression(resolvedExpression)) {
return placeholderUtils.renderProgressPlaceholder(item, expression); 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 imageSource = placeholderUtils.formatPlaceholderValue(resolved);
var source = getApiSourceById(sourceId); var source = getApiSourceById(sourceId);
imageSource = source && source.imageCache && source.imageCache[imageSource] ? source.imageCache[imageSource] : imageSource; imageSource = source && source.imageCache && source.imageCache[imageSource] ? source.imageCache[imageSource] : imageSource;
if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) { if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) {
return ''; 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 hasImageBounds = imageConfig.width && imageConfig.height;
var imageStyle = hasImageBounds var imageStyle = hasImageBounds
? 'display:block;width:100%;height:100%;object-fit:contain;' ? '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 sourceId = regionContent && regionContent.source_id !== undefined ? regionContent.source_id : null;
var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1; var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1;
var itemsPath = regionContent && regionContent.items_path !== undefined ? regionContent.items_path : ''; 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 fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize); var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor); 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 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) + ';'; 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) { if (!body) {
return ''; return '';
+64 -28
View File
@@ -3716,47 +3716,81 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.api-region-placeholder-section { .api-region-placeholder-section {
display: grid; display: grid;
gap: 0.5rem; gap: 0.4rem;
padding-top: 0.25rem; padding-top: 0.125rem;
padding-bottom: 0.5rem; padding-bottom: 0.25rem;
margin-bottom: 0; margin-bottom: 0;
} }
.api-region-placeholder-title { .api-region-placeholder-title {
color: var(--bs-primary); color: var(--bs-secondary-color);
font-size: 0.8rem; font-size: 0.78rem;
font-weight: 800; font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
} }
.api-region-placeholder-title-help { .api-region-placeholder-title-help {
font-size: 0.9rem; font-size: 0.8rem;
font-weight: 800; font-weight: 600;
letter-spacing: normal; letter-spacing: normal;
text-transform: none; text-transform: none;
} }
.api-region-sample-accordion { .api-region-placeholder-section .chip {
padding: 0.85rem 1rem 1rem; padding: 0.2rem 0.45rem;
margin-bottom: 0.75rem;
border: 1px solid var(--bs-border-color); border: 1px solid var(--bs-border-color);
border-radius: 0.85rem; border-radius: 0.3rem;
background: var(--bs-secondary-bg); 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; cursor: pointer;
list-style: none; font-size: 0.875rem;
color: var(--bs-primary);
font-size: 0.8rem;
font-weight: 800; font-weight: 800;
letter-spacing: 0.06em; text-align: left;
text-transform: uppercase;
} }
.api-region-sample-accordion > summary::-webkit-details-marker { .api-region-sample-toggle:focus-visible {
display: none; 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 { .api-region-sample-body {
@@ -3765,19 +3799,21 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
margin-top: 0.75rem; margin-top: 0.75rem;
} }
.api-region-sample-body[hidden] {
display: none;
}
.api-region-sample-meta { .api-region-sample-meta {
color: var(--bs-secondary-color); color: var(--bs-secondary-color);
font-size: 0.78rem; font-size: 0.78rem;
} }
.api-region-sample-preview { .api-region-sample-preview {
max-height: 12rem;
overflow: auto;
margin: 0; margin: 0;
padding: 0.75rem; padding: 0.75rem;
border-radius: 0.65rem; border: 0;
background: var(--bs-body-bg); border-radius: 0.375rem;
border: 1px solid var(--bs-border-color-translucent); background: var(--bs-secondary-bg);
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
font-size: 0.78rem; font-size: 0.78rem;
+58 -30
View File
@@ -47,15 +47,16 @@
return String(overridePath || '').trim(); return String(overridePath || '').trim();
} }
function getItems(sourceId, sources, itemsPathOverride) { function getItems(sourceId, sources, itemsPathOverride, sortBy, sortDirection) {
var source = getSourceById(sourceId, sources); var source = getSourceById(sourceId, sources);
var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null; var responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null;
var itemsPath = getSourceItemsPath(source, itemsPathOverride); 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) { function getItemCount(sourceId, sources, itemsPathOverride, sortBy, sortDirection) {
return getItems(sourceId, sources, itemsPathOverride).length; return getItems(sourceId, sources, itemsPathOverride, sortBy, sortDirection).length;
} }
function getCurrentConfig(region, existingContent, sources) { 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), 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, 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, 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(), default_items_path: String(source && (source.items_path || source.itemsPath) || '').trim(),
value: current.value !== undefined ? current.value : '' value: current.value !== undefined ? current.value : ''
}; };
} }
function substituteVariables(html, item, options) { function substituteVariables(html, item, options, items) {
var source = String(html || ''); var source = String(html || '');
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) { 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 ''; return '';
} }
@@ -82,16 +89,16 @@
return ''; return '';
} }
var resolved = placeholderUtils.resolvePlaceholderExpression(item, expression); var resolved = placeholderUtils.resolvePlaceholderExpression(expressionItem, resolvedExpression);
if (typeof placeholderUtils.isProgressPlaceholderExpression === 'function' && placeholderUtils.isProgressPlaceholderExpression(expression)) { if (typeof placeholderUtils.isProgressPlaceholderExpression === 'function' && placeholderUtils.isProgressPlaceholderExpression(resolvedExpression)) {
return placeholderUtils.renderProgressPlaceholder(item, expression); 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 imageSource = placeholderUtils.formatPlaceholderValue(resolved);
if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) { if (!/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/])/i.test(imageSource)) {
return ''; 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 var imageStyle = options && options.preview && imageConfig.width && imageConfig.height
? 'display:block;width:100%;height:100%;object-fit:contain;' ? '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;' : ''); : '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; var hasPreviewBounds = options && options.preview && imageConfig.width && imageConfig.height;
if (hasPreviewBounds || imageConfig.verticalAlign) { if (hasPreviewBounds || imageConfig.verticalAlign) {
var wrapperStyle = 'display:inline-flex;align-items:center;justify-content:center;'; var wrapperStyle = 'display:inline-flex;align-items:center;justify-content:center;';
var wrapperClass = hasPreviewBounds ? ' class="api-image-placeholder-bounds"' : '';
if (hasPreviewBounds) { if (hasPreviewBounds) {
wrapperStyle += 'box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);'; wrapperStyle += 'box-sizing:border-box;width:' + imageConfig.width + 'px;height:' + imageConfig.height + 'px;border:1px dashed rgba(120,120,120,.75);';
} }
if (imageConfig.verticalAlign) { if (imageConfig.verticalAlign) {
wrapperStyle += 'vertical-align:' + imageConfig.verticalAlign + ';'; wrapperStyle += 'vertical-align:' + imageConfig.verticalAlign + ';';
} }
return '<span style="' + wrapperStyle + '">' + image + '</span>'; return '<span' + wrapperClass + ' style="' + wrapperStyle + '">' + image + '</span>';
} }
return image; return image;
} }
@@ -113,8 +121,8 @@
}); });
} }
function getItem(sourceId, itemNumber, sources, itemsPathOverride) { function getItem(sourceId, itemNumber, sources, itemsPathOverride, sortBy, sortDirection) {
var items = getItems(sourceId, sources, itemsPathOverride); var items = getItems(sourceId, sources, itemsPathOverride, sortBy, sortDirection);
var parsedItemNumber = Math.max(1, Number(itemNumber || 1)); var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0; var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
return items[index] || null; return items[index] || null;
@@ -150,7 +158,7 @@
'<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>' + '<button type="button" class="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>' +
'</div>' + '</div>' +
'<div class="offcanvas-body">' + '<div class="offcanvas-body">' +
'<p class="small text-body-secondary">Placeholders read values from the selected API item. Nested fields use dots.</p>' + '<p class="small text-body-secondary">Use placeholders such as <code>{{name}}</code> for the selected result, or <code>{{0.name}}</code> and <code>{{1.name}}</code> to read sorted results by index. Indices start at 0. Nested fields use dots.</p>' +
(window.placeholderInfo ? window.placeholderInfo.renderTextTransforms() : '') + (window.placeholderInfo ? window.placeholderInfo.renderTextTransforms() : '') +
(window.placeholderInfo ? window.placeholderInfo.renderMathTransforms() : '') + (window.placeholderInfo ? window.placeholderInfo.renderMathTransforms() : '') +
(window.placeholderInfo ? window.placeholderInfo.renderDateFormatTokens() : '') + (window.placeholderInfo ? window.placeholderInfo.renderDateFormatTokens() : '') +
@@ -189,14 +197,15 @@
var sourceId = regionContent && regionContent.source_id !== undefined ? regionContent.source_id : config.source_id; 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 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 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 fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily);
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize); var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize);
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor); 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 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 || '') body = String(body || '')
.replace(/<pre[^>]*class="[^"]*api-region-sample-preview[^"]*"[^>]*>[\s\S]*?<\/pre>/gi, '') .replace(/<pre[^>]*class="[^"]*api-region-sample-preview[^"]*"[^>]*>[\s\S]*?<\/pre>/gi, '')
@@ -239,17 +248,13 @@
'<textarea class="editor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' + '<textarea class="editor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
'</div>' + '</div>' +
'<div class="row g-3 align-items-end">' + '<div class="row g-3 align-items-end">' +
'<div class="col-12 col-lg-5">' + '<div class="col-12 col-lg-4">' +
'<label class="form-label" for="region_api_source_id_' + region.id + '">API source</label>' + '<label class="form-label" for="region_api_source_id_' + region.id + '">API source</label>' +
'<select id="region_api_source_id_' + region.id + '" name="region_api_source_id_' + region.id + '" class="form-select">' + '<select id="region_api_source_id_' + region.id + '" name="region_api_source_id_' + region.id + '" class="form-select">' +
'<option value="">Select a source</option>' + '<option value="">Select a source</option>' +
sourceOptions + sourceOptions +
'</select>' + '</select>' +
'</div>' + '</div>' +
'<div class="col-6 col-lg-2">' +
'<label class="form-label" for="region_api_item_number_' + region.id + '">Item number</label>' +
'<input id="region_api_item_number_' + region.id + '" type="number" min="1" step="1" name="region_api_item_number_' + region.id + '" class="form-control" value="' + escapeHtml(config.item_number) + '" />' +
'</div>' +
'<div class="col-12 col-lg-5">' + '<div class="col-12 col-lg-5">' +
'<label class="form-label" for="region_api_items_path_' + region.id + '">Items path override</label>' + '<label class="form-label" for="region_api_items_path_' + region.id + '">Items path override</label>' +
'<div class="input-group flex-nowrap">' + '<div class="input-group flex-nowrap">' +
@@ -257,17 +262,36 @@
'<button type="button" class="btn btn-outline-secondary text-nowrap" data-api-items-path-reset data-api-default-items-path="' + escapeHtml(config.default_items_path || '') + '">Reset</button>' + '<button type="button" class="btn btn-outline-secondary text-nowrap" data-api-items-path-reset data-api-default-items-path="' + escapeHtml(config.default_items_path || '') + '">Reset</button>' +
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div class="col-12 col-lg-3 d-flex align-items-end">' +
'<button type="button" class="btn btn-outline-secondary w-100" data-api-advanced-toggle aria-expanded="false" aria-controls="api-region-advanced-' + region.id + '"><i class="bi bi-sliders me-2" aria-hidden="true"></i>Advanced options</button>' +
'</div>' +
'</div>' +
'<div id="api-region-advanced-' + region.id + '" class="row g-3 align-items-end api-region-advanced-options" data-api-advanced-options hidden>' +
'<div class="col-12 col-md-4">' +
'<label class="form-label" for="region_api_sort_mode_' + region.id + '">Order results</label>' +
'<select id="region_api_sort_mode_' + region.id + '" name="region_api_sort_mode_' + region.id + '" class="form-select"><option value="original"' + (config.sort_by ? '' : ' selected') + '>API order</option><option value="field"' + (config.sort_by ? ' selected' : '') + '>Sort by field</option></select>' +
'</div>' +
'<div class="col-12 col-md-5" data-api-sort-field-wrap' + (config.sort_by ? '' : ' hidden') + '>' +
'<label class="form-label" for="region_api_sort_by_' + region.id + '">Sort field</label>' +
'<input id="region_api_sort_by_' + region.id + '" type="text" name="region_api_sort_by_' + region.id + '" class="form-control" value="' + escapeHtml(config.sort_by || '') + '" placeholder="Enter a field path" />' +
'</div>' +
'<div class="col-12 col-md-3" data-api-sort-direction-wrap' + (config.sort_by ? '' : ' hidden') + '>' +
'<label class="form-label" for="region_api_sort_direction_' + region.id + '">Direction</label>' +
'<select id="region_api_sort_direction_' + region.id + '" name="region_api_sort_direction_' + region.id + '" class="form-select"><option value="asc"' + (config.sort_direction === 'asc' ? ' selected' : '') + '>Ascending</option><option value="desc"' + (config.sort_direction !== 'asc' ? ' selected' : '') + '>Descending</option></select>' +
'</div>' +
'</div>' + '</div>' +
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' + '<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' +
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' + '<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
'<div class="api-region-placeholder-section">' + '<div class="api-region-placeholder-section">' +
'<div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2">Available placeholders ' + renderTransformInfo(region.id) + '</div>' + '<div class="api-region-placeholder-title api-region-placeholder-title-help d-flex align-items-center gap-2">Available fields ' + renderTransformInfo(region.id) + '</div>' +
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + ((placeholderFields.length && window.placeholderChips && typeof window.placeholderChips.renderChips === 'function') ? window.placeholderChips.renderChips(placeholderFields) : placeholderChips || '<span class="muted slide-image-file">No JSON fields available.</span>') + '</div>' + '<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + ((placeholderFields.length && window.placeholderChips && typeof window.placeholderChips.renderChips === 'function') ? window.placeholderChips.renderChips(placeholderFields) : placeholderChips || '<span class="muted slide-image-file">No JSON fields available.</span>') + '</div>' +
'</div>' + '</div>' +
'<details class="api-region-sample-accordion" data-api-sample-data-accordion>' + '<div class="api-region-sample-accordion" data-api-sample-data-accordion>' +
'<summary>Data</summary>' + '<div class="api-region-sample-header">' +
'<div class="api-region-sample-body" data-api-sample-data-panel>' + sampleDataPreview + '</div>' + '<button type="button" class="btn btn-outline-primary api-region-sample-toggle" data-api-sample-toggle aria-expanded="false" aria-controls="api-region-sample-panel-' + region.id + '"><span><i class="bi bi-eye me-2" aria-hidden="true"></i>View results</span><i class="bi bi-chevron-down api-region-sample-chevron" aria-hidden="true"></i></button>' +
'</details>' + '</div>' +
'<div id="api-region-sample-panel-' + region.id + '" class="api-region-sample-body" data-api-sample-data-panel hidden>' + sampleDataPreview + '</div>' +
'</div>' +
'</div>' + '</div>' +
'</div>'; '</div>';
} }
@@ -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, 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, 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; } : current;
return { return {
@@ -322,6 +348,8 @@
source_id: content.source_id, source_id: content.source_id,
item_number: content.item_number, item_number: content.item_number,
items_path: content.items_path, items_path: content.items_path,
sort_by: content.sort_by,
sort_direction: content.sort_direction,
existingContent: existingContent || {}, existingContent: existingContent || {},
sources: Array.isArray(sources) ? sources : [] sources: Array.isArray(sources) ? sources : []
}; };
@@ -67,6 +67,44 @@
return value && typeof value === 'object' ? [value] : []; 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) { function parsePlaceholderExpression(expression) {
var raw = String(expression || '').trim(); var raw = String(expression || '').trim();
var segments = raw ? splitExpressionSegments(raw) : []; var segments = raw ? splitExpressionSegments(raw) : [];
@@ -604,6 +642,7 @@
return { return {
resolvePath: resolvePath, resolvePath: resolvePath,
resolveApiItems: resolveApiItems, resolveApiItems: resolveApiItems,
sortApiItems: sortApiItems,
parsePlaceholderExpression: parsePlaceholderExpression, parsePlaceholderExpression: parsePlaceholderExpression,
resolvePlaceholderExpression: resolvePlaceholderExpression, resolvePlaceholderExpression: resolvePlaceholderExpression,
isImagePlaceholderExpression: isImagePlaceholderExpression, isImagePlaceholderExpression: isImagePlaceholderExpression,
+64 -56
View File
@@ -178,18 +178,39 @@ export function createSlideFormRegionHelpers(options) {
return text.slice(0, limit).replace(/\s+$/, '') + '\n...'; return text.slice(0, limit).replace(/\s+$/, '') + '\n...';
} }
function buildApiSampleDataMarkup(sourceId, itemNumber, itemsPathOverride) { function buildApiSampleDataMarkup(sourceId, itemsPathOverride, sortBy, sortDirection) {
var source = getApiSourceById(sourceId); var source = getApiSourceById(sourceId);
if (!source) { if (!source) {
return '<div class="api-region-sample-meta">Select an API source to preview sample data.</div>'; return '<div class="api-region-sample-meta">Select an API source to preview sample data.</div>';
} }
var item = getApiSourceItems(sourceId, itemsPathOverride)[Math.max(0, Math.max(1, Number(itemNumber || 1)) - 1)] || null; var items = getApiSourceItems(sourceId, itemsPathOverride, sortBy, sortDirection);
if (!item) { if (!items.length) {
return '<div class="api-region-sample-meta">No sample item is available for the current selection.</div>'; return '<div class="api-region-sample-meta">No sample item is available for the current selection.</div>';
} }
return '<pre class="api-region-sample-preview" data-api-sample-data-output>' + escapeHtml(truncateJsonPreview(item, 1100)) + '</pre>'; 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, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
return '' +
'<div class="api-region-sample-example">' +
'<div class="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-1">' +
'<div class="api-region-sample-meta">' + label + '</div>' +
'<div class="btn-group btn-group-sm" role="group" aria-label="Example JSON controls">' +
'<button type="button" class="btn btn-outline-secondary" data-json-toggle-collapse-all><i class="bi bi-arrows-collapse me-1" aria-hidden="true"></i>Collapse all</button>' +
'<button type="button" class="btn btn-outline-secondary" data-json-toggle-expand-all><i class="bi bi-arrows-expand me-1" aria-hidden="true"></i>Expand all</button>' +
'</div>' +
'</div>' +
'<div data-json-toggle-panel>' +
'<script type="application/json" data-json-toggle-source>' + jsonSource + '</script>' +
'<div class="api-region-sample-preview font-monospace" data-json-toggle-output data-api-sample-data-output>' + escapeHtml(firstItemJson) + '</div>' +
'</div>' +
'</div>';
} }
function updatePlaceholderChipList(card, fieldList, emptyLabel) { function updatePlaceholderChipList(card, fieldList, emptyLabel) {
@@ -356,25 +377,31 @@ export function createSlideFormRegionHelpers(options) {
return String(overridePath || '').trim(); return String(overridePath || '').trim();
} }
function getApiSourceItems(sourceId, itemsPathOverride) { function getApiSourceItems(sourceId, itemsPathOverride, sortBy, sortDirection) {
var source = getApiSourceById(sourceId); var source = getApiSourceById(sourceId);
var module = getRegionTypeModule('api'); var module = getRegionTypeModule('api');
if (module && typeof module.getItems === 'function') { 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 responseJson = source && source.responseJson && typeof source.responseJson === 'object' ? source.responseJson : null;
var itemsPath = getApiSourceItemsPath(source, itemsPathOverride); 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) { function apiItemsPathIsArray(source, itemsPathOverride) {
var module = getRegionTypeModule('api'); var response = source && source.responseJson;
if (module && typeof module.getItemCount === 'function') { var itemsPath = getApiSourceItemsPath(source, itemsPathOverride);
return module.getItemCount(sourceId, apiSources, itemsPathOverride); if (itemsPath) {
return Array.isArray(window.placeholderUtils.resolvePath(response, itemsPath));
} }
if (Array.isArray(response)) {
return getApiSourceItems(sourceId, itemsPathOverride).length; return true;
}
return Boolean(response && ['items', 'results', 'data'].some(function (key) {
return Array.isArray(response[key]);
}));
} }
function getApiFieldList(sourceId, itemsPathOverride) { function getApiFieldList(sourceId, itemsPathOverride) {
@@ -399,7 +426,18 @@ export function createSlideFormRegionHelpers(options) {
container.innerHTML = buildLimitedPlaceholderChipMarkup(getApiFieldList(sourceId, itemsPathOverride), 'No JSON fields available.', false); 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; var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
if (!card) { if (!card) {
return; return;
@@ -410,44 +448,10 @@ export function createSlideFormRegionHelpers(options) {
return; return;
} }
container.innerHTML = buildApiSampleDataMarkup(sourceId, itemNumber, itemsPathOverride); container.innerHTML = buildApiSampleDataMarkup(sourceId, itemsPathOverride, sortBy, sortDirection);
if (typeof window.initJsonTogglePanels === 'function') {
window.initJsonTogglePanels(container);
} }
function updateApiItemNumberLimit(regionId, sourceId, 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));
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) { function getCurrentApiConfig(region) {
@@ -546,7 +550,7 @@ export function createSlideFormRegionHelpers(options) {
: region.region_type === 'rss' : region.region_type === 'rss'
? getRssFieldList(getCurrentRssConfig(region).feed_id) ? 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, timetableGroups: timetableGroups,
weatherLocations: weatherLocations, weatherLocations: weatherLocations,
mediaAssets: mediaAssets mediaAssets: mediaAssets
@@ -589,7 +593,7 @@ export function createSlideFormRegionHelpers(options) {
: region.region_type === 'rss' : region.region_type === 'rss'
? getRssFieldList(rssConfig.feed_id) ? 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, timetableGroups: timetableGroups,
weatherLocations: weatherLocations, weatherLocations: weatherLocations,
mediaAssets: mediaAssets mediaAssets: mediaAssets
@@ -754,7 +758,6 @@ export function createSlideFormRegionHelpers(options) {
var rssFeedInput = card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]'); 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 rssItemInput = card.querySelector('input[name="region_rss_item_number_' + region.id + '"]');
var apiSourceInput = card.querySelector('select[name="region_api_source_id_' + 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 + '"]'); var apiItemsPathInput = card.querySelector('input[name="region_api_items_path_' + region.id + '"]');
if (region.region_type === 'image') { if (region.region_type === 'image') {
@@ -779,8 +782,12 @@ export function createSlideFormRegionHelpers(options) {
} else if (region.region_type === 'api') { } else if (region.region_type === 'api') {
content.value = hiddenInput ? hiddenInput.value : (content.value || ''); content.value = hiddenInput ? hiddenInput.value : (content.value || '');
content.source_id = apiSourceInput ? apiSourceInput.value : content.source_id; 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; 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') { } else if (region.region_type === 'timetable') {
var timetableGroupInput = card.querySelector('select[name="region_timetable_group_id_' + region.id + '"]'); var timetableGroupInput = card.querySelector('select[name="region_timetable_group_id_' + region.id + '"]');
var timetableDisplayModeInput = card.querySelector('select[name="region_timetable_display_mode_' + region.id + '"]'); var timetableDisplayModeInput = card.querySelector('select[name="region_timetable_display_mode_' + region.id + '"]');
@@ -835,9 +842,10 @@ export function createSlideFormRegionHelpers(options) {
updateRssPlaceholderChips: updateRssPlaceholderChips, updateRssPlaceholderChips: updateRssPlaceholderChips,
getCurrentRssConfig: getCurrentRssConfig, getCurrentRssConfig: getCurrentRssConfig,
getApiFieldList: getApiFieldList, getApiFieldList: getApiFieldList,
buildApiSampleDataMarkup: buildApiSampleDataMarkup,
updateApiPlaceholderChips: updateApiPlaceholderChips, updateApiPlaceholderChips: updateApiPlaceholderChips,
updateApiItemsPathReset: updateApiItemsPathReset,
updateApiSampleDataPanel: updateApiSampleDataPanel, updateApiSampleDataPanel: updateApiSampleDataPanel,
updateApiItemNumberLimit: updateApiItemNumberLimit,
updateRssItemNumberLimit: updateRssItemNumberLimit, updateRssItemNumberLimit: updateRssItemNumberLimit,
getCurrentApiConfig: getCurrentApiConfig, getCurrentApiConfig: getCurrentApiConfig,
reduceAspectRatio: reduceAspectRatio, reduceAspectRatio: reduceAspectRatio,
+57 -18
View File
@@ -748,8 +748,8 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
var getCurrentRssConfig = slideRegionHelpers.getCurrentRssConfig; var getCurrentRssConfig = slideRegionHelpers.getCurrentRssConfig;
var getApiFieldList = slideRegionHelpers.getApiFieldList; var getApiFieldList = slideRegionHelpers.getApiFieldList;
var updateApiPlaceholderChips = slideRegionHelpers.updateApiPlaceholderChips; var updateApiPlaceholderChips = slideRegionHelpers.updateApiPlaceholderChips;
var updateApiItemsPathReset = slideRegionHelpers.updateApiItemsPathReset;
var updateApiSampleDataPanel = slideRegionHelpers.updateApiSampleDataPanel; var updateApiSampleDataPanel = slideRegionHelpers.updateApiSampleDataPanel;
var updateApiItemNumberLimit = slideRegionHelpers.updateApiItemNumberLimit;
var getCurrentApiConfig = slideRegionHelpers.getCurrentApiConfig; var getCurrentApiConfig = slideRegionHelpers.getCurrentApiConfig;
var updateRssItemNumberLimit = slideRegionHelpers.updateRssItemNumberLimit; 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) { templateFields.innerHTML = template.regions.length ? template.regions.map(function (region) {
return getRegionEditorCardHtml(region) || '<div class="muted">Region editor unavailable.</div>'; return getRegionEditorCardHtml(region) || '<div class="muted">Region editor unavailable.</div>';
}).join('') : '<div class="muted">This template has no regions.</div>'; }).join('') : '<div class="muted">This template has no regions.</div>';
if (typeof window.initJsonTogglePanels === 'function') {
window.initJsonTogglePanels(templateFields);
}
if (window.adminlte && typeof window.adminlte.CardWidget === 'function') { if (window.adminlte && typeof window.adminlte.CardWidget === 'function') {
templateFields.querySelectorAll('.card-widget[data-region-id]').forEach(function (card) { 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 () { window.requestAnimationFrame(function () {
bindRegionCards(); 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) { templateFields.querySelectorAll('input[name^="region_rss_item_number_"]').forEach(function (input) {
var regionId = input.name.replace(/^region_rss_item_number_/, ''); var regionId = input.name.replace(/^region_rss_item_number_/, '');
var feedInput = templateFields.querySelector('select[name="region_rss_feed_id_' + regionId + '"]'); 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 () { 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) { 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; 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); updateRssPlaceholderChips(regionId, rssFeed);
updateRssItemNumberLimit(regionId, rssFeed); updateRssItemNumberLimit(regionId, rssFeed);
} }
if (input.name.indexOf('region_api_') === 0) { 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 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; 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); updateApiPlaceholderChips(regionId, apiSource, apiItemsPath);
updateApiSampleDataPanel(regionId, apiSource, apiItemNumber, apiItemsPath); updateApiSampleDataPanel(regionId, apiSource, apiItemsPath, apiSortBy, apiSortDirectionValue);
} }
templateSelectorLock.markEdited(); templateSelectorLock.markEdited();
requestPreviewRender(); requestPreviewRender();
}); });
input.addEventListener('change', function () { 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) { 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; 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); updateRssPlaceholderChips(regionId, rssFeed);
updateRssItemNumberLimit(regionId, rssFeed); updateRssItemNumberLimit(regionId, rssFeed);
} }
if (input.name.indexOf('region_api_') === 0) { 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 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; 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); updateApiPlaceholderChips(regionId, apiSource, apiItemsPath);
updateApiSampleDataPanel(regionId, apiSource, apiItemNumber, apiItemsPath); updateApiSampleDataPanel(regionId, apiSource, apiItemsPath, apiSortBy, apiSortDirectionValue);
} }
templateSelectorLock.markEdited(); templateSelectorLock.markEdited();
requestPreviewRender(); requestPreviewRender();
@@ -989,6 +1004,30 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
requestPreviewRender(); requestPreviewRender();
}); });
templateFields.addEventListener('click', function (event) { 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 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; var webpageUpdateButton = event.target && typeof event.target.closest === 'function' ? event.target.closest('[data-webpage-preview-update]') : null;
if (!button) { if (!button) {
@@ -73,7 +73,7 @@ function buildApiSourceFormViewModel(apiSource, message, currentUser, options, i
showSaveSecondaryActions: true, showSaveSecondaryActions: true,
deleteDisabled: !isEdit || Boolean(viewApiSource.inUse), deleteDisabled: !isEdit || Boolean(viewApiSource.inUse),
formAction: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id : '/data-sources/api-sources', 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', cancelUrl: '/data-sources/api-sources',
deleteUrl: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id + '/delete' : '' deleteUrl: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id + '/delete' : ''
}; };
@@ -44,6 +44,10 @@
background: transparent; background: transparent;
} }
#popup-preview-canvas .api-image-placeholder-bounds {
border: 0 !important;
}
#popup-preview-canvas .template-region.chart, #popup-preview-canvas .template-region.chart,
#popup-preview-canvas .slide-preview-chart-region { #popup-preview-canvas .slide-preview-chart-region {
background: transparent !important; background: transparent !important;
+274
View File
@@ -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</);
const expandedMarkup = editor.renderEditorCard({
region: { id: 24 },
current: { value: '{{name}}' },
config: { source_id: 11, item_number: 1, items_path: '', sort_by: 'stats.kills', sort_direction: 'desc', default_items_path: '' },
sourceOptions: '',
placeholderFields: [],
placeholderChips: '',
sampleDataPreview: '[]',
fontSize: 32
});
assert.match(expandedMarkup, /btn-outline-secondary w-100" data-api-advanced-toggle aria-expanded="false"/);
assert.match(expandedMarkup, /id="api-region-advanced-24"[^>]*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 result</);
assert.doesNotMatch(objectMarkup, /first item/);
});
+2
View File
@@ -217,6 +217,8 @@ test('API source add and edit forms render connection diagnostics with the corre
assert.match(edit, /<p>Manage shared authentication, request URLs, and latest responses\.<\/p>/); assert.match(edit, /<p>Manage shared authentication, request URLs, and latest responses\.<\/p>/);
assert.match(add, /data-api-source-test-url="\/data-sources\/api-sources\/test"/); 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(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]) { for (const html of [add, edit]) {
assert.match(html, /<form id="api-source-form" method="post" action="[^"]+" data-async-save/); assert.match(html, /<form id="api-source-form" method="post" action="[^"]+" data-async-save/);
assert.match(html, /data-api-source-diagnostics hidden/); assert.match(html, /data-api-source-diagnostics hidden/);
+14
View File
@@ -218,6 +218,20 @@ test('shared API item selection treats object paths as one item and preserves ar
assert.equal(JSON.stringify(sandbox.window.placeholderUtils.resolveApiItems(response, 'data')), JSON.stringify([campaign])); assert.equal(JSON.stringify(sandbox.window.placeholderUtils.resolveApiItems(response, 'data')), JSON.stringify([campaign]));
}); });
test('shared API item sorting supports nested numeric and text fields with stable ties and missing values last', () => {
const helpers = require('../src/web/public/js/shared/placeholder-utils');
const rows = [
{ name: 'First', stats: { kills: 8 } },
{ name: 'Second', stats: { kills: 12 } },
{ name: 'Third', stats: { kills: 12 } },
{ name: 'Missing' }
];
assert.deepEqual(helpers.sortApiItems(rows, 'stats.kills', 'desc').map((item) => item.name), ['Second', 'Third', 'First', 'Missing']);
assert.deepEqual(helpers.sortApiItems(rows, 'name', 'asc').map((item) => item.name), ['First', 'Missing', 'Second', 'Third']);
assert.deepEqual(helpers.sortApiItems(rows, '', 'desc'), rows);
assert.deepEqual(helpers.sortApiItems(rows, 'stats.kills', 'asc').map((item) => item.name), ['First', 'Second', 'Third', 'Missing']);
});
test('API editor and player resolve object paths, root overrides, and numeric placeholders consistently', () => { test('API editor and player resolve object paths, root overrides, and numeric placeholders consistently', () => {
let editor; let editor;
const campaign = { name: 'Campaign', amount_raised: { value: '200' }, goal: { value: '1000' } }; const campaign = { name: 'Campaign', amount_raised: { value: '200' }, goal: { value: '1000' } };