Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package org.wordpress.gutenberg.model

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

/**
* Payload delivered to the host app when the web editor requests the native
* block inserter. Mirrors the shape produced by
* `preprocessBlockTypesForNativeInserter` in `src/utils/blocks.js` and the
* `BlockInserterBridge` component at `src/components/native-inserter/index.jsx`.
*/
@Serializable
data class BlockInserterPayload(
val sections: List<BlockInserterSection> = emptyList(),
val patterns: List<BlockPattern> = emptyList(),
val patternCategories: List<BlockPatternCategory> = emptyList(),
val sourceRect: SourceRect? = null,
) {
companion object {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}

fun fromJson(jsonString: String): BlockInserterPayload =
json.decodeFromString(serializer(), jsonString)
}
}

@Serializable
data class BlockInserterSection(
/** Stable category slug. Synthetic sections use `gbk-`-prefixed values (e.g. `gbk-most-used`). */
val category: String = "gbk-missing-category",
/** Localized display name. Null for synthetic sections that the host renders without a header. */
val name: String? = null,
val blocks: List<BlockType> = emptyList(),
)

@Serializable
data class BlockType(
/**
* Unique identifier for this block variant. NOT the same as [name] — multiple
* entries can share a `name` but have different `id` values to represent
* variants with different initial attributes (e.g. `core/embed/youtube`).
*/
val id: String,
val name: String,
val title: String? = null,
val description: String? = null,
val category: String? = null,
val keywords: List<String> = emptyList(),
/** SVG markup as a string, or null when the block has no renderable icon. */
val icon: String? = null,
val frecency: Double = 0.0,
val isDisabled: Boolean = false,
val isSearchOnly: Boolean = false,
val parents: List<String> = emptyList(),
)

@Serializable
data class BlockPattern(
val name: String,
val title: String,
/** HTML with Gutenberg block delimiters. */
val content: String,
val blockTypes: List<String> = emptyList(),
val categories: List<String> = emptyList(),
val description: String? = null,
val keywords: List<String> = emptyList(),
val source: String? = null,
val viewportWidth: Int? = null,
)

@Serializable
data class BlockPatternCategory(
/** Category slug (e.g. `gallery`). */
val name: String,
/** Localized display label. */
val label: String,
)

/**
* Position of the UI element that triggered the inserter, in CSS pixels relative
* to the WebView. Host apps can use this to anchor a popover on tablets.
*/
@Serializable
data class SourceRect(
val x: Double = 0.0,
val y: Double = 0.0,
val width: Double = 0.0,
val height: Double = 0.0,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package org.wordpress.gutenberg.model

import kotlinx.serialization.SerializationException
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test

class BlockInserterTest {

@Test
fun `parses a full payload from the web bridge`() {
val payload = BlockInserterPayload.fromJson(FULL_PAYLOAD_JSON)

val section = payload.sections.single()
assertEquals("gbk-most-used", section.category)
assertNull(section.name)

val block = section.blocks.single()
assertEquals("core/paragraph", block.id)
assertEquals("Paragraph", block.title)
assertEquals(listOf("text"), block.keywords)
assertEquals("<svg></svg>", block.icon)
assertEquals(0.5, block.frecency, 0.0)

val pattern = payload.patterns.single()
assertEquals("core/query-standard-posts", pattern.name)
assertEquals(listOf("core/query"), pattern.blockTypes)
assertNull(pattern.description)
assertEquals(1200, pattern.viewportWidth)

assertEquals("Galerie", payload.patternCategories.single().label)

val rect = payload.sourceRect
assertNotNull(rect)
assertEquals(10.0, rect!!.x, 0.0)
assertEquals(40.0, rect.height, 0.0)
}

@Test
fun `tolerates a minimal payload with only sections`() {
val json = """{"sections": [{"category": "text", "blocks": []}]}"""

val payload = BlockInserterPayload.fromJson(json)

assertEquals(1, payload.sections.size)
assertEquals("text", payload.sections.first().category)
assertTrue(payload.patterns.isEmpty())
assertTrue(payload.patternCategories.isEmpty())
assertNull(payload.sourceRect)
}

@Test
fun `uses fallback category when section is missing one`() {
val json = """{"sections": [{"name": null, "blocks": []}]}"""

val payload = BlockInserterPayload.fromJson(json)

assertEquals("gbk-missing-category", payload.sections.first().category)
}

@Test
fun `treats null strings as null, not empty`() {
val json = """
{"sections": [{"category": "text", "blocks": [
{"id": "core/paragraph", "name": "core/paragraph",
"title": "Paragraph", "description": null, "icon": null,
"category": null, "keywords": [], "frecency": 0,
"isDisabled": false, "isSearchOnly": false, "parents": []}
]}]}
""".trimIndent()

val block = BlockInserterPayload.fromJson(json).sections.first().blocks.first()

assertNull(block.description)
assertNull(block.icon)
assertNull(block.category)
}

@Test
fun `rejects null arrays on pattern fields`() {
val json = """
{"patterns": [
{"name": "core/p", "title": "P", "content": "",
"blockTypes": null, "categories": null, "keywords": null}
]}
""".trimIndent()

assertThrows(SerializationException::class.java) {
BlockInserterPayload.fromJson(json)
}
}

@Test
fun `throws SerializationException on malformed input`() {
assertThrows(SerializationException::class.java) {
BlockInserterPayload.fromJson("not json")
}
}
}

private val FULL_PAYLOAD_JSON = """
{
"sections": [
{
"category": "gbk-most-used",
"name": null,
"blocks": [
{
"id": "core/paragraph",
"name": "core/paragraph",
"title": "Paragraph",
"description": "Start with the basic building block of all narrative.",
"category": "text",
"keywords": ["text"],
"icon": "<svg></svg>",
"frecency": 0.5,
"isDisabled": false,
"isSearchOnly": false,
"parents": []
}
]
}
],
"patterns": [
{
"name": "core/query-standard-posts",
"title": "Standard Posts",
"content": "<!-- wp:query --><!-- /wp:query -->",
"blockTypes": ["core/query"],
"categories": ["query"],
"description": null,
"keywords": [],
"source": "pattern-directory",
"viewportWidth": 1200
}
],
"patternCategories": [
{"name": "gallery", "label": "Galerie"}
],
"sourceRect": {"x": 10, "y": 20, "width": 30, "height": 40}
}
""".trimIndent()
Loading