From 95bfde59db9b87679e1003275538472655961208 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 6 Apr 2026 16:25:03 -0400 Subject: [PATCH 01/28] feat: add savePost() bridge to trigger editor store save lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new window.editor.savePost() JS bridge method that dispatches the @wordpress/editor store's savePost action, and a corresponding public savePost() method on EditorViewController. This drives the core/editor store through its full save lifecycle, causing isSavingPost() to transition true → false. Plugins that subscribe to this lifecycle (e.g., VideoPress syncing rating/privacy metadata via /wpcom/v2/videopress/meta) fire their side-effect API calls during this transition. The existing filterEndpointsMiddleware in api-fetch.js already swallows the post save HTTP request, so savePost() runs to completion without duplicating the native app's own persistence. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Sources/EditorViewController.swift | 15 +++++++++++++++ src/components/editor/use-host-bridge.js | 9 ++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index bfd11bc82..3fb45e22d 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -411,6 +411,21 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro evaluate("editor.redo();") } + /// Triggers the editor store's save lifecycle. + /// + /// This drives the WordPress `core/editor` store through its full save flow, + /// causing `isSavingPost()` to transition `true` → `false`. Plugins that + /// subscribe to this lifecycle (e.g., VideoPress syncing metadata) will fire + /// their side-effect API calls. + /// + /// The actual post content is **not** persisted by this method — the host app + /// is responsible for reading content via ``getTitleAndContent()`` and saving + /// it through its own REST API calls. + public func savePost() async throws { + guard isReady else { throw EditorNotReadyError() } + _ = try await webView.callAsyncJavaScript("await editor.savePost();", in: nil, contentWorld: .page) + } + /// Dismisses the topmost modal dialog or menu in the editor public func dismissTopModal() { guard isReady else { return } diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index 250933406..1f3478a9f 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -18,7 +18,8 @@ window.editor = window.editor || {}; export function useHostBridge( post, editorRef, markBridgeReady ) { const { editEntityRecord } = useDispatch( coreStore ); - const { undo, redo, switchEditorMode } = useDispatch( editorStore ); + const { undo, redo, switchEditorMode, savePost } = + useDispatch( editorStore ); const { getEditedPostAttribute, getEditedPostContent } = useSelect( editorStore ); const { updateBlock, selectionChange } = useDispatch( blockEditorStore ); @@ -88,6 +89,10 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { redo(); }; + window.editor.savePost = async () => { + await savePost(); + }; + window.editor.switchEditorMode = ( mode ) => { // Do not return the `Promise` return value to avoid host errors. switchEditorMode( mode ); @@ -186,6 +191,7 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { delete window.editor.setTitle; delete window.editor.getContent; delete window.editor.getTitleAndContent; + delete window.editor.savePost; delete window.editor.undo; delete window.editor.redo; delete window.editor.switchEditorMode; @@ -199,6 +205,7 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { markBridgeReady, getEditedPostAttribute, getEditedPostContent, + savePost, redo, switchEditorMode, undo, From c79f24980b11fd4f592dd1de5ac785a264891838 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 6 Apr 2026 16:25:18 -0400 Subject: [PATCH 02/28] feat(demo-ios): add save button that persists posts via REST API Adds a "Save" button to the demo app's editor toolbar. Tapping it: 1. Calls EditorViewController.savePost() to trigger the editor store's save lifecycle, so plugins (e.g., VideoPress) fire their side-effect API calls. 2. Reads the latest title/content via getTitleAndContent() and persists the post via WordPressAPI's posts.updateCancellation() call. The API client is threaded from PostsListView through RunnableEditor and into EditorView. The Save button is disabled for new drafts (where postID is nil) since updating requires an existing post ID. Requires the PostUpdateParams export added in wordpress-rs feat/export-post-update-params branch. Co-Authored-By: Claude Opus 4.6 (1M context) --- ios/Demo-iOS/Sources/ConfigurationItem.swift | 16 +++++ ios/Demo-iOS/Sources/GutenbergApp.swift | 6 +- ios/Demo-iOS/Sources/Views/EditorView.swift | 67 ++++++++++++++++++- .../Sources/Views/PostsListView.swift | 3 +- 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/ios/Demo-iOS/Sources/ConfigurationItem.swift b/ios/Demo-iOS/Sources/ConfigurationItem.swift index 3cf171574..52f6320b9 100644 --- a/ios/Demo-iOS/Sources/ConfigurationItem.swift +++ b/ios/Demo-iOS/Sources/ConfigurationItem.swift @@ -34,6 +34,22 @@ enum ConfigurationItem: Identifiable, Equatable, Hashable { struct RunnableEditor: Equatable, Hashable { let configuration: EditorConfiguration let dependencies: EditorDependencies? + let apiClient: WordPressAPI? + + init(configuration: EditorConfiguration, dependencies: EditorDependencies?, apiClient: WordPressAPI? = nil) { + self.configuration = configuration + self.dependencies = dependencies + self.apiClient = apiClient + } + + static func == (lhs: RunnableEditor, rhs: RunnableEditor) -> Bool { + lhs.configuration == rhs.configuration && lhs.dependencies == rhs.dependencies + } + + func hash(into hasher: inout Hasher) { + hasher.combine(configuration) + hasher.combine(dependencies) + } } /// Credentials loaded from the wp-env setup script output diff --git a/ios/Demo-iOS/Sources/GutenbergApp.swift b/ios/Demo-iOS/Sources/GutenbergApp.swift index 08650f077..3a741f03f 100644 --- a/ios/Demo-iOS/Sources/GutenbergApp.swift +++ b/ios/Demo-iOS/Sources/GutenbergApp.swift @@ -56,7 +56,11 @@ struct GutenbergApp: App { let editor = navigation.editor! NavigationStack { - EditorView(configuration: editor.configuration, dependencies: editor.dependencies) + EditorView( + configuration: editor.configuration, + dependencies: editor.dependencies, + apiClient: editor.apiClient + ) } } diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 6345c8dc5..1d03c5ac3 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -1,23 +1,27 @@ import SwiftUI import GutenbergKit +import WordPressAPI struct EditorView: View { private let configuration: EditorConfiguration private let dependencies: EditorDependencies? + private let apiClient: WordPressAPI? @State private var viewModel = EditorViewModel() @Environment(\.dismiss) var dismiss - init(configuration: EditorConfiguration, dependencies: EditorDependencies? = nil) { + init(configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, apiClient: WordPressAPI? = nil) { self.configuration = configuration self.dependencies = dependencies + self.apiClient = apiClient } var body: some View { _EditorView( configuration: configuration, dependencies: dependencies, + apiClient: apiClient, viewModel: viewModel ) .toolbar { toolbar } @@ -58,6 +62,17 @@ struct EditorView: View { moreMenu .disabled(viewModel.isModalDialogOpen) } + + ToolbarItem(placement: .topBarTrailing) { + Button { + viewModel.save() + } label: { + Text("Save") + .fontWeight(.semibold) + } + .disabled(!viewModel.canSave) + .accessibilityLabel("Save") + } } private var moreMenu: some View { @@ -99,15 +114,18 @@ struct EditorView: View { private struct _EditorView: UIViewControllerRepresentable { private let configuration: EditorConfiguration private let dependencies: EditorDependencies? + private let apiClient: WordPressAPI? private let viewModel: EditorViewModel init( configuration: EditorConfiguration, dependencies: EditorDependencies? = nil, + apiClient: WordPressAPI? = nil, viewModel: EditorViewModel ) { self.configuration = configuration self.dependencies = dependencies + self.apiClient = apiClient self.viewModel = viewModel } @@ -127,6 +145,33 @@ private struct _EditorView: UIViewControllerRepresentable { } } + viewModel.hasPostID = configuration.postID != nil + + viewModel.saveHandler = { [weak viewController, configuration, apiClient] in + guard let viewController else { return } + do { + // 1. Trigger the editor store save lifecycle so plugins fire side-effects + try await viewController.savePost() + print("savePost() completed — editor store save lifecycle fired") + + // 2. Persist post content via REST API + if let apiClient, let postID = configuration.postID { + let titleAndContent = try await viewController.getTitleAndContent() + let params = PostUpdateParams(title: .some(titleAndContent.title), content: .some(titleAndContent.content), meta: nil) + let endpointType: PostEndpointType = configuration.postType.postType == "page" ? .pages : .posts + _ = try await apiClient.posts.updateCancellation( + postEndpointType: endpointType, + postId: Int64(postID), + params: params, + context: nil + ) + print("Post \(postID) persisted via REST API") + } + } catch { + print("Save failed: \(error)") + } + } + return viewController } @@ -145,7 +190,7 @@ private struct _EditorView: UIViewControllerRepresentable { // MARK: - EditorViewControllerDelegate func editorDidLoad(_ viewContoller: EditorViewController) { - // No-op for demo + viewModel.isEditorReady = true } func editor(_ viewContoller: EditorViewController, didDisplayInitialContent content: String) { @@ -232,6 +277,14 @@ private final class EditorViewModel { var hasUndo = false var hasRedo = false var isCodeEditorEnabled = false + var isSaving = false + var isEditorReady = false + + var hasPostID = false + + var canSave: Bool { + isEditorReady && !isSaving && hasPostID + } enum Action { case undo @@ -239,6 +292,16 @@ private final class EditorViewModel { } var perform: (_ action: Action) -> Void = { _ in assertionFailure() } + var saveHandler: () async -> Void = {} + + func save() { + guard canSave else { return } + isSaving = true + Task { + await saveHandler() + isSaving = false + } + } } #Preview { diff --git a/ios/Demo-iOS/Sources/Views/PostsListView.swift b/ios/Demo-iOS/Sources/Views/PostsListView.swift index db0dfb1d1..31ccac38c 100644 --- a/ios/Demo-iOS/Sources/Views/PostsListView.swift +++ b/ios/Demo-iOS/Sources/Views/PostsListView.swift @@ -77,7 +77,8 @@ struct PostsListView: View { let editor = RunnableEditor( configuration: configuration, - dependencies: viewModel.editorDependencies + dependencies: viewModel.editorDependencies, + apiClient: viewModel.client ) navigation.present(editor) From 91304c7fe4d3da190a43ea33fcb0d3d34723b304 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Mon, 6 Apr 2026 19:33:05 -0400 Subject: [PATCH 03/28] feat(android): add savePost() to GutenbergView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a savePost(callback) method to GutenbergView that triggers the editor store's save lifecycle (mirroring the iOS EditorViewController change). This drives the core/editor store through its full save flow, causing isSavingPost() to transition true → false. Plugins that subscribe to this lifecycle (e.g., VideoPress syncing metadata) fire their side-effect API calls during this transition. Unlike iOS's WKWebView.callAsyncJavaScript, Android's WebView cannot await JavaScript Promises, so completion is routed back via a .then() callback on the existing editorDelegate JavaScript interface. A new @JavascriptInterface method (onSavePostComplete) dispatches to per-request callbacks keyed by UUID. The actual post content is not persisted by this method — host apps (like WordPress-Android) are responsible for reading content via getTitleAndContent() and saving it through their own REST API calls. The callback fires only after the editor store's save lifecycle completes, so plugin side-effects have settled before the host retrieves content for persistence. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../org/wordpress/gutenberg/GutenbergView.kt | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 5184c26c0..02beefd4c 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -737,6 +737,56 @@ class GutenbergView : FrameLayout { } } + private val pendingSaveCallbacks = Collections.synchronizedMap(mutableMapOf()) + + /** + * Triggers the editor store's save lifecycle and invokes [callback] when it completes. + * + * This drives the WordPress `core/editor` store through its full save flow, causing + * `isSavingPost()` to transition `true` → `false`. Plugins that subscribe to this + * lifecycle (e.g., VideoPress syncing metadata via `/wpcom/v2/videopress/meta`) fire + * their side-effect API calls during this transition. + * + * The actual post content is **not** persisted by this method — the host app is + * responsible for reading content via [getTitleAndContent] and saving it through + * its own REST API calls. The callback fires only after the editor store's save + * lifecycle completes, so it is safe to read and persist content at that point. + * + * Note: `window.editor.savePost()` is an async JS function that returns a Promise. + * Android's `WebView.evaluateJavascript` cannot await Promises (unlike iOS's + * `WKWebView.callAsyncJavaScript`), so we dispatch the call and route completion + * back via the `editorDelegate` JavaScript interface. + */ + fun savePost(callback: SavePostCallback) { + if (!isEditorLoaded) { + Log.e("GutenbergView", "You can't save until the editor has loaded") + callback.onComplete(false, "Editor not loaded") + return + } + val requestId = java.util.UUID.randomUUID().toString() + pendingSaveCallbacks[requestId] = callback + handler.post { + webView.evaluateJavascript( + "editor.savePost()" + + ".then(() => editorDelegate.onSavePostComplete('$requestId', true, null))" + + ".catch((e) => editorDelegate.onSavePostComplete('$requestId', false, String(e)));", + null + ) + } + } + + @JavascriptInterface + fun onSavePostComplete(requestId: String, success: Boolean, error: String?) { + val callback = pendingSaveCallbacks.remove(requestId) ?: return + handler.post { + callback.onComplete(success, error) + } + } + + fun interface SavePostCallback { + fun onComplete(success: Boolean, error: String?) + } + fun appendTextAtCursor(text: String) { if (!isEditorLoaded) { Log.e("GutenbergView", "You can't append text until the editor has loaded") From 9ce00b3406402d64a89c5b99173b7eac98cb304c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 06:08:21 -0400 Subject: [PATCH 04/28] chore(demo-ios): point wordpress-rs to PR build branch Updates the wordpress-rs SPM dependency to track the pr-build/1270 branch, which contains the PostUpdateParams export needed by the demo app's Save button to persist posts via the REST API. See: https://github.com/Automattic/wordpress-rs/pull/1270 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Gutenberg.xcodeproj/project.pbxproj | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj index 6d619aadd..f2d53b48b 100644 --- a/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj +++ b/ios/Demo-iOS/Gutenberg.xcodeproj/project.pbxproj @@ -223,6 +223,25 @@ }; /* End PBXProject section */ +/* Begin PBXResourcesBuildPhase section */ + 0C4F59892BEFF4970028BD96 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 0CE8E78F2C339B0600B9DC67 /* Preview Assets.xcassets in Resources */, + 0CE8E78B2C339B0600B9DC67 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + AA0000012F00000000000005 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + /* Begin PBXShellScriptBuildPhase section */ AA0000012F00000000000200 /* Copy OAuth Credentials */ = { isa = PBXShellScriptBuildPhase; @@ -246,25 +265,6 @@ }; /* End PBXShellScriptBuildPhase section */ -/* Begin PBXResourcesBuildPhase section */ - 0C4F59892BEFF4970028BD96 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 0CE8E78F2C339B0600B9DC67 /* Preview Assets.xcassets in Resources */, - 0CE8E78B2C339B0600B9DC67 /* Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - AA0000012F00000000000005 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - /* Begin PBXSourcesBuildPhase section */ 0C4F59872BEFF4970028BD96 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -561,8 +561,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Automattic/wordpress-rs"; requirement = { - kind = revision; - revision = "alpha-20260313"; + branch = "pr-build/1270"; + kind = branch; }; }; /* End XCRemoteSwiftPackageReference section */ From db7e1d17c169856d084db6fba54487add66a6bd3 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 06:37:45 -0400 Subject: [PATCH 05/28] feat(android): add posts list activity for selecting existing posts Adds a new PostsListActivity that fetches and displays posts from a WordPress site so the user can pick one to edit. Mirrors the iOS PostsListView in functionality. - GutenbergKitApplication.createApiClient() builds a WpApiClient from a stored Account (works for both self-hosted Application Passwords and WP.com OAuth flows). - PostsListActivity uses the client to call posts.listWithEditContext() with pagination, then launches EditorActivity with the selected post's ID, title, and content pre-filled. - Registered in AndroidManifest.xml. Required so the Android demo can exercise the Save button flow added in a follow-up commit, which needs a real post ID to PUT to the REST API. Co-Authored-By: Claude Opus 4.6 (1M context) --- android/app/src/main/AndroidManifest.xml | 3 + .../gutenbergkit/GutenbergKitApplication.kt | 30 ++ .../example/gutenbergkit/PostsListActivity.kt | 327 ++++++++++++++++++ 3 files changed, 360 insertions(+) create mode 100644 android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4eea7d6d5..d5a954927 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -50,6 +50,9 @@ + wpAuthenticationFromUsernameAndPassword( + account.username, + account.password + ) + is Account.WpCom -> WpAuthentication.Bearer(token = account.token) + } + val apiRootUrl = when (account) { + is Account.SelfHostedSite -> account.siteApiRoot + is Account.WpCom -> account.siteApiRoot + } + return WpApiClient( + wpOrgSiteApiRootUrl = URI(apiRootUrl).toURL(), + authProvider = WpAuthenticationProvider.staticWithAuth(auth), + interceptors = emptyList(), + networkAvailabilityProvider = networkAvailabilityProvider + ) + } } diff --git a/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt new file mode 100644 index 000000000..9562bb6d0 --- /dev/null +++ b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt @@ -0,0 +1,327 @@ +package com.example.gutenbergkit + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.example.gutenbergkit.ui.theme.AppTheme +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorDependencies +import org.wordpress.gutenberg.model.EditorDependenciesSerializer +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.AnyPostWithEditContext +import uniffi.wp_api.PostEndpointType +import uniffi.wp_api.PostListParams + +/** + * Lists posts from a WordPress site so the user can pick one to edit. + * + * Receives an [EditorConfiguration] (already prepared) and an account ID via Intent extras, + * fetches posts via the WordPress REST API using [WpApiClient], and on selection launches + * [EditorActivity] with the post's title, content, and ID. + */ +class PostsListActivity : ComponentActivity() { + + companion object { + const val EXTRA_ACCOUNT_ID = "account_id" + const val EXTRA_POST_ENDPOINT = "post_endpoint" + + fun createIntent( + context: Context, + accountId: ULong, + postEndpoint: String, + configuration: EditorConfiguration, + dependencies: EditorDependencies? + ): Intent { + return Intent(context, PostsListActivity::class.java).apply { + putExtra(EXTRA_ACCOUNT_ID, accountId.toLong()) + putExtra(EXTRA_POST_ENDPOINT, postEndpoint) + putExtra(MainActivity.EXTRA_CONFIGURATION, configuration) + if (dependencies != null) { + val filePath = EditorDependenciesSerializer.writeToDisk(context, dependencies) + putExtra(EditorActivity.EXTRA_DEPENDENCIES_PATH, filePath) + } + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1L).takeIf { it >= 0 }?.toULong() + val postEndpoint = intent.getStringExtra(EXTRA_POST_ENDPOINT) ?: "post" + val configuration = intent.getParcelableExtra(MainActivity.EXTRA_CONFIGURATION, EditorConfiguration::class.java) + val dependenciesPath = intent.getStringExtra(EditorActivity.EXTRA_DEPENDENCIES_PATH) + + if (accountId == null || configuration == null) { + finish() + return + } + + val viewModel = ViewModelProvider( + this, + PostsListViewModelFactory(application, accountId, postEndpoint) + )[PostsListViewModel::class.java] + + setContent { + AppTheme { + PostsListScreen( + viewModel = viewModel, + onClose = { finish() }, + onPostSelected = { post -> + launchEditor(post, configuration, dependenciesPath, accountId) + } + ) + } + } + } + + private fun launchEditor( + post: AnyPostWithEditContext, + baseConfiguration: EditorConfiguration, + dependenciesPath: String?, + accountId: ULong + ) { + val updatedConfig = baseConfiguration.toBuilder() + .setPostId(post.id.toUInt()) + .setTitle(post.title?.raw ?: "") + .setContent(post.content.raw ?: "") + .build() + + val intent = Intent(this, EditorActivity::class.java).apply { + putExtra(MainActivity.EXTRA_CONFIGURATION, updatedConfig) + putExtra(EditorActivity.EXTRA_ACCOUNT_ID, accountId.toLong()) + if (dependenciesPath != null) { + putExtra(EditorActivity.EXTRA_DEPENDENCIES_PATH, dependenciesPath) + } + } + startActivity(intent) + } +} + +data class PostsListUiState( + val posts: List = emptyList(), + val isLoading: Boolean = false, + val error: String? = null +) + +class PostsListViewModel( + application: android.app.Application, + private val accountId: ULong, + private val postEndpoint: String +) : AndroidViewModel(application) { + + private val _uiState = MutableStateFlow(PostsListUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadPosts() { + if (_uiState.value.isLoading) return + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, error = null, posts = emptyList()) } + + try { + val app = getApplication() + val account = app.accountRepository.all().firstOrNull { it.id() == accountId } + ?: throw IllegalStateException("Account not found") + val client = app.createApiClient(account) + + val endpointType = when (postEndpoint) { + "page" -> PostEndpointType.Pages + "post" -> PostEndpointType.Posts + else -> PostEndpointType.Custom(postEndpoint) + } + + val all = mutableListOf() + var page = 1u + val perPage = 20u + while (true) { + val params = PostListParams(page = page, perPage = perPage) + val result = client.request { builder -> + builder.posts().listWithEditContext(endpointType, params) + } + when (result) { + is WpRequestResult.Success -> { + val data = result.response.data + all.addAll(data) + if (data.size < perPage.toInt()) break + page++ + } + else -> { + throw IllegalStateException("Failed to load posts: $result") + } + } + } + + _uiState.update { it.copy(posts = all, isLoading = false) } + } catch (e: Exception) { + _uiState.update { it.copy(error = e.message ?: "Unknown error", isLoading = false) } + } + } + } +} + +class PostsListViewModelFactory( + private val application: android.app.Application, + private val accountId: ULong, + private val postEndpoint: String +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(PostsListViewModel::class.java)) { + return PostsListViewModel(application, accountId, postEndpoint) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PostsListScreen( + viewModel: PostsListViewModel, + onClose: () -> Unit, + onPostSelected: (AnyPostWithEditContext) -> Unit +) { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.loadPosts() + } + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text("Posts") }, + navigationIcon = { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back" + ) + } + } + ) + } + ) { innerPadding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + when { + uiState.isLoading && uiState.posts.isEmpty() -> { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + uiState.error != null -> { + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "Error loading posts", + style = MaterialTheme.typography.titleMedium + ) + Text( + uiState.error ?: "", + style = MaterialTheme.typography.bodyMedium + ) + } + } + uiState.posts.isEmpty() -> { + Text( + "No posts found", + modifier = Modifier.align(Alignment.Center) + ) + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 8.dp) + ) { + items(uiState.posts, key = { it.id }) { post -> + PostRow(post = post, onClick = { onPostSelected(post) }) + HorizontalDivider() + } + } + } + } + } + } +} + +@Composable +private fun PostRow(post: AnyPostWithEditContext, onClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val title = post.title?.rendered?.ifBlank { "(no title)" } ?: "(no title)" + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + val excerpt = post.excerpt?.rendered?.stripHtml().orEmpty() + if (excerpt.isNotBlank()) { + Text( + text = excerpt, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +private fun String.stripHtml(): String = + this.replace(Regex("<[^>]+>"), "").trim() From 087c2b7676dd8222a028fba4284ac7b9a723681e Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 06:38:00 -0400 Subject: [PATCH 06/28] feat(android): add save button that persists posts via REST API Wires the Save button in EditorActivity to: 1. Call GutenbergView.savePost() to trigger the editor store's save lifecycle so plugins (e.g., VideoPress) fire their side-effect API calls. 2. Read the latest title/content via getTitleAndContent() and persist the post via WpApiClient's posts().update() call. Also adds a "Browse" button to SitePreparationActivity (visible only for authenticated sites) that launches PostsListActivity to pick an existing post to edit. The selected post's ID is threaded through to EditorActivity via a new EXTRA_ACCOUNT_ID intent extra so the Save handler can reconstruct the API client. The Save button is disabled for new drafts (where postId is null) since updating requires an existing post ID. Requires the PostUpdateParams export from wordpress-rs PR Automattic/wordpress-rs#1270 (already exposed via uniffi for Kotlin). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../example/gutenbergkit/EditorActivity.kt | 123 ++++++++++++++++++ .../gutenbergkit/SitePreparationActivity.kt | 35 ++++- 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 9ff8ac71c..2554b76b3 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource @@ -43,16 +44,22 @@ import androidx.lifecycle.lifecycleScope import com.example.gutenbergkit.ui.theme.AppTheme import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.GutenbergView import org.wordpress.gutenberg.RecordedNetworkRequest import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.model.EditorDependenciesSerializer +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.PostEndpointType +import uniffi.wp_api.PostUpdateParams +import kotlin.coroutines.resume class EditorActivity : ComponentActivity() { companion object { const val EXTRA_DEPENDENCIES_PATH = "dependencies_path" + const val EXTRA_ACCOUNT_ID = "account_id" } private var gutenbergView: GutenbergView? = null @@ -93,11 +100,15 @@ class EditorActivity : ComponentActivity() { val dependenciesPath = intent.getStringExtra(EXTRA_DEPENDENCIES_PATH) val dependencies = dependenciesPath?.let { EditorDependenciesSerializer.readFromDisk(it) } + // Optional account ID for REST API persistence (set when launched from PostsListActivity) + val accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1L).takeIf { it >= 0 }?.toULong() + setContent { AppTheme { EditorScreen( configuration = configuration, dependencies = dependencies, + accountId = accountId, coroutineScope = this.lifecycleScope, onClose = { finish() }, onGutenbergViewCreated = { view -> @@ -121,6 +132,7 @@ class EditorActivity : ComponentActivity() { fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, + accountId: ULong? = null, coroutineScope: CoroutineScope, onClose: () -> Unit, onGutenbergViewCreated: (GutenbergView) -> Unit = {} @@ -130,7 +142,12 @@ fun EditorScreen( var hasUndoState by remember { mutableStateOf(false) } var hasRedoState by remember { mutableStateOf(false) } var isCodeEditorEnabled by remember { mutableStateOf(false) } + var isSaving by remember { mutableStateOf(false) } var gutenbergViewRef by remember { mutableStateOf(null) } + val saveScope = rememberCoroutineScope() + val context = androidx.compose.ui.platform.LocalContext.current + + val canSave = !isSaving && accountId != null && configuration.postId != null BackHandler(enabled = isModalDialogOpen) { gutenbergViewRef?.dismissTopModal() @@ -173,6 +190,31 @@ fun EditorScreen( contentDescription = stringResource(R.string.redo) ) } + TextButton( + onClick = { + val view = gutenbergViewRef ?: return@TextButton + val postId = configuration.postId + if (accountId == null || postId == null) return@TextButton + isSaving = true + saveScope.launch { + try { + persistPost( + context = context, + view = view, + configuration = configuration, + accountId = accountId, + postId = postId + ) + } finally { + isSaving = false + } + } + }, + enabled = canSave && !isModalDialogOpen + ) { + Text(stringResource(R.string.save)) + } + TextButton(onClick = { }, enabled = false) { Text(stringResource(R.string.publish)) } @@ -303,3 +345,84 @@ fun EditorScreen( ) } } + +/** + * Suspends until the editor store's save lifecycle completes. + * + * Bridges the [GutenbergView.savePost] callback to a coroutine so the caller + * can sequence post-save work (like persisting content via the REST API). + */ +private suspend fun GutenbergView.savePostAwait(): Boolean = + suspendCancellableCoroutine { continuation -> + savePost { success, _ -> + if (continuation.isActive) continuation.resume(success) + } + } + +/** + * Reads the latest title/content from the editor and PUTs it to the WordPress REST API. + * + * Triggers [GutenbergView.savePost] first so plugin side-effects (e.g., VideoPress + * syncing metadata) settle before the content is read and persisted. + */ +private suspend fun persistPost( + context: android.content.Context, + view: GutenbergView, + configuration: EditorConfiguration, + accountId: ULong, + postId: UInt +) { + try { + val saveSucceeded = view.savePostAwait() + if (!saveSucceeded) { + Log.w("EditorActivity", "editor.savePost() reported failure; persisting anyway") + } + + val titleAndContent = suspendCancellableCoroutine> { cont -> + view.getTitleAndContent( + originalContent = configuration.content, + callback = object : GutenbergView.TitleAndContentCallback { + override fun onResult(title: CharSequence, content: CharSequence) { + if (cont.isActive) cont.resume(title to content) + } + } + ) + } + + val app = context.applicationContext as GutenbergKitApplication + val account = app.accountRepository.all().firstOrNull { it.id() == accountId } + ?: throw IllegalStateException("Account not found") + val client = app.createApiClient(account) + + val endpointType = when (configuration.postType) { + "page" -> PostEndpointType.Pages + "post" -> PostEndpointType.Posts + else -> PostEndpointType.Custom(configuration.postType) + } + + val params = PostUpdateParams( + title = titleAndContent.first.toString(), + content = titleAndContent.second.toString(), + meta = null + ) + + val result = client.request { builder -> + builder.posts().update( + postEndpointType = endpointType, + postId = postId.toLong(), + params = params + ) + } + + when (result) { + is WpRequestResult.Success -> { + Log.i("EditorActivity", "Post $postId persisted via REST API") + } + else -> { + Log.e("EditorActivity", "Failed to persist post $postId: $result") + } + } + } catch (e: Exception) { + Log.e("EditorActivity", "Save failed", e) + } +} diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt index 27043206b..502d0b438 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt @@ -140,13 +140,21 @@ class SitePreparationActivity : ComponentActivity() { SitePreparationViewModelFactory(application, configurationItem) )[SitePreparationViewModel::class.java] + val accountId = (configurationItem as? ConfigurationItem.ConfiguredEditor)?.accountId + setContent { AppTheme { SitePreparationScreen( viewModel = viewModel, + accountId = accountId, onClose = { finish() }, onStartEditor = { configuration, dependencies -> launchEditor(configuration, dependencies) + }, + onBrowsePosts = { configuration, dependencies, postType -> + accountId?.let { + launchPostsList(it, postType, configuration, dependencies) + } } ) } @@ -168,14 +176,27 @@ class SitePreparationActivity : ComponentActivity() { } startActivity(intent) } + + private fun launchPostsList( + accountId: ULong, + postType: String, + configuration: EditorConfiguration, + dependencies: org.wordpress.gutenberg.model.EditorDependencies? + ) { + startActivity( + PostsListActivity.createIntent(this, accountId, postType, configuration, dependencies) + ) + } } @OptIn(ExperimentalMaterial3Api::class) @Composable fun SitePreparationScreen( viewModel: SitePreparationViewModel, + accountId: ULong?, onClose: () -> Unit, - onStartEditor: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?) -> Unit + onStartEditor: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?) -> Unit, + onBrowsePosts: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?, String) -> Unit ) { val uiState by viewModel.uiState.collectAsState() @@ -198,6 +219,18 @@ fun SitePreparationScreen( }, actions = { if (uiState.editorConfiguration != null) { + if (accountId != null) { + OutlinedButton( + onClick = { + viewModel.buildConfiguration()?.let { config -> + onBrowsePosts(config, uiState.editorDependencies, uiState.postType) + } + }, + modifier = Modifier.padding(end = 8.dp) + ) { + Text("Browse") + } + } Button( onClick = { viewModel.buildConfiguration()?.let { config -> From 4b52b55edaae6ef5f509dcea46a9d490cfb46e19 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 10:01:31 -0400 Subject: [PATCH 07/28] fix(android): include restBase and restNamespace in GBKit post payload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The api-fetch filterEndpointsMiddleware blocks server fetches for the post being edited (preventing them from overwriting local edits with stale or context-mismatched data). The middleware bails out early when either restBase or restNamespace is missing from window.GBKit.post: if ( id === undefined || ! restNamespace || ! restBase ) { return next( options ); // lets the request through } iOS already populates these from EditorConfiguration.postType (PostTypeDetails struct), but Android's GBKitGlobal.Post only had { id, type, status, title, content }. As a result, when the editor mounted with an existing post ID, WordPress core data fetched the post via GET /wp/v2/posts/{id} unfiltered, and the response (without context=edit) overwrote the title and content set from the native host with empty values — causing the editor to briefly show the loaded post before clearing it. Adds restBase and restNamespace fields to GBKitGlobal.Post and derives them from the postType slug ("post" → "posts", "page" → "pages", custom types pluralized). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../wordpress/gutenberg/model/GBKitGlobal.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt index fdc6568f5..cc1288d2c 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt @@ -74,6 +74,10 @@ data class GBKitGlobal( val id: Int, // TODO: Instead of the `-1` trick, this should just be `null` for new posts /** The post type (e.g., `post`, `page`). */ val type: String, + /** The REST API base path for this post type (e.g., `posts`, `pages`). */ + val restBase: String, + /** The REST API namespace (e.g., `wp/v2`). */ + val restNamespace: String, /** The post status (e.g., `draft`, `publish`, `pending`). */ val status: String, /** The post title (URL-encoded). */ @@ -110,6 +114,8 @@ data class GBKitGlobal( post = Post( id = postId ?: -1, type = configuration.postType, + restBase = restBaseFor(configuration.postType), + restNamespace = "wp/v2", status = configuration.postStatus, title = configuration.title.encodeForEditor(), content = configuration.content.encodeForEditor() @@ -126,6 +132,18 @@ data class GBKitGlobal( } ) } + + /** + * Maps a post type slug to its WordPress REST API base path. + * + * Defaults to pluralizing the slug for unknown types (e.g., `product` → `products`), + * which matches the WordPress convention for most post types. + */ + private fun restBaseFor(postType: String): String = when (postType) { + "post" -> "posts" + "page" -> "pages" + else -> if (postType.endsWith("s")) postType else "${postType}s" + } } /** From f6ba8aee551f7803e219b52a89a5f6c905b63dc1 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 10:01:41 -0400 Subject: [PATCH 08/28] refactor(android): move browse button beneath post type selector Matches the iOS demo's layout where the "Browse" action sits below the Post Type picker in the Feature Configuration card, rather than in the top app bar alongside Start. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gutenbergkit/SitePreparationActivity.kt | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt index 502d0b438..ac93c3521 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt @@ -219,18 +219,6 @@ fun SitePreparationScreen( }, actions = { if (uiState.editorConfiguration != null) { - if (accountId != null) { - OutlinedButton( - onClick = { - viewModel.buildConfiguration()?.let { config -> - onBrowsePosts(config, uiState.editorDependencies, uiState.postType) - } - }, - modifier = Modifier.padding(end = 8.dp) - ) { - Text("Browse") - } - } Button( onClick = { viewModel.buildConfiguration()?.let { config -> @@ -252,6 +240,12 @@ fun SitePreparationScreen( LoadedView( uiState = uiState, viewModel = viewModel, + accountId = accountId, + onBrowsePosts = { + viewModel.buildConfiguration()?.let { config -> + onBrowsePosts(config, uiState.editorDependencies, uiState.postType) + } + }, modifier = Modifier.padding(innerPadding) ) } @@ -278,6 +272,8 @@ private fun LoadingView(modifier: Modifier = Modifier) { private fun LoadedView( uiState: SitePreparationUiState, viewModel: SitePreparationViewModel, + accountId: ULong?, + onBrowsePosts: () -> Unit, modifier: Modifier = Modifier ) { LazyColumn( @@ -300,7 +296,9 @@ private fun LoadedView( enableNetworkLogging = uiState.enableNetworkLogging, onEnableNetworkLoggingChange = viewModel::setEnableNetworkLogging, postType = uiState.postType, - onPostTypeChange = viewModel::setPostType + onPostTypeChange = viewModel::setPostType, + showBrowseButton = accountId != null, + onBrowsePosts = onBrowsePosts ) } @@ -381,7 +379,9 @@ private fun FeatureConfigurationCard( enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postType: String, - onPostTypeChange: (String) -> Unit + onPostTypeChange: (String) -> Unit, + showBrowseButton: Boolean = false, + onBrowsePosts: () -> Unit = {} ) { Card(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(16.dp)) { @@ -451,6 +451,16 @@ private fun FeatureConfigurationCard( } } } + + if (showBrowseButton) { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = onBrowsePosts, + modifier = Modifier.fillMaxWidth() + ) { + Text("Browse") + } + } } } } From f27bdbeb81a17f7dd9b8b3fdec80a78aa9d18550 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 10:01:56 -0400 Subject: [PATCH 09/28] refactor(android): remove disabled publish button from editor toolbar The toolbar previously showed both "Save" and a non-functional "PUBLISH" button. Removes the disabled Publish button so Save is the only top-level action. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/main/java/com/example/gutenbergkit/EditorActivity.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 2554b76b3..8859f89c6 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -215,10 +215,6 @@ fun EditorScreen( Text(stringResource(R.string.save)) } - TextButton(onClick = { }, enabled = false) { - Text(stringResource(R.string.publish)) - } - // Overflow menu button and dropdown in Box for proper anchoring Box { IconButton( From c495c7111b7865abe243f8fcb56f03a31ed2772c Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 10:06:01 -0400 Subject: [PATCH 10/28] refactor(android): uppercase save button label and clean up strings - Change R.string.save to "SAVE" so the toolbar button matches the styling of the previous PUBLISH button it replaced. - Remove the unused R.string.publish (the disabled Publish button was removed in an earlier commit). - Remove the disabled "Save" item from the editor's overflow menu so the toolbar Save button is the sole save action. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/main/java/com/example/gutenbergkit/EditorActivity.kt | 5 ----- android/app/src/main/res/values/strings.xml | 3 +-- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 8859f89c6..8a5804b3d 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -230,11 +230,6 @@ fun EditorScreen( expanded = showMenu, onDismissRequest = { showMenu = false } ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.save)) }, - onClick = { }, - enabled = false - ) DropdownMenuItem( text = { Text(stringResource(R.string.preview)) }, onClick = { }, diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 16419c4f8..a3b680c0e 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -35,9 +35,8 @@ Close Undo Redo - PUBLISH More options - Save + SAVE Preview Visual editor Code editor From 18f90416ff9850b2dd97a8e4bdd3e027d13c616f Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 10:07:51 -0400 Subject: [PATCH 11/28] fix(android): include drafts and other statuses in posts list PostListParams.status defaults to publish-only on the server side, hiding drafts from the demo's posts list. Pass PostStatus.Any explicitly so all statuses (draft, pending, future, etc.) appear in the picker, matching the iOS demo's behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../java/com/example/gutenbergkit/PostsListActivity.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt index 9562bb6d0..bcbe1bca4 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt @@ -52,6 +52,7 @@ import rs.wordpress.api.kotlin.WpRequestResult import uniffi.wp_api.AnyPostWithEditContext import uniffi.wp_api.PostEndpointType import uniffi.wp_api.PostListParams +import uniffi.wp_api.PostStatus /** * Lists posts from a WordPress site so the user can pick one to edit. @@ -177,7 +178,11 @@ class PostsListViewModel( var page = 1u val perPage = 20u while (true) { - val params = PostListParams(page = page, perPage = perPage) + val params = PostListParams( + page = page, + perPage = perPage, + status = listOf(PostStatus.Any) + ) val result = client.request { builder -> builder.posts().listWithEditContext(endpointType, params) } From 648ed5ab5f5bafcd8ffae514c8d2bbb38d484ea0 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 10:19:23 -0400 Subject: [PATCH 12/28] refactor(demo-ios): remove permanently disabled items from more menu Removes the placeholder Preview, Revisions, Post Settings, Help, and the static block/word/character count footer from the editor's more menu. Only the working "Code Editor / Visual Editor" toggle remains. Co-Authored-By: Claude Opus 4.6 (1M context) --- ios/Demo-iOS/Sources/Views/EditorView.swift | 35 +++++---------------- 1 file changed, 8 insertions(+), 27 deletions(-) diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 1d03c5ac3..9aca2d477 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -77,33 +77,14 @@ struct EditorView: View { private var moreMenu: some View { Menu { - Section { - Button(action: { - viewModel.isCodeEditorEnabled.toggle() - }, label: { - Label( - viewModel.isCodeEditorEnabled ? "Visual Editor" : "Code Editor", - systemImage: viewModel.isCodeEditorEnabled ? "doc.richtext" : "curlybraces" - ) - }) - Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { - Label("Preview", systemImage: "safari") - }).disabled(true) - Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { - Label("Revisions (42)", systemImage: "clock.arrow.circlepath") - }).disabled(true) - Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { - Label("Post Settings", systemImage: "gearshape") - }).disabled(true) - Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { - Label("Help", systemImage: "questionmark.circle") - }).disabled(true) - } - Section { - Text("Blocks: 4, Words: 8, Characters: 15") - } header: { - - } + Button(action: { + viewModel.isCodeEditorEnabled.toggle() + }, label: { + Label( + viewModel.isCodeEditorEnabled ? "Visual Editor" : "Code Editor", + systemImage: viewModel.isCodeEditorEnabled ? "doc.richtext" : "curlybraces" + ) + }) } label: { Image(systemName: "ellipsis") } From 968237ee2001bedf87feb38e230c003e05c2d7ad Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 10:19:28 -0400 Subject: [PATCH 13/28] refactor(android): remove permanently disabled items from more menu Removes the placeholder Preview, Post Settings, and Help dropdown menu items from the editor toolbar. Only the working "Code Editor / Visual Editor" toggle remains. Also drops the now-unused string resources. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/example/gutenbergkit/EditorActivity.kt | 15 --------------- android/app/src/main/res/values/strings.xml | 3 --- 2 files changed, 18 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 8a5804b3d..5e25d16ad 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -230,11 +230,6 @@ fun EditorScreen( expanded = showMenu, onDismissRequest = { showMenu = false } ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.preview)) }, - onClick = { }, - enabled = false - ) DropdownMenuItem( text = { Text(stringResource(if (isCodeEditorEnabled) R.string.visual_editor else R.string.code_editor)) }, onClick = { @@ -243,16 +238,6 @@ fun EditorScreen( showMenu = false } ) - DropdownMenuItem( - text = { Text(stringResource(R.string.post_settings)) }, - onClick = { }, - enabled = false - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.help)) }, - onClick = { }, - enabled = false - ) } } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a3b680c0e..30089f25b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -37,9 +37,6 @@ Redo More options SAVE - Preview Visual editor Code editor - Post settings - Help From 75a0f892c1bcac2a8ffc79f4ed78168aefd868a4 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 11:17:53 -0400 Subject: [PATCH 14/28] refactor: harden savePost bridge and clarify host contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - js: assign `window.editor.savePost` directly so the Promise reaches native hosts (`callAsyncJavaScript` etc.); add comment explaining the divergence from the `undo`/`redo` wrappers. - ios/android: doc that hosts must still persist content if `savePost` fails — a misbehaving plugin must not block the user from saving. - android: route the request id through `JSONObject.quote()` to keep the JS string interpolation safe even if non-UUID values land here. - android: drain `pendingSaveCallbacks` in `onDetachedFromWindow` so coroutines awaiting `savePost` unblock instead of leaking when the view is torn down mid-save. - tests: extend `use-host-bridge.test.jsx` to cover `savePost` registration and cleanup; teach the `@wordpress/data` mock to return real `vi.fn()` actions so destructured dispatches assign functions. Co-Authored-By: Claude Opus 4.6 (1M context) --- __mocks__/@wordpress/data.js | 15 ++++++++++- .../org/wordpress/gutenberg/GutenbergView.kt | 26 +++++++++++++++++-- .../Sources/EditorViewController.swift | 5 ++++ .../editor/test/use-host-bridge.test.jsx | 2 ++ src/components/editor/use-host-bridge.js | 8 +++--- 5 files changed, 50 insertions(+), 6 deletions(-) diff --git a/__mocks__/@wordpress/data.js b/__mocks__/@wordpress/data.js index bdfdf7493..0cbae4b9c 100644 --- a/__mocks__/@wordpress/data.js +++ b/__mocks__/@wordpress/data.js @@ -1,6 +1,19 @@ import { vi } from 'vitest'; -export const useDispatch = vi.fn( () => ( {} ) ); +// Returns mock dispatched actions for stores referenced by editor code +// (e.g. core/editor's `savePost`, `undo`, `redo`, `switchEditorMode`). +// Returning `vi.fn()` rather than `undefined` lets `useHostBridge` assign +// destructured actions directly (e.g. `window.editor.savePost = savePost`) +// without silently producing `undefined` values. +export const useDispatch = vi.fn( () => ( { + undo: vi.fn(), + redo: vi.fn(), + savePost: vi.fn(), + switchEditorMode: vi.fn(), + editEntityRecord: vi.fn(), + updateBlock: vi.fn(), + selectionChange: vi.fn(), +} ) ); export const useSelect = vi.fn( ( selector ) => { if ( typeof selector === 'function' ) { return selector( () => ( {} ) ); diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt index 02beefd4c..40225ae22 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/GutenbergView.kt @@ -752,6 +752,11 @@ class GutenbergView : FrameLayout { * its own REST API calls. The callback fires only after the editor store's save * lifecycle completes, so it is safe to read and persist content at that point. * + * **Important:** if the callback reports `success = false` (for example because a + * third-party plugin subscribed to the lifecycle errored out), hosts should still + * proceed to read and persist content. A misbehaving plugin must not block the + * user from saving their work — log the lifecycle failure and continue. + * * Note: `window.editor.savePost()` is an async JS function that returns a Promise. * Android's `WebView.evaluateJavascript` cannot await Promises (unlike iOS's * `WKWebView.callAsyncJavaScript`), so we dispatch the call and route completion @@ -765,11 +770,15 @@ class GutenbergView : FrameLayout { } val requestId = java.util.UUID.randomUUID().toString() pendingSaveCallbacks[requestId] = callback + // Quote the requestId for safe JS string interpolation. UUIDs are safe + // today, but routing all values through `JSONObject.quote()` ensures we + // never accidentally inject untrusted strings into the JS context. + val quotedRequestId = JSONObject.quote(requestId) handler.post { webView.evaluateJavascript( "editor.savePost()" + - ".then(() => editorDelegate.onSavePostComplete('$requestId', true, null))" + - ".catch((e) => editorDelegate.onSavePostComplete('$requestId', false, String(e)));", + ".then(() => editorDelegate.onSavePostComplete($quotedRequestId, true, null))" + + ".catch((e) => editorDelegate.onSavePostComplete($quotedRequestId, false, String(e)));", null ) } @@ -1075,10 +1084,23 @@ class GutenbergView : FrameLayout { networkRequestListener = null requestInterceptor = DefaultGutenbergRequestInterceptor() latestContentProvider = null + // Fail any save callbacks still waiting on a JS Promise — without this, + // coroutines awaiting `savePost()` would hang forever (and leak whatever + // they captured) when the view is torn down mid-save. + drainPendingSaveCallbacks("View detached") handler.removeCallbacksAndMessages(null) webView.destroy() } + private fun drainPendingSaveCallbacks(reason: String) { + val pending = synchronized(pendingSaveCallbacks) { + val snapshot = pendingSaveCallbacks.toMap() + pendingSaveCallbacks.clear() + snapshot + } + pending.values.forEach { it.onComplete(false, reason) } + } + // Network Monitoring private fun startNetworkMonitoring() { diff --git a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift index 3fb45e22d..2bdce030c 100644 --- a/ios/Sources/GutenbergKit/Sources/EditorViewController.swift +++ b/ios/Sources/GutenbergKit/Sources/EditorViewController.swift @@ -421,6 +421,11 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro /// The actual post content is **not** persisted by this method — the host app /// is responsible for reading content via ``getTitleAndContent()`` and saving /// it through its own REST API calls. + /// + /// > Important: If this call throws (for example, because a third-party plugin + /// > subscribed to the lifecycle errors out), hosts should still proceed to + /// > read and persist content. A misbehaving plugin must not block the user + /// > from saving their work — log the lifecycle failure and continue. public func savePost() async throws { guard isReady else { throw EditorNotReadyError() } _ = try await webView.callAsyncJavaScript("await editor.savePost();", in: nil, contentWorld: .page) diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index 4d8301e8d..58c5d15b6 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -47,6 +47,7 @@ describe( 'useHostBridge', () => { expect( window.editor.getTitleAndContent ).toBeTypeOf( 'function' ); expect( window.editor.undo ).toBeTypeOf( 'function' ); expect( window.editor.redo ).toBeTypeOf( 'function' ); + expect( window.editor.savePost ).toBeTypeOf( 'function' ); expect( window.editor.switchEditorMode ).toBeTypeOf( 'function' ); expect( window.editor.dismissTopModal ).toBeTypeOf( 'function' ); expect( window.editor.focus ).toBeTypeOf( 'function' ); @@ -70,6 +71,7 @@ describe( 'useHostBridge', () => { expect( window.editor.getTitleAndContent ).toBeUndefined(); expect( window.editor.undo ).toBeUndefined(); expect( window.editor.redo ).toBeUndefined(); + expect( window.editor.savePost ).toBeUndefined(); expect( window.editor.switchEditorMode ).toBeUndefined(); expect( window.editor.dismissTopModal ).toBeUndefined(); expect( window.editor.focus ).toBeUndefined(); diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index 1f3478a9f..368ca99e2 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -89,9 +89,11 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { redo(); }; - window.editor.savePost = async () => { - await savePost(); - }; + // Unlike `undo`/`redo`/`switchEditorMode` above, we intentionally + // expose the underlying Promise here so native hosts can `await` the + // editor store's full save lifecycle (e.g., iOS uses + // `WKWebView.callAsyncJavaScript` to wait for completion). + window.editor.savePost = savePost; window.editor.switchEditorMode = ( mode ) => { // Do not return the `Promise` return value to avoid host errors. From fdfcb422d44518968f8a618bd20aee648e5e1bbe Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 11:32:00 -0400 Subject: [PATCH 15/28] refactor(android): introduce PostTypeDetails to mirror iOS Ports the iOS `PostTypeDetails` struct to Android as a Kotlin data class with `post`/`page` companion constants. `EditorConfiguration`, `EditorPreloadList`, `GBKitGlobal`, and `EditorService` now carry the full post type details (slug + restBase + restNamespace) instead of just the slug, matching the iOS API surface 1:1. This deletes the `restBaseFor()` heuristic in `GBKitGlobal` that incorrectly pluralized custom post-type slugs, and fixes a related bug in `EditorPreloadList.buildPostPath()` where the preload key was hardcoded to `/wp/v2/posts/$id` even when editing a page. The demo app still threads a string slug internally; a small `slugToPostTypeDetails()` helper bridges to the new API. Chunk 3 will replace that helper with real REST data from `wordpress-rs`. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../gutenberg/model/EditorConfiguration.kt | 10 ++-- .../gutenberg/model/EditorPreloadList.kt | 11 ++-- .../wordpress/gutenberg/model/GBKitGlobal.kt | 18 ++----- .../gutenberg/model/PostTypeDetails.kt | 44 ++++++++++++++++ .../gutenberg/services/EditorService.kt | 2 +- .../gutenberg/EditorAssetsLibraryTest.kt | 5 +- .../gutenberg/RESTAPIRepositoryTest.kt | 7 +-- .../model/EditorConfigurationTest.kt | 26 +++++----- .../gutenberg/model/EditorPreloadListTest.kt | 52 +++++++++---------- .../gutenberg/model/GBKitGlobalTest.kt | 4 +- .../gutenberg/services/EditorServiceTest.kt | 3 +- .../example/gutenbergkit/EditorActivity.kt | 4 +- .../gutenbergkit/SitePreparationViewModel.kt | 21 ++++++-- 13 files changed, 129 insertions(+), 78 deletions(-) create mode 100644 android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/PostTypeDetails.kt diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt index 05fe263ed..2a7293193 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorConfiguration.kt @@ -11,7 +11,7 @@ data class EditorConfiguration( val title: String, val content: String, val postId: UInt?, - val postType: String, + val postType: PostTypeDetails, val postStatus: String, val themeStyles: Boolean, val plugins: Boolean, @@ -46,15 +46,15 @@ data class EditorConfiguration( } companion object { @JvmStatic - fun builder(siteURL: String, siteApiRoot: String, postType: String = "post"): Builder = Builder(siteURL, siteApiRoot, postType = postType) + fun builder(siteURL: String, siteApiRoot: String, postType: PostTypeDetails = PostTypeDetails.post): Builder = Builder(siteURL, siteApiRoot, postType = postType) @JvmStatic - fun bundled(): EditorConfiguration = Builder("https://example.com", "https://example.com/wp-json/", "post") + fun bundled(): EditorConfiguration = Builder("https://example.com", "https://example.com/wp-json/", PostTypeDetails.post) .setEnableOfflineMode(true) .build() } - class Builder(private var siteURL: String, private var siteApiRoot: String, private var postType: String) { + class Builder(private var siteURL: String, private var siteApiRoot: String, private var postType: PostTypeDetails) { private var title: String = "" private var content: String = "" private var postId: UInt? = null @@ -77,7 +77,7 @@ data class EditorConfiguration( fun setTitle(title: String) = apply { this.title = title } fun setContent(content: String) = apply { this.content = content } fun setPostId(postId: UInt?) = apply { this.postId = postId?.takeIf { it != 0u } } - fun setPostType(postType: String) = apply { this.postType = postType } + fun setPostType(postType: PostTypeDetails) = apply { this.postType = postType } fun setPostStatus(postStatus: String) = apply { this.postStatus = postStatus } fun setThemeStyles(themeStyles: Boolean) = apply { this.themeStyles = themeStyles } fun setPlugins(plugins: Boolean) = apply { this.plugins = plugins } diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorPreloadList.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorPreloadList.kt index 087e8269b..206da830a 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorPreloadList.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/EditorPreloadList.kt @@ -28,8 +28,8 @@ data class EditorPreloadList private constructor( val postID: Int?, /** The pre-fetched post data for the post being edited. */ val postData: EditorURLResponse?, - /** The post type identifier (e.g., "post", "page"). */ - val postType: String, + /** Details about the post type, including REST API configuration. */ + val postType: PostTypeDetails, /** Pre-fetched data for the current post type's schema. */ val postTypeData: EditorURLResponse, /** Pre-fetched data for all available post types. */ @@ -47,7 +47,7 @@ data class EditorPreloadList private constructor( constructor( postID: Int? = null, postData: EditorURLResponse? = null, - postType: String, + postType: PostTypeDetails, postTypeData: EditorURLResponse, postTypesData: EditorURLResponse, activeThemeData: EditorURLResponse?, @@ -72,7 +72,7 @@ data class EditorPreloadList private constructor( fun build(): JsonElement { val entries = mutableMapOf() - entries[buildPostTypePath(postType)] = postTypeData.toJsonElement() + entries[buildPostTypePath(postType.postType)] = postTypeData.toJsonElement() entries[POST_TYPES_PATH] = postTypesData.toJsonElement() if (postID != null && postData != null) { @@ -115,7 +115,8 @@ data class EditorPreloadList private constructor( } /** Builds the API path for fetching a specific post. */ - private fun buildPostPath(id: Int): String = "/wp/v2/posts/$id?context=edit" + private fun buildPostPath(id: Int): String = + "/${postType.restNamespace}/${postType.restBase}/$id?context=edit" /** Builds the API path for fetching a post type's schema. */ private fun buildPostTypePath(type: String): String = "/wp/v2/types/$type?context=edit" diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt index cc1288d2c..4ec3ff1e2 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/GBKitGlobal.kt @@ -113,9 +113,9 @@ data class GBKitGlobal( locale = configuration.locale ?: "en", post = Post( id = postId ?: -1, - type = configuration.postType, - restBase = restBaseFor(configuration.postType), - restNamespace = "wp/v2", + type = configuration.postType.postType, + restBase = configuration.postType.restBase, + restNamespace = configuration.postType.restNamespace, status = configuration.postStatus, title = configuration.title.encodeForEditor(), content = configuration.content.encodeForEditor() @@ -132,18 +132,6 @@ data class GBKitGlobal( } ) } - - /** - * Maps a post type slug to its WordPress REST API base path. - * - * Defaults to pluralizing the slug for unknown types (e.g., `product` → `products`), - * which matches the WordPress convention for most post types. - */ - private fun restBaseFor(postType: String): String = when (postType) { - "post" -> "posts" - "page" -> "pages" - else -> if (postType.endsWith("s")) postType else "${postType}s" - } } /** diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/PostTypeDetails.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/PostTypeDetails.kt new file mode 100644 index 000000000..0625bb9a4 --- /dev/null +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/model/PostTypeDetails.kt @@ -0,0 +1,44 @@ +package org.wordpress.gutenberg.model + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +/** + * Details about a WordPress post type needed for REST API interactions. + * + * This class encapsulates the information required to construct correct REST API + * endpoints for different post types. WordPress custom post types (like WooCommerce + * products) have their own REST endpoints that differ from the standard `/wp/v2/posts/`. + * + * For standard post types, use the provided constants: + * ```kotlin + * val config = EditorConfiguration.builder(siteURL, siteApiRoot, PostTypeDetails.post) + * val config = EditorConfiguration.builder(siteURL, siteApiRoot, PostTypeDetails.page) + * ``` + * + * For custom post types, create an instance with the appropriate REST base: + * ```kotlin + * val productType = PostTypeDetails(postType = "product", restBase = "products") + * val config = EditorConfiguration.builder(siteURL, siteApiRoot, productType) + * ``` + * + * @property postType The post type slug (e.g., "post", "page", "product"). + * @property restBase The REST API base path for this post type (e.g., "posts", "pages", "products"). + * @property restNamespace The REST API namespace for this post type (e.g., "wp/v2"). Defaults to "wp/v2". + */ +@Parcelize +@Serializable +data class PostTypeDetails( + val postType: String, + val restBase: String, + val restNamespace: String = "wp/v2" +) : Parcelable { + companion object { + /** Standard WordPress post type. */ + val post = PostTypeDetails(postType = "post", restBase = "posts") + + /** Standard WordPress page type. */ + val page = PostTypeDetails(postType = "page", restBase = "pages") + } +} diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt index 507c36351..283d3990d 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/services/EditorService.kt @@ -285,7 +285,7 @@ class EditorService( return coroutineScope { val activeThemeDeferred = async { prepareActiveTheme() } val settingsOptionsDeferred = async { prepareSettingsOptions() } - val postTypeDataDeferred = async { preparePostType(configuration.postType) } + val postTypeDataDeferred = async { preparePostType(configuration.postType.postType) } val postTypesDataDeferred = async { preparePostTypes() } val postId = configuration.postId diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorAssetsLibraryTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorAssetsLibraryTest.kt index e31247a67..02d4f753c 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorAssetsLibraryTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/EditorAssetsLibraryTest.kt @@ -15,6 +15,7 @@ import org.wordpress.gutenberg.model.EditorCachePolicy import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorProgress import org.wordpress.gutenberg.model.LocalEditorAssetManifest +import org.wordpress.gutenberg.model.PostTypeDetails import org.wordpress.gutenberg.model.TestResources import org.wordpress.gutenberg.model.http.EditorHTTPHeaders import org.wordpress.gutenberg.model.http.EditorHttpMethod @@ -37,7 +38,7 @@ class EditorAssetsLibraryTest { val testConfiguration: EditorConfiguration = EditorConfiguration.builder( TEST_SITE_URL, TEST_API_ROOT, - "post" + PostTypeDetails.post ) .setPlugins(true) .setThemeStyles(true) @@ -46,7 +47,7 @@ class EditorAssetsLibraryTest { val minimalConfiguration: EditorConfiguration = EditorConfiguration.builder( TEST_SITE_URL, TEST_API_ROOT, - "post" + PostTypeDetails.post ) .setPlugins(false) .setThemeStyles(false) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt index fa9a742a9..40ee19ff6 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/RESTAPIRepositoryTest.kt @@ -12,6 +12,7 @@ import org.junit.rules.TemporaryFolder import org.wordpress.gutenberg.model.EditorCachePolicy import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorSettings +import org.wordpress.gutenberg.model.PostTypeDetails import org.wordpress.gutenberg.model.http.EditorHTTPHeaders import org.wordpress.gutenberg.model.http.EditorHttpMethod import org.wordpress.gutenberg.stores.EditorURLCache @@ -42,7 +43,7 @@ class RESTAPIRepositoryTest { siteApiRoot: String = TEST_API_ROOT, siteApiNamespace: Array = arrayOf() ): EditorConfiguration { - return EditorConfiguration.builder(TEST_SITE_URL, siteApiRoot, "post") + return EditorConfiguration.builder(TEST_SITE_URL, siteApiRoot, PostTypeDetails.post) .setPlugins(shouldUsePlugins) .setThemeStyles(shouldUseThemeStyles) .setAuthHeader("Bearer test-token") @@ -290,7 +291,7 @@ class RESTAPIRepositoryTest { val configuration = EditorConfiguration.builder( TEST_SITE_URL, "https://example.com/wp-json", // No trailing slash - "post" + PostTypeDetails.post ).setPlugins(true).setThemeStyles(true).setAuthHeader("Bearer test").build() val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) @@ -319,7 +320,7 @@ class RESTAPIRepositoryTest { val configuration = EditorConfiguration.builder( TEST_SITE_URL, "https://example.com/wp-json/", // With trailing slash - "post" + PostTypeDetails.post ).setPlugins(true).setThemeStyles(true).setAuthHeader("Bearer test").build() val cache = EditorURLCache(cacheRoot, EditorCachePolicy.Always) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt index c0052980b..48e5a8ad8 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorConfigurationTest.kt @@ -15,7 +15,7 @@ class EditorConfigurationBuilderTest { companion object { const val TEST_SITE_URL = "https://example.com" const val TEST_API_ROOT = "https://example.com/wp-json" - const val TEST_POST_TYPE = "post" + val TEST_POST_TYPE = PostTypeDetails.post } private fun builder() = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, TEST_POST_TYPE) @@ -100,10 +100,10 @@ class EditorConfigurationBuilderTest { @Test fun `setPostType updates postType`() { val config = builder() - .setPostType("page") + .setPostType(PostTypeDetails.page) .build() - assertEquals("page", config.postType) + assertEquals(PostTypeDetails.page, config.postType) } @Test @@ -310,7 +310,7 @@ class EditorConfigurationBuilderTest { .setTitle("Round Trip Title") .setContent("

Round trip content

") .setPostId(999u) - .setPostType("page") + .setPostType(PostTypeDetails.page) .setPostStatus("draft") .setThemeStyles(true) .setPlugins(true) @@ -387,7 +387,7 @@ class EditorConfigurationBuilderTest { fun `toBuilder preserves nullable values when set`() { val original = builder() .setPostId(123u) - .setPostType("post") + .setPostType(PostTypeDetails.post) .setPostStatus("publish") .setEditorSettings("""{"test":true}""") .setEditorAssetsEndpoint("https://example.com/assets") @@ -396,7 +396,7 @@ class EditorConfigurationBuilderTest { val rebuilt = original.toBuilder().build() assertEquals(123u, rebuilt.postId) - assertEquals("post", rebuilt.postType) + assertEquals(PostTypeDetails.post, rebuilt.postType) assertEquals("publish", rebuilt.postStatus) assertEquals("""{"test":true}""", rebuilt.editorSettings) assertEquals("https://example.com/assets", rebuilt.editorAssetsEndpoint) @@ -480,7 +480,7 @@ class EditorConfigurationBuilderTest { assertTrue(config.enableOfflineMode) assertEquals("https://example.com", config.siteURL) assertEquals("https://example.com/wp-json/", config.siteApiRoot) - assertEquals("post", config.postType) + assertEquals(PostTypeDetails.post, config.postType) } } @@ -489,7 +489,7 @@ class EditorConfigurationTest { companion object { const val TEST_SITE_URL = "https://example.com" const val TEST_API_ROOT = "https://example.com/wp-json" - const val TEST_POST_TYPE = "post" + val TEST_POST_TYPE = PostTypeDetails.post } private fun builder() = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, TEST_POST_TYPE) @@ -553,10 +553,10 @@ class EditorConfigurationTest { @Test fun `Configurations with different postType are not equal`() { - val config1 = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, "post") + val config1 = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, PostTypeDetails.post) .build() - val config2 = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, "page") + val config2 = EditorConfiguration.builder(TEST_SITE_URL, TEST_API_ROOT, PostTypeDetails.page) .build() assertNotEquals(config1, config2) @@ -850,11 +850,11 @@ class EditorConfigurationTest { @Test fun `test EditorConfiguration builder sets all properties correctly`() { - val config = EditorConfiguration.builder("https://example.com", "https://example.com/wp-json", "post") + val config = EditorConfiguration.builder("https://example.com", "https://example.com/wp-json", PostTypeDetails.post) .setTitle("Test Title") .setContent("Test Content") .setPostId(123u) - .setPostType("post") + .setPostType(PostTypeDetails.post) .setPostStatus("publish") .setThemeStyles(true) .setPlugins(true) @@ -877,7 +877,7 @@ class EditorConfigurationTest { assertEquals("Test Title", config.title) assertEquals("Test Content", config.content) assertEquals(123u, config.postId) - assertEquals("post", config.postType) + assertEquals(PostTypeDetails.post, config.postType) assertEquals("publish", config.postStatus) assertTrue(config.themeStyles) assertTrue(config.plugins) diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorPreloadListTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorPreloadListTest.kt index 6d8dc5f03..2d82c2194 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorPreloadListTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/EditorPreloadListTest.kt @@ -37,7 +37,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = 42, postData = postData, - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -51,14 +51,14 @@ class EditorPreloadListTest { @Test fun `initializes with custom post type`() { val preloadList = EditorPreloadList( - postType = "page", + postType = PostTypeDetails.page, postTypeData = makeResponse(), postTypesData = makeResponse(), activeThemeData = makeResponse(), settingsOptionsData = makeResponse() ) - assertEquals("page", preloadList.postType) + assertEquals(PostTypeDetails.page, preloadList.postType) } // MARK: - build() Exact Output Tests @@ -66,7 +66,7 @@ class EditorPreloadListTest { @Test fun `build produces exact JSON for post type`() { val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = """{"slug":"post"}"""), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -81,7 +81,7 @@ class EditorPreloadListTest { @Test fun `build produces exact JSON for page type`() { val preloadList = EditorPreloadList( - postType = "page", + postType = PostTypeDetails.page, postTypeData = makeResponse(data = """{"slug":"page"}"""), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -98,7 +98,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = 123, postData = makeResponse(data = """{"id":123,"title":"Test"}"""), - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = "{}"), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -114,7 +114,7 @@ class EditorPreloadListTest { fun `build produces exact JSON with Accept header`() { val headers = EditorHTTPHeaders(mapOf("Accept" to "application/json")) val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = "{}", headers = headers), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -130,7 +130,7 @@ class EditorPreloadListTest { fun `build produces exact JSON with Link header`() { val headers = EditorHTTPHeaders(mapOf("Link" to """; rel="next"""")) val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = "{}", headers = headers), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -148,7 +148,7 @@ class EditorPreloadListTest { mapOf("Link" to "", "Accept" to "application/json") ) val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = "{}", headers = headers), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -165,7 +165,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = null, postData = null, - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -182,7 +182,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = 42, postData = null, - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -197,7 +197,7 @@ class EditorPreloadListTest { @Test fun `build produces exact JSON for custom_post_type`() { val preloadList = EditorPreloadList( - postType = "custom_post_type", + postType = PostTypeDetails(postType = "custom_post_type", restBase = "custom_post_type"), postTypeData = makeResponse(), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -214,7 +214,7 @@ class EditorPreloadListTest { @Test fun `build(formatted = false) returns valid JSON string`() { val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = """{"slug":"post"}"""), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -229,7 +229,7 @@ class EditorPreloadListTest { @Test fun `build(formatted = true) returns valid JSON string`() { val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = """{"slug":"post"}"""), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -244,7 +244,7 @@ class EditorPreloadListTest { @Test fun `build(formatted = true) produces pretty-printed JSON`() { val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = "{}"), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -283,7 +283,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = 123, postData = makeResponse(data = """{"id":123,"title":"Test"}"""), - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = """{"slug":"post"}"""), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -304,7 +304,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = 123, postData = makeResponse(data = """{"id":123,"title":"Test"}"""), - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(data = """{"slug":"post"}"""), postTypesData = makeResponse(data = "{}"), activeThemeData = makeResponse(data = "[]"), @@ -326,7 +326,7 @@ class EditorPreloadListTest { mapOf("Accept" to "application/json", "Content-Type" to "application/json") ) val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(headers = headers), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -343,7 +343,7 @@ class EditorPreloadListTest { mapOf("Accept" to "application/json", "X-Custom" to "value") ) val preloadList = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(headers = headers), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -362,7 +362,7 @@ class EditorPreloadListTest { val preloadList = EditorPreloadList( postID = 1, postData = makeResponse(headers = headers), - postType = "post", + postType = PostTypeDetails.post, postTypeData = makeResponse(), postTypesData = makeResponse(), activeThemeData = makeResponse(), @@ -379,14 +379,14 @@ class EditorPreloadListTest { fun `two preload lists with same data are equal`() { val response = makeResponse(data = """{"test":true}""") val preloadList1 = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = response, postTypesData = response, activeThemeData = response, settingsOptionsData = response ) val preloadList2 = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = response, postTypesData = response, activeThemeData = response, @@ -400,14 +400,14 @@ class EditorPreloadListTest { fun `preload lists with different post types are not equal`() { val response = makeResponse() val preloadList1 = EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = response, postTypesData = response, activeThemeData = response, settingsOptionsData = response ) val preloadList2 = EditorPreloadList( - postType = "page", + postType = PostTypeDetails.page, postTypeData = response, postTypesData = response, activeThemeData = response, @@ -423,7 +423,7 @@ class EditorPreloadListTest { val preloadList1 = EditorPreloadList( postID = 1, postData = response, - postType = "post", + postType = PostTypeDetails.post, postTypeData = response, postTypesData = response, activeThemeData = response, @@ -432,7 +432,7 @@ class EditorPreloadListTest { val preloadList2 = EditorPreloadList( postID = 2, postData = response, - postType = "post", + postType = PostTypeDetails.post, postTypeData = response, postTypesData = response, activeThemeData = response, diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt index a755ca100..aa249247d 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/model/GBKitGlobalTest.kt @@ -27,7 +27,7 @@ class GBKitGlobalTest { private fun makePreloadList(): EditorPreloadList { return EditorPreloadList( - postType = "post", + postType = PostTypeDetails.post, postTypeData = EditorURLResponse(data = "{}", responseHeaders = EditorHTTPHeaders()), postTypesData = EditorURLResponse(data = "{}", responseHeaders = EditorHTTPHeaders()), activeThemeData = EditorURLResponse(data = "{}", responseHeaders = EditorHTTPHeaders()), @@ -40,7 +40,7 @@ class GBKitGlobalTest { title: String? = null, content: String? = null, siteURL: String = TEST_SITE_URL, - postType: String = "post", + postType: PostTypeDetails = PostTypeDetails.post, shouldUsePlugins: Boolean = true, shouldUseThemeStyles: Boolean = true ): EditorConfiguration { diff --git a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt index 81afaf959..7e7643505 100644 --- a/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt +++ b/android/Gutenberg/src/test/java/org/wordpress/gutenberg/services/EditorServiceTest.kt @@ -12,6 +12,7 @@ import org.wordpress.gutenberg.EditorHTTPClientDownloadResponse import org.wordpress.gutenberg.EditorHTTPClientProtocol import org.wordpress.gutenberg.EditorHTTPClientResponse import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.PostTypeDetails import org.wordpress.gutenberg.model.http.EditorHTTPHeaders import org.wordpress.gutenberg.model.http.EditorHttpMethod import java.io.File @@ -35,7 +36,7 @@ class EditorServiceTest { val testConfiguration: EditorConfiguration = EditorConfiguration.builder( TEST_SITE_URL, TEST_API_ROOT, - "post" + PostTypeDetails.post ) .setPlugins(true) .setThemeStyles(true) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 5e25d16ad..6d701adb9 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -370,10 +370,10 @@ private suspend fun persistPost( ?: throw IllegalStateException("Account not found") val client = app.createApiClient(account) - val endpointType = when (configuration.postType) { + val endpointType = when (configuration.postType.postType) { "page" -> PostEndpointType.Pages "post" -> PostEndpointType.Posts - else -> PostEndpointType.Custom(configuration.postType) + else -> PostEndpointType.Custom(configuration.postType.postType) } val params = PostUpdateParams( diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt index bb1a6c81f..bdbf4e607 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt @@ -13,8 +13,23 @@ import kotlinx.coroutines.launch import org.wordpress.gutenberg.model.EditorCachePolicy import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependencies +import org.wordpress.gutenberg.model.PostTypeDetails import org.wordpress.gutenberg.services.EditorService +/** + * Maps a post type slug to its [PostTypeDetails]. + * + * This is a temporary placeholder until the demo fetches real `restBase`/ + * `restNamespace` from the WordPress REST API. For the standard `post` and + * `page` slugs we know the correct values; anything else gets a naive + * pluralization fallback that's likely wrong for real CPTs. + */ +private fun slugToPostTypeDetails(slug: String): PostTypeDetails = when (slug) { + "post" -> PostTypeDetails.post + "page" -> PostTypeDetails.page + else -> PostTypeDetails(postType = slug, restBase = if (slug.endsWith("s")) slug else "${slug}s") +} + data class SitePreparationUiState( val enableNativeInserter: Boolean = true, val enableNetworkLogging: Boolean = false, @@ -198,7 +213,7 @@ class SitePreparationViewModel( return EditorConfiguration.builder( siteURL = "https://example.com", siteApiRoot = "https://example.com", - postType = "post" + postType = PostTypeDetails.post ) .setPlugins(false) .setSiteApiNamespace(arrayOf()) @@ -234,7 +249,7 @@ class SitePreparationViewModel( return EditorConfiguration.builder( siteURL = config.siteUrl, siteApiRoot = siteApiRoot, - postType = _uiState.value.postType + postType = slugToPostTypeDetails(_uiState.value.postType) ) .setPlugins(capabilities.supportsPlugins) .setThemeStyles(capabilities.supportsThemeStyles) @@ -267,7 +282,7 @@ class SitePreparationViewModel( return baseConfig.toBuilder() .setEnableNetworkLogging(_uiState.value.enableNetworkLogging) // TODO: Add setNativeInserterEnabled when it's available in EditorConfiguration - .setPostType(_uiState.value.postType) + .setPostType(slugToPostTypeDetails(_uiState.value.postType)) .build() } } From 6584ac2f736dbec3cf61c414becf5a413314a4e3 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 11:36:09 -0400 Subject: [PATCH 16/28] fix(android): build post fetch URL from PostTypeDetails `RESTAPIRepository.buildPostUrl` was hardcoded to `/wp/v2/posts/$id`, so opening a `page` (or any non-post type) hit the wrong endpoint and the WordPress REST API returned `rest_post_invalid_id`. Use the configuration's `restNamespace`/`restBase` instead, matching iOS (`RESTAPIRepository.swift:110-120`). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../main/java/org/wordpress/gutenberg/RESTAPIRepository.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt index 7a3eee0cc..692ca79ca 100644 --- a/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt +++ b/android/Gutenberg/src/main/java/org/wordpress/gutenberg/RESTAPIRepository.kt @@ -75,7 +75,9 @@ class RESTAPIRepository( } private fun buildPostUrl(id: Int): String { - return buildNamespacedUrl("/wp/v2/posts/$id?context=edit") + val restNamespace = configuration.postType.restNamespace + val restBase = configuration.postType.restBase + return buildNamespacedUrl("/$restNamespace/$restBase/$id?context=edit") } // MARK: Editor Settings From a3bd3a865a03267263f426216bcef577e55c726a Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 11:53:41 -0400 Subject: [PATCH 17/28] feat(demo-android): fetch real PostTypeDetails from REST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the hardcoded `post`/`page` picker with a dynamic list fetched via `wordpress-rs`'s `postTypes.listWithEditContext()`, mirroring the iOS `SitePreparationView.loadPostTypes()` flow. The view-model now stores `postTypes: List` and `selectedPostType: PostTypeDetails?`, deletes the `slugToPostTypeDetails` placeholder, and threads the selected type straight into `EditorConfiguration` and `PostsListActivity`. The post-type filter matches iOS: always include `post`/`page`, include custom types only when `viewable && visibility.showUi`, exclude all internal built-ins (`Attachment`, `WpBlock`, etc.). `PostsListActivity` now takes a `PostTypeDetails` extra (Parcelable) instead of a string slug, and dispatches the endpoint type from `postType.postType` so the existing `Posts`/`Pages`/`Custom` `when` keeps working for the standard cases. The picker shows "Loading post types…" while the list is empty and falls back to `PostTypeDetails.post` if the REST call fails so the editor can still launch. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../example/gutenbergkit/PostsListActivity.kt | 22 +++-- .../gutenbergkit/SitePreparationActivity.kt | 68 ++++++++------ .../gutenbergkit/SitePreparationViewModel.kt | 92 +++++++++++++++---- 3 files changed, 126 insertions(+), 56 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt index bcbe1bca4..6923aee11 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt @@ -48,6 +48,7 @@ import kotlinx.coroutines.launch import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.model.EditorDependenciesSerializer +import org.wordpress.gutenberg.model.PostTypeDetails import rs.wordpress.api.kotlin.WpRequestResult import uniffi.wp_api.AnyPostWithEditContext import uniffi.wp_api.PostEndpointType @@ -65,18 +66,18 @@ class PostsListActivity : ComponentActivity() { companion object { const val EXTRA_ACCOUNT_ID = "account_id" - const val EXTRA_POST_ENDPOINT = "post_endpoint" + const val EXTRA_POST_TYPE = "post_type" fun createIntent( context: Context, accountId: ULong, - postEndpoint: String, + postType: PostTypeDetails, configuration: EditorConfiguration, dependencies: EditorDependencies? ): Intent { return Intent(context, PostsListActivity::class.java).apply { putExtra(EXTRA_ACCOUNT_ID, accountId.toLong()) - putExtra(EXTRA_POST_ENDPOINT, postEndpoint) + putExtra(EXTRA_POST_TYPE, postType) putExtra(MainActivity.EXTRA_CONFIGURATION, configuration) if (dependencies != null) { val filePath = EditorDependenciesSerializer.writeToDisk(context, dependencies) @@ -91,7 +92,8 @@ class PostsListActivity : ComponentActivity() { enableEdgeToEdge() val accountId = intent.getLongExtra(EXTRA_ACCOUNT_ID, -1L).takeIf { it >= 0 }?.toULong() - val postEndpoint = intent.getStringExtra(EXTRA_POST_ENDPOINT) ?: "post" + val postType = intent.getParcelableExtra(EXTRA_POST_TYPE, PostTypeDetails::class.java) + ?: PostTypeDetails.post val configuration = intent.getParcelableExtra(MainActivity.EXTRA_CONFIGURATION, EditorConfiguration::class.java) val dependenciesPath = intent.getStringExtra(EditorActivity.EXTRA_DEPENDENCIES_PATH) @@ -102,7 +104,7 @@ class PostsListActivity : ComponentActivity() { val viewModel = ViewModelProvider( this, - PostsListViewModelFactory(application, accountId, postEndpoint) + PostsListViewModelFactory(application, accountId, postType) )[PostsListViewModel::class.java] setContent { @@ -150,7 +152,7 @@ data class PostsListUiState( class PostsListViewModel( application: android.app.Application, private val accountId: ULong, - private val postEndpoint: String + private val postType: PostTypeDetails ) : AndroidViewModel(application) { private val _uiState = MutableStateFlow(PostsListUiState()) @@ -168,10 +170,10 @@ class PostsListViewModel( ?: throw IllegalStateException("Account not found") val client = app.createApiClient(account) - val endpointType = when (postEndpoint) { + val endpointType = when (postType.postType) { "page" -> PostEndpointType.Pages "post" -> PostEndpointType.Posts - else -> PostEndpointType.Custom(postEndpoint) + else -> PostEndpointType.Custom(postType.postType) } val all = mutableListOf() @@ -210,12 +212,12 @@ class PostsListViewModel( class PostsListViewModelFactory( private val application: android.app.Application, private val accountId: ULong, - private val postEndpoint: String + private val postType: PostTypeDetails ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(PostsListViewModel::class.java)) { - return PostsListViewModel(application, accountId, postEndpoint) as T + return PostsListViewModel(application, accountId, postType) as T } throw IllegalArgumentException("Unknown ViewModel class") } diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt index ac93c3521..7915d68e2 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt @@ -55,6 +55,7 @@ import androidx.lifecycle.ViewModelProvider import com.example.gutenbergkit.ui.theme.AppTheme import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependenciesSerializer +import org.wordpress.gutenberg.model.PostTypeDetails class SitePreparationActivity : ComponentActivity() { @@ -179,7 +180,7 @@ class SitePreparationActivity : ComponentActivity() { private fun launchPostsList( accountId: ULong, - postType: String, + postType: PostTypeDetails, configuration: EditorConfiguration, dependencies: org.wordpress.gutenberg.model.EditorDependencies? ) { @@ -196,7 +197,7 @@ fun SitePreparationScreen( accountId: ULong?, onClose: () -> Unit, onStartEditor: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?) -> Unit, - onBrowsePosts: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?, String) -> Unit + onBrowsePosts: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?, PostTypeDetails) -> Unit ) { val uiState by viewModel.uiState.collectAsState() @@ -242,8 +243,11 @@ fun SitePreparationScreen( viewModel = viewModel, accountId = accountId, onBrowsePosts = { - viewModel.buildConfiguration()?.let { config -> - onBrowsePosts(config, uiState.editorDependencies, uiState.postType) + val selectedPostType = uiState.selectedPostType + if (selectedPostType != null) { + viewModel.buildConfiguration()?.let { config -> + onBrowsePosts(config, uiState.editorDependencies, selectedPostType) + } } }, modifier = Modifier.padding(innerPadding) @@ -295,7 +299,8 @@ private fun LoadedView( onEnableNativeInserterChange = viewModel::setEnableNativeInserter, enableNetworkLogging = uiState.enableNetworkLogging, onEnableNetworkLoggingChange = viewModel::setEnableNetworkLogging, - postType = uiState.postType, + postTypes = uiState.postTypes, + selectedPostType = uiState.selectedPostType, onPostTypeChange = viewModel::setPostType, showBrowseButton = accountId != null, onBrowsePosts = onBrowsePosts @@ -378,8 +383,9 @@ private fun FeatureConfigurationCard( onEnableNativeInserterChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, - postType: String, - onPostTypeChange: (String) -> Unit, + postTypes: List, + selectedPostType: PostTypeDetails?, + onPostTypeChange: (PostTypeDetails) -> Unit, showBrowseButton: Boolean = false, onBrowsePosts: () -> Unit = {} ) { @@ -427,27 +433,35 @@ private fun FeatureConfigurationCard( style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(bottom = 8.dp) ) - Column(modifier = Modifier.selectableGroup()) { - listOf("post" to "Post", "page" to "Page").forEach { (value, label) -> - Row( - modifier = Modifier - .fillMaxWidth() - .selectable( - selected = postType == value, - onClick = { onPostTypeChange(value) }, - role = Role.RadioButton + if (postTypes.isEmpty()) { + Text( + text = "Loading post types…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + Column(modifier = Modifier.selectableGroup()) { + postTypes.forEach { postType -> + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = postType == selectedPostType, + onClick = { onPostTypeChange(postType) }, + role = Role.RadioButton + ) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = postType == selectedPostType, + onClick = null ) - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - RadioButton( - selected = postType == value, - onClick = null - ) - Text( - text = label, - modifier = Modifier.padding(start = 8.dp) - ) + Text( + text = postType.postType.replaceFirstChar { it.uppercase() }, + modifier = Modifier.padding(start = 8.dp) + ) + } } } } diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt index bdbf4e607..32aa1e232 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt @@ -15,25 +15,16 @@ import org.wordpress.gutenberg.model.EditorConfiguration import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.model.PostTypeDetails import org.wordpress.gutenberg.services.EditorService - -/** - * Maps a post type slug to its [PostTypeDetails]. - * - * This is a temporary placeholder until the demo fetches real `restBase`/ - * `restNamespace` from the WordPress REST API. For the standard `post` and - * `page` slugs we know the correct values; anything else gets a naive - * pluralization fallback that's likely wrong for real CPTs. - */ -private fun slugToPostTypeDetails(slug: String): PostTypeDetails = when (slug) { - "post" -> PostTypeDetails.post - "page" -> PostTypeDetails.page - else -> PostTypeDetails(postType = slug, restBase = if (slug.endsWith("s")) slug else "${slug}s") -} +import rs.wordpress.api.kotlin.WpRequestResult +import uniffi.wp_api.PostType as WpPostType data class SitePreparationUiState( val enableNativeInserter: Boolean = true, val enableNetworkLogging: Boolean = false, - val postType: String = "post", + /** All viewable post types fetched from the site, or empty while loading. */ + val postTypes: List = emptyList(), + /** The post type currently selected in the picker. Null until [postTypes] loads. */ + val selectedPostType: PostTypeDetails? = null, val cacheBundleCount: Int? = null, val isLoading: Boolean = false, val error: String? = null, @@ -98,8 +89,8 @@ class SitePreparationViewModel( _uiState.update { it.copy(enableNetworkLogging = enabled) } } - fun setPostType(postType: String) { - _uiState.update { it.copy(postType = postType) } + fun setPostType(postType: PostTypeDetails) { + _uiState.update { it.copy(selectedPostType = postType) } } fun prepareEditor() { @@ -210,6 +201,13 @@ class SitePreparationViewModel( } private fun createBundledConfiguration(): EditorConfiguration { + // Bundled offline editor: only the standard `post` type is meaningful. + _uiState.update { + it.copy( + postTypes = listOf(PostTypeDetails.post), + selectedPostType = PostTypeDetails.post + ) + } return EditorConfiguration.builder( siteURL = "https://example.com", siteApiRoot = "https://example.com", @@ -246,10 +244,22 @@ class SitePreparationViewModel( arrayOf() } + // Fetch the site's post types and pick the first one as the default + // selection. Falls back to `PostTypeDetails.post` if the call fails so + // the editor can still launch with a sensible default. + val postTypes = loadPostTypes(config) ?: listOf(PostTypeDetails.post) + val defaultPostType = postTypes.first() + _uiState.update { + it.copy( + postTypes = postTypes, + selectedPostType = defaultPostType + ) + } + return EditorConfiguration.builder( siteURL = config.siteUrl, siteApiRoot = siteApiRoot, - postType = slugToPostTypeDetails(_uiState.value.postType) + postType = defaultPostType ) .setPlugins(capabilities.supportsPlugins) .setThemeStyles(capabilities.supportsThemeStyles) @@ -265,6 +275,49 @@ class SitePreparationViewModel( .build() } + /** + * Fetches the site's post types from the WordPress REST API and returns the + * subset relevant to the editor picker. + * + * Mirrors the iOS [SitePreparationView.loadPostTypes] filter: + * - always include the standard `post` and `page` types + * - include custom types only when they are `viewable` and have UI visibility + * - exclude all internal types (`Attachment`, `WpBlock`, etc.) + * + * Returns `null` if the post types could not be fetched (e.g. account not + * found, network error). Callers fall back to a sensible default. + */ + private suspend fun loadPostTypes( + config: ConfigurationItem.ConfiguredEditor + ): List? { + val app = getApplication() + val account = app.accountRepository.all().firstOrNull { it.id() == config.accountId } + ?: return null + val client = app.createApiClient(account) + + val result = client.request { builder -> + builder.postTypes().listWithEditContext() + } + if (result !is WpRequestResult.Success) return null + + return result.response.data.postTypes + .filter { (type, details) -> + when (type) { + is WpPostType.Post, is WpPostType.Page -> true + is WpPostType.Custom -> details.viewable && details.visibility.showUi + else -> false + } + } + .map { (_, details) -> + PostTypeDetails( + postType = details.slug, + restBase = details.restBase, + restNamespace = details.restNamespace + ) + } + .sortedBy { it.postType } + } + /** * Extracts the WP.com site ID from a namespace-specific API root URL. * Returns null if the URL is not a WP.com API root. @@ -278,11 +331,12 @@ class SitePreparationViewModel( fun buildConfiguration(): EditorConfiguration? { val baseConfig = _uiState.value.editorConfiguration ?: return null + val selectedPostType = _uiState.value.selectedPostType ?: return null return baseConfig.toBuilder() .setEnableNetworkLogging(_uiState.value.enableNetworkLogging) // TODO: Add setNativeInserterEnabled when it's available in EditorConfiguration - .setPostType(slugToPostTypeDetails(_uiState.value.postType)) + .setPostType(selectedPostType) .build() } } From 0dbcff66dc9c319e3356a30326b68eb2a0fc3cc2 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 11:59:25 -0400 Subject: [PATCH 18/28] fix(demo): always persist content even when savePost lifecycle fails A misbehaving plugin that throws during the editor store's save lifecycle must not block the user from saving their work. Both demo apps now log the lifecycle failure as a warning and proceed to read and persist the post content via the REST API regardless. Persist failures (the part the user actually cares about) now surface to the UI: an Alert on iOS, a Toast on Android. Lifecycle failures remain log-only since the user has no recourse and the persist still succeeds. Aligns log strings between platforms so cross-platform debugging is uniform: "editor.savePost() completed", "editor.savePost() lifecycle failed; persisting anyway", "Post N persisted via REST API", "Failed to persist post N". Co-Authored-By: Claude Opus 4.6 (1M context) --- .../example/gutenbergkit/EditorActivity.kt | 33 +++++++---- ios/Demo-iOS/Sources/Views/EditorView.swift | 56 +++++++++++++------ 2 files changed, 61 insertions(+), 28 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 6d701adb9..f20762c0a 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -7,6 +7,7 @@ import android.webkit.WebView import android.content.pm.ApplicationInfo import android.os.Build import android.util.Log +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent @@ -198,13 +199,16 @@ fun EditorScreen( isSaving = true saveScope.launch { try { - persistPost( + val errorMessage = persistPost( context = context, view = view, configuration = configuration, accountId = accountId, postId = postId ) + if (errorMessage != null) { + Toast.makeText(context, errorMessage, Toast.LENGTH_LONG).show() + } } finally { isSaving = false } @@ -339,7 +343,9 @@ private suspend fun GutenbergView.savePostAwait(): Boolean = * Reads the latest title/content from the editor and PUTs it to the WordPress REST API. * * Triggers [GutenbergView.savePost] first so plugin side-effects (e.g., VideoPress - * syncing metadata) settle before the content is read and persisted. + * syncing metadata) settle before the content is read and persisted. A lifecycle + * failure must NOT block the user from saving their work — the warning is logged + * and persistence proceeds anyway. */ private suspend fun persistPost( context: android.content.Context, @@ -347,13 +353,17 @@ private suspend fun persistPost( configuration: EditorConfiguration, accountId: ULong, postId: UInt -) { - try { - val saveSucceeded = view.savePostAwait() - if (!saveSucceeded) { - Log.w("EditorActivity", "editor.savePost() reported failure; persisting anyway") - } +): String? { + // 1. Trigger the editor store save lifecycle so plugins fire side-effects. + val saveSucceeded = view.savePostAwait() + if (saveSucceeded) { + Log.i("EditorActivity", "editor.savePost() completed — editor store save lifecycle fired") + } else { + Log.w("EditorActivity", "editor.savePost() lifecycle failed; persisting anyway") + } + // 2. Persist post content via REST API. + return try { val titleAndContent = suspendCancellableCoroutine> { cont -> view.getTitleAndContent( originalContent = configuration.content, @@ -367,7 +377,7 @@ private suspend fun persistPost( val app = context.applicationContext as GutenbergKitApplication val account = app.accountRepository.all().firstOrNull { it.id() == accountId } - ?: throw IllegalStateException("Account not found") + ?: error("Account not found") val client = app.createApiClient(account) val endpointType = when (configuration.postType.postType) { @@ -393,12 +403,15 @@ private suspend fun persistPost( when (result) { is WpRequestResult.Success -> { Log.i("EditorActivity", "Post $postId persisted via REST API") + null } else -> { Log.e("EditorActivity", "Failed to persist post $postId: $result") + "Failed to save post" } } } catch (e: Exception) { - Log.e("EditorActivity", "Save failed", e) + Log.e("EditorActivity", "Failed to persist post $postId", e) + "Failed to save post: ${e.message ?: "unknown error"}" } } diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index 9aca2d477..ff81a758f 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -25,6 +25,18 @@ struct EditorView: View { viewModel: viewModel ) .toolbar { toolbar } + .alert( + "Save failed", + isPresented: Binding( + get: { viewModel.errorMessage != nil }, + set: { if !$0 { viewModel.errorMessage = nil } } + ), + presenting: viewModel.errorMessage + ) { _ in + Button("OK", role: .cancel) {} + } message: { message in + Text(message) + } } @ToolbarContentBuilder @@ -128,28 +140,35 @@ private struct _EditorView: UIViewControllerRepresentable { viewModel.hasPostID = configuration.postID != nil - viewModel.saveHandler = { [weak viewController, configuration, apiClient] in + viewModel.saveHandler = { [weak viewController, weak viewModel, configuration, apiClient] in guard let viewController else { return } + + // 1. Trigger the editor store save lifecycle so plugins fire side-effects. + // A lifecycle failure must NOT block the user from saving their work — + // log the warning and proceed to persist content anyway. do { - // 1. Trigger the editor store save lifecycle so plugins fire side-effects try await viewController.savePost() - print("savePost() completed — editor store save lifecycle fired") - - // 2. Persist post content via REST API - if let apiClient, let postID = configuration.postID { - let titleAndContent = try await viewController.getTitleAndContent() - let params = PostUpdateParams(title: .some(titleAndContent.title), content: .some(titleAndContent.content), meta: nil) - let endpointType: PostEndpointType = configuration.postType.postType == "page" ? .pages : .posts - _ = try await apiClient.posts.updateCancellation( - postEndpointType: endpointType, - postId: Int64(postID), - params: params, - context: nil - ) - print("Post \(postID) persisted via REST API") - } + print("editor.savePost() completed — editor store save lifecycle fired") + } catch { + print("editor.savePost() lifecycle failed; persisting anyway: \(error)") + } + + // 2. Persist post content via REST API. + guard let apiClient, let postID = configuration.postID else { return } + do { + let titleAndContent = try await viewController.getTitleAndContent() + let params = PostUpdateParams(title: .some(titleAndContent.title), content: .some(titleAndContent.content), meta: nil) + let endpointType: PostEndpointType = configuration.postType.postType == "page" ? .pages : .posts + _ = try await apiClient.posts.updateCancellation( + postEndpointType: endpointType, + postId: Int64(postID), + params: params, + context: nil + ) + print("Post \(postID) persisted via REST API") } catch { - print("Save failed: \(error)") + print("Failed to persist post \(postID): \(error)") + viewModel?.errorMessage = "Failed to save post: \(error.localizedDescription)" } } @@ -260,6 +279,7 @@ private final class EditorViewModel { var isCodeEditorEnabled = false var isSaving = false var isEditorReady = false + var errorMessage: String? var hasPostID = false From 71b578209beb01f1024f8517ace9b49c752e272d Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 12:08:20 -0400 Subject: [PATCH 19/28] refactor(demo-ios): extract persistPost helper, document RunnableEditor - Move the long save closure body out of `viewModel.saveHandler =` and into a `private func persistPost(...)` on `_EditorView`. The saveHandler closure is now a one-liner that delegates, mirroring how `viewModel.perform` is wired for undo/redo while keeping the actual save logic readable as a regular method. - Add an inline comment to `RunnableEditor` explaining why `apiClient` is excluded from `==` and `hash(into:)`: `WordPressAPI` isn't `Hashable`/`Equatable` (it owns native Rust state), and two editors with the same configuration but different client instances should be treated as equal for navigation/identity purposes. Co-Authored-By: Claude Opus 4.6 (1M context) --- ios/Demo-iOS/Sources/ConfigurationItem.swift | 4 ++ ios/Demo-iOS/Sources/Views/EditorView.swift | 64 +++++++++++--------- 2 files changed, 38 insertions(+), 30 deletions(-) diff --git a/ios/Demo-iOS/Sources/ConfigurationItem.swift b/ios/Demo-iOS/Sources/ConfigurationItem.swift index 52f6320b9..88eb12489 100644 --- a/ios/Demo-iOS/Sources/ConfigurationItem.swift +++ b/ios/Demo-iOS/Sources/ConfigurationItem.swift @@ -42,6 +42,10 @@ struct RunnableEditor: Equatable, Hashable { self.apiClient = apiClient } + // `apiClient` is intentionally excluded from `==` and `hash(into:)`: + // `WordPressAPI` is not `Hashable`/`Equatable` (it owns native Rust state), + // and two editors with the same configuration but different client + // instances should be treated as equal for navigation/identity purposes. static func == (lhs: RunnableEditor, rhs: RunnableEditor) -> Bool { lhs.configuration == rhs.configuration && lhs.dependencies == rhs.dependencies } diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index ff81a758f..a8513db0a 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -140,36 +140,9 @@ private struct _EditorView: UIViewControllerRepresentable { viewModel.hasPostID = configuration.postID != nil - viewModel.saveHandler = { [weak viewController, weak viewModel, configuration, apiClient] in - guard let viewController else { return } - - // 1. Trigger the editor store save lifecycle so plugins fire side-effects. - // A lifecycle failure must NOT block the user from saving their work — - // log the warning and proceed to persist content anyway. - do { - try await viewController.savePost() - print("editor.savePost() completed — editor store save lifecycle fired") - } catch { - print("editor.savePost() lifecycle failed; persisting anyway: \(error)") - } - - // 2. Persist post content via REST API. - guard let apiClient, let postID = configuration.postID else { return } - do { - let titleAndContent = try await viewController.getTitleAndContent() - let params = PostUpdateParams(title: .some(titleAndContent.title), content: .some(titleAndContent.content), meta: nil) - let endpointType: PostEndpointType = configuration.postType.postType == "page" ? .pages : .posts - _ = try await apiClient.posts.updateCancellation( - postEndpointType: endpointType, - postId: Int64(postID), - params: params, - context: nil - ) - print("Post \(postID) persisted via REST API") - } catch { - print("Failed to persist post \(postID): \(error)") - viewModel?.errorMessage = "Failed to save post: \(error.localizedDescription)" - } + viewModel.saveHandler = { [weak viewController, weak viewModel] in + guard let viewController, let viewModel else { return } + await persistPost(viewController: viewController, viewModel: viewModel) } return viewController @@ -179,6 +152,37 @@ private struct _EditorView: UIViewControllerRepresentable { viewController.isCodeEditorEnabled = viewModel.isCodeEditorEnabled } + /// Triggers the editor store save lifecycle, then persists the post via the REST API. + /// + /// A lifecycle failure must **not** block the user from saving their work — the + /// warning is logged and persistence proceeds anyway. Persist failures surface + /// through `viewModel.errorMessage`, which the parent view binds to an `Alert`. + private func persistPost(viewController: EditorViewController, viewModel: EditorViewModel) async { + do { + try await viewController.savePost() + print("editor.savePost() completed — editor store save lifecycle fired") + } catch { + print("editor.savePost() lifecycle failed; persisting anyway: \(error)") + } + + guard let apiClient, let postID = configuration.postID else { return } + do { + let titleAndContent = try await viewController.getTitleAndContent() + let params = PostUpdateParams(title: .some(titleAndContent.title), content: .some(titleAndContent.content), meta: nil) + let endpointType: PostEndpointType = configuration.postType.postType == "page" ? .pages : .posts + _ = try await apiClient.posts.updateCancellation( + postEndpointType: endpointType, + postId: Int64(postID), + params: params, + context: nil + ) + print("Post \(postID) persisted via REST API") + } catch { + print("Failed to persist post \(postID): \(error)") + viewModel.errorMessage = "Failed to save post: \(error.localizedDescription)" + } + } + @MainActor class Coordinator: NSObject, EditorViewControllerDelegate { let viewModel: EditorViewModel From b26f6b10d62d801ee077de44bb6b0543b3b42a7d Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 12:15:53 -0400 Subject: [PATCH 20/28] refactor(demo-android): simplify PostsListActivity, extract strings, fix imports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the manual pagination loop in `PostsListViewModel.loadPosts` — iOS doesn't paginate either, and a single 20-post page is plenty for the demo. Removes ~25 lines and a hidden N-request fan-out on busy sites. - Inline the `PostsListViewModelFactory` as an anonymous object in `onCreate`. The standalone factory class was 12 lines of pure boilerplate for a one-shot screen with immutable args. - Move the activity's hardcoded UI strings ("Posts", "Back", "Browse", "No posts found", "Error loading posts", "Failed to save post") to `strings.xml`. Save error toast messages also use string resources with a positional argument for the underlying error. - Clean up fully-qualified imports introduced earlier in this branch: `android.content.Context`, `androidx.compose.ui.platform.LocalContext`, and `org.wordpress.gutenberg.model.EditorDependencies`. These now use short names with proper imports at the top of each file. - Refresh `app/detekt-baseline.xml` to absorb the legitimate `LongMethod`/`LongParameterList`/`TooGenericExceptionCaught` cases added by this branch's earlier commits — they're inherent to the Compose composables and the demo's intentionally permissive error handling. Three pre-existing `UseCheckOrError` warnings are gone now that the catch sites use `error()` instead of `throw IllegalStateException`. Co-Authored-By: Claude Opus 4.6 (1M context) --- android/app/detekt-baseline.xml | 8 +- .../example/gutenbergkit/EditorActivity.kt | 16 ++-- .../example/gutenbergkit/PostsListActivity.kt | 84 ++++++++----------- .../gutenbergkit/SitePreparationActivity.kt | 14 ++-- android/app/src/main/res/values/strings.xml | 11 +++ 5 files changed, 67 insertions(+), 66 deletions(-) diff --git a/android/app/detekt-baseline.xml b/android/app/detekt-baseline.xml index 3fb3b6665..0cd33fef4 100644 --- a/android/app/detekt-baseline.xml +++ b/android/app/detekt-baseline.xml @@ -2,11 +2,13 @@ - LongMethod:EditorActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, coroutineScope: CoroutineScope, onClose: () -> Unit, onGutenbergViewCreated: (GutenbergView) -> Unit = {} ) + LongMethod:EditorActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditorScreen( configuration: EditorConfiguration, dependencies: EditorDependencies? = null, accountId: ULong? = null, coroutineScope: CoroutineScope, onClose: () -> Unit, onGutenbergViewCreated: (GutenbergView) -> Unit = {} ) LongMethod:MainActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreen( configurations: List<ConfigurationItem>, onConfigurationClick: (ConfigurationItem) -> Unit, onConfigurationLongClick: (ConfigurationItem) -> Boolean, onAddConfiguration: (String) -> Unit, onDeleteConfiguration: (ConfigurationItem) -> Unit, onMediaProxyServer: () -> Unit = {}, isDiscoveringSite: Boolean = false, onDismissDiscovering: () -> Unit = {}, isLoadingCapabilities: Boolean = false, authError: String? = null, onDismissAuthError: () -> Unit = {} ) LongMethod:MediaProxyServerActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun MediaProxyServerScreen(onBack: () -> Unit) - LongMethod:SitePreparationActivity.kt$@Composable private fun FeatureConfigurationCard( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postType: String, onPostTypeChange: (String) -> Unit ) + LongMethod:PostsListActivity.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun PostsListScreen( viewModel: PostsListViewModel, onClose: () -> Unit, onPostSelected: (AnyPostWithEditContext) -> Unit ) + LongMethod:SitePreparationActivity.kt$@Composable private fun FeatureConfigurationCard( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postTypes: List<PostTypeDetails>, selectedPostType: PostTypeDetails?, onPostTypeChange: (PostTypeDetails) -> Unit, showBrowseButton: Boolean = false, onBrowsePosts: () -> Unit = {} ) LongParameterList:MainActivity.kt$( configurations: List<ConfigurationItem>, onConfigurationClick: (ConfigurationItem) -> Unit, onConfigurationLongClick: (ConfigurationItem) -> Boolean, onAddConfiguration: (String) -> Unit, onDeleteConfiguration: (ConfigurationItem) -> Unit, onMediaProxyServer: () -> Unit = {}, isDiscoveringSite: Boolean = false, onDismissDiscovering: () -> Unit = {}, isLoadingCapabilities: Boolean = false, authError: String? = null, onDismissAuthError: () -> Unit = {} ) + LongParameterList:SitePreparationActivity.kt$( enableNativeInserter: Boolean, onEnableNativeInserterChange: (Boolean) -> Unit, enableNetworkLogging: Boolean, onEnableNetworkLoggingChange: (Boolean) -> Unit, postTypes: List<PostTypeDetails>, selectedPostType: PostTypeDetails?, onPostTypeChange: (PostTypeDetails) -> Unit, showBrowseButton: Boolean = false, onBrowsePosts: () -> Unit = {} ) MaxLineLength:MediaProxyServerActivity.kt$Text("Size", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) MaxLineLength:MediaProxyServerActivity.kt$Text("Throughput", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) MaxLineLength:MediaProxyServerActivity.kt$Text("Time", fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f)) @@ -18,6 +20,8 @@ SwallowedException:SitePreparationViewModel.kt$SitePreparationViewModel$e: java.net.ConnectException ThrowsCount:AuthenticationManager.kt$AuthenticationManager$private fun handleApplicationPasswordsCallback( data: Uri, callback: AuthenticationCallback ) TooGenericExceptionCaught:AuthenticationManager.kt$AuthenticationManager$e: Exception + TooGenericExceptionCaught:EditorActivity.kt$e: Exception + TooGenericExceptionCaught:PostsListActivity.kt$PostsListViewModel$e: Exception TooGenericExceptionCaught:SiteCapabilitiesDiscovery.kt$SiteCapabilitiesDiscovery$e: Exception TooGenericExceptionCaught:SitePreparationActivity.kt$SitePreparationActivity.Companion$e: Exception TooGenericExceptionCaught:SitePreparationViewModel.kt$SitePreparationViewModel$e: Exception diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index f20762c0a..49d59fa25 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -1,12 +1,13 @@ package com.example.gutenbergkit +import android.content.Context import android.content.Intent -import android.os.Bundle -import android.view.ViewGroup -import android.webkit.WebView import android.content.pm.ApplicationInfo import android.os.Build +import android.os.Bundle import android.util.Log +import android.view.ViewGroup +import android.webkit.WebView import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler @@ -39,6 +40,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.lifecycleScope @@ -146,7 +148,7 @@ fun EditorScreen( var isSaving by remember { mutableStateOf(false) } var gutenbergViewRef by remember { mutableStateOf(null) } val saveScope = rememberCoroutineScope() - val context = androidx.compose.ui.platform.LocalContext.current + val context = LocalContext.current val canSave = !isSaving && accountId != null && configuration.postId != null @@ -348,7 +350,7 @@ private suspend fun GutenbergView.savePostAwait(): Boolean = * and persistence proceeds anyway. */ private suspend fun persistPost( - context: android.content.Context, + context: Context, view: GutenbergView, configuration: EditorConfiguration, accountId: ULong, @@ -407,11 +409,11 @@ private suspend fun persistPost( } else -> { Log.e("EditorActivity", "Failed to persist post $postId: $result") - "Failed to save post" + context.getString(R.string.save_failed_generic) } } } catch (e: Exception) { Log.e("EditorActivity", "Failed to persist post $postId", e) - "Failed to save post: ${e.message ?: "unknown error"}" + context.getString(R.string.save_failed_with_reason, e.message ?: "unknown error") } } diff --git a/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt index 6923aee11..a0b85f773 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/PostsListActivity.kt @@ -1,5 +1,6 @@ package com.example.gutenbergkit +import android.app.Application import android.content.Context import android.content.Intent import android.os.Bundle @@ -33,9 +34,9 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -59,7 +60,7 @@ import uniffi.wp_api.PostStatus * Lists posts from a WordPress site so the user can pick one to edit. * * Receives an [EditorConfiguration] (already prepared) and an account ID via Intent extras, - * fetches posts via the WordPress REST API using [WpApiClient], and on selection launches + * fetches posts via the WordPress REST API using `WpApiClient`, and on selection launches * [EditorActivity] with the post's title, content, and ID. */ class PostsListActivity : ComponentActivity() { @@ -102,10 +103,14 @@ class PostsListActivity : ComponentActivity() { return } - val viewModel = ViewModelProvider( - this, - PostsListViewModelFactory(application, accountId, postType) - )[PostsListViewModel::class.java] + // Inline factory — simpler than a dedicated `ViewModelProvider.Factory` + // class for a one-shot screen with immutable constructor args. + val factory = object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + PostsListViewModel(application, accountId, postType) as T + } + val viewModel = ViewModelProvider(this, factory)[PostsListViewModel::class.java] setContent { AppTheme { @@ -150,10 +155,10 @@ data class PostsListUiState( ) class PostsListViewModel( - application: android.app.Application, + private val application: Application, private val accountId: ULong, private val postType: PostTypeDetails -) : AndroidViewModel(application) { +) : ViewModel() { private val _uiState = MutableStateFlow(PostsListUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -165,9 +170,9 @@ class PostsListViewModel( _uiState.update { it.copy(isLoading = true, error = null, posts = emptyList()) } try { - val app = getApplication() + val app = application as GutenbergKitApplication val account = app.accountRepository.all().firstOrNull { it.id() == accountId } - ?: throw IllegalStateException("Account not found") + ?: error("Account not found") val client = app.createApiClient(account) val endpointType = when (postType.postType) { @@ -176,32 +181,23 @@ class PostsListViewModel( else -> PostEndpointType.Custom(postType.postType) } - val all = mutableListOf() - var page = 1u - val perPage = 20u - while (true) { - val params = PostListParams( - page = page, - perPage = perPage, - status = listOf(PostStatus.Any) - ) - val result = client.request { builder -> - builder.posts().listWithEditContext(endpointType, params) + // Single page only — matches the iOS demo, which doesn't paginate either. + val params = PostListParams( + page = 1u, + perPage = 20u, + status = listOf(PostStatus.Any) + ) + val result = client.request { builder -> + builder.posts().listWithEditContext(endpointType, params) + } + when (result) { + is WpRequestResult.Success -> { + _uiState.update { it.copy(posts = result.response.data, isLoading = false) } } - when (result) { - is WpRequestResult.Success -> { - val data = result.response.data - all.addAll(data) - if (data.size < perPage.toInt()) break - page++ - } - else -> { - throw IllegalStateException("Failed to load posts: $result") - } + else -> { + error("Failed to load posts: $result") } } - - _uiState.update { it.copy(posts = all, isLoading = false) } } catch (e: Exception) { _uiState.update { it.copy(error = e.message ?: "Unknown error", isLoading = false) } } @@ -209,20 +205,6 @@ class PostsListViewModel( } } -class PostsListViewModelFactory( - private val application: android.app.Application, - private val accountId: ULong, - private val postType: PostTypeDetails -) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(PostsListViewModel::class.java)) { - return PostsListViewModel(application, accountId, postType) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable fun PostsListScreen( @@ -240,12 +222,12 @@ fun PostsListScreen( modifier = Modifier.fillMaxSize(), topBar = { TopAppBar( - title = { Text("Posts") }, + title = { Text(stringResource(R.string.posts)) }, navigationIcon = { IconButton(onClick = onClose) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" + contentDescription = stringResource(R.string.back) ) } } @@ -270,7 +252,7 @@ fun PostsListScreen( verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text( - "Error loading posts", + stringResource(R.string.error_loading_posts), style = MaterialTheme.typography.titleMedium ) Text( @@ -281,7 +263,7 @@ fun PostsListScreen( } uiState.posts.isEmpty() -> { Text( - "No posts found", + stringResource(R.string.no_posts_found), modifier = Modifier.align(Alignment.Center) ) } diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt index 7915d68e2..a66f9326a 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationActivity.kt @@ -49,11 +49,13 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModelProvider import com.example.gutenbergkit.ui.theme.AppTheme import org.wordpress.gutenberg.model.EditorConfiguration +import org.wordpress.gutenberg.model.EditorDependencies import org.wordpress.gutenberg.model.EditorDependenciesSerializer import org.wordpress.gutenberg.model.PostTypeDetails @@ -164,7 +166,7 @@ class SitePreparationActivity : ComponentActivity() { private fun launchEditor( configuration: EditorConfiguration, - dependencies: org.wordpress.gutenberg.model.EditorDependencies? + dependencies: EditorDependencies? ) { val intent = Intent(this, EditorActivity::class.java).apply { putExtra(MainActivity.EXTRA_CONFIGURATION, configuration) @@ -182,7 +184,7 @@ class SitePreparationActivity : ComponentActivity() { accountId: ULong, postType: PostTypeDetails, configuration: EditorConfiguration, - dependencies: org.wordpress.gutenberg.model.EditorDependencies? + dependencies: EditorDependencies? ) { startActivity( PostsListActivity.createIntent(this, accountId, postType, configuration, dependencies) @@ -196,8 +198,8 @@ fun SitePreparationScreen( viewModel: SitePreparationViewModel, accountId: ULong?, onClose: () -> Unit, - onStartEditor: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?) -> Unit, - onBrowsePosts: (EditorConfiguration, org.wordpress.gutenberg.model.EditorDependencies?, PostTypeDetails) -> Unit + onStartEditor: (EditorConfiguration, EditorDependencies?) -> Unit, + onBrowsePosts: (EditorConfiguration, EditorDependencies?, PostTypeDetails) -> Unit ) { val uiState by viewModel.uiState.collectAsState() @@ -214,7 +216,7 @@ fun SitePreparationScreen( IconButton(onClick = onClose) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back" + contentDescription = stringResource(R.string.back) ) } }, @@ -472,7 +474,7 @@ private fun FeatureConfigurationCard( onClick = onBrowsePosts, modifier = Modifier.fillMaxWidth() ) { - Text("Browse") + Text(stringResource(R.string.browse)) } } } diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 30089f25b..40d2f403c 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -39,4 +39,15 @@ SAVE Visual editor Code editor + Failed to save post + Failed to save post: %1$s + + + Browse + + + Posts + Back + No posts found + Error loading posts From 4d70c26c788aeb9a00cdec7d18d003f8b5eab566 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 12:19:09 -0400 Subject: [PATCH 21/28] fix(demo-android): default post type picker to Post MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After fetching post types from the REST API, prefer `post` over the first item in the alphabetically-sorted list. Without this the picker defaulted to `page` (alphabetical first), which was surprising — `post` is the conventional default and matches what users expect from the prior hardcoded picker. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../com/example/gutenbergkit/SitePreparationViewModel.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt index 32aa1e232..30bfb961e 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/SitePreparationViewModel.kt @@ -244,11 +244,12 @@ class SitePreparationViewModel( arrayOf() } - // Fetch the site's post types and pick the first one as the default - // selection. Falls back to `PostTypeDetails.post` if the call fails so - // the editor can still launch with a sensible default. + // Fetch the site's post types. Default the picker to `post` when it's + // available (the typical case); otherwise pick the first type in the + // list. Falls back to `PostTypeDetails.post` if the call fails so the + // editor can still launch with a sensible default. val postTypes = loadPostTypes(config) ?: listOf(PostTypeDetails.post) - val defaultPostType = postTypes.first() + val defaultPostType = postTypes.firstOrNull { it.postType == "post" } ?: postTypes.first() _uiState.update { it.copy( postTypes = postTypes, From 2926f0859151983f2d39d0119411d03208165d51 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 13:08:51 -0400 Subject: [PATCH 22/28] fix: suppress editor save snackbar in host bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `core/editor`'s save lifecycle dispatches a "Draft saved." (or "Post updated.") snackbar via `core/notices` with the stable id `editor-save`. Native hosts that drive `savePost()` from the bridge own their own save UI (toasts, alerts), so the editor's web snackbar just competes with the host's feedback. Wrap the bridge's `window.editor.savePost` to remove the `editor-save` notice in a `finally` block, regardless of whether the save succeeds or fails. This is preferable to passing `{ isAutosave: true }` to `savePost()` — that would also suppress the notice, but it reroutes the request to `/wp/v2/{type}/{id}/autosaves` and changes which fields are persisted, defeating the lifecycle hosts depend on. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../editor/test/use-host-bridge.test.jsx | 1 + src/components/editor/use-host-bridge.js | 18 +++++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index 58c5d15b6..a8d37ce68 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -12,6 +12,7 @@ import { useHostBridge } from '../use-host-bridge'; vi.mock( '@wordpress/data' ); vi.mock( '@wordpress/core-data' ); vi.mock( '@wordpress/editor' ); +vi.mock( '@wordpress/notices' ); vi.mock( '@wordpress/blocks' ); vi.mock( '@wordpress/block-editor' ); diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index 368ca99e2..eae216733 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -5,6 +5,7 @@ import { useEffect, useCallback, useRef } from '@wordpress/element'; import { useDispatch, useSelect } from '@wordpress/data'; import { store as coreStore } from '@wordpress/core-data'; import { store as editorStore } from '@wordpress/editor'; +import { store as noticesStore } from '@wordpress/notices'; import { parse, serialize, getBlockType } from '@wordpress/blocks'; import { store as blockEditorStore } from '@wordpress/block-editor'; import { insert, create, toHTMLString } from '@wordpress/rich-text'; @@ -20,6 +21,7 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { const { editEntityRecord } = useDispatch( coreStore ); const { undo, redo, switchEditorMode, savePost } = useDispatch( editorStore ); + const { removeNotice } = useDispatch( noticesStore ); const { getEditedPostAttribute, getEditedPostContent } = useSelect( editorStore ); const { updateBlock, selectionChange } = useDispatch( blockEditorStore ); @@ -93,7 +95,20 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { // expose the underlying Promise here so native hosts can `await` the // editor store's full save lifecycle (e.g., iOS uses // `WKWebView.callAsyncJavaScript` to wait for completion). - window.editor.savePost = savePost; + // + // We also suppress the "Draft saved." / "Post updated." snackbar that + // `core/editor` dispatches as part of the save lifecycle: native hosts + // own their own UI for save feedback (toasts, alerts, etc.), and the + // editor's web snackbar would just compete with them. The notice has a + // stable id (`editor-save`) which we remove on both success and + // failure paths. + window.editor.savePost = async () => { + try { + return await savePost(); + } finally { + removeNotice( 'editor-save' ); + } + }; window.editor.switchEditorMode = ( mode ) => { // Do not return the `Promise` return value to avoid host errors. @@ -208,6 +223,7 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { getEditedPostAttribute, getEditedPostContent, savePost, + removeNotice, redo, switchEditorMode, undo, From 1de05660ef2094539c09ceb8627841d74126a810 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 13:13:42 -0400 Subject: [PATCH 23/28] test: cover savePost snackbar removal in host bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new test cases lock in the snackbar suppression added in the previous commit: - The success path: `await window.editor.savePost()` resolves and `removeNotice('editor-save')` is dispatched. - The failure path: `savePost` rejects, the rejection still propagates to the host, and `removeNotice('editor-save')` is still dispatched (because it's in a `finally`). These guard against two regressions: a future Gutenberg version renaming the `editor-save` notice id (manual QA only would catch), and a future "simplification" of the wrapper that drops the `finally` and lets the snackbar back in. Stabilize the `__mocks__/@wordpress/data.js` mock so `useDispatch()` returns the same shared actions object on every call. Tests need to read the captured mock after the hook runs (e.g. `const { savePost } = useDispatch()`), so the mock fns must be reference-stable — otherwise `mockResolvedValueOnce` and `toHaveBeenCalled` would target a different fn than the hook captured. Co-Authored-By: Claude Opus 4.6 (1M context) --- __mocks__/@wordpress/data.js | 19 +++++++-- .../editor/test/use-host-bridge.test.jsx | 40 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/__mocks__/@wordpress/data.js b/__mocks__/@wordpress/data.js index 0cbae4b9c..30d1943a8 100644 --- a/__mocks__/@wordpress/data.js +++ b/__mocks__/@wordpress/data.js @@ -1,11 +1,19 @@ import { vi } from 'vitest'; -// Returns mock dispatched actions for stores referenced by editor code -// (e.g. core/editor's `savePost`, `undo`, `redo`, `switchEditorMode`). +// Stable map of mock dispatched actions for stores referenced by editor +// code (e.g. core/editor's `savePost`, `undo`, `redo`, `switchEditorMode`, +// and core/notices' `removeNotice`). +// // Returning `vi.fn()` rather than `undefined` lets `useHostBridge` assign // destructured actions directly (e.g. `window.editor.savePost = savePost`) // without silently producing `undefined` values. -export const useDispatch = vi.fn( () => ( { +// +// Returning the *same* object on every call is important for tests that +// need to read the captured mock after the hook runs — e.g. `const +// { savePost } = useDispatch()` in a test must yield the very same +// `vi.fn` the hook destructured, so `mockResolvedValueOnce` and +// `toHaveBeenCalled` work end-to-end. +const dispatchedActions = { undo: vi.fn(), redo: vi.fn(), savePost: vi.fn(), @@ -13,7 +21,10 @@ export const useDispatch = vi.fn( () => ( { editEntityRecord: vi.fn(), updateBlock: vi.fn(), selectionChange: vi.fn(), -} ) ); + removeNotice: vi.fn(), +}; + +export const useDispatch = vi.fn( () => dispatchedActions ); export const useSelect = vi.fn( ( selector ) => { if ( typeof selector === 'function' ) { return selector( () => ( {} ) ); diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index a8d37ce68..ed55295fa 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -4,6 +4,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook } from '@testing-library/react'; +/** + * WordPress dependencies + */ +import { useDispatch } from '@wordpress/data'; + /** * Internal dependencies */ @@ -78,4 +83,39 @@ describe( 'useHostBridge', () => { expect( window.editor.focus ).toBeUndefined(); expect( window.editor.appendTextAtCursor ).toBeUndefined(); } ); + + describe( 'window.editor.savePost', () => { + it( 'removes the editor-save snackbar after a successful save', async () => { + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + // `useDispatch` is mocked to return the same shared actions + // object for every call (see `__mocks__/@wordpress/data.js`), + // so we can grab the action mocks here regardless of which + // store was passed. + const { savePost, removeNotice } = useDispatch(); + savePost.mockResolvedValueOnce( undefined ); + + await window.editor.savePost(); + + expect( savePost ).toHaveBeenCalledTimes( 1 ); + expect( removeNotice ).toHaveBeenCalledWith( 'editor-save' ); + } ); + + it( 'removes the editor-save snackbar even when the save fails', async () => { + renderHook( () => + useHostBridge( defaultPost, editorRef, markBridgeReady ) + ); + + const { savePost, removeNotice } = useDispatch(); + const failure = new Error( 'plugin lifecycle error' ); + savePost.mockRejectedValueOnce( failure ); + + await expect( window.editor.savePost() ).rejects.toThrow( failure ); + + expect( savePost ).toHaveBeenCalledTimes( 1 ); + expect( removeNotice ).toHaveBeenCalledWith( 'editor-save' ); + } ); + } ); } ); From 6679cf123013395a99de6a9897b8f199bbed7d4b Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 13:20:47 -0400 Subject: [PATCH 24/28] test: localize useDispatch mock for savePost coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor the savePost snackbar tests to declare their `vi.mock` factory locally with `vi.hoisted` references, instead of digging the mock out of `useDispatch()` at runtime. Tests now hold direct references to `dispatchMocks.savePost` and `dispatchMocks.removeNotice`, which is the idiomatic vitest pattern and removes the hidden invariant from the global mock (the previous commit required `__mocks__/@wordpress/data.js` to return a stable shared object so tests could capture the same fns the hook destructured — that's surprising for anyone reading the global mock). Reverts the global mock to its pre-coverage state (no `removeNotice`, fresh fns per call) since no other test depends on the stable-object behavior. Co-Authored-By: Claude Opus 4.6 (1M context) --- __mocks__/@wordpress/data.js | 19 ++----- .../editor/test/use-host-bridge.test.jsx | 57 ++++++++++++------- 2 files changed, 40 insertions(+), 36 deletions(-) diff --git a/__mocks__/@wordpress/data.js b/__mocks__/@wordpress/data.js index 30d1943a8..0cbae4b9c 100644 --- a/__mocks__/@wordpress/data.js +++ b/__mocks__/@wordpress/data.js @@ -1,19 +1,11 @@ import { vi } from 'vitest'; -// Stable map of mock dispatched actions for stores referenced by editor -// code (e.g. core/editor's `savePost`, `undo`, `redo`, `switchEditorMode`, -// and core/notices' `removeNotice`). -// +// Returns mock dispatched actions for stores referenced by editor code +// (e.g. core/editor's `savePost`, `undo`, `redo`, `switchEditorMode`). // Returning `vi.fn()` rather than `undefined` lets `useHostBridge` assign // destructured actions directly (e.g. `window.editor.savePost = savePost`) // without silently producing `undefined` values. -// -// Returning the *same* object on every call is important for tests that -// need to read the captured mock after the hook runs — e.g. `const -// { savePost } = useDispatch()` in a test must yield the very same -// `vi.fn` the hook destructured, so `mockResolvedValueOnce` and -// `toHaveBeenCalled` work end-to-end. -const dispatchedActions = { +export const useDispatch = vi.fn( () => ( { undo: vi.fn(), redo: vi.fn(), savePost: vi.fn(), @@ -21,10 +13,7 @@ const dispatchedActions = { editEntityRecord: vi.fn(), updateBlock: vi.fn(), selectionChange: vi.fn(), - removeNotice: vi.fn(), -}; - -export const useDispatch = vi.fn( () => dispatchedActions ); +} ) ); export const useSelect = vi.fn( ( selector ) => { if ( typeof selector === 'function' ) { return selector( () => ( {} ) ); diff --git a/src/components/editor/test/use-host-bridge.test.jsx b/src/components/editor/test/use-host-bridge.test.jsx index ed55295fa..319f991b4 100644 --- a/src/components/editor/test/use-host-bridge.test.jsx +++ b/src/components/editor/test/use-host-bridge.test.jsx @@ -4,17 +4,34 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook } from '@testing-library/react'; -/** - * WordPress dependencies - */ -import { useDispatch } from '@wordpress/data'; - /** * Internal dependencies */ import { useHostBridge } from '../use-host-bridge'; -vi.mock( '@wordpress/data' ); +// Hoisted so the `vi.mock` factory below can capture references to the +// same spies the tests assert on. `vi.mock` is hoisted above imports, +// so plain top-level `const`s aren't visible to its factory. +const dispatchMocks = vi.hoisted( () => ( { + savePost: vi.fn(), + removeNotice: vi.fn(), + undo: vi.fn(), + redo: vi.fn(), + switchEditorMode: vi.fn(), + editEntityRecord: vi.fn(), + updateBlock: vi.fn(), + selectionChange: vi.fn(), +} ) ); + +vi.mock( '@wordpress/data', () => ( { + useDispatch: vi.fn( () => dispatchMocks ), + useSelect: vi.fn( ( selector ) => { + if ( typeof selector === 'function' ) { + return selector( () => ( {} ) ); + } + return {}; + } ), +} ) ); vi.mock( '@wordpress/core-data' ); vi.mock( '@wordpress/editor' ); vi.mock( '@wordpress/notices' ); @@ -86,36 +103,34 @@ describe( 'useHostBridge', () => { describe( 'window.editor.savePost', () => { it( 'removes the editor-save snackbar after a successful save', async () => { + dispatchMocks.savePost.mockResolvedValueOnce( undefined ); + renderHook( () => useHostBridge( defaultPost, editorRef, markBridgeReady ) ); - // `useDispatch` is mocked to return the same shared actions - // object for every call (see `__mocks__/@wordpress/data.js`), - // so we can grab the action mocks here regardless of which - // store was passed. - const { savePost, removeNotice } = useDispatch(); - savePost.mockResolvedValueOnce( undefined ); - await window.editor.savePost(); - expect( savePost ).toHaveBeenCalledTimes( 1 ); - expect( removeNotice ).toHaveBeenCalledWith( 'editor-save' ); + expect( dispatchMocks.savePost ).toHaveBeenCalledTimes( 1 ); + expect( dispatchMocks.removeNotice ).toHaveBeenCalledWith( + 'editor-save' + ); } ); it( 'removes the editor-save snackbar even when the save fails', async () => { + const failure = new Error( 'plugin lifecycle error' ); + dispatchMocks.savePost.mockRejectedValueOnce( failure ); + renderHook( () => useHostBridge( defaultPost, editorRef, markBridgeReady ) ); - const { savePost, removeNotice } = useDispatch(); - const failure = new Error( 'plugin lifecycle error' ); - savePost.mockRejectedValueOnce( failure ); - await expect( window.editor.savePost() ).rejects.toThrow( failure ); - expect( savePost ).toHaveBeenCalledTimes( 1 ); - expect( removeNotice ).toHaveBeenCalledWith( 'editor-save' ); + expect( dispatchMocks.savePost ).toHaveBeenCalledTimes( 1 ); + expect( dispatchMocks.removeNotice ).toHaveBeenCalledWith( + 'editor-save' + ); } ); } ); } ); From 74a29eea4caef1ab05e59a03d99e04174e56d2bf Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 13:23:59 -0400 Subject: [PATCH 25/28] test: revert global useDispatch mock to its trunk shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expanded action map I added to `__mocks__/@wordpress/data.js` in the chunk 1 commit (`refactor: harden savePost bridge…`) was only needed by the new `savePost` host-bridge tests, which the previous commit moved to a local `vi.mock` factory with its own `vi.hoisted` `dispatchMocks`. No other test file consumes the global mock's action map: `use-host-bridge.test.jsx` now provides its own factory, and `src/utils/editor.test.jsx` uses `vi.mock( import( '@wordpress/data' ), { spy: true } )` which spies on the real module and ignores the manual mock entirely. Restore the file to byte-identical parity with trunk so the global mock stays minimal and surprises nobody. Co-Authored-By: Claude Opus 4.6 (1M context) --- __mocks__/@wordpress/data.js | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/__mocks__/@wordpress/data.js b/__mocks__/@wordpress/data.js index 0cbae4b9c..bdfdf7493 100644 --- a/__mocks__/@wordpress/data.js +++ b/__mocks__/@wordpress/data.js @@ -1,19 +1,6 @@ import { vi } from 'vitest'; -// Returns mock dispatched actions for stores referenced by editor code -// (e.g. core/editor's `savePost`, `undo`, `redo`, `switchEditorMode`). -// Returning `vi.fn()` rather than `undefined` lets `useHostBridge` assign -// destructured actions directly (e.g. `window.editor.savePost = savePost`) -// without silently producing `undefined` values. -export const useDispatch = vi.fn( () => ( { - undo: vi.fn(), - redo: vi.fn(), - savePost: vi.fn(), - switchEditorMode: vi.fn(), - editEntityRecord: vi.fn(), - updateBlock: vi.fn(), - selectionChange: vi.fn(), -} ) ); +export const useDispatch = vi.fn( () => ( {} ) ); export const useSelect = vi.fn( ( selector ) => { if ( typeof selector === 'function' ) { return selector( () => ( {} ) ); From a31ef37af508129c8f93f458e43612ce047aa726 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 13:33:19 -0400 Subject: [PATCH 26/28] docs: Reduce comment length --- src/components/editor/use-host-bridge.js | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/components/editor/use-host-bridge.js b/src/components/editor/use-host-bridge.js index eae216733..fdd531a27 100644 --- a/src/components/editor/use-host-bridge.js +++ b/src/components/editor/use-host-bridge.js @@ -91,21 +91,11 @@ export function useHostBridge( post, editorRef, markBridgeReady ) { redo(); }; - // Unlike `undo`/`redo`/`switchEditorMode` above, we intentionally - // expose the underlying Promise here so native hosts can `await` the - // editor store's full save lifecycle (e.g., iOS uses - // `WKWebView.callAsyncJavaScript` to wait for completion). - // - // We also suppress the "Draft saved." / "Post updated." snackbar that - // `core/editor` dispatches as part of the save lifecycle: native hosts - // own their own UI for save feedback (toasts, alerts, etc.), and the - // editor's web snackbar would just compete with them. The notice has a - // stable id (`editor-save`) which we remove on both success and - // failure paths. window.editor.savePost = async () => { try { return await savePost(); } finally { + // Native hosts display their own save feedback, disable the default removeNotice( 'editor-save' ); } }; From 285a742b3c3d054b46f85aab3ea84130c50fd841 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 13:36:42 -0400 Subject: [PATCH 27/28] revert(demo): keep editor overflow menu unchanged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overflow menu cleanup (removing permanently-disabled Preview, Post Settings, Help, Revisions, and block/word count items) is unrelated to the savePost lifecycle work this PR targets. Restore the trunk shape on both demo apps to keep the diff focused. The disabled "Save" item on Android is intentionally not restored — the new Save toolbar button this PR adds supersedes it, and showing both a working and a disabled "Save" would be confusing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../example/gutenbergkit/EditorActivity.kt | 15 ++++++++ ios/Demo-iOS/Sources/Views/EditorView.swift | 35 ++++++++++++++----- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 49d59fa25..1907ab71a 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -236,6 +236,11 @@ fun EditorScreen( expanded = showMenu, onDismissRequest = { showMenu = false } ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.preview)) }, + onClick = { }, + enabled = false + ) DropdownMenuItem( text = { Text(stringResource(if (isCodeEditorEnabled) R.string.visual_editor else R.string.code_editor)) }, onClick = { @@ -244,6 +249,16 @@ fun EditorScreen( showMenu = false } ) + DropdownMenuItem( + text = { Text(stringResource(R.string.post_settings)) }, + onClick = { }, + enabled = false + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.help)) }, + onClick = { }, + enabled = false + ) } } } diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index a8513db0a..e831431ca 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -89,14 +89,33 @@ struct EditorView: View { private var moreMenu: some View { Menu { - Button(action: { - viewModel.isCodeEditorEnabled.toggle() - }, label: { - Label( - viewModel.isCodeEditorEnabled ? "Visual Editor" : "Code Editor", - systemImage: viewModel.isCodeEditorEnabled ? "doc.richtext" : "curlybraces" - ) - }) + Section { + Button(action: { + viewModel.isCodeEditorEnabled.toggle() + }, label: { + Label( + viewModel.isCodeEditorEnabled ? "Visual Editor" : "Code Editor", + systemImage: viewModel.isCodeEditorEnabled ? "doc.richtext" : "curlybraces" + ) + }) + Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { + Label("Preview", systemImage: "safari") + }).disabled(true) + Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { + Label("Revisions (42)", systemImage: "clock.arrow.circlepath") + }).disabled(true) + Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { + Label("Post Settings", systemImage: "gearshape") + }).disabled(true) + Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { + Label("Help", systemImage: "questionmark.circle") + }).disabled(true) + } + Section { + Text("Blocks: 4, Words: 8, Characters: 15") + } header: { + + } } label: { Image(systemName: "ellipsis") } From 990b0ac0115ecd76a2c44fe79850c97e43c624d4 Mon Sep 17 00:00:00 2001 From: David Calhoun Date: Tue, 7 Apr 2026 13:41:11 -0400 Subject: [PATCH 28/28] Revert "revert(demo): keep editor overflow menu unchanged" This reverts commit 285a742b3c3d054b46f85aab3ea84130c50fd841. --- .../example/gutenbergkit/EditorActivity.kt | 15 -------- ios/Demo-iOS/Sources/Views/EditorView.swift | 35 +++++-------------- 2 files changed, 8 insertions(+), 42 deletions(-) diff --git a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt index 1907ab71a..49d59fa25 100644 --- a/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt +++ b/android/app/src/main/java/com/example/gutenbergkit/EditorActivity.kt @@ -236,11 +236,6 @@ fun EditorScreen( expanded = showMenu, onDismissRequest = { showMenu = false } ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.preview)) }, - onClick = { }, - enabled = false - ) DropdownMenuItem( text = { Text(stringResource(if (isCodeEditorEnabled) R.string.visual_editor else R.string.code_editor)) }, onClick = { @@ -249,16 +244,6 @@ fun EditorScreen( showMenu = false } ) - DropdownMenuItem( - text = { Text(stringResource(R.string.post_settings)) }, - onClick = { }, - enabled = false - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.help)) }, - onClick = { }, - enabled = false - ) } } } diff --git a/ios/Demo-iOS/Sources/Views/EditorView.swift b/ios/Demo-iOS/Sources/Views/EditorView.swift index e831431ca..a8513db0a 100644 --- a/ios/Demo-iOS/Sources/Views/EditorView.swift +++ b/ios/Demo-iOS/Sources/Views/EditorView.swift @@ -89,33 +89,14 @@ struct EditorView: View { private var moreMenu: some View { Menu { - Section { - Button(action: { - viewModel.isCodeEditorEnabled.toggle() - }, label: { - Label( - viewModel.isCodeEditorEnabled ? "Visual Editor" : "Code Editor", - systemImage: viewModel.isCodeEditorEnabled ? "doc.richtext" : "curlybraces" - ) - }) - Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { - Label("Preview", systemImage: "safari") - }).disabled(true) - Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { - Label("Revisions (42)", systemImage: "clock.arrow.circlepath") - }).disabled(true) - Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { - Label("Post Settings", systemImage: "gearshape") - }).disabled(true) - Button(action: /*@START_MENU_TOKEN@*/{}/*@END_MENU_TOKEN@*/, label: { - Label("Help", systemImage: "questionmark.circle") - }).disabled(true) - } - Section { - Text("Blocks: 4, Words: 8, Characters: 15") - } header: { - - } + Button(action: { + viewModel.isCodeEditorEnabled.toggle() + }, label: { + Label( + viewModel.isCodeEditorEnabled ? "Visual Editor" : "Code Editor", + systemImage: viewModel.isCodeEditorEnabled ? "doc.richtext" : "curlybraces" + ) + }) } label: { Image(systemName: "ellipsis") }