-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Add Conditional editor theme styles #25160
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: trunk
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,13 +1,112 @@ | ||
| import Foundation | ||
| import WordPressAPI | ||
| import WordPressAPIInternal | ||
|
|
||
| /// Protocol defining the WordPress API methods that WordPressClient needs. | ||
| /// This abstraction allows for mocking in tests using the `NoHandle` constructors | ||
| /// available on the executor classes. | ||
| public protocol WordPressClientAPI: Sendable { | ||
| var apiRoot: ApiRootRequestExecutor { get } | ||
| var users: UsersRequestExecutor { get } | ||
| var themes: ThemesRequestExecutor { get } | ||
| var plugins: PluginsRequestExecutor { get } | ||
| var comments: CommentsRequestExecutor { get } | ||
| var media: MediaRequestExecutor { get } | ||
| var taxonomies: TaxonomiesRequestExecutor { get } | ||
| var terms: TermsRequestExecutor { get } | ||
| var applicationPasswords: ApplicationPasswordsRequestExecutor { get } | ||
|
|
||
| func uploadMedia( | ||
| params: MediaCreateParams, | ||
| fulfilling progress: Progress | ||
| ) async throws -> MediaRequestCreateResponse | ||
| } | ||
|
|
||
| /// WordPressAPI already has these properties with the correct types, | ||
| /// so conformance is automatic. | ||
| extension WordPressAPI: WordPressClientAPI {} | ||
|
|
||
| public actor WordPressClient { | ||
|
|
||
| public let api: WordPressAPI | ||
| public enum Feature { | ||
| /// A block theme is required to style the editor. | ||
| case blockTheme | ||
|
|
||
| /// The block editor settings API is required to style the editor. | ||
| case blockEditorSettings | ||
|
|
||
| /// Application Password Extras grants additional capabilities using Application Passwords. | ||
| case applicationPasswordExtras | ||
|
|
||
| /// WordPress.com sites don't all support plugins. | ||
| case plugins | ||
|
|
||
| public var stringValue: String { | ||
| switch self { | ||
| case .blockTheme: "is-block-theme" | ||
| case .blockEditorSettings: "block-editor-settings" | ||
| case .applicationPasswordExtras: "application-password-extras" | ||
| case .plugins: "plugins" | ||
| } | ||
| } | ||
| } | ||
|
|
||
| public let api: any WordPressClientAPI | ||
| public let rootUrl: String | ||
|
|
||
| public init(api: WordPressAPI, rootUrl: ParsedUrl) { | ||
| private var loadSiteInfoTask: Task<(WpApiDetails, UserWithEditContext, ThemeWithEditContext?), Error> | ||
|
|
||
| public init(api: any WordPressClientAPI, rootUrl: ParsedUrl) { | ||
| self.api = api | ||
| self.rootUrl = rootUrl.url() | ||
|
|
||
| self.loadSiteInfoTask = Task { [api] in | ||
| async let apiRootTask = try await api.apiRoot.get().data | ||
| async let currentUserTask = try await api.users.retrieveMeWithEditContext().data | ||
| async let activeThemeTask = try await api.themes.listWithEditContext( | ||
| params: ThemeListParams(status: .active) | ||
| ).data.first(where: { $0.status == .active }) | ||
|
|
||
| return try await (apiRootTask, currentUserTask, activeThemeTask) | ||
| } | ||
| } | ||
|
|
||
| public func supports(_ feature: Feature, forSiteId siteId: Int? = nil) async throws -> Bool { | ||
| let apiRoot = try await fetchApiRoot() | ||
| let isBlockTheme: Bool = try await fetchActiveTheme()?.isBlockTheme ?? false | ||
|
|
||
| if let siteId { | ||
| return switch feature { | ||
| case .blockEditorSettings: apiRoot.hasRoute(route: "/wp-block-editor/v1/sites/\(siteId)/settings") | ||
| case .blockTheme: isBlockTheme | ||
| case .plugins: apiRoot.hasRoute(route: "/wp/v2/plugins") | ||
| case .applicationPasswordExtras: apiRoot.hasRoute(route: "/application-password-extras/v1/admin-ajax") | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nitpick: using the urlResolver property in |
||
| } | ||
|
|
||
| return switch feature { | ||
| case .blockEditorSettings: apiRoot.hasRoute(route: "/wp-block-editor/v1/settings") | ||
| case .blockTheme: isBlockTheme | ||
| case .plugins: apiRoot.hasRoute(route: "/wp/v2/plugins") | ||
| case .applicationPasswordExtras: apiRoot.hasRoute(route: "/application-password-extras/v1/admin-ajax") | ||
| } | ||
| } | ||
|
|
||
| /// Asynchronously read the site's API details. This value is cached internally. | ||
| /// | ||
| private var apiRoot: WpApiDetails { | ||
| get async throws { | ||
| try await self.fetchApiRoot() | ||
| } | ||
| } | ||
|
|
||
| private func fetchApiRoot() async throws -> WpApiDetails { | ||
| // Wait for the `loadSiteInfoTask` to finish the initial load then use that value | ||
| return try await loadSiteInfoTask.value.0 | ||
| } | ||
|
|
||
| private func fetchActiveTheme() async throws -> ThemeWithEditContext? { | ||
| // Wait for the `loadSiteInfoTask` to finish the initial load then use that value | ||
| return try await loadSiteInfoTask.value.2 | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| import Foundation | ||
| import WordPressAPI | ||
| import WordPressAPIInternal | ||
| @testable import WordPressCore | ||
|
|
||
| /// Tracks call counts for API methods to verify caching behavior. | ||
| final class MockWordPressClientAPI: WordPressClientAPI, @unchecked Sendable { | ||
| private let lock = NSLock() | ||
|
|
||
| private var _apiRootCallCount = 0 | ||
| private var _usersCallCount = 0 | ||
| private var _themesCallCount = 0 | ||
|
|
||
| var apiRootCallCount: Int { | ||
| lock.lock() | ||
| defer { lock.unlock() } | ||
| return _apiRootCallCount | ||
| } | ||
|
|
||
| var usersCallCount: Int { | ||
| lock.lock() | ||
| defer { lock.unlock() } | ||
| return _usersCallCount | ||
| } | ||
|
|
||
| var themesCallCount: Int { | ||
| lock.lock() | ||
| defer { lock.unlock() } | ||
| return _themesCallCount | ||
| } | ||
|
|
||
| var mockRoutes: Set<String> = [] | ||
| var mockIsBlockTheme: Bool = false | ||
|
|
||
| var apiRoot: ApiRootRequestExecutor { | ||
| lock.lock() | ||
| _apiRootCallCount += 1 | ||
| lock.unlock() | ||
| return MockApiRootRequestExecutor(routes: mockRoutes) | ||
| } | ||
|
|
||
| var users: UsersRequestExecutor { | ||
| lock.lock() | ||
| _usersCallCount += 1 | ||
| lock.unlock() | ||
| return MockUsersRequestExecutor(noHandle: UsersRequestExecutor.NoHandle()) | ||
| } | ||
|
|
||
| var themes: ThemesRequestExecutor { | ||
| lock.lock() | ||
| _themesCallCount += 1 | ||
| lock.unlock() | ||
| return MockThemesRequestExecutor(isBlockTheme: mockIsBlockTheme) | ||
| } | ||
|
|
||
| // Unused in WordPressClient.supports() - provide minimal implementations | ||
| var plugins: PluginsRequestExecutor { fatalError("Not implemented") } | ||
| var comments: CommentsRequestExecutor { fatalError("Not implemented") } | ||
| var media: MediaRequestExecutor { fatalError("Not implemented") } | ||
| var taxonomies: TaxonomiesRequestExecutor { fatalError("Not implemented") } | ||
| var terms: TermsRequestExecutor { fatalError("Not implemented") } | ||
| var applicationPasswords: ApplicationPasswordsRequestExecutor { fatalError("Not implemented") } | ||
|
|
||
| func uploadMedia(params: MediaCreateParams, fulfilling progress: Progress) async throws -> MediaRequestCreateResponse { | ||
| fatalError("Not implemented") | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Mock Executors | ||
|
|
||
| final class MockApiRootRequestExecutor: ApiRootRequestExecutor { | ||
| private var routes: Set<String> | ||
|
|
||
| init(routes: Set<String>) { | ||
| self.routes = routes | ||
| super.init(noHandle: ApiRootRequestExecutor.NoHandle()) | ||
| } | ||
|
|
||
| required init(unsafeFromHandle handle: UInt64) { | ||
| self.routes = [] | ||
| super.init(unsafeFromHandle: handle) | ||
| } | ||
|
|
||
| override func getCancellation(context: RequestContext?) async throws -> ApiRootRequestGetResponse { | ||
| let mockApiDetails = MockWpApiDetails(routes: routes) | ||
| let mockHeaderMap = WpNetworkHeaderMap(noHandle: WpNetworkHeaderMap.NoHandle()) | ||
| return ApiRootRequestGetResponse(data: mockApiDetails, headerMap: mockHeaderMap) | ||
| } | ||
| } | ||
|
|
||
| final class MockUsersRequestExecutor: UsersRequestExecutor { | ||
| override init(noHandle: UsersRequestExecutor.NoHandle) { | ||
| super.init(noHandle: noHandle) | ||
| } | ||
|
|
||
| required init(unsafeFromHandle handle: UInt64) { | ||
| super.init(unsafeFromHandle: handle) | ||
| } | ||
|
|
||
| override func retrieveMeWithEditContextCancellation(context: RequestContext?) async throws -> UsersRequestRetrieveMeWithEditContextResponse { | ||
| let mockUser = UserWithEditContext( | ||
| id: UserId(1), | ||
| username: "testuser", | ||
| name: "Test User", | ||
| firstName: "Test", | ||
| lastName: "User", | ||
| email: "test@example.com", | ||
| url: "", | ||
| description: "", | ||
| link: "https://example.com/author/testuser", | ||
| locale: "en_US", | ||
| nickname: "testuser", | ||
| slug: "testuser", | ||
| registeredDate: "2024-01-01T00:00:00", | ||
| roles: [], | ||
| capabilities: [:], | ||
| extraCapabilities: [:], | ||
| avatarUrls: nil | ||
| ) | ||
| let mockHeaderMap = WpNetworkHeaderMap(noHandle: WpNetworkHeaderMap.NoHandle()) | ||
| return UsersRequestRetrieveMeWithEditContextResponse(data: mockUser, headerMap: mockHeaderMap) | ||
| } | ||
| } | ||
|
|
||
| final class MockThemesRequestExecutor: ThemesRequestExecutor { | ||
| private var isBlockTheme: Bool | ||
|
|
||
| init(isBlockTheme: Bool) { | ||
| self.isBlockTheme = isBlockTheme | ||
| super.init(noHandle: ThemesRequestExecutor.NoHandle()) | ||
| } | ||
|
|
||
| required init(unsafeFromHandle handle: UInt64) { | ||
| self.isBlockTheme = false | ||
| super.init(unsafeFromHandle: handle) | ||
| } | ||
|
|
||
| override func listWithEditContextCancellation(params: ThemeListParams, context: RequestContext?) async throws -> ThemesRequestListWithEditContextResponse { | ||
| let mockTheme = ThemeWithEditContext( | ||
| stylesheet: ThemeStylesheet(value: "twentytwentyfour"), | ||
| template: "twentytwentyfour", | ||
| requiresPhp: "7.0", | ||
| requiresWp: "6.4", | ||
| textdomain: "twentytwentyfour", | ||
| version: "1.0", | ||
| screenshot: "", | ||
| author: ThemeAuthor(raw: "WordPress", rendered: "WordPress"), | ||
| authorUri: ThemeAuthorUri(raw: "", rendered: ""), | ||
| description: ThemeDescription(raw: "", rendered: ""), | ||
| name: ThemeName(raw: "Twenty Twenty-Four", rendered: "Twenty Twenty-Four"), | ||
| tags: ThemeTags(raw: [], rendered: ""), | ||
| themeUri: ThemeUri(raw: "", rendered: ""), | ||
| status: .active, | ||
| isBlockTheme: isBlockTheme, | ||
| stylesheetUri: "", | ||
| templateUri: "", | ||
| themeSupports: nil | ||
| ) | ||
| let mockHeaderMap = WpNetworkHeaderMap(noHandle: WpNetworkHeaderMap.NoHandle()) | ||
| return ThemesRequestListWithEditContextResponse(data: [mockTheme], headerMap: mockHeaderMap) | ||
| } | ||
| } | ||
|
|
||
| final class MockWpApiDetails: WpApiDetails { | ||
| private var routes: Set<String> | ||
|
|
||
| init(routes: Set<String>) { | ||
| self.routes = routes | ||
| super.init(noHandle: WpApiDetails.NoHandle()) | ||
| } | ||
|
|
||
| required init(unsafeFromHandle handle: UInt64) { | ||
| self.routes = [] | ||
| super.init(unsafeFromHandle: handle) | ||
| } | ||
|
|
||
| override func hasRoute(route: String) -> Bool { | ||
| routes.contains(route) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If one of the requests fails, the whole
loadSiteInfoTaskfails, which means we won't get any of the "site info". That's probably not something we'd want here?