Release v2.17.4: add API region sorting and indexed placeholders
This commit is contained in:
1 parent
bc8075379a
commit
46636939de
18 files changed
+709
-232
No files matched your search
+89
-77
@@ -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,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,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": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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 '';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -149,8 +157,8 @@
|
|||||||
'<h2 class="offcanvas-title fs-5" id="' + offcanvasId + '-label">API placeholder transforms</h2>' +
|
'<h2 class="offcanvas-title fs-5" id="' + offcanvasId + '-label">API placeholder transforms</h2>' +
|
||||||
'<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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -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/);
|
||||||
|
|||||||
@@ -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' } };
|
||||||
|
|||||||
Reference in new issue
Block a user