Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

- [#295](https://github.com/os2display/display-admin-client/pull/295)
- Fixed slide media array not syncing with content on media removal.

## [2.6.0] - 2025-12-05

- [#293](https://github.com/os2display/display-admin-client/pull/293)
Expand Down
116 changes: 116 additions & 0 deletions e2e/slide-media-sync.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { test, expect } from "@playwright/test";
import rebuildMediaFromContent from "../src/components/slide/slide-media-utils";

test.describe("Slide media sync", () => {
test("It returns media IRIs referenced in content fields", () => {
const content = {
mainImage: ["/v2/media/1", "/v2/media/2"],
backgroundVideo: ["/v2/media/3"],
};

const result = rebuildMediaFromContent(content);

expect(result).toEqual(["/v2/media/1", "/v2/media/2", "/v2/media/3"]);
});

test("It excludes TEMP-- IDs that have not been uploaded yet", () => {
const content = {
mainImage: ["TEMP--abc123", "/v2/media/1"],
};

const result = rebuildMediaFromContent(content);

expect(result).toEqual(["/v2/media/1"]);
});

test("It removes media no longer referenced in any content field", () => {
const content = {
mainImage: ["/v2/media/2"],
};

const result = rebuildMediaFromContent(content);

expect(result).toEqual(["/v2/media/2"]);
expect(result).not.toContain("/v2/media/1");
});

test("It returns empty array when all media is removed from content", () => {
const content = {
mainImage: [],
backgroundVideo: ["/v2/media/3"],
};

const result = rebuildMediaFromContent(content);

expect(result).toEqual(["/v2/media/3"]);
});

test("It deduplicates media used across multiple content fields", () => {
const content = {
mainImage: ["/v2/media/1"],
thumbnail: ["/v2/media/1"],
};

const result = rebuildMediaFromContent(content);

expect(result).toEqual(["/v2/media/1"]);
});

test("It handles non-existent content fields gracefully", () => {
const content = {};

const result = rebuildMediaFromContent(content);

expect(result).toEqual([]);
});

test("It handles nested content field paths", () => {
const content = {
sections: {
hero: ["/v2/media/1"],
},
};

const result = rebuildMediaFromContent(content);

expect(result).toEqual(["/v2/media/1"]);
});

test("It ignores non-media content values when scanning top-level keys", () => {
const content = {
images: ["/v2/media/1"],
title: "Some text",
separator: true,
contacts: [{ name: "John", image: ["/v2/media/2"], tags: ["news"] }],
};

const result = rebuildMediaFromContent(content);

expect(result).toContain("/v2/media/1");
expect(result).toContain("/v2/media/2");
expect(result).not.toContain("news");
});

test("It does not include non-media string arrays from content", () => {
const content = {
images: ["/v2/media/1"],
tags: ["news", "sports"],
};

const result = rebuildMediaFromContent(content);

expect(result).toEqual(["/v2/media/1"]);
expect(result).not.toContain("news");
expect(result).not.toContain("sports");
});

test("It avoids infinite recursion when content contains circular references", () => {
const circular = { images: ["/v2/media/1"] };
circular.self = circular; // create an explicit cycle

expect(() => rebuildMediaFromContent(circular)).not.toThrow();

const result = rebuildMediaFromContent(circular);
expect(result).toContain("/v2/media/1");
});
});
19 changes: 10 additions & 9 deletions src/components/slide/slide-manager.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import PropTypes from "prop-types";
import { useDispatch } from "react-redux";
import dayjs from "dayjs";
import { useNavigate } from "react-router-dom";
import rebuildMediaFromContent from "./slide-media-utils";
import UserContext from "../../context/user-context";
import {
api,
Expand Down Expand Up @@ -337,13 +338,11 @@ function SlideManager({
const localFormStateObject = { ...formStateObject };
const localMediaData = { ...mediaData };
// Set field as a field to look into for new references.
setMediaFields([...new Set([...mediaFields, fieldId])]);
const updatedMediaFields = [...new Set([...mediaFields, fieldId])];
setMediaFields(updatedMediaFields);

const newField = [];

if (Array.isArray(fieldValue) && fieldValue.length === 0) {
localFormStateObject.media = [];
}
// Handle each entry in field.
if (Array.isArray(fieldValue)) {
fieldValue.forEach((entry) => {
Expand Down Expand Up @@ -383,17 +382,19 @@ function SlideManager({
!Object.prototype.hasOwnProperty.call(localMediaData, entry["@id"])
) {
set(localMediaData, entry["@id"], entry);

localFormStateObject.media.push(entry["@id"]);
}
}
});
}

set(localFormStateObject.content, fieldId, newField);
set(localFormStateObject, "media", [
...new Set([...localFormStateObject.media]),
]);

// Rebuild the media array from all content fields to keep it in sync.
set(
localFormStateObject,
"media",
rebuildMediaFromContent(localFormStateObject.content)
);

setFormStateObject(localFormStateObject);
setMediaData(localMediaData);
Expand Down
61 changes: 61 additions & 0 deletions src/components/slide/slide-media-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Rebuild the media array from all content fields that reference media.
*
* This ensures that the top-level `media` array (sent to the API as slide_media
* associations) always matches the media actually referenced in the slide's
* `content` object.
*
* @param {object} content - The slide content object.
* @returns {string[]} Deduplicated array of media IRIs.
*/
export default function rebuildMediaFromContent(content) {
const media = [];

const mediaIriRegex = /\/v2\/media\/.+/;

const isMediaIri = (value) =>
typeof value === "string" &&
!value.startsWith("TEMP--") &&
mediaIriRegex.test(value);

const collectMediaFromValue = (value, seen = new Set()) => {
// 1) Ignore empty values early (nothing to scan)
if (value === null || value === undefined) return;

// 2) If it's a string, it might be a media IRI; validate and collect it
if (typeof value === "string") {
if (isMediaIri(value)) media.push(value);
return;
}

// 3) If it's not an object (e.g. number/boolean/function), it cannot contain nested media
if (typeof value !== "object") return;

// 4) Defensive guard against circular references:
// - JSON content won't have cycles, but runtime objects might.
// - If we've seen this object/array already, stop to avoid infinite recursion.
if (seen.has(value)) return;
seen.add(value);

// 5) If it's an array, scan each element (elements can be strings, objects, or more arrays)
if (Array.isArray(value)) {
value.forEach((item) => collectMediaFromValue(item, seen));
return;
}

// 6) Otherwise it's a plain object: scan its property values recursively
Object.values(value).forEach((item) => collectMediaFromValue(item, seen));
};

const fieldsToScan = new Set([]);

// Scan content for media references.
if (content && typeof content === "object") {
Object.keys(content).forEach((key) => fieldsToScan.add(key));
}

// Scan the entire content object (one traversal)
collectMediaFromValue(content);

return [...new Set(media)];
}