From 045f84f78d8b89346c1240733fd0b01ef1f251ef Mon Sep 17 00:00:00 2001 From: Maxime MICHEL Date: Wed, 23 Sep 2026 17:54:22 +0200 Subject: [PATCH 1/3] :sparkles: Add operation tags and list sorting Operations can now declare OpenAPI tags - previously silently ignored (the parser's own KDoc listed it among unmodeled fields). OperationObject/Operation both gain tags: List (empty by default, additive), threaded through OpenApiParser.parse() the same way version/failureRate already are. Purely a display/filter label, like version - no effect on request matching. NetworkMockScreen gains a fourth per-tab filter chip row (tag_filter_row, multi-select, hidden when the current spec has no tagged operations) following the exact same pattern as the method row, and a sort control (spec order, path, method, tag - OperationSort/sortedByOption, internal rather than private specifically so ordering can be unit-tested directly without a full Compose UI test). Sort state lives in ContentState's own mutableStateMapOf, per spec tab, exactly like every other filter here - not the ViewModel. Placement of the sort control deliberately isn't the shared DevView.kt TopAppBar the handoff doc's prompt describes: NetworkMockScreen has no TopAppBar of its own, and DevView.kt's per-destination action(icon) { onClick } DSL only supports a single-tap icon (optionally with a confirm AlertDialog), with no slot for an anchored multi-item DropdownMenu - and sort state has to live in ContentState, which that shared bar has no access to anyway. Instead it's a plain trailing-aligned row local to this screen's own content, above the global mock toggle - keeps the change fully scoped to this module, matches "top app bar area" in spirit, and avoids a second nested Scaffold topBar duplicating Material app-bar chrome/insets under the global one. api.txt regenerated for devview-networkmock-core (via metalavaGenerateSignature); docs/modules/networkmock-core.md gains a "Tags" section, docs/modules/networkmock-ui.md documents the tag filter row and sort control. --- CHANGELOG.md | 12 ++ devview-networkmock-core/api/api.txt | 7 +- .../core/model/MockConfiguration.kt | 7 +- .../core/openapi/OpenApiDocument.kt | 8 +- .../networkmock/core/openapi/OpenApiParser.kt | 6 +- .../repository/MockConfigRepositoryTest.kt | 35 +++- .../networkmock/NetworkMockScreenTest.kt | 92 ++++++++ .../fixtures/MockScreenTestData.kt | 24 ++- .../devview/networkmock/NetworkMockScreen.kt | 197 ++++++++++++++++-- .../networkmock/NetworkMockScreenSortTest.kt | 89 ++++++++ docs/modules/networkmock-core.md | 20 ++ docs/modules/networkmock-ui.md | 4 +- 12 files changed, 468 insertions(+), 33 deletions(-) create mode 100644 devview-networkmock/src/commonTest/kotlin/com/worldline/devview/networkmock/NetworkMockScreenSortTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index d4731a1..2a0d8c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- NetworkMock: operations can now declare OpenAPI `tags`, read verbatim into a new + `Operation.tags: List` (empty by default, display-only — no effect on request + matching, same as `Operation.version`). The operation list gains a fourth per-tab filter chip + row (`tag_filter_row`, multi-select, hidden when the current spec has no tagged operations) + and a sort control — an overflow menu above the global mock toggle with spec order (default), + path (A-Z), method (`HttpMethod.DefaultMethods` order), and tag (an operation's first declared + tag; "Tag" itself is hidden from the menu when the current spec has no tags). Sort selection + is per-tab, plain client-side state in `NetworkMockScreen`'s `ContentState`, not the + ViewModel — same convention as the existing filters. See `docs/modules/networkmock-core.md`'s + new "Tags" section and `docs/modules/networkmock-ui.md`. + (`devview-networkmock-core`, `devview-networkmock`, #116, #117) + - NetworkMock: an operation can now declare narrow request-body match constraints — required top-level fields and/or a discriminator field's value, read from its `requestBody.content..schema` — to disambiguate operations that would otherwise diff --git a/devview-networkmock-core/api/api.txt b/devview-networkmock-core/api/api.txt index 73d648a..5e7d4a4 100644 --- a/devview-networkmock-core/api/api.txt +++ b/devview-networkmock-core/api/api.txt @@ -145,8 +145,9 @@ package com.worldline.devview.networkmock.core.model { } @androidx.compose.runtime.Immutable @kotlinx.serialization.Serializable public final class Operation { - ctor @KotlinOnly public Operation(String operationId, String name, String path, com.worldline.devview.networkmock.core.model.HttpMethod method, optional java.util.Map? queryParameters, optional Long? delayMs, optional String? version, optional Double? failureRate, optional com.worldline.devview.networkmock.core.model.RequestBodyMatch? requestBodyMatch); + ctor @KotlinOnly public Operation(String operationId, String name, String path, com.worldline.devview.networkmock.core.model.HttpMethod method, optional java.util.Map? queryParameters, optional Long? delayMs, optional String? version, optional Double? failureRate, optional com.worldline.devview.networkmock.core.model.RequestBodyMatch? requestBodyMatch, optional java.util.List tags); method public String component1(); + method public java.util.List component10(); method public String component2(); method public String component3(); method @KotlinOnly public operator com.worldline.devview.networkmock.core.model.HttpMethod component4(); @@ -155,7 +156,7 @@ package com.worldline.devview.networkmock.core.model { method public String? component7(); method public Double? component8(); method public com.worldline.devview.networkmock.core.model.RequestBodyMatch? component9(); - method @KotlinOnly public com.worldline.devview.networkmock.core.model.Operation copy(optional String operationId, optional String name, optional String path, optional com.worldline.devview.networkmock.core.model.HttpMethod method, optional java.util.Map? queryParameters, optional Long? delayMs, optional String? version, optional Double? failureRate, optional com.worldline.devview.networkmock.core.model.RequestBodyMatch? requestBodyMatch); + method @KotlinOnly public com.worldline.devview.networkmock.core.model.Operation copy(optional String operationId, optional String name, optional String path, optional com.worldline.devview.networkmock.core.model.HttpMethod method, optional java.util.Map? queryParameters, optional Long? delayMs, optional String? version, optional Double? failureRate, optional com.worldline.devview.networkmock.core.model.RequestBodyMatch? requestBodyMatch, optional java.util.List tags); method @InaccessibleFromKotlin public Long? getDelayMs(); method @InaccessibleFromKotlin public Double? getFailureRate(); method @InaccessibleFromKotlin public String getName(); @@ -163,6 +164,7 @@ package com.worldline.devview.networkmock.core.model { method @InaccessibleFromKotlin public String getPath(); method @InaccessibleFromKotlin public java.util.Map? getQueryParameters(); method @InaccessibleFromKotlin public com.worldline.devview.networkmock.core.model.RequestBodyMatch? getRequestBodyMatch(); + method @InaccessibleFromKotlin public java.util.List getTags(); method @InaccessibleFromKotlin public String? getVersion(); property public Long? delayMs; property public Double? failureRate; @@ -172,6 +174,7 @@ package com.worldline.devview.networkmock.core.model { property public String path; property public java.util.Map? queryParameters; property public com.worldline.devview.networkmock.core.model.RequestBodyMatch? requestBodyMatch; + property public java.util.List tags; property public String? version; } diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt index e51f7aa..4d2fd07 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/model/MockConfiguration.kt @@ -115,6 +115,10 @@ public data class RequestBodyMatch( * here matches any request body, mirroring how `null` [queryParameters] matches any query * string. Exists to disambiguate operations that would otherwise collide on path, method, * and query alone (see [RequestBodyMatch]). + * @property tags Display-only labels from the operation's OpenAPI `tags` array, or an empty + * list if none are declared. Like [version], this has no effect on request matching — it + * drives the NetworkMock UI's tag filter chips and the "Tag" sort option only (see + * `devview-networkmock`'s `NetworkMockScreen`). * @see ApiSpec * @see com.worldline.devview.networkmock.core.repository.RequestMatcher */ @@ -129,7 +133,8 @@ public data class Operation( val delayMs: Long? = null, val version: String? = null, val failureRate: Double? = null, - val requestBodyMatch: RequestBodyMatch? = null + val requestBodyMatch: RequestBodyMatch? = null, + val tags: List = emptyList() ) /** diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiDocument.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiDocument.kt index aafb2eb..18bf67a 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiDocument.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiDocument.kt @@ -21,11 +21,14 @@ import kotlinx.serialization.Serializable * that kaml does not provide for `kotlinx.serialization.json.JsonElement`-shaped values. * * Only fields consumed by [OpenApiParser] are modeled. Everything else in a real spec - * (`deprecated`, `tags`, `security`, …) is silently ignored via lenient/non-strict decoding — - * this parser mocks, it does not validate. [SchemaObject] is the one exception, read in two + * (`deprecated`, `security`, …) is silently ignored via lenient/non-strict decoding — this + * parser mocks, it does not validate. [SchemaObject] is the one exception, read in two * narrow ways: to *synthesize* a response body when a spec declares no `examples` for a status * code (see [SchemaSynthesizer]), and to build a [RequestBodyObject]'s match constraints (see * [OpenApiParser]'s request-body matching scope decision) — neither is full validation. + * `tags` (see [OperationObject.tags]) is also read, purely as a display/filter label for + * `devview-networkmock`'s UI — like [ParameterObject.example], it has no effect on request + * matching. */ @Serializable internal data class OpenApiDocument( @@ -72,6 +75,7 @@ internal data class PathItemObject( internal data class OperationObject( val operationId: String? = null, val summary: String? = null, + val tags: List = emptyList(), val parameters: List = emptyList(), val requestBody: RequestBodyObject? = null, val responses: Map = emptyMap(), diff --git a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiParser.kt b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiParser.kt index f388d43..a86142b 100644 --- a/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiParser.kt +++ b/devview-networkmock-core/src/commonMain/kotlin/com/worldline/devview/networkmock/core/openapi/OpenApiParser.kt @@ -66,6 +66,9 @@ internal data class ResolvedResponse( * null` (matches any body), same as an operation declaring no `requestBody` at all. Only one * media type is read per `requestBody` (`application/json` if declared, otherwise whichever * is declared first). + * - `tags` (see #116) is read verbatim into [Operation.tags], purely a display/filter label for + * `devview-networkmock`'s UI (tag filter chips, "Tag" sort option) — it has no effect on + * request matching, same as [Operation.version]. */ internal object OpenApiParser { /** @@ -120,7 +123,8 @@ internal object OpenApiParser { requestBodyMatch = context.buildRequestBodyMatch( raw = rawOperation.requestBody, document = document - ) + ), + tags = rawOperation.tags ) responseIndex[operationId] = context.resolveResponseIndex( diff --git a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt index ca675f1..3918ac7 100644 --- a/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt +++ b/devview-networkmock-core/src/commonTest/kotlin/com/worldline/devview/networkmock/core/repository/MockConfigRepositoryTest.kt @@ -660,6 +660,38 @@ class MockConfigRepositoryTest { versionByPath shouldBe cases } + @Test + fun `operation tags are parsed from the OpenAPI tags array`() = runTest { + val spec = """ + { + "info": { "title": "Example" }, + "servers": [ { "url": "https://api.example.com" } ], + "paths": { + "/api/users": { + "get": { + "operationId": "listUsers", + "tags": ["Users", "Admin"], + "responses": {} + }, + "post": { + "operationId": "createUser", + "responses": {} + } + } + } + } + """.trimIndent() + val repository = createRepository(resources = mapOf(SPEC_PATH to spec)) + + val config = repository.loadConfiguration().getOrThrow() + val operations = config.specs[0].operations.associateBy { it.operationId } + + operations.getValue("listUsers").tags shouldContainExactly listOf("Users", "Admin") + // No tags declared at all - defaults to an empty list, not null, same as queryParameters + // defaulting to null rather than every operation carrying a placeholder. + operations.getValue("createUser").tags shouldBe emptyList() + } + @Test fun `local dollar-ref to a components response resolves correctly`() = runTest { val spec = $$""" @@ -1017,7 +1049,7 @@ class MockConfigRepositoryTest { // boolean (a different concept from schema.required, and not modeled at all - must be // silently ignored), three status codes all $ref-ing the *same* response schema, a // folded (unquoted, line-wrapped) summary string, a double-quoted description with a - // backslash line continuation, and a tags block sequence (unmodeled until #116/PR 10). + // backslash line continuation, and a tags block sequence. val yamlSpec = $$""" info: title: Example @@ -1091,6 +1123,7 @@ class MockConfigRepositoryTest { operation.name shouldBe "Init mobile authentication activation workflow. It will reset " + "any previously activated mobile authentication for this user and device." operation.requestBodyMatch?.requiredFields shouldContainExactly listOf("deviceId") + operation.tags shouldContainExactly listOf("Authentication V1", "Authentication") val responses = repository.discoverResponseFiles( key = OperationKey(specId = "example", operationId = "mobileLogin") diff --git a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockScreenTest.kt b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockScreenTest.kt index 867561e..5fc9cb1 100644 --- a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockScreenTest.kt +++ b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockScreenTest.kt @@ -296,6 +296,98 @@ class NetworkMockScreenTest { onAllNodesWithTag(testTag = "endpoint_card_example_health").assertCountEquals(expectedSize = 0) } + @Test + fun tagFilterRow_isAbsent_forSpecWithNoTags() = runComposeUiTest { + setScreen(uiState = MockScreenTestData.contentState()) + expandFilters() + + onNodeWithTag(testTag = "spec_tab_catalog").performClick() + waitForIdle() + + onAllNodesWithTag(testTag = "tag_filter_row_catalog").assertCountEquals(expectedSize = 0) + } + + @Test + fun tagFilterChip_narrowsToThatTag() = runComposeUiTest { + // Only createUser carries the "Admin" tag in MockScreenTestData. + setScreen(uiState = MockScreenTestData.contentState()) + expandFilters() + + onNodeWithTag(testTag = "tag_filter_example_Admin").performClick() + waitForIdle() + + onNodeWithTag(testTag = "endpoint_card_example_createUser").assertIsDisplayed() + onAllNodesWithTag(testTag = "endpoint_card_example_getUser").assertCountEquals(expectedSize = 0) + onAllNodesWithTag(testTag = "endpoint_card_example_health").assertCountEquals(expectedSize = 0) + } + + @Test + fun tagFilterChips_unionMultipleSelections() = runComposeUiTest { + setScreen(uiState = MockScreenTestData.contentState()) + expandFilters() + + onNodeWithTag(testTag = "tag_filter_example_Users").performClick() + waitForIdle() + onNodeWithTag(testTag = "tag_filter_example_Admin").performClick() + waitForIdle() + + onNodeWithTag(testTag = "endpoint_card_example_getUser").assertIsDisplayed() + onNodeWithTag(testTag = "endpoint_card_example_createUser").assertIsDisplayed() + onAllNodesWithTag(testTag = "endpoint_card_example_health").assertCountEquals(expectedSize = 0) + } + + @Test + fun tagFilterChip_deselecting_restoresFullList() = runComposeUiTest { + setScreen(uiState = MockScreenTestData.contentState()) + expandFilters() + + onNodeWithTag(testTag = "tag_filter_example_Admin").performClick() + waitForIdle() + onNodeWithTag(testTag = "tag_filter_example_Admin").performClick() + waitForIdle() + + onNodeWithTag(testTag = "endpoint_card_example_getUser").assertIsDisplayed() + onNodeWithTag(testTag = "endpoint_card_example_createUser").assertIsDisplayed() + onNodeWithTag(testTag = "endpoint_card_example_health").assertIsDisplayed() + } + + @Test + fun sortMenuButton_opensDropdownWithSortOptions() = runComposeUiTest { + setScreen(uiState = MockScreenTestData.contentState()) + + onNodeWithTag(testTag = "sort_menu_button").performClick() + waitForIdle() + + onNodeWithTag(testTag = "sort_menu_item_SPEC_ORDER").assertIsDisplayed() + onNodeWithTag(testTag = "sort_menu_item_PATH").assertIsDisplayed() + onNodeWithTag(testTag = "sort_menu_item_METHOD").assertIsDisplayed() + onNodeWithTag(testTag = "sort_menu_item_TAG").assertIsDisplayed() + } + + @Test + fun sortMenuTagOption_hiddenWhenCurrentSpecHasNoTags() = runComposeUiTest { + setScreen(uiState = MockScreenTestData.contentState()) + + onNodeWithTag(testTag = "spec_tab_catalog").performClick() + waitForIdle() + onNodeWithTag(testTag = "sort_menu_button").performClick() + waitForIdle() + + onAllNodesWithTag(testTag = "sort_menu_item_TAG").assertCountEquals(expectedSize = 0) + } + + @Test + fun sortMenuItem_selection_closesTheMenu() = runComposeUiTest { + setScreen(uiState = MockScreenTestData.contentState()) + + onNodeWithTag(testTag = "sort_menu_button").performClick() + waitForIdle() + onNodeWithTag(testTag = "sort_menu_item_PATH").performClick() + waitForIdle() + + onAllNodesWithTag(testTag = "sort_menu_item_PATH").assertCountEquals(expectedSize = 0) + } + private fun ComposeUiTest.expandFilters() { onNodeWithTag(testTag = "expand_filter_button").performClick() waitForIdle() diff --git a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/fixtures/MockScreenTestData.kt b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/fixtures/MockScreenTestData.kt index 5dc3bf1..5b94f4d 100644 --- a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/fixtures/MockScreenTestData.kt +++ b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/fixtures/MockScreenTestData.kt @@ -16,8 +16,17 @@ internal object MockScreenTestData { * @param versioned Whether this spec's `getUser`/`createUser` operations carry a `/v{n}/` * path segment. `example` is versioned, `catalog` is not — covering both the * chip/filter-row-present and filter-row-absent cases. + * @param tagged Whether this spec's operations carry tags — `getUser`="Users", + * `createUser`="Admin", `health`="Public" (one distinct tag each, so a single tag chip + * narrows to exactly one operation). `example` is tagged, `catalog` is not — same + * present/absent coverage as [versioned]. */ - private fun spec(specId: String, name: String, versioned: Boolean): ApiSpecUiModel = ApiSpecUiModel( + private fun spec( + specId: String, + name: String, + versioned: Boolean, + tagged: Boolean + ): ApiSpecUiModel = ApiSpecUiModel( specId = specId, name = name, operations = persistentListOf( @@ -29,7 +38,8 @@ internal object MockScreenTestData { name = "Get User", path = if (versioned) "/api/v1/users/{userId}" else "/api/users/{userId}", method = HttpMethod.Get, - version = if (versioned) "v1" else null + version = if (versioned) "v1" else null, + tags = if (tagged) listOf("Users") else emptyList() ) ), currentState = OperationMockState.Network @@ -42,7 +52,8 @@ internal object MockScreenTestData { name = "Create User", path = if (versioned) "/api/v2/users" else "/api/users", method = HttpMethod.Post, - version = if (versioned) "v2" else null + version = if (versioned) "v2" else null, + tags = if (tagged) listOf("Admin") else emptyList() ) ), currentState = OperationMockState.Mock(statusCode = 201, exampleName = "default") @@ -54,7 +65,8 @@ internal object MockScreenTestData { operationId = "health", name = "Health", path = "/health", - method = HttpMethod.Get + method = HttpMethod.Get, + tags = if (tagged) listOf("Public") else emptyList() ) ), currentState = OperationMockState.Network @@ -66,8 +78,8 @@ internal object MockScreenTestData { NetworkMockUiState.Content( globalMockingEnabled = globalMockingEnabled, specs = persistentListOf( - spec(specId = "example", name = "Example", versioned = true), - spec(specId = "catalog", name = "Catalog", versioned = false) + spec(specId = "example", name = "Example", versioned = true, tagged = true), + spec(specId = "catalog", name = "Catalog", versioned = false, tagged = false) ) ) } diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt index edb5651..e0ee298 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt @@ -3,6 +3,7 @@ package com.worldline.devview.networkmock import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues @@ -24,7 +25,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Done import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -194,6 +198,12 @@ private fun ContentState( // Keyed by ApiSpec.id so each tab keeps its own selection independently of the others. val selectedVersions = remember { mutableStateMapOf() } val selectedMethods = remember { mutableStateMapOf>() } + val selectedTags = remember { mutableStateMapOf>() } + + // Sort is also a per-spec, client-side-only concern (see `OperationSort`) — deliberately + // not in NetworkMockViewModel, same reasoning as the filters above. + val selectedSort = remember { mutableStateMapOf() } + var sortMenuExpanded by remember { mutableStateOf(value = false) } // Not keyed by spec — mocked-ness is a question about everything, not the current tab, // so unlike version/method the selection persists across tab switches. @@ -239,6 +249,19 @@ private fun ContentState( HttpMethod.DefaultMethods.indexOf(element = method).takeIf { it >= 0 } ?: Int.MAX_VALUE } } + val availableTags = remember(key1 = currentSpecOperations) { + currentSpecOperations + .orEmpty() + .asSequence() + .flatMap { it.descriptor.config.tags } + .distinct() + .sorted() + .toList() + } + // "Tag" only makes sense as a sort key when the current spec actually declares tags. + val sortOptions = remember(key1 = availableTags) { + OperationSort.entries.filter { it != OperationSort.TAG || availableTags.isNotEmpty() } + } Scaffold( modifier = modifier @@ -355,6 +378,38 @@ private fun ContentState( } } } + if (currentSpecId != null && availableTags.isNotEmpty()) { + val activeTags = selectedTags[currentSpecId].orEmpty() + HorizontalDivider() + LazyRow( + modifier = Modifier + .fillMaxWidth() + .testTag(tag = "tag_filter_row_$currentSpecId"), + horizontalArrangement = Arrangement.spacedBy(space = 8.dp), + contentPadding = PaddingValues( + horizontal = 16.dp, + vertical = 8.dp + ) + ) { + items(items = availableTags) { tag -> + val selected = tag in activeTags + FilterChip( + modifier = Modifier.testTag( + tag = "tag_filter_${currentSpecId}_$tag" + ), + selected = selected, + onClick = { + selectedTags[currentSpecId] = if (selected) { + activeTags - tag + } else { + activeTags + tag + } + }, + label = { Text(text = tag) } + ) + } + } + } } } HorizontalDivider() @@ -415,14 +470,69 @@ private fun ContentState( modifier = Modifier.fillMaxSize() ) { Surface { - GlobalMockToggle( - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp), - enabled = uiState.globalMockingEnabled, - mockedCount = mockedOperations, - totalCount = totalOperations, - onToggle = onGlobalToggle - ) + Column { + // Sort control: a plain trailing-aligned row local to this screen's own + // content, not the shared DevView.kt TopAppBar (its per-destination + // `action(icon) { onClick }` only supports a single-tap icon, with no slot + // for an anchored multi-item DropdownMenu) and not a second nested + // Scaffold `topBar` (would duplicate Material app-bar chrome under the + // global one and need its own inset bookkeeping). Keeps sort state local to + // `ContentState`, exactly like every other filter here. + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp), + horizontalArrangement = Arrangement.End + ) { + val currentSort = selectedSort[currentSpecId] ?: OperationSort.SPEC_ORDER + Box { + IconButton( + modifier = Modifier.testTag(tag = "sort_menu_button"), + onClick = { sortMenuExpanded = true } + ) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = "Sort operations" + ) + } + DropdownMenu( + expanded = sortMenuExpanded, + onDismissRequest = { sortMenuExpanded = false } + ) { + sortOptions.forEach { option -> + DropdownMenuItem( + modifier = Modifier.testTag( + tag = "sort_menu_item_${option.name}" + ), + text = { Text(text = option.label) }, + trailingIcon = { + if (option == currentSort) { + Icon( + imageVector = Icons.Rounded.Done, + contentDescription = null + ) + } + }, + onClick = { + currentSpecId?.let { specId -> + selectedSort[specId] = option + } + sortMenuExpanded = false + } + ) + } + } + } + } + GlobalMockToggle( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp), + enabled = uiState.globalMockingEnabled, + mockedCount = mockedOperations, + totalCount = totalOperations, + onToggle = onGlobalToggle + ) + } } HorizontalDivider() @@ -452,21 +562,27 @@ private fun ContentState( val selectedVersion = selectedVersions[spec.specId] val methodFilter = selectedMethods[spec.specId].orEmpty() + val tagFilter = selectedTags[spec.specId].orEmpty() + val sortOption = selectedSort[spec.specId] ?: OperationSort.SPEC_ORDER val filteredOperations = remember( spec.operations, searchQuery, selectedVersion, methodFilter, - selectedMockStates + tagFilter, + selectedMockStates, + sortOption ) { - spec.operations.filter { - it.matches( - query = searchQuery, - version = selectedVersion, - methods = methodFilter, - mockStates = selectedMockStates - ) - } + spec.operations + .filter { + it.matches( + query = searchQuery, + version = selectedVersion, + methods = methodFilter, + tags = tagFilter, + mockStates = selectedMockStates + ) + }.sortedByOption(sort = sortOption) } LazyColumn( @@ -520,13 +636,14 @@ private fun ContentState( /** * Whether this operation's name, path, or operationId contains [query], and matches - * [version], [methods], and [mockStates]. + * [version], [methods], [tags], and [mockStates]. */ @Suppress("DocumentationOverPrivateFunction") private fun OperationUiModel.matches( query: String, version: String?, methods: Set, + tags: Set, mockStates: Set ): Boolean { val config = descriptor.config @@ -536,6 +653,7 @@ private fun OperationUiModel.matches( config.operationId.contains(other = query, ignoreCase = true) val matchesVersion = version == null || config.version == version val matchesMethod = methods.isEmpty() || config.method in methods + val matchesTags = tags.isEmpty() || config.tags.any { it in tags } val matchesMockState = mockStates.isEmpty() || when (currentState) { // Failure counts as "Mocked" for this filter — like Mock, it's a deliberately // configured non-default state, distinct only from plain pass-through. @@ -544,7 +662,7 @@ private fun OperationUiModel.matches( is OperationMockState.Failure -> MockStateFilter.MOCKED in mockStates OperationMockState.Network -> MockStateFilter.NETWORK in mockStates } - return matchesQuery && matchesVersion && matchesMethod && matchesMockState + return matchesQuery && matchesVersion && matchesMethod && matchesTags && matchesMockState } /** Filter dimension over whether an operation is currently mocked or passing through to the network. */ @@ -553,6 +671,47 @@ private enum class MockStateFilter(val label: String) { NETWORK(label = "Network") } +/** + * Client-side sort order for the operation list — a pure display concern, exactly like search + * and the filter chips above: state lives in [ContentState]'s own `remember`/`mutableStateMapOf`, + * never in [NetworkMockViewModel]. `internal` rather than `private`, specifically so + * [sortedByOption] can be unit-tested directly (see `NetworkMockScreenSortTest.kt`) without a + * full Compose UI test to verify list ordering. + */ +internal enum class OperationSort(val label: String) { + /** + * Whatever order [com.worldline.devview.networkmock.core.openapi.OpenApiParser] produced — + * the default, a no-op. + */ + SPEC_ORDER(label = "Default"), + + /** Alphabetical by [com.worldline.devview.networkmock.core.model.Operation.path]. */ + PATH(label = "Path (A-Z)"), + + /** Uses [HttpMethod.DefaultMethods]' canonical order — same as the method filter chips. */ + METHOD(label = "Method"), + + /** Sorts by an operation's first declared tag; untagged operations sort first. */ + TAG(label = "Tag") +} + +/** Applies [sort] to [this] — see [OperationSort] for what each key does. */ +internal fun List.sortedByOption(sort: OperationSort): List = + when (sort) { + OperationSort.SPEC_ORDER -> this + OperationSort.PATH -> sortedBy { it.descriptor.config.path } + OperationSort.METHOD -> sortedBy { operation -> + HttpMethod.DefaultMethods + .indexOf(element = operation.descriptor.config.method) + .takeIf { it >= 0 } ?: Int.MAX_VALUE + } + OperationSort.TAG -> sortedBy { + it.descriptor.config.tags + .firstOrNull() + .orEmpty() + } + } + @Preview(locale = "en") @Composable private fun NetworkMockScreenPreview( diff --git a/devview-networkmock/src/commonTest/kotlin/com/worldline/devview/networkmock/NetworkMockScreenSortTest.kt b/devview-networkmock/src/commonTest/kotlin/com/worldline/devview/networkmock/NetworkMockScreenSortTest.kt new file mode 100644 index 0000000..3091921 --- /dev/null +++ b/devview-networkmock/src/commonTest/kotlin/com/worldline/devview/networkmock/NetworkMockScreenSortTest.kt @@ -0,0 +1,89 @@ +package com.worldline.devview.networkmock + +import com.worldline.devview.networkmock.core.model.HttpMethod +import com.worldline.devview.networkmock.core.model.Operation +import com.worldline.devview.networkmock.core.model.OperationDescriptor +import com.worldline.devview.networkmock.core.model.OperationKey +import com.worldline.devview.networkmock.core.model.OperationMockState +import com.worldline.devview.networkmock.model.OperationUiModel +import io.kotest.matchers.collections.shouldContainExactly +import kotlin.test.Test + +/** + * [OperationSort.SPEC_ORDER] isn't asserted here beyond "returns the same list unmodified" — + * there's nothing else to check for a documented no-op. + */ +class NetworkMockScreenSortTest { + + @Test + fun `SPEC_ORDER returns operations in their original order`() { + val operations = listOf( + operation(operationId = "b", path = "/b"), + operation(operationId = "a", path = "/a") + ) + + val sorted = operations.sortedByOption(sort = OperationSort.SPEC_ORDER) + + sorted.map { it.descriptor.operationId } shouldContainExactly listOf("b", "a") + } + + @Test + fun `PATH sorts operations alphabetically by path`() { + val operations = listOf( + operation(operationId = "c", path = "/c"), + operation(operationId = "a", path = "/a"), + operation(operationId = "b", path = "/b") + ) + + val sorted = operations.sortedByOption(sort = OperationSort.PATH) + + sorted.map { it.descriptor.config.path } shouldContainExactly listOf("/a", "/b", "/c") + } + + @Test + fun `METHOD sorts operations using HttpMethod DefaultMethods canonical order`() { + val operations = listOf( + operation(operationId = "delete", path = "/x", method = HttpMethod.Delete), + operation(operationId = "get", path = "/x", method = HttpMethod.Get), + operation(operationId = "post", path = "/x", method = HttpMethod.Post) + ) + + val sorted = operations.sortedByOption(sort = OperationSort.METHOD) + + sorted.map { it.descriptor.operationId } shouldContainExactly listOf("get", "post", "delete") + } + + @Test + fun `TAG sorts operations by their first declared tag with untagged operations first`() { + val operations = listOf( + operation(operationId = "users", path = "/x", tags = listOf("Users")), + operation(operationId = "untagged", path = "/y", tags = emptyList()), + operation(operationId = "admin", path = "/z", tags = listOf("Admin")) + ) + + val sorted = operations.sortedByOption(sort = OperationSort.TAG) + + sorted.map { it.descriptor.operationId } shouldContainExactly + listOf("untagged", "admin", "users") + } + + private fun operation( + operationId: String, + path: String, + method: HttpMethod = HttpMethod.Get, + tags: List = emptyList() + ): OperationUiModel = OperationUiModel( + descriptor = OperationDescriptor( + key = OperationKey(specId = "spec", operationId = operationId), + config = Operation( + operationId = operationId, + name = operationId, + path = path, + method = method, + tags = tags + ) + ), + currentState = OperationMockState.Network + ) +} + diff --git a/docs/modules/networkmock-core.md b/docs/modules/networkmock-core.md index bcba5b6..e058764 100644 --- a/docs/modules/networkmock-core.md +++ b/docs/modules/networkmock-core.md @@ -52,6 +52,7 @@ Key concepts: - **`requestBody.content..schema`** → optionally disambiguates operations that collide on path/method/query — see [Request body matching](#request-body-matching) below. - **`x-devview.delayMs`** → simulated response delay, at the document root (spec-wide default) and/or per operation (overrides the default). See [x-devview extension](#x-devview-extension) below. - **`{param}` placeholders**: Path segments like `{userId}` match any value during request matching. +- **`tags`** → display-only labels, read into `Operation.tags` — see [Tags](#tags) below. ## Version Tags @@ -64,6 +65,25 @@ remain two distinct operations matched only by path, method, and query params (s [Request Matching](#request-matching) below). The extraction pattern is not currently configurable. +## Tags + +`Operation.tags` is read verbatim from the operation's OpenAPI `tags` array (`emptyList()` if +absent) — purely a display/filter label, like [`version`](#version-tags): it has no effect on +request matching. It drives the NetworkMock UI's tag filter chips and its "Tag" sort option — +see [NetworkMock UI](networkmock-ui.md). + +```json +"paths": { + "/api/users": { + "get": { + "operationId": "listUsers", + "tags": ["Users", "Admin"], + "responses": { "...": "..." } + } + } +} +``` + ## Request Matching `MockConfigRepository.findMatchingMock(host, path, method, queryParameters, requestBody)` resolves a mock in four steps: diff --git a/docs/modules/networkmock-ui.md b/docs/modules/networkmock-ui.md index a7cacaf..1b28bc1 100644 --- a/docs/modules/networkmock-ui.md +++ b/docs/modules/networkmock-ui.md @@ -9,11 +9,13 @@ The `devview-networkmock` module provides the Compose UI for the network mocking The main screen shows a global mock toggle at the top, followed by a scrollable tab row with one tab per OpenAPI spec (e.g. "My Backend"). Each tab lists every operation declared in that spec with its current mock state — there is no environment axis, so a spec spanning multiple API versions shows all of its operations side by side in one tab. - **Global toggle**: enables or disables all mocking globally. When off, all requests go to the real network regardless of per-endpoint settings. Shows a count of mocked operations (e.g. "3 of 47 mocked") computed across every spec, not just the visible tab. -- **Search & filter bar**: pinned to the bottom of the screen (a `Scaffold` `bottomBar`, reachable one-handed). The search field is always visible; a chevron button expands/collapses the mock-state, version, and HTTP method filter rows below it. All filters are plain client-side filters over already-loaded data and combine with AND (multi-select chips within one row combine with OR). +- **Sort control**: a `MoreVert` overflow menu, top-right of the screen's own content (above the global toggle) — sort keys are spec order (default, document order), path (A-Z), method (`GET`/`POST`/`PUT`/`PATCH`/`DELETE`/`HEAD`/`OPTIONS` order), and tag (an operation's first declared tag, untagged operations first); "Tag" is hidden from the menu entirely when the current tab has no tagged operations. Selection is per-tab, like the version and method filters, and — like every filter here — pure client-side state, not persisted or round-tripped through the ViewModel. +- **Search & filter bar**: pinned to the bottom of the screen (a `Scaffold` `bottomBar`, reachable one-handed). The search field is always visible; a chevron button expands/collapses the mock-state, version, HTTP method, and tag filter rows below it. All filters are plain client-side filters over already-loaded data and combine with AND (multi-select chips within one row combine with OR). - **Search field**: filters the visible operations in the current tab by name, path, or operationId, live as you type. - **Mock-state filter**: a row of "Mocked"/"Network" chips, not scoped to the current tab — selecting one persists across tab switches, since "what's mocked" is a question about every spec. - **Version filter**: a per-tab row of chips — "All" plus one per distinct `Operation.version` present among that tab's operations (see [Version Tags](networkmock-core.md#version-tags)). Hidden entirely when a spec has no versioned operations. - **Method filter**: a per-tab row of chips, one per distinct `Operation.method` present among that tab's operations, ordered `GET`/`POST`/`PUT`/`PATCH`/`DELETE`/`HEAD`/`OPTIONS`. Multi-select — no chip selected shows every method; selecting one or more narrows the list to operations using any of the selected methods. +- **Tag filter**: a per-tab row of chips, one per distinct `Operation.tag` present among that tab's operations (see [Tags](networkmock-core.md#tags)), alphabetically ordered. Multi-select, same semantics as the method filter. Hidden entirely when a spec has no tagged operations. - **Endpoint rows**: a leading colour rail (the state chip's colour, transparent when not mocked) followed by the endpoint name, a colour-coded HTTP method badge, the path (wraps instead of truncating — never cut off), and the current state chip (Network / bare status code, e.g. "404"). There is no separate version badge — `Operation.version` is parsed from the path shown right next to it, so it would only repeat what's already visible. Tap a row to open its operation sheet. - **Reset to Network**: toolbar action that resets every endpoint to `Network` state in one tap. - **Reload Config**: toolbar action that re-reads and re-parses every configured OpenAPI spec from disk, picking up edits to a spec file without restarting the app. Operations added, removed, or renamed in the spec appear immediately; per-operation mock selections are untouched. See [Caching & Reload](networkmock-core.md#caching--reload). From 8b10fb4fcfc46d0196a970165a6e0abea8cd7cc2 Mon Sep 17 00:00:00 2001 From: Maxime MICHEL Date: Wed, 23 Sep 2026 19:40:46 +0200 Subject: [PATCH 2/3] :sparkles: Add toolbar dropdown menus and use one for NetworkMock's sort control DevView's shared top app bar could only render a single-tap icon action (optionally guarded by a confirm/cancel popup) - a control offering several discrete choices (e.g. "Sort by: Path / Method / Tag") had nowhere to live except inside a module's own screen content, duplicating chrome. ModuleDestinationAction gains menuItems: PersistentList? (new public class ModuleDestinationActionMenuItem: label + onClick). DestinationMetadataBuilder gains a menu(icon) { item(label) { ... } } DSL alongside the existing action(icon) { ... }. DevView.kt's TopAppBar renders menuItems as an anchored DropdownMenu when present; precedence when an icon is tapped is menuItems -> popup -> action, and a menu action's own action lambda defaults to a no-op (only the individual item callbacks fire). NetworkMock's "Sort" toolbar action - previously a single-tap icon that cycled spec order -> path -> method -> tag on every tap, a workaround noted explicitly at the time as "the shared toolbar action model only supports a single-tap icon" - now uses menu(...) to offer all four OperationSort options directly. Picking "Tag" when the current spec has no tagged operations is a harmless no-op (sortedByOption sorts by each operation's absent first tag, an equal empty string for all). OperationSort itself stays internal (sort is a pure client-side composable concern per this module's own convention, never part of the public API) even though NetworkMockScreen - a public composable - now needs to carry a selected sort across the module boundary: the flow crossing NetworkMock -> NetworkMockScreen carries OperationSort.label: String instead, mapped back to the enum inside ContentState. A public composable can't expose an internal type in its signature, and both ends of this label-based protocol live in this module. Tests: DestinationMetadataExtensionsTest covers the new menu DSL; NetworkMockScreenTest's sort test now asserts direct selection instead of cycling; new NetworkMockModuleTest verifies the toolbar registers Refresh/Sort-menu/Reset in order with the correct menu labels. api.txt regenerated for devview and devview-networkmock (metalavaGenerateSignature). devview/CLAUDE.md documents the menu DSL; devview-networkmock/CLAUDE.md gains a "Sort" toolbar dropdown section (and a missing tag filter row mention); docs/modules/networkmock-ui.md and CHANGELOG.md updated to match. --- CHANGELOG.md | 22 ++-- devview-networkmock/CLAUDE.md | 31 ++++- devview-networkmock/api/api.txt | 2 +- .../networkmock/NetworkMockScreenTest.kt | 53 ++++----- .../devview/networkmock/NetworkMock.kt | 32 ++++- .../devview/networkmock/NetworkMockScreen.kt | 111 ++++++------------ .../networkmock/NetworkMockModuleTest.kt | 56 +++++++++ devview/CLAUDE.md | 8 +- devview/api/api.txt | 23 +++- .../com/worldline/devview/DevViewTest.kt | 73 +++++++++++- .../kotlin/com/worldline/devview/DevView.kt | 50 ++++++-- .../devview/core/DestinationMetadata.kt | 36 ++++++ .../devview/core/ModuleDestinationAction.kt | 32 ++++- .../core/ModuleDestinationActionMenuItem.kt | 52 ++++++++ .../core/DestinationMetadataExtensionsTest.kt | 49 ++++++++ docs/modules/networkmock-ui.md | 2 +- 16 files changed, 495 insertions(+), 137 deletions(-) create mode 100644 devview-networkmock/src/commonTest/kotlin/com/worldline/devview/networkmock/NetworkMockModuleTest.kt create mode 100644 devview/src/commonMain/kotlin/com/worldline/devview/core/ModuleDestinationActionMenuItem.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a0d8c9..a263181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,13 +12,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `Operation.tags: List` (empty by default, display-only — no effect on request matching, same as `Operation.version`). The operation list gains a fourth per-tab filter chip row (`tag_filter_row`, multi-select, hidden when the current spec has no tagged operations) - and a sort control — an overflow menu above the global mock toggle with spec order (default), - path (A-Z), method (`HttpMethod.DefaultMethods` order), and tag (an operation's first declared - tag; "Tag" itself is hidden from the menu when the current spec has no tags). Sort selection - is per-tab, plain client-side state in `NetworkMockScreen`'s `ContentState`, not the - ViewModel — same convention as the existing filters. See `docs/modules/networkmock-core.md`'s - new "Tags" section and `docs/modules/networkmock-ui.md`. - (`devview-networkmock-core`, `devview-networkmock`, #116, #117) + and a sort control — a new "Sort" toolbar dropdown, wired via the shared toolbar's new + `DestinationMetadataBuilder.menu` action (`NetworkMock` exposes a `sortSharedFlow`, + `NetworkMockScreen` collects it). Tapping it opens a menu with one entry per sort key: spec + order (default), path (A-Z), method (`HttpMethod.DefaultMethods` order), and tag (an + operation's first declared tag); picking an entry sets that sort directly. Sort + selection is per-tab, plain client-side state in `NetworkMockScreen`'s `ContentState`, not + the ViewModel — same convention as the existing filters. See + `docs/modules/networkmock-core.md`'s new "Tags" section and `docs/modules/networkmock-ui.md`. + (`devview`, `devview-networkmock-core`, `devview-networkmock`, #116, #117) +- DevView: the shared top app bar's contextual actions can now be a dropdown menu of discrete + choices, not just a single-tap icon or a confirm/cancel popup. `ModuleDestinationAction` gains + a `menuItems: PersistentList?` property (new public class); + `DestinationMetadataBuilder` gains a `menu(icon) { item(label) { ... } }` DSL alongside the + existing `action`. Precedence when both `action` and `menu` could apply: `menuItems` wins, + then `popup`, then the plain `action` lambda. (`devview`, #117) - NetworkMock: an operation can now declare narrow request-body match constraints — required top-level fields and/or a discriminator field's value, read from its diff --git a/devview-networkmock/CLAUDE.md b/devview-networkmock/CLAUDE.md index 07c2aef..e0fb7fd 100644 --- a/devview-networkmock/CLAUDE.md +++ b/devview-networkmock/CLAUDE.md @@ -81,9 +81,11 @@ version/method it stays selected across tab switches. Only the search field and a chevron `IconButton` are visible by default (mirrors `devview-analytics`'s `AnalyticsScreen` bottom bar). Tapping the chevron toggles `filtersExpanded`, revealing — top to bottom — the mock-state filter row, the version filter row (if the current -spec has versioned operations), then the method filter row (if it has more than one method) inside -an `AnimatedVisibility`. The chevron rotates via `graphicsLayer(rotationX = ...)` driven by -`animateFloatAsState`, identical to the Analytics pattern. +spec has versioned operations), the method filter row (if it has more than one method), then the +tag filter row (if the current spec has any tagged operations — see +[Tags](../docs/modules/networkmock-core.md#tags)) inside an `AnimatedVisibility`. The chevron +rotates via `graphicsLayer(rotationX = ...)` driven by `animateFloatAsState`, identical to the +Analytics pattern. ### Global mocked-count header @@ -121,6 +123,29 @@ deliberately don't (see "Status code colors and icons" below). Wired via a `MutableSharedFlow` (capacity 1, `DROP_OLDEST`) created in `NetworkMock` and passed into `NetworkMockScreen`. `resetAllToNetwork()` resets every operation in the parsed config (not just those stored in DataStore) to avoid gaps for operations the user has never touched. +### "Sort" toolbar dropdown + +Registered via `DestinationMetadataBuilder.menu` (the shared toolbar's dropdown-menu action — +see `devview/DevView.kt` and `ModuleDestinationAction.menuItems`), not the plain single-tap +`action` used by "Refresh"/"Reset to Network" — a sort key is a discrete choice among several, so +it gets an anchored dropdown instead of a cycling single-tap icon. One menu entry per +`OperationSort` value (`Default`, `Path (A-Z)`, `Method`, `Tag`), built once at module-construction +time from `OperationSort.entries`; because the shared `menu` DSL takes a fixed list of items with +no access to the currently-visible spec, "Tag" is always offered even when the current spec has no +tagged operations — picking it in that case is a harmless no-op (`sortedByOption` sorts by every +operation's absent first tag, i.e. an equal empty string for all, so the list order doesn't +change). + +The flow crossing `NetworkMock` → `NetworkMockScreen` carries an `OperationSort.label: String`, +not `OperationSort` itself: `NetworkMockScreen` is public API but `OperationSort` is deliberately +`internal` (see "Search and filters live in the composable, not the ViewModel" above), and a +public composable can't expose an internal type in its signature. `ContentState` maps the label +back to the enum entry via `OperationSort.entries.firstOrNull { it.label == label }`; both ends of +this flow live in this module and share the same `OperationSort.label` values, so the label is a +safe, internal-only protocol despite the public parameter type. Sort selection itself is stored +the same way as the version/method/tag filters — plain `mutableStateMapOf` +in `ContentState`, keyed by spec ID, never round-tripped through `NetworkMockViewModel`. + ### Operation sheet: one sheet, two pages `NetworkMockOperationSheet.kt` renders `NetworkMockViewModel.sheetState` as a `ModalBottomSheet` diff --git a/devview-networkmock/api/api.txt b/devview-networkmock/api/api.txt index ddd30c1..983b4b3 100644 --- a/devview-networkmock/api/api.txt +++ b/devview-networkmock/api/api.txt @@ -27,7 +27,7 @@ package com.worldline.devview.networkmock { } public final class NetworkMockScreenKt { - method @KotlinOnly @androidx.compose.runtime.Composable public static void NetworkMockScreen(kotlinx.coroutines.flow.SharedFlow resetToNetworkSharedFlow, kotlinx.coroutines.flow.SharedFlow reloadConfigSharedFlow, com.worldline.devview.networkmock.viewmodel.NetworkMockViewModel viewModel, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.unit.Dp bottomPadding); + method @KotlinOnly @androidx.compose.runtime.Composable public static void NetworkMockScreen(kotlinx.coroutines.flow.SharedFlow resetToNetworkSharedFlow, kotlinx.coroutines.flow.SharedFlow reloadConfigSharedFlow, kotlinx.coroutines.flow.SharedFlow sortSharedFlow, com.worldline.devview.networkmock.viewmodel.NetworkMockViewModel viewModel, optional androidx.compose.ui.Modifier modifier, optional androidx.compose.ui.unit.Dp bottomPadding); } } diff --git a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockScreenTest.kt b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockScreenTest.kt index 5fc9cb1..0624fca 100644 --- a/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockScreenTest.kt +++ b/devview-networkmock/src/androidDeviceTest/kotlin/com/worldline/devview/networkmock/NetworkMockScreenTest.kt @@ -18,6 +18,9 @@ import com.worldline.devview.networkmock.fixtures.MockScreenTestData import com.worldline.devview.networkmock.viewmodel.NetworkMockUiState import io.kotest.matchers.shouldBe import kotlin.test.Test +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow class NetworkMockScreenTest { @@ -352,40 +355,28 @@ class NetworkMockScreenTest { } @Test - fun sortMenuButton_opensDropdownWithSortOptions() = runComposeUiTest { - setScreen(uiState = MockScreenTestData.contentState()) - - onNodeWithTag(testTag = "sort_menu_button").performClick() - waitForIdle() - - onNodeWithTag(testTag = "sort_menu_item_SPEC_ORDER").assertIsDisplayed() - onNodeWithTag(testTag = "sort_menu_item_PATH").assertIsDisplayed() - onNodeWithTag(testTag = "sort_menu_item_METHOD").assertIsDisplayed() - onNodeWithTag(testTag = "sort_menu_item_TAG").assertIsDisplayed() - } - - @Test - fun sortMenuTagOption_hiddenWhenCurrentSpecHasNoTags() = runComposeUiTest { - setScreen(uiState = MockScreenTestData.contentState()) - - onNodeWithTag(testTag = "spec_tab_catalog").performClick() - waitForIdle() - onNodeWithTag(testTag = "sort_menu_button").performClick() - waitForIdle() - - onAllNodesWithTag(testTag = "sort_menu_item_TAG").assertCountEquals(expectedSize = 0) - } + fun sortSharedFlow_emittingMethodLabel_reordersTheOperationList() = runComposeUiTest { + // "example" is tagged, so the dropdown offers [SPEC_ORDER, PATH, METHOD, TAG]. Emitting + // "Method" directly selects that sort key. Spec order is [getUser(GET), createUser(POST), + // health(GET)] — sorting by method is a stable sort, so the GET group keeps its + // relative order (getUser, health) ahead of the POST group (createUser), moving + // "Health" above "Create User" relative to the untouched spec-order default. + val sortSharedFlow = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + setScreen(uiState = MockScreenTestData.contentState(), sortSharedFlow = sortSharedFlow) - @Test - fun sortMenuItem_selection_closesTheMenu() = runComposeUiTest { - setScreen(uiState = MockScreenTestData.contentState()) + val initialCreateUserY = onNodeWithText(text = "Create User").fetchSemanticsNode().positionInRoot.y + val initialHealthY = onNodeWithText(text = "Health").fetchSemanticsNode().positionInRoot.y + (initialCreateUserY < initialHealthY) shouldBe true - onNodeWithTag(testTag = "sort_menu_button").performClick() - waitForIdle() - onNodeWithTag(testTag = "sort_menu_item_PATH").performClick() + sortSharedFlow.tryEmit(value = "Method") waitForIdle() - onAllNodesWithTag(testTag = "sort_menu_item_PATH").assertCountEquals(expectedSize = 0) + val sortedCreateUserY = onNodeWithText(text = "Create User").fetchSemanticsNode().positionInRoot.y + val sortedHealthY = onNodeWithText(text = "Health").fetchSemanticsNode().positionInRoot.y + (sortedHealthY < sortedCreateUserY) shouldBe true } private fun ComposeUiTest.expandFilters() { @@ -397,6 +388,7 @@ class NetworkMockScreenTest { uiState: NetworkMockUiState, onGlobalToggle: (Boolean) -> Unit = {}, onSelectOperation: (OperationKey) -> Unit = { }, + sortSharedFlow: SharedFlow = MutableSharedFlow(), ) { setContent { MaterialTheme { @@ -404,6 +396,7 @@ class NetworkMockScreenTest { uiState = uiState, onGlobalToggle = onGlobalToggle, onSelectOperation = onSelectOperation, + sortSharedFlow = sortSharedFlow, ) } } diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMock.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMock.kt index 3773cd5..e5bdf9a 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMock.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMock.kt @@ -2,6 +2,7 @@ package com.worldline.devview.networkmock import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Sort import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material.icons.rounded.Restore import androidx.compose.runtime.Composable @@ -132,6 +133,11 @@ public class NetworkMock( action(icon = Icons.Rounded.Refresh) { onReloadConfig.tryEmit(value = Unit) } + menu(icon = Icons.AutoMirrored.Rounded.Sort) { + OperationSort.entries.forEach { sort -> + item(label = sort.label) { onSortSelected.tryEmit(value = sort.label) } + } + } action(icon = Icons.Rounded.Restore) { onResetToNetwork.tryEmit(value = Unit) } @@ -158,6 +164,29 @@ public class NetworkMock( onBufferOverflow = BufferOverflow.DROP_OLDEST ) + // Emits the OperationSort.label of the entry tapped in the toolbar's "Sort" dropdown menu. + // Collected by NetworkMockScreen to set the sort order for the currently visible spec tab + // directly — unlike onResetToNetwork and onReloadConfig, this is a + // DestinationMetadataBuilder.menu action (not a single-tap action), since the shared toolbar + // now supports an anchored dropdown of discrete choices. + // + // A label String — rather than OperationSort itself — crosses this boundary because + // NetworkMockScreen is public API while OperationSort is deliberately internal (sort is a + // pure client-side composable concern, per devview-networkmock/CLAUDE.md's "Search and + // filters live in the composable, not the ViewModel"); a public composable can't expose an + // internal type in its signature. Both ends of this flow live in this module and share the + // same OperationSort.label values, so the label is a safe, internal-only protocol despite the + // public parameter type. + // + // The menu's entries are fixed at module construction time (one per OperationSort value, in + // declaration order) and cannot vary per spec tab; picking "Tag" when the current tab has no + // tagged operations is a harmless no-op — sortedByOption sorts by each operation's absent + // first tag (empty string for all), so the list order doesn't change. + private val onSortSelected = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + override fun EntryProviderScope.registerContent( onNavigateBack: () -> Unit, onNavigate: (NavKey) -> Unit, @@ -175,7 +204,8 @@ public class NetworkMock( }, bottomPadding = bottomPadding, resetToNetworkSharedFlow = onResetToNetwork, - reloadConfigSharedFlow = onReloadConfig + reloadConfigSharedFlow = onReloadConfig, + sortSharedFlow = onSortSelected ) } } diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt index e0ee298..0f7cc1c 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt @@ -3,7 +3,6 @@ package com.worldline.devview.networkmock import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.PaddingValues @@ -25,10 +24,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Done import androidx.compose.material.icons.rounded.KeyboardArrowUp -import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -72,6 +68,7 @@ import com.worldline.devview.networkmock.preview.NetworkMockUiStatePreviewParame import com.worldline.devview.networkmock.viewmodel.NetworkMockUiState import com.worldline.devview.networkmock.viewmodel.NetworkMockViewModel import com.worldline.devview.networkmock.viewmodel.OperationSheetState +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow /** @@ -89,6 +86,12 @@ import kotlinx.coroutines.flow.SharedFlow * @param reloadConfigSharedFlow Shared flow emitted by [NetworkMock] when the user triggers the * "Reload Config" toolbar action. Collected here to call [NetworkMockViewModel.reloadConfiguration], * picking up edits to a spec file without restarting the app. + * @param sortSharedFlow Shared flow emitted by [NetworkMock] when the user picks an entry from + * the "Sort" toolbar dropdown — each emission is an [OperationSort.label] string, not the + * (internal) [OperationSort] itself, since this is a public composable's parameter; see + * [NetworkMock]'s `onSortSelected` for why. Collected in [ContentState] (not here — sort is + * per-tab, client-side state, never round-tripped through [NetworkMockViewModel]) to set the + * [OperationSort] for the currently visible spec tab directly. * @param viewModel The [NetworkMockViewModel] instance. Constructed and provided by * [NetworkMock.registerContent] via the `viewModel { }` factory so that it is scoped to the * navigation entry. Also owns the operation sheet's state — see [NetworkMockViewModel.sheetState]. @@ -100,6 +103,7 @@ import kotlinx.coroutines.flow.SharedFlow public fun NetworkMockScreen( resetToNetworkSharedFlow: SharedFlow, reloadConfigSharedFlow: SharedFlow, + sortSharedFlow: SharedFlow, viewModel: NetworkMockViewModel, modifier: Modifier = Modifier, bottomPadding: Dp = 0.dp @@ -123,6 +127,7 @@ public fun NetworkMockScreen( uiState = uiState, onGlobalToggle = viewModel::setGlobalMockingEnabled, onSelectOperation = viewModel::openOperation, + sortSharedFlow = sortSharedFlow, modifier = modifier, bottomPadding = bottomPadding ) @@ -165,7 +170,8 @@ internal fun NetworkMockScreenContent( onGlobalToggle: (Boolean) -> Unit, onSelectOperation: (OperationKey) -> Unit, modifier: Modifier = Modifier, - bottomPadding: Dp = 0.dp + bottomPadding: Dp = 0.dp, + sortSharedFlow: SharedFlow = MutableSharedFlow() ) { when (uiState) { is NetworkMockUiState.Loading -> LoadingState(modifier = modifier) @@ -176,6 +182,7 @@ internal fun NetworkMockScreenContent( uiState = uiState, onGlobalToggle = onGlobalToggle, onSelectOperation = onSelectOperation, + sortSharedFlow = sortSharedFlow, modifier = modifier, bottomPadding = bottomPadding ) @@ -188,6 +195,7 @@ private fun ContentState( uiState: NetworkMockUiState.Content, onGlobalToggle: (Boolean) -> Unit, onSelectOperation: (OperationKey) -> Unit, + sortSharedFlow: SharedFlow, modifier: Modifier = Modifier, bottomPadding: Dp = 0.dp ) { @@ -201,9 +209,9 @@ private fun ContentState( val selectedTags = remember { mutableStateMapOf>() } // Sort is also a per-spec, client-side-only concern (see `OperationSort`) — deliberately - // not in NetworkMockViewModel, same reasoning as the filters above. + // not in NetworkMockViewModel, same reasoning as the filters above. Set directly by + // sortSharedFlow (the toolbar's "Sort" dropdown), never by direct user input here. val selectedSort = remember { mutableStateMapOf() } - var sortMenuExpanded by remember { mutableStateOf(value = false) } // Not keyed by spec — mocked-ness is a question about everything, not the current tab, // so unlike version/method the selection persists across tab switches. @@ -258,9 +266,21 @@ private fun ContentState( .sorted() .toList() } - // "Tag" only makes sense as a sort key when the current spec actually declares tags. - val sortOptions = remember(key1 = availableTags) { - OperationSort.entries.filter { it != OperationSort.TAG || availableTags.isNotEmpty() } + // Triggered by the "Sort" toolbar dropdown in the shared DevView.kt TopAppBar (see + // NetworkMock.onSortSelected) — sets the OperationSort for the currently visible spec tab + // directly. Each emission is an OperationSort.label string, not OperationSort itself (see + // NetworkMock.onSortSelected's KDoc for why); firstOrNull defensively falls back to no-op if + // a label is ever unrecognized, rather than crashing. The dropdown's entries are fixed at + // module-construction time (one per OperationSort, see NetworkMock.kt), so "Tag" is offered + // even when the current spec has no tagged operations; picking it in that case is a harmless + // no-op since sortedByOption sorts by each operation's absent first tag (empty string for + // all). + LaunchedEffect(key1 = currentSpecId) { + sortSharedFlow.collect { label -> + val specId = currentSpecId ?: return@collect + val sort = OperationSort.entries.firstOrNull { it.label == label } ?: return@collect + selectedSort[specId] = sort + } } Scaffold( @@ -470,69 +490,14 @@ private fun ContentState( modifier = Modifier.fillMaxSize() ) { Surface { - Column { - // Sort control: a plain trailing-aligned row local to this screen's own - // content, not the shared DevView.kt TopAppBar (its per-destination - // `action(icon) { onClick }` only supports a single-tap icon, with no slot - // for an anchored multi-item DropdownMenu) and not a second nested - // Scaffold `topBar` (would duplicate Material app-bar chrome under the - // global one and need its own inset bookkeeping). Keeps sort state local to - // `ContentState`, exactly like every other filter here. - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp), - horizontalArrangement = Arrangement.End - ) { - val currentSort = selectedSort[currentSpecId] ?: OperationSort.SPEC_ORDER - Box { - IconButton( - modifier = Modifier.testTag(tag = "sort_menu_button"), - onClick = { sortMenuExpanded = true } - ) { - Icon( - imageVector = Icons.Rounded.MoreVert, - contentDescription = "Sort operations" - ) - } - DropdownMenu( - expanded = sortMenuExpanded, - onDismissRequest = { sortMenuExpanded = false } - ) { - sortOptions.forEach { option -> - DropdownMenuItem( - modifier = Modifier.testTag( - tag = "sort_menu_item_${option.name}" - ), - text = { Text(text = option.label) }, - trailingIcon = { - if (option == currentSort) { - Icon( - imageVector = Icons.Rounded.Done, - contentDescription = null - ) - } - }, - onClick = { - currentSpecId?.let { specId -> - selectedSort[specId] = option - } - sortMenuExpanded = false - } - ) - } - } - } - } - GlobalMockToggle( - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp), - enabled = uiState.globalMockingEnabled, - mockedCount = mockedOperations, - totalCount = totalOperations, - onToggle = onGlobalToggle - ) - } + GlobalMockToggle( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp), + enabled = uiState.globalMockingEnabled, + mockedCount = mockedOperations, + totalCount = totalOperations, + onToggle = onGlobalToggle + ) } HorizontalDivider() diff --git a/devview-networkmock/src/commonTest/kotlin/com/worldline/devview/networkmock/NetworkMockModuleTest.kt b/devview-networkmock/src/commonTest/kotlin/com/worldline/devview/networkmock/NetworkMockModuleTest.kt new file mode 100644 index 0000000..3af13df --- /dev/null +++ b/devview-networkmock/src/commonTest/kotlin/com/worldline/devview/networkmock/NetworkMockModuleTest.kt @@ -0,0 +1,56 @@ +package com.worldline.devview.networkmock + +import com.worldline.devview.core.Section +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import kotlin.test.Test + +class NetworkMockModuleTest { + + private fun module() = NetworkMock( + resourceLoader = { ByteArray(size = 0) }, + specPaths = listOf("files/networkmocks/specs/example.json") + ) + + @Test + fun `network mock module exposes expected metadata and destinations`() { + val module = module() + + module.section shouldBe Section.NETWORK + module.destinations.keys.shouldContain(NetworkMockDestination.Main::class) + module.entryDestination::class shouldBe NetworkMockDestination.Main::class + } + + @Test + fun `toolbar registers refresh and sort menu and reset actions in order`() { + val module = module() + + val mainMetadata = module.destinations[NetworkMockDestination.Main::class].shouldNotBeNull() + mainMetadata.title shouldBe "Network Mock" + mainMetadata.actions shouldHaveSize 3 + + val (refreshAction, sortAction, resetAction) = mainMetadata.actions + + refreshAction.menuItems.shouldBeNull() + resetAction.menuItems.shouldBeNull() + + val menuItems = sortAction.menuItems.shouldNotBeNull() + menuItems shouldHaveSize OperationSort.entries.size + menuItems.map { it.label } shouldContainExactly OperationSort.entries.map { it.label } + } + + @Test + fun `sort menu items and plain actions can be invoked without throwing`() { + val module = module() + val mainMetadata = module.destinations[NetworkMockDestination.Main::class].shouldNotBeNull() + val (refreshAction, sortAction, resetAction) = mainMetadata.actions + + refreshAction.action() + resetAction.action() + sortAction.menuItems.shouldNotBeNull().forEach { it.onClick() } + } +} diff --git a/devview/CLAUDE.md b/devview/CLAUDE.md index a175835..28eb9af 100644 --- a/devview/CLAUDE.md +++ b/devview/CLAUDE.md @@ -18,8 +18,9 @@ The `devview` module is the core framework: it defines the `Module` interface th | `Section` enum | `core/Section.kt` | `SETTINGS`, `FEATURES`, `NETWORK`, `LOGGING`, `CUSTOM` — controls home screen grouping and default icons | | `DestinationMetadata` | `core/DestinationMetadata.kt` | Per-destination top bar title + action list | | `DestinationMetadataBuilder` | `core/DestinationMetadata.kt` | DSL receiver inside `withTitle { }` / `withActions { }` blocks | -| `ModuleDestinationAction` | `core/ModuleDestinationAction.kt` | Icon button descriptor (icon, callback, optional popup) | +| `ModuleDestinationAction` | `core/ModuleDestinationAction.kt` | Icon button descriptor (icon, callback, optional popup, optional dropdown menu) | | `ModuleDestinationActionPopup` | `core/ModuleDestinationActionPopup.kt` | Confirmation `AlertDialog` data (title, subtitle, button labels) | +| `ModuleDestinationActionMenuItem` | `core/ModuleDestinationActionMenuItem.kt` | Single labeled entry in a `ModuleDestinationAction`'s dropdown menu | | `NavKey` extension fns | `core/DestinationMetadataExtensions.kt` | `asDestination()`, `withTitle()`, `withActions()` — available on both `NavKey` instances and `KClass` | | `Home` | `HomeScreen.kt` | Serializable `data object` / NavKey for the home screen | | `@Poko` | `core/Poko.kt` | Annotation for the Poko compiler plugin (generates `equals`/`hashCode`/`toString`/`copy` on non-data-classes) | @@ -35,7 +36,7 @@ DevView (composable) │ ├── Title: resolved in order: HasTitle (framework screens) → │ │ DestinationMetadata.title → Module.moduleName │ └── Actions: DestinationMetadata.actions rendered as IconButtons; -│ if action.popup != null, shows AlertDialog before invoking action +│ precedence per action is menuItems (DropdownMenu) → popup (AlertDialog) → action └── NavDisplay (Navigation3) ├── entry → HomeScreen (groups modules by Section, sticky headers) │ └── ModuleItem (card per module, shape adapts by ModulePosition) @@ -66,6 +67,9 @@ Use the instance extension (`MyDest.Main.withTitle(...)`) for `data object` dest **Top app bar actions and ViewModels:** `ModuleDestinationAction.action` is a plain lambda captured at construction time. To trigger a ViewModel from an action, expose a `MutableSharedFlow` on the module and observe it where the ViewModel is in scope (e.g. with `LaunchedEffect` in the host composable). +**Top app bar dropdown menu actions:** +Use `DestinationMetadataBuilder.menu(icon) { item(label) { ... } }` instead of `action(icon) { ... }` when a single icon needs to offer more than one discrete choice (e.g. "Sort by: Path / Method"). Built via `ModuleDestinationActionMenuBuilder`, stored on `ModuleDestinationAction.menuItems`. Precedence when an icon is tapped: `menuItems` (opens a `DropdownMenu`) → `popup` (opens a confirmation `AlertDialog`) → `action` (runs immediately). A `menu(...)` action's own `action` lambda defaults to a no-op and is never invoked directly — only the individual `item(...)` callbacks fire. + **`rememberModules` initialization order:** For any module implementing `RequiresDataStore` (from `devview-utils`), `initDataStore()` is called before `initModule()`. Each module is initialized at most once per composition (tracked via an internal `mutableSetOf`). diff --git a/devview/api/api.txt b/devview/api/api.txt index 750300d..57661e3 100644 --- a/devview/api/api.txt +++ b/devview/api/api.txt @@ -29,6 +29,7 @@ package com.worldline.devview.core { public final class DestinationMetadataBuilder { method public void action(androidx.compose.ui.graphics.vector.ImageVector icon, optional com.worldline.devview.core.ModuleDestinationActionPopup? popup, kotlin.jvm.functions.Function0 action); + method public void menu(androidx.compose.ui.graphics.vector.ImageVector icon, kotlin.jvm.functions.Function1 block); } public final class DestinationMetadataExtensionsKt { @@ -64,19 +65,37 @@ package com.worldline.devview.core { } public final class ModuleDestinationAction { - ctor public ModuleDestinationAction(androidx.compose.ui.graphics.vector.ImageVector icon, kotlin.jvm.functions.Function0 action, optional com.worldline.devview.core.ModuleDestinationActionPopup? popup); + ctor public ModuleDestinationAction(androidx.compose.ui.graphics.vector.ImageVector icon, optional kotlin.jvm.functions.Function0 action, optional com.worldline.devview.core.ModuleDestinationActionPopup? popup, optional kotlinx.collections.immutable.PersistentList? menuItems); method public androidx.compose.ui.graphics.vector.ImageVector component1(); method public kotlin.jvm.functions.Function0 component2(); method public com.worldline.devview.core.ModuleDestinationActionPopup? component3(); - method public com.worldline.devview.core.ModuleDestinationAction copy(optional androidx.compose.ui.graphics.vector.ImageVector icon, optional kotlin.jvm.functions.Function0 action, optional com.worldline.devview.core.ModuleDestinationActionPopup? popup); + method public kotlinx.collections.immutable.PersistentList? component4(); + method public com.worldline.devview.core.ModuleDestinationAction copy(optional androidx.compose.ui.graphics.vector.ImageVector icon, optional kotlin.jvm.functions.Function0 action, optional com.worldline.devview.core.ModuleDestinationActionPopup? popup, optional kotlinx.collections.immutable.PersistentList? menuItems); method @InaccessibleFromKotlin public kotlin.jvm.functions.Function0 getAction(); method @InaccessibleFromKotlin public androidx.compose.ui.graphics.vector.ImageVector getIcon(); + method @InaccessibleFromKotlin public kotlinx.collections.immutable.PersistentList? getMenuItems(); method @InaccessibleFromKotlin public com.worldline.devview.core.ModuleDestinationActionPopup? getPopup(); property public kotlin.jvm.functions.Function0 action; property public androidx.compose.ui.graphics.vector.ImageVector icon; + property public kotlinx.collections.immutable.PersistentList? menuItems; property public com.worldline.devview.core.ModuleDestinationActionPopup? popup; } + public final class ModuleDestinationActionMenuBuilder { + method public void item(String label, kotlin.jvm.functions.Function0 onClick); + } + + public final class ModuleDestinationActionMenuItem { + ctor public ModuleDestinationActionMenuItem(String label, kotlin.jvm.functions.Function0 onClick); + method public String component1(); + method public kotlin.jvm.functions.Function0 component2(); + method public com.worldline.devview.core.ModuleDestinationActionMenuItem copy(optional String label, optional kotlin.jvm.functions.Function0 onClick); + method @InaccessibleFromKotlin public String getLabel(); + method @InaccessibleFromKotlin public kotlin.jvm.functions.Function0 getOnClick(); + property public String label; + property public kotlin.jvm.functions.Function0 onClick; + } + public final class ModuleDestinationActionPopup { ctor public ModuleDestinationActionPopup(String title, optional String? subtitle, String confirmButton, String dismissButton); method public String component1(); diff --git a/devview/src/androidDeviceTest/kotlin/com/worldline/devview/DevViewTest.kt b/devview/src/androidDeviceTest/kotlin/com/worldline/devview/DevViewTest.kt index 9ee0970..4c95c11 100644 --- a/devview/src/androidDeviceTest/kotlin/com/worldline/devview/DevViewTest.kt +++ b/devview/src/androidDeviceTest/kotlin/com/worldline/devview/DevViewTest.kt @@ -2,6 +2,8 @@ package com.worldline.devview import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Sort import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue @@ -15,9 +17,7 @@ import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.printToLog import androidx.compose.ui.test.v2.runComposeUiTest import androidx.compose.ui.unit.Dp import androidx.navigation3.runtime.EntryProviderScope @@ -31,15 +31,16 @@ import androidx.navigationevent.compose.rememberNavigationEventDispatcherOwner import com.worldline.devview.core.DestinationMetadata import com.worldline.devview.core.Module import com.worldline.devview.core.Section +import com.worldline.devview.core.withActions import com.worldline.devview.core.withTitle import kotlin.reflect.KClass +import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlinx.collections.immutable.PersistentMap import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.serialization.Serializable import kotlinx.serialization.modules.PolymorphicModuleBuilder -import kotlin.test.assertEquals -import kotlin.test.assertFalse import org.junit.Test class DevViewTest { @@ -135,6 +136,35 @@ class DevViewTest { assertEquals(expected = 1, actual = hostBackCount) } } + + @Test + fun devView_menu_action_opens_dropdown_and_invokes_selected_item() = runComposeUiTest { + var selectedLabel: String? = null + + setContent { + DevView( + devViewIsOpen = true, + closeDevView = {}, + modules = persistentListOf(MenuModule(onItemSelected = { selectedLabel = it })) + ) + } + + onNodeWithTag(testTag = "module_item_${MenuModule.MODULE_NAME}").performClick() + + // The menu items are not part of the tree until the toolbar icon is tapped + onAllNodesWithText(text = "Path").assertCountEquals(0) + + onNodeWithTag(testTag = "top_bar_action_0").performClick() + + onNodeWithText(text = "Path").assertIsDisplayed() + onNodeWithText(text = "Method").assertIsDisplayed() + + onNodeWithText(text = "Method").performClick() + + // Selecting an item collapses the menu and invokes its callback exactly once + onAllNodesWithText(text = "Path").assertCountEquals(0) + runOnIdle { assertEquals(expected = "Method", actual = selectedLabel) } + } } @Serializable @@ -159,3 +189,38 @@ private data object DevViewModule : Module { } } } + +@Serializable +private data object MenuDestination : NavKey + +private class MenuModule(private val onItemSelected: (String) -> Unit) : Module { + override val moduleName: String = MODULE_NAME + override val section: Section = Section.NETWORK + override val destinations: PersistentMap, DestinationMetadata> = + persistentMapOf( + MenuDestination.withActions { + menu(icon = Icons.AutoMirrored.Default.Sort) { + item(label = "Path") { onItemSelected("Path") } + item(label = "Method") { onItemSelected("Method") } + } + } + ) + override val entryDestination: NavKey + get() = MenuDestination + override val registerSerializers: PolymorphicModuleBuilder.() -> Unit = {} + + override fun EntryProviderScope.registerContent( + onNavigateBack: () -> Unit, + onNavigate: (NavKey) -> Unit, + bottomPadding: Dp + ) { + entry { + Box(modifier = Modifier.fillMaxSize()) + } + } + + companion object { + const val MODULE_NAME: String = "Menu Module" + } +} + diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/DevView.kt b/devview/src/commonMain/kotlin/com/worldline/devview/DevView.kt index e964bd0..dd89ed5 100644 --- a/devview/src/commonMain/kotlin/com/worldline/devview/DevView.kt +++ b/devview/src/commonMain/kotlin/com/worldline/devview/DevView.kt @@ -1,12 +1,15 @@ package com.worldline.devview import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -27,6 +30,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavKey @@ -270,6 +274,9 @@ public fun DevView( // Tracks which action's confirmation popup (if any) is currently shown var activePopupIndex by rememberSaveable { mutableStateOf(value = null) } + // Tracks which action's dropdown menu (if any) is currently expanded + var expandedMenuIndex by rememberSaveable { mutableStateOf(value = null) } + AnimatedVisibility( visible = devViewIsOpen ) { @@ -285,19 +292,40 @@ public fun DevView( }, actions = { currentActions.forEachIndexed { index, action -> - IconButton( - onClick = { - if (action.popup != null) { - activePopupIndex = index - } else { - action.action() + Box { + IconButton( + modifier = Modifier.testTag(tag = "top_bar_action_$index"), + onClick = { + when { + action.menuItems != null -> + expandedMenuIndex = + index + action.popup != null -> activePopupIndex = index + else -> action.action() + } + } + ) { + Icon( + imageVector = action.icon, + contentDescription = null + ) + } + action.menuItems?.let { menuItems -> + DropdownMenu( + expanded = expandedMenuIndex == index, + onDismissRequest = { expandedMenuIndex = null } + ) { + menuItems.forEach { menuItem -> + DropdownMenuItem( + text = { Text(text = menuItem.label) }, + onClick = { + menuItem.onClick() + expandedMenuIndex = null + } + ) + } } } - ) { - Icon( - imageVector = action.icon, - contentDescription = null - ) } } } diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/core/DestinationMetadata.kt b/devview/src/commonMain/kotlin/com/worldline/devview/core/DestinationMetadata.kt index 131826c..a210292 100644 --- a/devview/src/commonMain/kotlin/com/worldline/devview/core/DestinationMetadata.kt +++ b/devview/src/commonMain/kotlin/com/worldline/devview/core/DestinationMetadata.kt @@ -132,6 +132,42 @@ public class DestinationMetadataBuilder internal constructor() { actions.add(element = ModuleDestinationAction(icon = icon, action = action, popup = popup)) } + /** + * Adds a dropdown menu action to this destination's top app bar. + * + * Rendered as an [IconButton][androidx.compose.material3.IconButton] that, when tapped, + * expands a [DropdownMenu][androidx.compose.material3.DropdownMenu] listing the entries + * registered via [ModuleDestinationActionMenuBuilder.item] inside [block]. Tapping an entry + * invokes its callback and collapses the menu. + * + * Use this instead of [action] when a single icon needs to offer more than one discrete + * choice (e.g. "Sort by: Path / Method / Tag") rather than a single tap behaviour or a + * confirm/cancel dialog. + * + * ## Example + * ```kotlin + * menu(icon = Icons.AutoMirrored.Rounded.Sort) { + * item(label = "Path") { onSortChanged.tryEmit(Sort.PATH) } + * item(label = "Method") { onSortChanged.tryEmit(Sort.METHOD) } + * } + * ``` + * + * @param icon The icon to display for this action button. + * @param block A [ModuleDestinationActionMenuBuilder] DSL block in which you register menu + * entries via [ModuleDestinationActionMenuBuilder.item]. + * + * @see ModuleDestinationActionMenuItem + * @see action + */ + public fun menu(icon: ImageVector, block: ModuleDestinationActionMenuBuilder.() -> Unit) { + actions.add( + element = ModuleDestinationAction( + icon = icon, + menuItems = ModuleDestinationActionMenuBuilder().apply(block = block).build() + ) + ) + } + /** * Builds and returns the immutable list of registered [ModuleDestinationAction] items. * diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/core/ModuleDestinationAction.kt b/devview/src/commonMain/kotlin/com/worldline/devview/core/ModuleDestinationAction.kt index 66313a2..66cd53b 100644 --- a/devview/src/commonMain/kotlin/com/worldline/devview/core/ModuleDestinationAction.kt +++ b/devview/src/commonMain/kotlin/com/worldline/devview/core/ModuleDestinationAction.kt @@ -1,9 +1,37 @@ package com.worldline.devview.core import androidx.compose.ui.graphics.vector.ImageVector +import kotlinx.collections.immutable.PersistentList +/** + * Descriptor for a single icon button rendered in the DevView top app bar for the active + * destination. + * + * Tapping the resulting icon button resolves to exactly one of three behaviours, in this order + * of precedence: + * 1. If [menuItems] is non-null, a dropdown menu listing those entries is shown. + * 2. Otherwise, if [popup] is non-null, a confirmation dialog is shown; [action] runs when + * confirmed. + * 3. Otherwise, [action] runs immediately. + * + * Prefer building instances via [DestinationMetadataBuilder.action] or + * [DestinationMetadataBuilder.menu] rather than this constructor directly. + * + * @property icon The icon to display for this action button. + * @property action The callback invoked when the icon is tapped directly (no [menuItems], no + * [popup]) or when a [popup] confirmation is confirmed. Ignored when [menuItems] is non-null. + * @property popup Optional confirmation dialog shown before [action] runs. Ignored when + * [menuItems] is non-null. + * @property menuItems Optional dropdown menu entries shown when the icon is tapped, instead of + * invoking [action] or [popup] directly. `null` (the default) means no dropdown. + * + * @see DestinationMetadataBuilder.action + * @see DestinationMetadataBuilder.menu + * @see ModuleDestinationActionMenuItem + */ public data class ModuleDestinationAction( val icon: ImageVector, - val action: () -> Unit, - val popup: ModuleDestinationActionPopup? = null + val action: () -> Unit = {}, + val popup: ModuleDestinationActionPopup? = null, + val menuItems: PersistentList? = null ) diff --git a/devview/src/commonMain/kotlin/com/worldline/devview/core/ModuleDestinationActionMenuItem.kt b/devview/src/commonMain/kotlin/com/worldline/devview/core/ModuleDestinationActionMenuItem.kt new file mode 100644 index 0000000..cee1b4f --- /dev/null +++ b/devview/src/commonMain/kotlin/com/worldline/devview/core/ModuleDestinationActionMenuItem.kt @@ -0,0 +1,52 @@ +package com.worldline.devview.core + +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.toPersistentList + +/** + * A single selectable entry within a [ModuleDestinationAction]'s dropdown menu. + * + * Rendered as a [DropdownMenuItem][androidx.compose.material3.DropdownMenuItem]. Tapping it + * invokes [onClick] and then dismisses the menu. + * + * @property label The text displayed for this menu entry. + * @property onClick The callback invoked when this entry is tapped. + * + * @see ModuleDestinationAction.menuItems + * @see DestinationMetadataBuilder.menu + */ +public data class ModuleDestinationActionMenuItem(val label: String, val onClick: () -> Unit) + +/** + * DSL builder for constructing the ordered list of [ModuleDestinationActionMenuItem] entries + * of a [DestinationMetadataBuilder.menu] action. + * + * Not intended to be instantiated directly — always go through [DestinationMetadataBuilder.menu]. + * + * @see DestinationMetadataBuilder.menu + * @see ModuleDestinationActionMenuItem + */ +public class ModuleDestinationActionMenuBuilder internal constructor() { + private val items = mutableListOf() + + /** + * Adds a selectable entry to this dropdown menu. + * + * @param label The text displayed for this menu entry. + * @param onClick The callback invoked when this entry is tapped. Captured at construction + * time — see [DestinationMetadataBuilder.action]'s "Action scope" section for guidance on + * triggering lifecycle-bound code (e.g. a ViewModel) from here. + */ + public fun item(label: String, onClick: () -> Unit) { + items.add(element = ModuleDestinationActionMenuItem(label = label, onClick = onClick)) + } + + /** + * Builds and returns the immutable list of registered [ModuleDestinationActionMenuItem] + * entries. + * + * Called internally by [DestinationMetadataBuilder.menu] after the builder block has been + * applied. Not intended for direct use. + */ + internal fun build(): PersistentList = items.toPersistentList() +} diff --git a/devview/src/commonTest/kotlin/com/worldline/devview/core/DestinationMetadataExtensionsTest.kt b/devview/src/commonTest/kotlin/com/worldline/devview/core/DestinationMetadataExtensionsTest.kt index d4fdf4f..d4baf33 100644 --- a/devview/src/commonTest/kotlin/com/worldline/devview/core/DestinationMetadataExtensionsTest.kt +++ b/devview/src/commonTest/kotlin/com/worldline/devview/core/DestinationMetadataExtensionsTest.kt @@ -7,6 +7,7 @@ import androidx.navigation3.runtime.NavKey import io.kotest.matchers.collections.shouldBeEmpty import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import io.kotest.matchers.types.shouldBeSameInstanceAs import kotlin.test.Test @@ -96,6 +97,54 @@ class DestinationMetadataExtensionsTest { calls shouldBe 1 } + + @Test + fun `menu builds an action with ordered menu items and no popup`() { + val key = TestNavKey::class + val icon = Icons.Default.Check + var firstCalls = 0 + var secondCalls = 0 + + val (registeredKey, metadata) = key.withActions { + menu(icon = icon) { + item(label = "First") { firstCalls++ } + item(label = "Second") { secondCalls++ } + } + } + + registeredKey shouldBeSameInstanceAs key + metadata.actions shouldHaveSize 1 + + val action = metadata.actions.single() + action.icon shouldBeSameInstanceAs icon + action.popup.shouldBeNull() + + val menuItems = action.menuItems.shouldNotBeNull() + menuItems shouldHaveSize 2 + menuItems[0].label shouldBe "First" + menuItems[1].label shouldBe "Second" + + menuItems[0].onClick() + menuItems[1].onClick() + + firstCalls shouldBe 1 + secondCalls shouldBe 1 + } + + @Test + fun `menu action invoking the plain action lambda is a no-op`() { + val key = TestNavKey::class + + val (_, metadata) = key.withActions { + menu(icon = Icons.Default.Check) { + item(label = "Only") {} + } + } + + // The base `action` lambda defaults to a no-op for menu actions — it must never throw + // and is simply ignored by DevView's rendering when menuItems is non-null. + metadata.actions.single().action() + } } private data object TestNavKey : NavKey diff --git a/docs/modules/networkmock-ui.md b/docs/modules/networkmock-ui.md index 1b28bc1..a5f7f4a 100644 --- a/docs/modules/networkmock-ui.md +++ b/docs/modules/networkmock-ui.md @@ -9,7 +9,6 @@ The `devview-networkmock` module provides the Compose UI for the network mocking The main screen shows a global mock toggle at the top, followed by a scrollable tab row with one tab per OpenAPI spec (e.g. "My Backend"). Each tab lists every operation declared in that spec with its current mock state — there is no environment axis, so a spec spanning multiple API versions shows all of its operations side by side in one tab. - **Global toggle**: enables or disables all mocking globally. When off, all requests go to the real network regardless of per-endpoint settings. Shows a count of mocked operations (e.g. "3 of 47 mocked") computed across every spec, not just the visible tab. -- **Sort control**: a `MoreVert` overflow menu, top-right of the screen's own content (above the global toggle) — sort keys are spec order (default, document order), path (A-Z), method (`GET`/`POST`/`PUT`/`PATCH`/`DELETE`/`HEAD`/`OPTIONS` order), and tag (an operation's first declared tag, untagged operations first); "Tag" is hidden from the menu entirely when the current tab has no tagged operations. Selection is per-tab, like the version and method filters, and — like every filter here — pure client-side state, not persisted or round-tripped through the ViewModel. - **Search & filter bar**: pinned to the bottom of the screen (a `Scaffold` `bottomBar`, reachable one-handed). The search field is always visible; a chevron button expands/collapses the mock-state, version, HTTP method, and tag filter rows below it. All filters are plain client-side filters over already-loaded data and combine with AND (multi-select chips within one row combine with OR). - **Search field**: filters the visible operations in the current tab by name, path, or operationId, live as you type. - **Mock-state filter**: a row of "Mocked"/"Network" chips, not scoped to the current tab — selecting one persists across tab switches, since "what's mocked" is a question about every spec. @@ -19,6 +18,7 @@ The main screen shows a global mock toggle at the top, followed by a scrollable - **Endpoint rows**: a leading colour rail (the state chip's colour, transparent when not mocked) followed by the endpoint name, a colour-coded HTTP method badge, the path (wraps instead of truncating — never cut off), and the current state chip (Network / bare status code, e.g. "404"). There is no separate version badge — `Operation.version` is parsed from the path shown right next to it, so it would only repeat what's already visible. Tap a row to open its operation sheet. - **Reset to Network**: toolbar action that resets every endpoint to `Network` state in one tap. - **Reload Config**: toolbar action that re-reads and re-parses every configured OpenAPI spec from disk, picking up edits to a spec file without restarting the app. Operations added, removed, or renamed in the spec appear immediately; per-operation mock selections are untouched. See [Caching & Reload](networkmock-core.md#caching--reload). +- **Sort**: toolbar dropdown — `NetworkMock` exposes a `sortSharedFlow`, and `NetworkMockScreen` collects it in `ContentState`. Tapping the icon opens an anchored menu with one entry per sort key: spec order (default, document order), path (A-Z), method (`HttpMethod.DefaultMethods` order), and tag (an operation's first declared tag, untagged operations first); picking an entry sets that sort directly. The menu always offers "Tag" even when the current tab has no tagged operations — it's a fixed list built once at module-construction time — but picking it in that case is a no-op. Sort selection is per-tab, like the version and method filters, and — like every filter here — pure client-side state, not persisted or round-tripped through the ViewModel. ### Operation sheet From 25538c6e815a4edff57a35d67805cb71f169948e Mon Sep 17 00:00:00 2001 From: Maxime MICHEL Date: Wed, 23 Sep 2026 20:52:54 +0200 Subject: [PATCH 3/3] :bug: Fix NetworkMock bottom bar padding overlapping filter chip rows The nav-bar inset (bottomPadding) was applied to the whole bottom bar Surface, pushing every filter chip row down along with the search field and expand button. Now it's applied only to the search field and expand button themselves, matching AnalyticsScreen's existing layout. Also expands the sample app's jsonplaceholder.json spec from 4 to 24 tagged operations (Users/Posts/Comments/Albums/Photos/Todos) to manually validate the fix with a realistically long, scrollable endpoint list. Verified: 69/69 devview-networkmock device tests pass on a physical device. --- CHANGELOG.md | 4 + .../devview/networkmock/NetworkMockScreen.kt | 9 +- .../networkmocks/specs/jsonplaceholder.json | 473 ++++++++++++++++++ 3 files changed, 482 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a263181..8ddda66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 or silence this via `Logger.setMinSeverity(...)`; `devview-consolelogger`, if installed, captures it automatically. Detekt's `ForbiddenMethodCall` rule (`println`/`print`) is now enforced repo-wide. (`devview-networkmock-core`, `devview-networkmock-ktor`, #86) +- NetworkMock: the bottom bar's search field and expand-filter button were padded as a whole + `Surface`, pushing every filter chip row above them down by the system navigation bar inset + as well — the inset now only pads the search field and button themselves, matching + `AnalyticsScreen`'s existing (correct) layout. (`devview-networkmock`) ## [0.2.0-alpha03] - 2026-09-11 diff --git a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt index 0f7cc1c..04a3ac3 100644 --- a/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt +++ b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMockScreen.kt @@ -288,9 +288,7 @@ private fun ContentState( .fillMaxSize() .imePadding(), bottomBar = { - Surface( - modifier = Modifier.padding(bottom = bottomPadding) - ) { + Surface { Column { AnimatedVisibility(visible = filtersExpanded) { Column { @@ -445,6 +443,7 @@ private fun ContentState( modifier = Modifier .weight(weight = 1f) .padding(vertical = 8.dp) + .padding(bottom = bottomPadding) .testTag(tag = "networkmock_search_field"), value = searchQuery, onValueChange = { searchQuery = it }, @@ -472,7 +471,9 @@ private fun ContentState( ) VerticalDivider(modifier = Modifier.fillMaxHeight()) IconButton( - modifier = Modifier.testTag(tag = "expand_filter_button"), + modifier = Modifier + .padding(bottom = bottomPadding) + .testTag(tag = "expand_filter_button"), onClick = { filtersExpanded = !filtersExpanded } ) { Icon( diff --git a/sample/network/src/commonMain/composeResources/files/networkmocks/specs/jsonplaceholder.json b/sample/network/src/commonMain/composeResources/files/networkmocks/specs/jsonplaceholder.json index 9d7a64b..5c5d654 100644 --- a/sample/network/src/commonMain/composeResources/files/networkmocks/specs/jsonplaceholder.json +++ b/sample/network/src/commonMain/composeResources/files/networkmocks/specs/jsonplaceholder.json @@ -8,6 +8,7 @@ "get": { "operationId": "getUser", "summary": "Get User by ID", + "tags": ["Users"], "x-devview": { "delayMs": 500 }, "responses": { "200": { @@ -39,12 +40,49 @@ } } } + }, + "put": { + "operationId": "updateUser", + "summary": "Update User", + "tags": ["Users"], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "username": { "type": "string" }, + "email": { "type": "string" } + } + } + } + } + } + } + }, + "delete": { + "operationId": "deleteUser", + "summary": "Delete User", + "tags": ["Users"], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { "type": "object" } + } + } + } + } } }, "/users": { "get": { "operationId": "listUsers", "summary": "List All Users", + "tags": ["Users"], "parameters": [ { "name": "type", "in": "query", "example": "user" } ], @@ -60,12 +98,60 @@ } } } + }, + "post": { + "operationId": "createUser", + "summary": "Create User", + "tags": ["Users"], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "name": { "type": "string" }, + "username": { "type": "string" }, + "email": { "type": "string" } + } + } + } + } + } + } } }, "/posts": { + "get": { + "operationId": "listPosts", + "summary": "List All Posts", + "tags": ["Posts"], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "userId": { "type": "integer" }, + "title": { "type": "string" }, + "body": { "type": "string" } + } + } + } + } + } + } + } + }, "post": { "operationId": "createPost", "summary": "Create Post", + "tags": ["Posts"], "responses": { "201": { "content": { @@ -101,6 +187,7 @@ "get": { "operationId": "getPost", "summary": "Get Post by ID", + "tags": ["Posts"], "responses": { "200": { "content": { @@ -121,6 +208,392 @@ } } } + }, + "put": { + "operationId": "updatePost", + "summary": "Update Post", + "tags": ["Posts"], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "userId": { "type": "integer" }, + "title": { "type": "string" }, + "body": { "type": "string" } + } + } + } + } + } + } + }, + "delete": { + "operationId": "deletePost", + "summary": "Delete Post", + "tags": ["Posts"], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { "type": "object" } + } + } + } + } + } + }, + "/posts/{postId}/comments": { + "get": { + "operationId": "getPostComments", + "summary": "Get Comments for Post", + "tags": ["Comments", "Posts"], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "postId": { "type": "integer" }, + "name": { "type": "string" }, + "email": { "type": "string" }, + "body": { "type": "string" } + } + } + } + } + } + } + } + } + }, + "/comments": { + "get": { + "operationId": "listComments", + "summary": "List All Comments", + "tags": ["Comments"], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "postId": { "type": "integer" }, + "name": { "type": "string" }, + "email": { "type": "string" }, + "body": { "type": "string" } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "createComment", + "summary": "Create Comment", + "tags": ["Comments"], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "postId": { "type": "integer" }, + "name": { "type": "string" }, + "email": { "type": "string" }, + "body": { "type": "string" } + } + } + } + } + } + } + } + }, + "/comments/{commentId}": { + "get": { + "operationId": "getComment", + "summary": "Get Comment by ID", + "tags": ["Comments"], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "postId": { "type": "integer" }, + "name": { "type": "string" }, + "email": { "type": "string" }, + "body": { "type": "string" } + } + } + } + } + } + } + } + }, + "/albums": { + "get": { + "operationId": "listAlbums", + "summary": "List All Albums", + "tags": ["Albums"], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "userId": { "type": "integer" }, + "title": { "type": "string" } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "createAlbum", + "summary": "Create Album", + "tags": ["Albums"], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "userId": { "type": "integer" }, + "title": { "type": "string" } + } + } + } + } + } + } + } + }, + "/albums/{albumId}": { + "get": { + "operationId": "getAlbum", + "summary": "Get Album by ID", + "tags": ["Albums"], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "userId": { "type": "integer" }, + "title": { "type": "string" } + } + } + } + } + } + } + } + }, + "/albums/{albumId}/photos": { + "get": { + "operationId": "getAlbumPhotos", + "summary": "Get Photos for Album", + "tags": ["Photos", "Albums"], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "albumId": { "type": "integer" }, + "title": { "type": "string" }, + "url": { "type": "string" }, + "thumbnailUrl": { "type": "string" } + } + } + } + } + } + } + } + } + }, + "/photos": { + "get": { + "operationId": "listPhotos", + "summary": "List All Photos", + "tags": ["Photos"], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "albumId": { "type": "integer" }, + "title": { "type": "string" }, + "url": { "type": "string" }, + "thumbnailUrl": { "type": "string" } + } + } + } + } + } + } + } + } + }, + "/photos/{photoId}": { + "get": { + "operationId": "getPhoto", + "summary": "Get Photo by ID", + "tags": ["Photos"], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "albumId": { "type": "integer" }, + "title": { "type": "string" }, + "url": { "type": "string" }, + "thumbnailUrl": { "type": "string" } + } + } + } + } + } + } + } + }, + "/todos": { + "get": { + "operationId": "listTodos", + "summary": "List All Todos", + "tags": ["Todos"], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "userId": { "type": "integer" }, + "title": { "type": "string" }, + "completed": { "type": "boolean" } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "createTodo", + "summary": "Create Todo", + "tags": ["Todos"], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "userId": { "type": "integer" }, + "title": { "type": "string" }, + "completed": { "type": "boolean" } + } + } + } + } + } + } + } + }, + "/todos/{todoId}": { + "get": { + "operationId": "getTodo", + "summary": "Get Todo by ID", + "tags": ["Todos"], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "userId": { "type": "integer" }, + "title": { "type": "string" }, + "completed": { "type": "boolean" } + } + } + } + } + } + } + }, + "patch": { + "operationId": "updateTodo", + "summary": "Update Todo", + "tags": ["Todos"], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { "type": "integer" }, + "userId": { "type": "integer" }, + "title": { "type": "string" }, + "completed": { "type": "boolean" } + } + } + } + } + } + } } } }