diff --git a/CHANGELOG.md b/CHANGELOG.md index d4731a1c..8ddda66c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,26 @@ 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 — 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 `requestBody.content..schema` — to disambiguate operations that would otherwise @@ -98,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-core/api/api.txt b/devview-networkmock-core/api/api.txt index 73d648ad..5e7d4a45 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 e51f7aa0..4d2fd079 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 aafb2eb8..18bf67a1 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 f388d43b..a86142b6 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 ca675f1a..3918ac7d 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/CLAUDE.md b/devview-networkmock/CLAUDE.md index 07c2aef0..e0fb7fd7 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 ddd30c12..983b4b3e 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 867561e1..0624fca5 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 { @@ -296,6 +299,86 @@ 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 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) + + val initialCreateUserY = onNodeWithText(text = "Create User").fetchSemanticsNode().positionInRoot.y + val initialHealthY = onNodeWithText(text = "Health").fetchSemanticsNode().positionInRoot.y + (initialCreateUserY < initialHealthY) shouldBe true + + sortSharedFlow.tryEmit(value = "Method") + waitForIdle() + + 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() { onNodeWithTag(testTag = "expand_filter_button").performClick() waitForIdle() @@ -305,6 +388,7 @@ class NetworkMockScreenTest { uiState: NetworkMockUiState, onGlobalToggle: (Boolean) -> Unit = {}, onSelectOperation: (OperationKey) -> Unit = { }, + sortSharedFlow: SharedFlow = MutableSharedFlow(), ) { setContent { MaterialTheme { @@ -312,6 +396,7 @@ class NetworkMockScreenTest { uiState = uiState, onGlobalToggle = onGlobalToggle, onSelectOperation = onSelectOperation, + sortSharedFlow = sortSharedFlow, ) } } 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 5dc3bf10..5b94f4d6 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/NetworkMock.kt b/devview-networkmock/src/commonMain/kotlin/com/worldline/devview/networkmock/NetworkMock.kt index 3773cd5e..e5bdf9a3 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 edb56517..04a3ac30 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 @@ -68,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 /** @@ -85,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]. @@ -96,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 @@ -119,6 +127,7 @@ public fun NetworkMockScreen( uiState = uiState, onGlobalToggle = viewModel::setGlobalMockingEnabled, onSelectOperation = viewModel::openOperation, + sortSharedFlow = sortSharedFlow, modifier = modifier, bottomPadding = bottomPadding ) @@ -161,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) @@ -172,6 +182,7 @@ internal fun NetworkMockScreenContent( uiState = uiState, onGlobalToggle = onGlobalToggle, onSelectOperation = onSelectOperation, + sortSharedFlow = sortSharedFlow, modifier = modifier, bottomPadding = bottomPadding ) @@ -184,6 +195,7 @@ private fun ContentState( uiState: NetworkMockUiState.Content, onGlobalToggle: (Boolean) -> Unit, onSelectOperation: (OperationKey) -> Unit, + sortSharedFlow: SharedFlow, modifier: Modifier = Modifier, bottomPadding: Dp = 0.dp ) { @@ -194,6 +206,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. Set directly by + // sortSharedFlow (the toolbar's "Sort" dropdown), never by direct user input here. + val selectedSort = remember { mutableStateMapOf() } // 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,15 +257,38 @@ 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() + } + // 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( modifier = modifier .fillMaxSize() .imePadding(), bottomBar = { - Surface( - modifier = Modifier.padding(bottom = bottomPadding) - ) { + Surface { Column { AnimatedVisibility(visible = filtersExpanded) { Column { @@ -355,6 +396,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() @@ -370,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 }, @@ -397,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( @@ -452,21 +528,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 +602,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 +619,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 +628,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 +637,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/NetworkMockModuleTest.kt b/devview-networkmock/src/commonTest/kotlin/com/worldline/devview/networkmock/NetworkMockModuleTest.kt new file mode 100644 index 00000000..3af13df7 --- /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-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 00000000..30919214 --- /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/devview/CLAUDE.md b/devview/CLAUDE.md index a175835d..28eb9af1 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 750300de..57661e3c 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 9ee0970c..4c95c112 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 e964bd09..dd89ed54 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 131826c8..a210292d 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 66313a2e..66cd53ba 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 00000000..cee1b4f8 --- /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 d4fdf4f6..d4baf333 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-core.md b/docs/modules/networkmock-core.md index bcba5b62..e0587642 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 a7cacaf6..a5f7f4a0 100644 --- a/docs/modules/networkmock-ui.md +++ b/docs/modules/networkmock-ui.md @@ -9,14 +9,16 @@ 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). +- **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). +- **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 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 9d7a64bb..5c5d654d 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" } + } + } + } + } + } + } } } }