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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ class GutenbergView : FrameLayout {
private var onFileChooserRequested: ((Intent, Int) -> Unit)? = null
private var contentChangeListener: ContentChangeListener? = null
private var historyChangeListener: HistoryChangeListener? = null
private var saveAvailabilityListener: SaveAvailabilityListener? = null
private var featuredImageChangeListener: FeaturedImageChangeListener? = null
private var openMediaLibraryListener: OpenMediaLibraryListener? = null
private var editorDidBecomeAvailableListener: EditorAvailableListener? = null
Expand Down Expand Up @@ -243,6 +244,10 @@ class GutenbergView : FrameLayout {
historyChangeListener = listener
}

fun setSaveAvailabilityListener(listener: SaveAvailabilityListener) {
saveAvailabilityListener = listener
}

fun setFeaturedImageChangeListener(listener: FeaturedImageChangeListener) {
featuredImageChangeListener = listener
}
Expand Down Expand Up @@ -862,6 +867,40 @@ class GutenbergView : FrameLayout {
fun onHistoryChanged(hasUndo: Boolean, hasRedo: Boolean)
}

/**
* Whether the post can be saved, as reported by the editor.
*
* @property isSaveable `true` if the post has a title, excerpt, or content
* worth saving.
* @property isSavingLocked `true` while something in the editor, such as a
* media upload, has locked saving.
*/
data class SaveAvailability(
val isSaveable: Boolean,
val isSavingLocked: Boolean
)

/**
* Notified on the main thread with the initial save availability, which
* may arrive before [EditorAvailableListener.onEditorAvailable], then each
* time it changes. Register before loading the editor to receive it.
*
* Hosts should not save the post to the server while
* [SaveAvailability.isSavingLocked] is `true`, for example while a media
* upload is in progress, so they don't persist temporary media URLs. That
* covers saves the host starts in code, such as autosaves, not only its
* save controls.
* Gutenberg releases the lock for a multi-file upload as soon as any file
* fails, while the remaining files may still be uploading.
*
* Disregard the last reported availability after
* [EditorUnavailableListener.onEditorUnavailable]; the editor reports it
* again once it reloads.
*/
fun interface SaveAvailabilityListener {
fun onSaveAvailabilityChanged(availability: SaveAvailability)
}

interface FeaturedImageChangeListener {
fun onFeaturedImageChanged(mediaID: Long)
}
Expand Down Expand Up @@ -1115,6 +1154,14 @@ class GutenbergView : FrameLayout {
historyChangeListener?.onHistoryChanged(hasUndo, hasRedo)
}

@JavascriptInterface
fun onEditorSaveAvailabilityChanged(isSaveable: Boolean, isSavingLocked: Boolean) {
val availability = SaveAvailability(isSaveable, isSavingLocked)
handler.post {
saveAvailabilityListener?.onSaveAvailabilityChanged(availability)
}
}

@JavascriptInterface
fun onEditorFeaturedImageChanged(mediaID: Long) {
featuredImageChangeListener?.onFeaturedImageChanged(mediaID)
Expand Down Expand Up @@ -1337,6 +1384,7 @@ class GutenbergView : FrameLayout {
webView.stopLoading()
contentChangeListener = null
historyChangeListener = null
saveAvailabilityListener = null
featuredImageChangeListener = null
openMediaLibraryListener = null
logJsExceptionListener = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,17 @@ class GutenbergViewTest {
assertEquals(gutenbergView, notified)
}

@Test
fun `onEditorSaveAvailabilityChanged notifies the listener`() {
var notified: GutenbergView.SaveAvailability? = null
gutenbergView.setSaveAvailabilityListener { availability -> notified = availability }

gutenbergView.onEditorSaveAvailabilityChanged(isSaveable = true, isSavingLocked = true)
shadowOf(Looper.getMainLooper()).idle()

assertEquals(GutenbergView.SaveAvailability(isSaveable = true, isSavingLocked = true), notified)
}

@Test
fun `onEditorUnavailable stops content reads from reaching the web view`() {
gutenbergView.onEditorLoaded()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ fun EditorScreen(
var isCodeEditorEnabled by remember { mutableStateOf(false) }
var isEditorAvailable by remember { mutableStateOf(false) }
var isSaving by remember { mutableStateOf(false) }
// Whether the editor has locked saving, e.g. while a media upload is in progress.
var isSavingLocked by remember { mutableStateOf(false) }
var gutenbergViewRef by remember { mutableStateOf<GutenbergView?>(null) }
val openedContent = GutenbergView.LatestContent(configuration.title, configuration.content)
// The newest content read from the editor, held in memory as a host app's
Expand All @@ -177,7 +179,7 @@ fun EditorScreen(
val context = LocalContext.current

val hasChanges = latestContent != savedContent
val canSave = isEditorAvailable && !isSaving && accountId != null && configuration.postId != null && hasChanges
val canSave = isEditorAvailable && !isSaving && !isSavingLocked && accountId != null && configuration.postId != null && hasChanges

BackHandler(enabled = isModalDialogOpen) {
gutenbergViewRef?.dismissTopModal()
Expand Down Expand Up @@ -312,7 +314,14 @@ fun EditorScreen(

gutenbergViewRef = this
setEditorDidBecomeAvailable { isEditorAvailable = true }
setEditorDidBecomeUnavailable { isEditorAvailable = false }
setEditorDidBecomeUnavailable {
isEditorAvailable = false
// The crashed editor's save lock no longer applies.
isSavingLocked = false
}
setSaveAvailabilityListener { availability ->
isSavingLocked = availability.isSavingLocked
}
setModalDialogStateListener(object : GutenbergView.ModalDialogStateListener {
override fun onModalDialogOpened(dialogType: String) {
isModalDialogOpen = true
Expand Down
10 changes: 10 additions & 0 deletions docs/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ extension YourViewController: EditorViewControllerDelegate {
// Undo/redo state changed - use state.hasUndo and state.hasRedo
}

func editor(_ viewController: EditorViewController, didUpdateSaveAvailability availability: EditorSaveAvailability) {
// Optional - while availability.isSavingLocked (e.g. during uploads), don't save
// the post to the server, whether from a save control or in code (e.g. autosave)
}

func editor(_ viewController: EditorViewController, didRequestMediaFromSiteMediaLibrary config: OpenMediaLibraryAction) {
// User requested media picker - present your media library UI
}
Expand Down Expand Up @@ -202,6 +207,11 @@ gutenbergView.setHistoryChangeListener { hasUndo, hasRedo ->
// Undo/redo state changed
}

gutenbergView.setSaveAvailabilityListener { availability ->
// While availability.isSavingLocked (e.g. during uploads), don't save the post
// to the server, whether from a save control or in code (e.g. autosave)
}

gutenbergView.setOpenMediaLibraryListener { config ->
// User requested media picker
// config.allowedTypes, config.multiple, etc.
Expand Down
10 changes: 9 additions & 1 deletion ios/Demo-iOS/Sources/Views/EditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,8 @@ private struct _EditorView: UIViewControllerRepresentable {

func editorDidBecomeUnavailable(_ viewController: EditorViewController) {
viewModel.isEditorReady = false
// The crashed editor's save lock no longer applies.
viewModel.isSavingLocked = false
}

func editor(_ viewContoller: EditorViewController, didDisplayInitialContent content: String) {
Expand Down Expand Up @@ -259,6 +261,10 @@ private struct _EditorView: UIViewControllerRepresentable {
viewModel.hasRedo = state.hasRedo
}

func editor(_ viewController: EditorViewController, didUpdateSaveAvailability availability: EditorSaveAvailability) {
viewModel.isSavingLocked = availability.isSavingLocked
}

func editor(_ viewController: EditorViewController, didUpdateFeaturedImage mediaID: Int) {
// No-op for demo
}
Expand Down Expand Up @@ -406,6 +412,8 @@ private final class EditorViewModel {
var isCodeEditorEnabled = false
var isSaving = false
var isEditorReady = false
/// Whether the editor has locked saving, e.g. while a media upload is in progress.
var isSavingLocked = false

var hasPostID = false

Expand All @@ -419,7 +427,7 @@ private final class EditorViewModel {
}

var canSave: Bool {
isEditorReady && !isSaving && hasPostID && hasChanges
isEditorReady && !isSaving && !isSavingLocked && hasPostID && hasChanges
}

enum Action {
Expand Down
2 changes: 2 additions & 0 deletions ios/Sources/GutenbergKit/Sources/EditorJSMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ struct EditorJSMessage {
case onEditorContentChanged
/// The editor history (undo, redo) changed.
case onEditorHistoryChanged
/// The post's save availability changed.
case onEditorSaveAvailabilityChanged
/// The editor featured image changed.
case onEditorFeaturedImageChanged
/// The editor logged an exception.
Expand Down
3 changes: 3 additions & 0 deletions ios/Sources/GutenbergKit/Sources/EditorViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -845,6 +845,9 @@ public final class EditorViewController: UIViewController, GutenbergEditorContro
self.state.hasUndo = body.hasUndo
self.state.hasRedo = body.hasRedo
delegate?.editor(self, didUpdateHistoryState: state)
case .onEditorSaveAvailabilityChanged:
let availability = try message.decode(EditorSaveAvailability.self)
delegate?.editor(self, didUpdateSaveAvailability: availability)
case .onEditorFeaturedImageChanged:
let body = try message.decode(EditorJSMessage.DidUpdateFeaturedImageBody.self)
delegate?.editor(self, didUpdateFeaturedImage: body.mediaID)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ public protocol EditorViewControllerDelegate: AnyObject {
/// Notifies the client about new history state.
func editor(_ viewController: EditorViewController, didUpdateHistoryState state: EditorState)

/// Notifies the client that the post's save availability changed.
///
/// Called with the initial availability, which may arrive before
/// ``editorDidLoad(_:)``, then each time it changes. Clients should not save
/// the post to the server while ``EditorSaveAvailability/isSavingLocked``
/// is `true`, for example while a media upload is in progress, so they
/// don't persist temporary media URLs. That covers saves the client starts
/// in code, such as autosaves, not only its save controls.
///
/// - warning: Gutenberg releases the lock for a multi-file upload as soon
/// as any file fails, while the remaining files may still be uploading.
///
/// - note: Disregard the last reported availability after
/// ``editorDidBecomeUnavailable(_:)``. The editor reports it again once it
/// reloads.
func editor(_ viewController: EditorViewController, didUpdateSaveAvailability availability: EditorSaveAvailability)

/// Notifies the client about a new featured image.
func editor(_ viewController: EditorViewController, didUpdateFeaturedImage mediaID: Int)

Expand Down Expand Up @@ -99,6 +116,7 @@ public protocol EditorViewControllerDelegate: AnyObject {

extension EditorViewControllerDelegate {
public func editor(_ viewController: EditorViewController, didFailToLoad error: Error) {}
public func editor(_ viewController: EditorViewController, didUpdateSaveAvailability availability: EditorSaveAvailability) {}
}

#endif
Expand All @@ -112,6 +130,20 @@ public struct EditorState {
public var hasRedo = false
}

/// Whether the post can be saved, as reported by the editor.
public struct EditorSaveAvailability: Decodable, Equatable, Sendable {
/// `true` if the post has a title, excerpt, or content worth saving.
public let isSaveable: Bool
/// `true` while something in the editor, such as a media upload, has
/// locked saving.
public let isSavingLocked: Bool

public init(isSaveable: Bool, isSavingLocked: Bool) {
self.isSaveable = isSaveable
self.isSavingLocked = isSavingLocked
}
}

// Definition of JavaScript exception, which will be used to
// log exception to the Crash Logging service.
public struct GutenbergJSException {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Foundation
import Testing

@testable import GutenbergKit

@Suite
struct EditorSaveAvailabilityTests {

@Test("Decodes the onEditorSaveAvailabilityChanged message body")
func decodesMessageBody() throws {
let json = #"{"isSaveable":true,"isSavingLocked":true}"#

let availability = try JSONDecoder().decode(EditorSaveAvailability.self, from: Data(json.utf8))

#expect(availability == EditorSaveAvailability(isSaveable: true, isSavingLocked: true))
}
}
2 changes: 2 additions & 0 deletions src/components/editor/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { store as editorStore, EditorProvider } from '@wordpress/editor';
import VisualEditor from '../visual-editor';
import './style.scss';
import { useSyncHistoryControls } from './use-sync-history-controls';
import { useSyncSaveAvailability } from './use-sync-save-availability';
import { useHostBridge } from './use-host-bridge';
import { useEditorReady } from './use-editor-ready';
import { useHostExceptionLogging } from './use-host-exception-logging';
Expand Down Expand Up @@ -34,6 +35,7 @@ export default function Editor( { post, children, hideTitle } ) {
useHostBridge( post, editorRef, markBridgeReady );
useSyncFeaturedImage();
useSyncHistoryControls();
useSyncSaveAvailability();
useHostExceptionLogging();
useEditorSetup( post );
useMediaUpload();
Expand Down
81 changes: 81 additions & 0 deletions src/components/editor/test/use-sync-save-availability.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import { useSyncSaveAvailability } from '../use-sync-save-availability';
import { onEditorSaveAvailabilityChanged } from '../../../utils/bridge';

let editorState;

vi.mock( '@wordpress/data', () => ( {
useSelect: ( mapSelect ) =>
mapSelect( () => ( {
__unstableIsEditorReady: () => editorState.isReady,
isEditedPostSaveable: () => editorState.isSaveable,
isPostSavingLocked: () => editorState.isSavingLocked,
} ) ),
} ) );

vi.mock( '@wordpress/editor', () => ( { store: {} } ) );

vi.mock( '../../../utils/bridge', () => ( {
onEditorSaveAvailabilityChanged: vi.fn(),
} ) );

describe( 'useSyncSaveAvailability', () => {
beforeEach( () => {
vi.clearAllMocks();
editorState = {
isReady: true,
isSaveable: true,
isSavingLocked: false,
};
} );

it( 'reports the initial availability once the editor is ready', () => {
editorState.isReady = false;
const { rerender } = renderHook( () => useSyncSaveAvailability() );

expect( onEditorSaveAvailabilityChanged ).not.toHaveBeenCalled();

editorState.isReady = true;
rerender();

expect( onEditorSaveAvailabilityChanged ).toHaveBeenCalledTimes( 1 );
expect( onEditorSaveAvailabilityChanged ).toHaveBeenCalledWith(
true,
false
);
} );

it( 'reports each change once', () => {
const { rerender } = renderHook( () => useSyncSaveAvailability() );
vi.clearAllMocks();

editorState.isSavingLocked = true;
rerender();
rerender();

expect( onEditorSaveAvailabilityChanged ).toHaveBeenCalledTimes( 1 );
expect( onEditorSaveAvailabilityChanged ).toHaveBeenCalledWith(
true,
true
);

editorState.isSaveable = false;
rerender();

expect( onEditorSaveAvailabilityChanged ).toHaveBeenCalledTimes( 2 );
expect( onEditorSaveAvailabilityChanged ).toHaveBeenLastCalledWith(
false,
true
);
} );

it( 'stops reporting after unmounting', () => {
const { unmount } = renderHook( () => useSyncSaveAvailability() );
vi.clearAllMocks();

unmount();

expect( onEditorSaveAvailabilityChanged ).not.toHaveBeenCalled();
} );
} );
Loading
Loading