├── .babelrc ├── .cursor └── rules │ ├── coding-standards.mdc │ └── plugin-environment.mdc ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── api_integration_issue.md │ ├── bug_report.md │ └── general_feedback.md ├── codecov.yml ├── dependabot.yml └── workflows │ ├── ai-docs.yml │ ├── e2e-tests.yml │ ├── integration-tests.yml │ ├── lint.yml │ ├── pr-build-live-branch.yml │ ├── release.yml │ ├── scripts │ └── generate-playground-blueprint.js │ └── test.yml ├── .gitignore ├── .husky └── pre-commit ├── .lintstagedrc.json ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── .vscode └── launch.json ├── .wp-env.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── bin ├── conf │ └── htaccess ├── release ├── start └── stop ├── blueprint.json ├── blueprint.local.json ├── blueprint.settings.json ├── composer.json ├── composer.lock ├── docker-compose.overrides.yml ├── docs ├── assets │ ├── block-insert-remote-html.gif │ ├── google-console.png │ ├── insert-shopify-block.gif │ ├── patterns-right-panel.png │ └── search-input.png ├── concepts │ ├── block-bindings.md │ ├── helper-blocks.md │ ├── index.md │ └── inline-bindings.md ├── extending │ ├── ai-prompts.md │ ├── block-patterns.md │ ├── block-registration.md │ ├── data-source.md │ ├── hooks.md │ ├── index.md │ ├── overrides.md │ ├── query-input-schema.md │ ├── query-output-schema.md │ └── query.md ├── for-ai.md ├── index.md ├── local-development.md ├── quickstart.md ├── troubleshooting.md └── tutorials │ ├── airtable.md │ ├── google-sheets.md │ ├── http.md │ ├── index.md │ └── shopify.md ├── example ├── .cursor │ └── rules │ │ └── project-scope.mdc ├── README.md ├── assets │ └── blueprint-content.wxr ├── blocks │ ├── art-block │ │ └── art-block.php │ ├── github-markdown-block │ │ ├── github-markdown-block.php │ │ └── inc │ │ │ ├── github-query-runner.php │ │ │ ├── markdown-links.php │ │ │ └── patterns │ │ │ └── file-render.html │ ├── shopify-mock-store-block │ │ └── shopify-mock-store-block.php │ ├── weather-block │ │ ├── patterns │ │ │ └── weather-block-pattern.html │ │ └── weather-block.php │ └── zip-code-block │ │ └── zip-code-block.php └── templates │ ├── airtable-block │ └── airtable-block.php │ ├── airtable-map-block │ ├── .gitignore │ ├── README.md │ ├── airtable-map-block.php │ ├── package.json │ └── src │ │ └── leaflet-map │ │ ├── block.json │ │ ├── edit.js │ │ ├── index.js │ │ ├── render.php │ │ └── view.js │ ├── google-sheets-block │ └── google-sheets-block.php │ ├── rest-api-block-from-ui-data-source │ └── rest-api-block-from-ui-data-source.php │ ├── rest-api-block │ └── rest-api-block.php │ ├── shopify-product-block │ └── shopify-product-block.php │ └── theme │ ├── README.md │ ├── functions.php │ ├── screenshot.png │ ├── style-remote-data-blocks.css │ ├── style.css │ └── theme.json ├── functions.php ├── inc ├── Config │ ├── ArraySerializable.php │ ├── ArraySerializableInterface.php │ ├── BlockAttribute │ │ └── RemoteDataBlockAttribute.php │ ├── DataSource │ │ ├── DataSourceInterface.php │ │ ├── HttpDataSource.php │ │ └── HttpDataSourceInterface.php │ ├── Query │ │ ├── GraphqlMutation.php │ │ ├── GraphqlQuery.php │ │ ├── HttpQuery.php │ │ ├── HttpQueryInterface.php │ │ └── QueryInterface.php │ └── QueryRunner │ │ ├── QueryResponseParser.php │ │ ├── QueryRunner.php │ │ └── QueryRunnerInterface.php ├── Editor │ ├── AdminNotices │ │ └── AdminNotices.php │ ├── Assets │ │ └── Assets.php │ ├── BlockManagement │ │ ├── BlockRegistration.php │ │ ├── ConfigRegistry.php │ │ └── ConfigStore.php │ ├── BlockPatterns │ │ ├── BlockPatterns.php │ │ └── templates │ │ │ ├── columns.html │ │ │ ├── empty.html │ │ │ ├── heading.html │ │ │ ├── html.html │ │ │ ├── image.html │ │ │ └── paragraph.html │ ├── DataBinding │ │ ├── BlockBindings.php │ │ ├── InlineBindings.php │ │ └── Pagination.php │ └── PatternEditor │ │ └── PatternEditor.php ├── ExampleApi │ ├── Data │ │ ├── ExampleApiData.php │ │ └── items.json │ ├── ExampleApi.php │ └── Queries │ │ └── ExampleApiQueryRunner.php ├── Formatting │ ├── FieldFormatter.php │ └── StringFormatter.php ├── HttpClient │ ├── HttpClient.php │ ├── RdbCacheMiddleware.php │ ├── RdbCacheStrategy.php │ ├── RdbLogMiddleware.php │ └── WPRemoteRequestHandler.php ├── Integrations │ ├── Airtable │ │ ├── AirtableDataSource.php │ │ ├── AirtableIntegration.php │ │ └── templates │ │ │ └── block_registration.template │ ├── GenericHttp │ │ ├── GenericHttpDataSource.php │ │ ├── GenericHttpIntegration.php │ │ └── templates │ │ │ └── block_registration.template │ ├── GitHub │ │ └── GitHubDataSource.php │ ├── Google │ │ ├── Auth │ │ │ ├── GoogleAuth.php │ │ │ └── GoogleServiceAccountKey.php │ │ └── Sheets │ │ │ ├── GoogleSheetsDataSource.php │ │ │ ├── GoogleSheetsIntegration.php │ │ │ └── templates │ │ │ └── block_registration.template │ ├── Shopify │ │ ├── Patterns │ │ │ └── product-teaser.html │ │ ├── Queries │ │ │ ├── GetProductById.graphql │ │ │ └── SearchProducts.graphql │ │ ├── ShopifyDataSource.php │ │ ├── ShopifyIntegration.php │ │ ├── assets │ │ │ └── shopify_logo_black.png │ │ └── templates │ │ │ └── block_registration.template │ ├── VipBlockDataApi │ │ └── VipBlockDataApi.php │ └── constants.php ├── Logging │ ├── AbstractLogger.php │ ├── LogLevel.php │ ├── Logger.php │ ├── LoggerInterface.php │ └── QueryMonitor │ │ ├── QueryMonitor.php │ │ ├── RdbBlockBindingCollector.php │ │ ├── RdbBlockBindingOutputHtml.php │ │ ├── RdbHttpRequestCollector.php │ │ ├── RdbHttpRequestOutputHtml.php │ │ ├── RdbLogCollector.php │ │ ├── RdbLogOutputHtml.php │ │ ├── RdbLogOutputRaw.php │ │ ├── RdbMainCollector.php │ │ ├── RdbMainOutputHtml.php │ │ ├── RdbValidationCollector.php │ │ └── RdbValidationOutputHtml.php ├── PluginSettings │ └── PluginSettings.php ├── REST │ ├── AuthController.php │ ├── DataSourceController.php │ └── RemoteDataController.php ├── Sanitization │ ├── Sanitizer.php │ └── SanitizerInterface.php ├── Snippet │ └── Snippet.php ├── Store │ └── DataSource │ │ ├── ConstantConfigStore.php │ │ └── DataSourceConfigManager.php ├── Telemetry │ ├── DataSourceTelemetry.php │ └── Telemetry.php ├── Validation │ ├── ConfigSchemas.php │ ├── Types.php │ ├── Validator.php │ └── ValidatorInterface.php └── WpdbStorage │ ├── DataEncryption.php │ └── DataSourceCrud.php ├── package-lock.json ├── package.json ├── phpcs.xml ├── phpunit-integration.xml ├── phpunit.xml ├── playwright.config.ts ├── psalm.xml ├── remote-data-blocks.php ├── src ├── block-editor │ ├── binding-sources │ │ └── remote-data-binding.ts │ ├── filters │ │ ├── addUsesContext.ts │ │ ├── index.ts │ │ └── withBlockBinding.tsx │ ├── format-types │ │ └── inline-binding │ │ │ ├── components │ │ │ ├── InlineBinding.scss │ │ │ ├── InlineBindingButton.tsx │ │ │ ├── InlineBindingSelectExisting.tsx │ │ │ ├── InlineBindingSelectFieldPopover.tsx │ │ │ ├── InlineBindingSelectMeta.tsx │ │ │ ├── InlineBindingSelectNew.tsx │ │ │ └── InlineBindingSelection.tsx │ │ │ ├── hooks │ │ │ └── useExistingRemoteData.ts │ │ │ ├── index.ts │ │ │ └── settings.ts │ └── index.ts ├── blocks │ ├── remote-data-container │ │ ├── block.json │ │ ├── components │ │ │ ├── BlockBindingControls.tsx │ │ │ ├── EditErrorBoundary.tsx │ │ │ ├── InnerBlocks.tsx │ │ │ ├── item-list │ │ │ │ ├── ItemList.tsx │ │ │ │ └── ItemListField.tsx │ │ │ ├── modals │ │ │ │ ├── BaseModal.tsx │ │ │ │ ├── DataViewsModal.tsx │ │ │ │ └── InputModal.tsx │ │ │ ├── panels │ │ │ │ ├── DataPanel.tsx │ │ │ │ ├── OverridesPanel.tsx │ │ │ │ └── QueryInputsPanel.tsx │ │ │ ├── pattern-selection │ │ │ │ ├── PatternSelection.tsx │ │ │ │ └── PatternSelectionModal.tsx │ │ │ ├── placeholders │ │ │ │ ├── ItemSelectQueryType.tsx │ │ │ │ ├── Placeholder.tsx │ │ │ │ ├── PlaceholderError.scss │ │ │ │ └── PlaceholderError.tsx │ │ │ └── popovers │ │ │ │ ├── InputPopover.tsx │ │ │ │ └── style.scss │ │ ├── config │ │ │ └── constants.ts │ │ ├── edit.tsx │ │ ├── editor.scss │ │ ├── hooks │ │ │ ├── useModalState.ts │ │ │ ├── usePaginationVariables.ts │ │ │ ├── usePatterns.ts │ │ │ ├── useRemoteData.ts │ │ │ ├── useRemoteDataContext.ts │ │ │ └── useSearchVariables.ts │ │ ├── index.ts │ │ ├── render.php │ │ ├── save.tsx │ │ ├── style.scss │ │ └── utils │ │ │ ├── tracks.spec.ts │ │ │ └── tracks.ts │ ├── remote-data-no-results │ │ ├── block.json │ │ ├── edit.tsx │ │ ├── editor.scss │ │ ├── index.ts │ │ ├── render.php │ │ ├── save.tsx │ │ └── style.scss │ ├── remote-data-pagination │ │ ├── block.json │ │ ├── edit.tsx │ │ ├── editor.scss │ │ ├── index.ts │ │ ├── render.php │ │ ├── save.tsx │ │ └── style.scss │ ├── remote-data-template │ │ ├── block.json │ │ ├── components │ │ │ ├── item-preview │ │ │ │ └── ItemPreview.tsx │ │ │ └── loop-template │ │ │ │ ├── LoopTemplate.tsx │ │ │ │ └── LoopTemplateInnerBlocks.tsx │ │ ├── context │ │ │ └── PreviewIndexContext.ts │ │ ├── edit.tsx │ │ ├── editor.scss │ │ ├── filters │ │ │ ├── index.ts │ │ │ └── withPreviewIndex.tsx │ │ ├── hooks │ │ │ └── useGetInnerBlocks.ts │ │ ├── index.ts │ │ ├── render.php │ │ └── save.tsx │ └── remote-html │ │ ├── block.json │ │ ├── edit.tsx │ │ ├── editor.scss │ │ ├── index.ts │ │ ├── render.php │ │ └── save.tsx ├── config │ └── constants.ts ├── data-sources │ ├── DataSourceList.scss │ ├── DataSourceList.tsx │ ├── DataSourceMetaTags.tsx │ ├── DataSourceSettings.scss │ ├── DataSourceSettings.tsx │ ├── airtable │ │ ├── AirtableSettings.tsx │ │ ├── constants.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── api-clients │ │ ├── airtable.ts │ │ ├── auth.ts │ │ ├── google.ts │ │ └── shopify.ts │ ├── components │ │ ├── AddDataSourceDropdown.tsx │ │ ├── CodeSnippet.tsx │ │ ├── CustomFormFieldToken.tsx │ │ ├── DataSourceForm.tsx │ │ ├── DataSourceFormActions.tsx │ │ ├── FieldsSelection.tsx │ │ ├── HttpAuthSettingsInput.tsx │ │ └── PasswordInputControl.tsx │ ├── constants.ts │ ├── google-sheets │ │ └── GoogleSheetsSettings.tsx │ ├── hooks │ │ ├── useAirtable.ts │ │ ├── useDataSources.ts │ │ ├── useGoogleApi.ts │ │ ├── useGoogleAuth.ts │ │ └── useShopify.tsx │ ├── http │ │ ├── HttpSettings.tsx │ │ └── types.ts │ ├── shopify │ │ └── ShopifySettings.tsx │ ├── types.ts │ └── utils.tsx ├── dataviews │ └── index.ts ├── hooks │ ├── useDebouncedState.ts │ ├── useEditedPostAttribute.ts │ ├── useForm.ts │ ├── usePostMeta.ts │ └── useQuery.ts ├── pattern-editor │ ├── components │ │ └── PatternEditorSettingsPanel.tsx │ ├── config │ │ └── constants.ts │ └── index.tsx ├── settings │ ├── Notices.tsx │ ├── SettingsPage.tsx │ ├── hooks │ │ └── useSettingsNav.ts │ ├── icons │ │ ├── AirtableIcon.tsx │ │ ├── CheckIcon.tsx │ │ ├── ErrorIcon.tsx │ │ ├── GoogleSheetsIcon.tsx │ │ ├── HttpIcon.tsx │ │ └── ShopifyIcon.tsx │ ├── index.scss │ └── index.tsx ├── types │ ├── common.ts │ ├── google.ts │ └── input.ts └── utils │ ├── block-binding.ts │ ├── errors.ts │ ├── function.ts │ ├── i18n.ts │ ├── input-validation.ts │ ├── localized-block-data.ts │ ├── object.ts │ ├── remote-data.ts │ ├── string.ts │ └── type-narrowing.ts ├── tests ├── e2e │ └── settings │ │ └── activation.spec.ts ├── inc │ ├── Config │ │ ├── ArraySerializableTest.php │ │ ├── HttpDataSourceTest.php │ │ ├── QueryResponseParserTest.php │ │ ├── QueryRunnerTest.php │ │ ├── QueryTest.php │ │ └── RemoteDataBlockAttributeTest.php │ ├── Editor │ │ ├── BlockPatternsTest.php │ │ ├── ConfigStoreTest.php │ │ └── DataBinding │ │ │ ├── BlockBindingsTest.php │ │ │ └── PaginationTest.php │ ├── Functions │ │ └── FunctionsTest.php │ ├── HttpClient │ │ └── HttpClientTest.php │ ├── Integrations │ │ ├── Airtable │ │ │ └── AirtableDataSourceTest.php │ │ ├── GenericHttp │ │ │ └── GenericHttpDataSourceTest.php │ │ ├── Google │ │ │ └── Sheets │ │ │ │ └── GoogleSheetsDataSourceTest.php │ │ └── VipBlockDataApi │ │ │ └── VipBlockDataApiTest.php │ ├── Mocks │ │ ├── MockDataSource.php │ │ ├── MockLogger.php │ │ ├── MockQuery.php │ │ ├── MockQueryRunner.php │ │ ├── MockSerializableClass.php │ │ ├── MockSerializableSubclass.php │ │ ├── MockTelemetry.php │ │ ├── MockValidator.php │ │ └── MockWordPressFunctions.php │ ├── PluginSettings │ │ └── PluginSettingsTest.php │ ├── Sanitization │ │ └── SanitizerTest.php │ ├── Store │ │ └── DataSource │ │ │ ├── ConstantConfigStoreTest.php │ │ │ └── DataSourceConfigManagerTest.php │ ├── Validation │ │ └── ValidatorTest.php │ ├── WpdbStorage │ │ ├── DataEncryptionTest.php │ │ └── DataSourceCrudTest.php │ ├── bootstrap.php │ ├── stubs.php │ └── test-utils.php ├── integration │ ├── RDBTestCase.php │ ├── blocks │ │ ├── BlockConfigTest.php │ │ ├── BlockWithNestedConfigTest.php │ │ ├── RemoteDataTemplateBlockTest.php │ │ ├── RemoteHtmlBlockTest.php │ │ └── ZipCodeBlockTest.php │ ├── bootstrap.php │ └── telemetry │ │ └── TelemetryTest.php └── src │ ├── block-editor │ └── filters │ │ └── withBlockBinding.test.tsx │ ├── blocks │ ├── remote-data-container │ │ ├── components │ │ │ ├── item-list │ │ │ │ └── ItemList.test.tsx │ │ │ ├── modals │ │ │ │ └── InputModal.test.tsx │ │ │ └── panels │ │ │ │ └── QueryInputsPanel.test.tsx │ │ └── hooks │ │ │ ├── useModalState.test.ts │ │ │ └── usePaginationVariables.test.ts │ └── remote-data-template │ │ └── components │ │ └── loop-template │ │ ├── LoopTemplate.test.tsx │ │ └── LoopTemplateInnerBlocks.test.tsx │ ├── utils │ ├── block-binding.test.ts │ ├── errors.test.ts │ ├── function.test.ts │ ├── input-validation.test.ts │ ├── object.test.ts │ ├── remote-data.test.ts │ ├── string.test.ts │ └── type-narrowing.test.ts │ └── vitest.setup.ts ├── tsconfig.json ├── types ├── globals.d.ts ├── localized-block-data.d.ts ├── localized-settings.d.ts ├── query-monitor.d.ts ├── remote-data.d.ts ├── tracks.d.ts ├── utils.d.ts ├── wordpress__block-editor │ └── index.d.ts ├── wordpress__blocks │ └── index.d.ts ├── wordpress__data │ └── index.d.ts ├── wordpress__notices │ └── index.d.ts ├── wordpress__rich-text │ └── index.d.ts └── wordpress__server-side-render │ └── index.d.ts ├── vitest.config.mts ├── webpack.config.js └── webpack.utils.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-react" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.cursor/rules/coding-standards.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | 7 | - You are an experienced WordPress plugin developer who follows WordPress VIP coding standards and best practices. This means writing TypeScript and PHP code that would be approved by the official WordPress VIP tooling (PHPCS, ESLint, and WP-Prettier). 8 | - If you need to violate a PHPCS or ESLint rule, provide a comment to disable the rule for a single line. 9 | - Use PSR-4 for file organization and autoloading. 10 | - Always provide type hints for PHP code. Write code that passes Psalm error level 7. 11 | - Prefer writing TypeScript over JavaScript. 12 | - Provide docblock comments that describe classes, methods, and functions. Describe parameters only when the type hint and variable name are insufficient. 13 | - Optimize code for readability. 14 | -------------------------------------------------------------------------------- /.cursor/rules/plugin-environment.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: 3 | globs: 4 | alwaysApply: true 5 | --- 6 | 7 | - You are contributing to a public, open-source WordPress plugin. This plugin enhances the block editor, provides REST API endpoints, and connects with remote data sources (APIs). 8 | - The plugin requires PHP 8.1 or higher, so you should take advantage of language features available in PHP 8.1. 9 | - The plugin requires WordPress 6.7 or higher. 10 | - The plugin includes Guzzle as a vendor dependency but uses it only to orchestrate requests. The actual HTTP requests are delegated to `wp_remote_request`. 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_style = tab 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | 10 | [*.json,*.yaml,*.yml] 11 | indent_style = space 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | example/templates/airtable-map-block/src 3 | node_modules 4 | vendor 5 | *.php 6 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | require( '@automattic/eslint-plugin-wpvip/init' ); 2 | 3 | module.exports = { 4 | extends: [ 'plugin:@automattic/wpvip/recommended' ], 5 | globals: { 6 | REMOTE_DATA_BLOCKS: 'readonly', 7 | REMOTE_DATA_BLOCKS_SETTINGS: 'readonly', 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners 2 | * @Automattic/vip-cafe 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/api_integration_issue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: API integration issue 3 | about: Report an issue integrating an API with Remote Data Blocks. 4 | title: '' 5 | labels: bug 6 | assignees: smithjw1 7 | --- 8 | 9 | ## API information 10 | 11 | Please describe the API you are trying to integrate with Remote Data Blocks. Include the name, endpoint, version, and any relevant documentation links. 12 | 13 | ## Query information 14 | 15 | A brief description of the query or mutation you are trying to execute. If possible, include a sample request and response. Reproductions using cURL are preferred. 16 | 17 | ## Block information 18 | 19 | A brief description of the remote data block you are trying to create and its intended purpose. 20 | 21 | ## What went wrong 22 | 23 | A clear and concise description of the problem you encountered while integrating the API with Remote Data Blocks. If applicable, include any error messages or unexpected behavior you observed. 24 | 25 | ### Screenshots or logs 26 | 27 | If applicable, please add screenshots or logs to help explain your bug report. 28 | 29 | ### Environment 30 | 31 | Provide details about your environment: 32 | 33 | - Remote Data Blocks plugin version: 34 | - WordPress version: 35 | - PHP version: 36 | - Browser and version: 37 | - Operating system: 38 | 39 | ### Additional context 40 | 41 | Add any other context about the problem here. GitHub issues are public! Please be careful to not share any confidential information.\ 42 | 43 | ## What could have helped 44 | 45 | Suggestions are welcome for specific debugging information that would have been useful, and how you expect that information to be surfaced. 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: General bug report 3 | about: Report an issue not related to integrating an API. 4 | title: '' 5 | labels: bug 6 | assignees: smithjw1 7 | --- 8 | 9 | ## Describe the bug 10 | 11 | A one sentence description of the bug. 12 | 13 | ## Expected behavior 14 | 15 | A clear and concise description of what you expected to happen. 16 | 17 | ## Actual behavior 18 | 19 | A clear and concise description of what actually happened. 20 | 21 | ## Reproduction 22 | 23 | Steps to reproduce the bug. 24 | 25 | 1. Step one 26 | 2. Step two 27 | 3. Step three 28 | 29 | ### Screenshots or logs 30 | 31 | If applicable, please add screenshots or logs to help explain your bug report. 32 | 33 | ### Environment 34 | 35 | Provide details about your environment: 36 | 37 | - Remote Data Blocks plugin version: 38 | - WordPress version: 39 | - PHP version: 40 | - Browser and version: 41 | - Operating system: 42 | 43 | ### Additional context 44 | 45 | Add any other context about the problem here. GitHub issues are public! Please be careful to not share any confidential information. 46 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/general_feedback.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feedback 3 | about: Let us know what you think. 4 | title: '' 5 | labels: feedback 6 | assignees: smithjw1 7 | --- 8 | 9 | ## Summarize your feedback 10 | 11 | Provide a one-sentence summary of your feedback. 12 | 13 | ## Detailed Feedback 14 | 15 | Please provide your full feedback here. Be as detailed as possible. Issues are public, so please do not share any confidential information. 16 | 17 | ## Version of the plugin 18 | 19 | Specify the version of the plugin you are using. 20 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | coverage: 3 | status: 4 | project: 5 | default: 6 | informational: true 7 | patch: 8 | default: 9 | informational: true 10 | github_checks: 11 | annotations: false 12 | ignore: 13 | - 'example' 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/dependabot-2.0.json 2 | version: 2 3 | updates: 4 | - package-ecosystem: 'npm' 5 | directory: '/' 6 | schedule: 7 | interval: 'weekly' 8 | ignore: 9 | - dependency-name: '*' 10 | update-types: ['version-update:semver-patch'] 11 | groups: 12 | wordpress: 13 | patterns: 14 | - '@wordpress/*' 15 | 16 | - package-ecosystem: 'composer' 17 | directory: '/' 18 | schedule: 19 | interval: 'weekly' 20 | ignore: 21 | - dependency-name: '*' 22 | update-types: ['version-update:semver-patch'] 23 | -------------------------------------------------------------------------------- /.github/workflows/ai-docs.yml: -------------------------------------------------------------------------------- 1 | name: Update AI documentation 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | generate-ai-docs: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup Node.js 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: '20' 21 | 22 | - name: Generate AI documentation 23 | run: npm run build:docs:ai 24 | 25 | - name: Check for changes 26 | id: git-check 27 | run: | 28 | git add docs/for-ai.md 29 | git diff --staged --quiet docs/for-ai.md || echo "changes=true" >> $GITHUB_OUTPUT 30 | 31 | - name: Commit changes 32 | if: steps.git-check.outputs.changes == 'true' 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | PR_BRANCH: 'update/for-ai-${{ github.run_id }}' 36 | TRUNK_BRANCH: 'trunk' 37 | run: | 38 | git switch -c "$PR_BRANCH" 39 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 40 | git config --local user.name "github-actions[bot]" 41 | git commit -m "Update docs/for-ai.md with the latest documentation and examples" docs/for-ai.md 42 | git push --set-upstream origin "$PR_BRANCH" 43 | gh pr create -B "$TRUNK_BRANCH" -H "$PR_BRANCH" --title "Update docs/for-ai.md" --body "Update docs/for-ai.md with the latest documentation and examples." --label "documentation" 44 | -------------------------------------------------------------------------------- /.github/workflows/integration-tests.yml: -------------------------------------------------------------------------------- 1 | name: Integration Tests 2 | 3 | on: 4 | # Run on all pull requests. 5 | pull_request: 6 | push: 7 | branches: 8 | - trunk 9 | 10 | # Cancels all previous workflow runs for pull requests that have not completed. 11 | concurrency: 12 | # The concurrency group contains the workflow name and the branch name for pull requests 13 | # or the commit hash for any other events. 14 | group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | test: 19 | name: WordPress Integration Tests 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: Checkout code 24 | uses: actions/checkout@v4 25 | 26 | - name: Use desired version of NodeJS 27 | uses: actions/setup-node@v4.1.0 28 | with: 29 | node-version: 20 30 | cache: npm 31 | 32 | - name: Npm install 33 | run: npm ci 34 | 35 | - name: Start up WordPress environment 36 | env: 37 | WP_ENV_CORE: WordPress/WordPress#6.7 38 | WP_ENV_PHP_VERSION: 8.1 39 | run: npm run dev:build 40 | 41 | - name: Output WordPress and PHP version 42 | run: | 43 | npm run wp-cli cli info 44 | npm run wp-cli core version 45 | 46 | - name: Run Integration tests 47 | run: npm run test:integration 48 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Static analysis 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - trunk 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | lint: 17 | name: eslint, prettier, wp-scripts 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Setup Node.js 21 | uses: Automattic/vip-actions/nodejs-setup@trunk 22 | with: 23 | node-version-file: .nvmrc 24 | ignore-scripts: true 25 | 26 | - name: Run ESLint 27 | run: npm run lint 28 | 29 | - name: Run CSS lint 30 | run: npm run lint:css 31 | 32 | - name: Check formatting 33 | run: npm run format:check 34 | 35 | - name: Check types 36 | run: npm run check-types 37 | 38 | phpcs: 39 | name: phpcs 40 | runs-on: ubuntu-latest 41 | steps: 42 | - name: Checkout code 43 | uses: actions/checkout@v4 44 | 45 | - name: Setup PHP 46 | uses: shivammathur/setup-php@v2 47 | with: 48 | php-version: '8.1' 49 | 50 | - name: Install dependencies 51 | run: composer install 52 | 53 | - name: Run phpcs 54 | run: composer phpcs 55 | 56 | psalm: 57 | name: Psalm 58 | runs-on: ubuntu-latest 59 | steps: 60 | - name: Checkout code 61 | uses: actions/checkout@v4 62 | 63 | - name: Run Psalm 64 | uses: docker://ghcr.io/psalm/psalm-github-actions:6.5.1 65 | with: 66 | composer_require_dev: true 67 | php-version: '8.1' 68 | 69 | dependaban: 70 | name: Dependaban 71 | runs-on: ubuntu-latest 72 | steps: 73 | - uses: Automattic/vip-actions/dependaban@trunk 74 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - trunk 6 | 7 | jobs: 8 | check_and_release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | 15 | - name: Check for version change 16 | id: version_check 17 | run: | 18 | OLD_VERSION=$(git show HEAD^:remote-data-blocks.php | sed -n 's/.*Version: *//p' | tr -d '[:space:]') 19 | NEW_VERSION=$(git show HEAD:remote-data-blocks.php | sed -n 's/.*Version: *//p' | tr -d '[:space:]') 20 | if [ "$OLD_VERSION" != "$NEW_VERSION" ]; then 21 | echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT 22 | echo "Version changed from $OLD_VERSION to $NEW_VERSION" 23 | else 24 | echo "No version change detected" 25 | fi 26 | 27 | - name: Setup PHP 28 | if: steps.version_check.outputs.new_version 29 | uses: shivammathur/setup-php@v2 30 | with: 31 | php-version: '8.1' 32 | 33 | - name: Build plugin zip 34 | if: steps.version_check.outputs.new_version 35 | run: npm ci && npm run plugin-zip 36 | 37 | - name: Create Release 38 | if: steps.version_check.outputs.new_version 39 | uses: softprops/action-gh-release@v2 40 | with: 41 | tag_name: v${{ steps.version_check.outputs.new_version }} 42 | files: remote-data-blocks.zip 43 | generate_release_notes: true 44 | -------------------------------------------------------------------------------- /.github/workflows/scripts/generate-playground-blueprint.js: -------------------------------------------------------------------------------- 1 | async function run( { github, context } ) { 2 | const commentInfo = { 3 | owner: context.repo.owner, 4 | repo: context.repo.repo, 5 | issue_number: context.issue.number, 6 | }; 7 | 8 | const comments = ( await github.rest.issues.listComments( commentInfo ) ).data; 9 | let existingCommentId = null; 10 | 11 | for ( const currentComment of comments ) { 12 | if ( currentComment.user.type === 'Bot' && currentComment.body.includes( 'Test this PR in' ) ) { 13 | existingCommentId = currentComment.id; 14 | break; 15 | } 16 | } 17 | 18 | const body = `Test this PR in [WordPress Playground](https://playground.wordpress.net/#{"landingPage":"/wp-admin/admin.php?page=remote-data-blocks-settings","features":{"networking":true},"login":true,"preferredVersions":{"php":"8.2","wp":"latest"},"steps":[{"step":"setSiteOptions","options":{"blogname":"Remote%20Data%20Blocks%20PR#${ context.issue.number }","blogdescription":"Explore%20the%20Remote%20Data%20Blocks%20plugin%20in%20a%20WordPress%20Playground"}},{"step":"installPlugin","pluginData":{"caption":"Installing%20RDB","resource":"url","url":"https://playground.wordpress.net/plugin-proxy.php?org=Automattic&repo=remote-data-blocks&workflow=Build%20Live%20Branch&artifact=remote-data-blocks-${ context.issue.number }&pr=${ context.issue.number }"},"options":{"activate":true,"targetFolderName":"remote-data-blocks"}}]}).`; 19 | 20 | if ( existingCommentId ) { 21 | await github.rest.issues.updateComment( { 22 | owner: commentInfo.owner, 23 | repo: commentInfo.repo, 24 | comment_id: existingCommentId, 25 | body: body, 26 | } ); 27 | } else { 28 | commentInfo.body = body; 29 | await github.rest.issues.createComment( commentInfo ); 30 | } 31 | } 32 | 33 | module.exports = { run }; 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .wp-env.override.json 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Coverage directory used by tools like istanbul 11 | coverage 12 | 13 | # Build output (only at root) 14 | /build/ 15 | 16 | # Private examples 17 | /example/private 18 | 19 | # Testing 20 | /artifacts/ 21 | /test-results/ 22 | 23 | # Dependency directories 24 | node_modules/ 25 | 26 | # Optional npm cache directory 27 | .npm 28 | 29 | # Optional eslint cache 30 | .eslintcache 31 | 32 | # Output of `npm pack` 33 | *.tgz 34 | 35 | # Output of `wp-scripts plugin-zip` 36 | *.zip 37 | 38 | # dotenv environment variables file 39 | .env 40 | 41 | # Composer dev dependencies 42 | vendor/ 43 | .DS_Store 44 | 45 | # PHPUnit 46 | .phpunit.cache/ 47 | .phpunit.result.cache 48 | 49 | # Editors 50 | .idea/* 51 | !.idea/runConfigurations/ 52 | .vscode/* 53 | !.vscode/launch.json 54 | .zed 55 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | if [ -d "$(git rev-parse --git-path rebase-merge)" ] || [ -d "$(git rev-parse --git-path rebase-apply)" ]; then 2 | echo "Rebase in progress. Skipping pre-commit hook.\n" 3 | exit 0 4 | fi 5 | 6 | lint-staged 7 | -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "*.css": [ "npm run lint:css" ], 3 | "*.{js,jsx,ts,tsx}": [ "npm run lint" ], 4 | "*.php": [ "npm run lint:php" ], 5 | "*.{js,json,jsx,md,ts,tsx,yml,yaml}": [ "npm run format:check" ] 6 | } 7 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | vendor 4 | *.php 5 | package-lock.json 6 | package.json 7 | docs/for-ai.md 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | "@automattic/eslint-plugin-wpvip/prettierrc" -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Listen for Xdebug", 6 | "type": "php", 7 | "request": "launch", 8 | "port": 9003, 9 | "pathMappings": { 10 | "/var/www/html/wp-content/plugins/remote-data-blocks": "${workspaceFolder}/" 11 | } 12 | }, 13 | { 14 | "type": "chrome", 15 | "request": "launch", 16 | "name": "Launch Chrome against localhost", 17 | "url": "http://localhost:8888/", 18 | "webRoot": "${workspaceFolder}" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.wp-env.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/WordPress/gutenberg/refs/heads/trunk/schemas/json/wp-env.json", 3 | "config": { 4 | "WP_DEBUG_DISPLAY": true, 5 | "WP_DEVELOPMENT_MODE": "plugin", 6 | "WP_ENVIRONMENT_TYPE": "development", 7 | "WP_REDIS_DISABLE_BANNERS": true, 8 | "WP_REDIS_HOST": "host.docker.internal" 9 | }, 10 | "mappings": { 11 | ".htaccess": "./bin/conf/htaccess" 12 | }, 13 | "plugins": [ 14 | ".", 15 | "https://downloads.wordpress.org/plugin/query-monitor.latest-stable.zip", 16 | "https://downloads.wordpress.org/plugin/redis-cache.latest-stable.zip" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Guidelines 4 | 5 | - All contributors are expected to follow our [Code of Conduct](https://make.wordpress.org/handbook/community-code-of-conduct/), to ensure a welcoming environment for everyone. 6 | 7 | - Contributors should review the WordPress [PHP coding standards](https://developer.wordpress.org/coding-standards/wordpress-coding-standards/php/), [JavaScript coding standards](https://developer.wordpress.org/coding-standards/wordpress-coding-standards/javascript/), and [accessibility coding standards](https://developer.wordpress.org/coding-standards/wordpress-coding-standards/accessibility/). Accessibility in particular should be top of mind and thoroughly tested. 8 | 9 | - You maintain copyright over any contribution you make. By submitting a pull request, you agree to release that code under [our license](https://github.com/Automattic/remote-data-blocks/blob/trunk/LICENSE). 10 | 11 | - Before opening a pull request, please first discuss the change you wish to make via an issue or discussion. 12 | 13 | ## Reporting security issues 14 | 15 | Please see [SECURITY.md](SECURITY.md). 16 | 17 | ## Development environment 18 | 19 | Please see our guide to setting up a [local development environment](docs/local-development.md). 20 | 21 | ## Versioning 22 | 23 | Remote Data Blocks uses [semantic versioning](https://semver.org/). 24 | 25 | ## Release process 26 | 27 | 1. Checkout the `trunk` branch and ensure it is up to date. 28 | 2. Run the release script: `./bin/release ` 29 | 3. Push the new release branch to the remote repository and create a pull request. 30 | 4. Merge the pull request into `trunk`. 31 | 32 | A new release will be automatically published on GitHub via GitHub Actions. 33 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Reporting Security Issues 2 | 3 | WPVIP and Automattic take security issues seriously. We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions. 4 | 5 | To report a security issue, please visit [Automattic's HackerOne](https://hackerone.com/automattic) program. 6 | -------------------------------------------------------------------------------- /bin/conf/htaccess: -------------------------------------------------------------------------------- 1 | # BEGIN WordPress 2 | # The directives (lines) between "BEGIN WordPress" and "END WordPress" are 3 | # dynamically generated, and should only be modified via WordPress filters. 4 | # Any changes to the directives between these markers will be overwritten. 5 | 6 | RewriteEngine On 7 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 8 | RewriteBase / 9 | RewriteRule ^index\.php$ - [L] 10 | RewriteCond %{REQUEST_FILENAME} !-f 11 | RewriteCond %{REQUEST_FILENAME} !-d 12 | RewriteRule . /index.php [L] 13 | 14 | 15 | # END WordPress 16 | -------------------------------------------------------------------------------- /bin/stop: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | TASK="${1-stop}" 4 | 5 | teardown_redis() { 6 | echo "🔽 Shutting down Redis..." 7 | docker compose -f docker-compose.overrides.yml down 8 | } 9 | 10 | teardown_wordpress() { 11 | if [ "$TASK" = "destroy" ]; then 12 | npx wp-env destroy 13 | echo "👋 Run \`npm run dev\` to recreate." 14 | else 15 | npx wp-env stop 16 | echo "⏹️ Rerun \`npm run dev\` to resume or \`npm run dev:destroy\` to clean up." 17 | fi 18 | } 19 | 20 | # Kill any lingering Node.js processes. 21 | pkill -f 'remote-data-blocks/node_modules/' 22 | 23 | teardown_redis 24 | teardown_wordpress 25 | -------------------------------------------------------------------------------- /blueprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://playground.wordpress.net/blueprint-schema.json", 3 | "meta": { 4 | "title": "Remote Data Blocks latest", 5 | "description": "Installs the latest release of remote-data-blocks plugin to WordPress Playground", 6 | "author": "WordPress VIP", 7 | "categories": [ "Content" ] 8 | }, 9 | "features": { 10 | "networking": true 11 | }, 12 | "landingPage": "/wp-admin/post.php?post=4&action=edit", 13 | "login": true, 14 | "preferredVersions": { 15 | "php": "8.2", 16 | "wp": "latest" 17 | }, 18 | "steps": [ 19 | { 20 | "step": "setSiteOptions", 21 | "options": { 22 | "blogname": "Remote Data Blocks", 23 | "blogdescription": "Explore the Remote Data Blocks plugin in a WordPress Playground" 24 | } 25 | }, 26 | { 27 | "step": "installPlugin", 28 | "options": { 29 | "activate": true, 30 | "targetFolderName": "remote-data-blocks" 31 | }, 32 | "pluginData": { 33 | "caption": "Installing Remote Data Blocks", 34 | "resource": "url", 35 | "url": "https://playground.wordpress.net/plugin-proxy.php?repo=Automattic/remote-data-blocks&name=remote-data-blocks.zip" 36 | } 37 | }, 38 | { 39 | "step": "importWxr", 40 | "file": { 41 | "resource": "url", 42 | "url": "https://raw.githubusercontent.com/Automattic/remote-data-blocks/refs/heads/trunk/example/assets/blueprint-content.wxr" 43 | } 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /blueprint.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://playground.wordpress.net/blueprint-schema.json", 3 | "meta": { 4 | "title": "Remote Data Blocks local playground", 5 | "description": "Runs Remote Data Blocks plugin in a local WordPress Playground", 6 | "author": "WordPress VIP", 7 | "categories": [ "Content" ] 8 | }, 9 | "features": { 10 | "networking": true 11 | }, 12 | "landingPage": "/wp-admin/post.php?post=4&action=edit", 13 | "login": true, 14 | "preferredVersions": { 15 | "php": "8.2", 16 | "wp": "latest" 17 | }, 18 | "steps": [ 19 | { 20 | "step": "setSiteOptions", 21 | "options": { 22 | "blogname": "Remote Data Blocks", 23 | "blogdescription": "Explore the Remote Data Blocks plugin in a WordPress Playground" 24 | } 25 | }, 26 | { 27 | "step": "activatePlugin", 28 | "pluginPath": "remote-data-blocks/remote-data-blocks.php" 29 | }, 30 | { 31 | "step": "importWxr", 32 | "file": { 33 | "resource": "url", 34 | "url": "https://raw.githubusercontent.com/Automattic/remote-data-blocks/refs/heads/trunk/example/assets/blueprint-content.wxr" 35 | } 36 | } 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /blueprint.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://playground.wordpress.net/blueprint-schema.json", 3 | "meta": { 4 | "title": "Remote Data Blocks latest", 5 | "description": "Installs the latest release of remote-data-blocks plugin to WordPress Playground", 6 | "author": "WordPress VIP", 7 | "categories": [ "Content" ] 8 | }, 9 | "features": { 10 | "networking": true 11 | }, 12 | "landingPage": "/wp-admin/admin.php?page=remote-data-blocks-settings", 13 | "login": true, 14 | "preferredVersions": { 15 | "php": "8.2", 16 | "wp": "latest" 17 | }, 18 | "steps": [ 19 | { 20 | "step": "setSiteOptions", 21 | "options": { 22 | "blogname": "Remote Data Blocks", 23 | "blogdescription": "Explore the Remote Data Blocks plugin in a WordPress Playground" 24 | } 25 | }, 26 | { 27 | "step": "installPlugin", 28 | "options": { 29 | "activate": true, 30 | "targetFolderName": "remote-data-blocks" 31 | }, 32 | "pluginData": { 33 | "caption": "Installing Remote Data Blocks", 34 | "resource": "url", 35 | "url": "https://playground.wordpress.net/plugin-proxy.php?repo=Automattic/remote-data-blocks&name=remote-data-blocks.zip" 36 | } 37 | }, 38 | { 39 | "step": "importWxr", 40 | "file": { 41 | "resource": "url", 42 | "url": "https://raw.githubusercontent.com/Automattic/remote-data-blocks/refs/heads/trunk/example/assets/blueprint-content.wxr" 43 | } 44 | } 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "automattic/remote-data-blocks", 3 | "authors": [ 4 | { 5 | "name": "WPVIP" 6 | } 7 | ], 8 | "license": "GPL-2.0-or-later", 9 | "autoload": { 10 | "psr-4": { 11 | "RemoteDataBlocks\\": "inc/" 12 | }, 13 | "files": [ 14 | "functions.php", 15 | "inc/Integrations/constants.php" 16 | ] 17 | }, 18 | "autoload-dev": { 19 | "psr-4": { 20 | "RemoteDataBlocks\\Tests\\": "tests/inc/" 21 | } 22 | }, 23 | "scripts": { 24 | "phpcs": "phpcs", 25 | "phpcs-fix": "phpcbf", 26 | "psalm": "psalm.phar --no-cache", 27 | "test": "phpunit", 28 | "test-coverage": "phpunit --coverage-clover ./coverage/phpunit/clover.xml" 29 | }, 30 | "require": { 31 | "php": ">=8.1", 32 | "galbar/jsonpath": "^3.0", 33 | "guzzlehttp/guzzle": "^7.8", 34 | "kevinrob/guzzle-cache-middleware": "^6.0", 35 | "erusev/parsedown": "^1.7", 36 | "symfony/var-exporter": "^6" 37 | }, 38 | "require-dev": { 39 | "automattic/vipwpcs": "^3.0", 40 | "phpcompatibility/phpcompatibility-wp": "^2.1", 41 | "phpunit/phpunit": "^9", 42 | "slevomat/coding-standard": "^8.15", 43 | "php-stubs/wordpress-stubs": "^6.6", 44 | "psalm/phar": "^6.5", 45 | "mockery/mockery": "^1.6", 46 | "wp-phpunit/wp-phpunit": "^6.7", 47 | "yoast/phpunit-polyfills": "^4.0", 48 | "php-stubs/wordpress-globals": "^0.2.0", 49 | "php-stubs/wordpress-tests-stubs": "^6.7" 50 | }, 51 | "config": { 52 | "allow-plugins": { 53 | "dealerdirect/phpcodesniffer-composer-installer": true 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /docker-compose.overrides.yml: -------------------------------------------------------------------------------- 1 | --- 2 | services: 3 | valkey: 4 | image: valkey/valkey:7-alpine 5 | container_name: remote-data-blocks-valkey 6 | ports: 7 | - '6379:6379' 8 | -------------------------------------------------------------------------------- /docs/assets/block-insert-remote-html.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/remote-data-blocks/fce72063f3ad1b8c9023c103c0ef2947565f5d68/docs/assets/block-insert-remote-html.gif -------------------------------------------------------------------------------- /docs/assets/google-console.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/remote-data-blocks/fce72063f3ad1b8c9023c103c0ef2947565f5d68/docs/assets/google-console.png -------------------------------------------------------------------------------- /docs/assets/insert-shopify-block.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/remote-data-blocks/fce72063f3ad1b8c9023c103c0ef2947565f5d68/docs/assets/insert-shopify-block.gif -------------------------------------------------------------------------------- /docs/assets/patterns-right-panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/remote-data-blocks/fce72063f3ad1b8c9023c103c0ef2947565f5d68/docs/assets/patterns-right-panel.png -------------------------------------------------------------------------------- /docs/assets/search-input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/remote-data-blocks/fce72063f3ad1b8c9023c103c0ef2947565f5d68/docs/assets/search-input.png -------------------------------------------------------------------------------- /docs/concepts/block-bindings.md: -------------------------------------------------------------------------------- 1 | # Block bindings 2 | 3 | Remote Data Blocks takes advantage of the [block bindings API](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-bindings/). This core WordPress API allows you to “bind” dynamic data to the attributes of core blocks, which are then reflected in the final HTML markup. Generally, this avoids the need to write and maintain custom blocks. 4 | 5 | For a quick overview of block bindings, the [announcement post](https://make.wordpress.org/core/2024/03/06/new-feature-the-block-bindings-api/) is very helpful; for a deeper dive, consult the [public documentation](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-bindings/). That said, an in-depth understanding of block bindings isn't necessary to use Remote Data Blocks: just know that the plugin is built on core, stable WordPress APIs. 6 | -------------------------------------------------------------------------------- /docs/concepts/helper-blocks.md: -------------------------------------------------------------------------------- 1 | # Helper Blocks 2 | 3 | Remote Data Blocks adds some accessory blocks for bindings, listed below. 4 | 5 | ## Remote HTML Block 6 | 7 | Use this block to bind to HTML from a remote data source. This block only works when placed inside a remote data block container and bound to a field containing HTML. 8 | 9 | ![Screen recording showing the insertion and binding of a Remote HTML Block in the editor](https://raw.githubusercontent.com/Automattic/remote-data-blocks/trunk/docs/assets/block-insert-remote-html.gif) 10 | 11 | Fields defined by a query’s `output_schema` must have type `html` in order to be available to Remote HTML blocks: 12 | 13 | ```php 14 | $my_query = [ 15 | /* ... */ 16 | 'output_schema' => 17 | 'is_collection' => false, 18 | 'output_schema' => [ 19 | 'type' => [ 20 | 'header' => [ 21 | 'name' => 'Header', 22 | 'path' => '$.header', 23 | 'type' => 'string', 24 | ], 25 | 'myHtmlContent' => [ 26 | 'name' => 'My HTML Content', 27 | 'path' => '$.myHtmlContent', 28 | 'type' => 'html', // <-- required 29 | ], 30 | ], 31 | ], 32 | ]; 33 | 34 | register_remote_data_block( [ 35 | 'title' => 'My HTML API', 36 | 'render_query' => [ 37 | 'query' => $my_query, 38 | ], 39 | ] ); 40 | ``` 41 | 42 | ## No Results Block 43 | 44 | This block is used to display a message or content when a remote data block query returns no results. It is automatically inserted whenever you use a query that resolves to a collection, even if the collection is not currently empty. 45 | -------------------------------------------------------------------------------- /docs/concepts/inline-bindings.md: -------------------------------------------------------------------------------- 1 | # Inline bindings 2 | 3 | One of the current limitations of the [block bindings API](block-bindings.md) is that it is restricted to a small number of core blocks and attributes. For example, currently, you cannot bind to the content of a table block or a custom block. You also cannot bind to a _subset_ of a block's content. 4 | 5 | As a partial workaround, this plugin provides a way to use remote data in some places where block bindings are not supported. This feature is named "inline bindings" and it is available in any block that uses [rich text](https://developer.wordpress.org/block-editor/reference-guides/richtext/), such as tables, lists, and some custom blocks. Look for the inline binding button in the rich text formatting toolbar: 6 | 7 | Inline binding button 8 | 9 | Clicking this button will open a modal that allows you to select a field from a remote data source, resulting in an inline remote data binding. Just like remote data blocks, this binding will resolve from the remote source when the content is rendered. 10 | 11 | A bulleted list using several inline bindings to describe three conference events 12 | 13 | Inline bindings compile to HTML, so they are portable, safe, and have a built-in fallback. 14 | -------------------------------------------------------------------------------- /docs/extending/data-source.md: -------------------------------------------------------------------------------- 1 | # Data source 2 | 3 | A data source defines the basic reusable properties of an API and is used by a [query](query.md) to reduce duplicative code. It also helps define how your data source looks in the WordPress admin. 4 | 5 | Simple data sources can be configured via the plugin's settings screen, while others may require custom PHP code. 6 | 7 | ## Example 8 | 9 | Here's an example of a data source configuration for an HTTP API: 10 | 11 | ```php 12 | $data_source = [ 13 | 'display_name' => 'Example API', 14 | 'endpoint' => 'https://api.example.com/', 15 | 'request_headers' => [ 16 | 'Content-Type' => 'application/json', 17 | 'X-Api-Key' => constant( 'MY_API_KEY_CONSTANT' ), 18 | ], 19 | ]; 20 | ``` 21 | 22 | And here is an example of a data source that was defined in the plugin settings screen, loaded by its UUID: 23 | 24 | ```php 25 | $data_source = HttpDataSource::from_uuid( '{{ Data source UUID }}' ); 26 | ``` 27 | 28 | ## Configuration 29 | 30 | ### display_name: string (required) 31 | 32 | The display name is used in the UI to identify your data source. 33 | 34 | ### endpoint: string (required) 35 | 36 | This is the default or base endpoint for the data source. [Queries](query.md) that use a data source can override or append paths to its endpoint. 37 | 38 | ### image_url: string 39 | 40 | An optional image URL can be used in the UI to help identify your data source. 41 | 42 | ### request_headers: array 43 | 44 | An associative array of headers that will be sent with each HTTP request. Queries that use a data source can override or append headers. 45 | 46 | When providing authentication credentials, take care to avoid committing them to code repositories. We strongly recommend using environment variables or secure storage. 47 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Documentation 2 | 3 | ## Table of Contents 4 | 5 | - [Quickstart](quickstart.md) 6 | - [Core concepts](concepts/index.md) 7 | 8 | - [Block bindings](concepts/block-bindings.md) 9 | - [Helper blocks](concepts/helper-blocks.md) 10 | - [Inline bindings](concepts/inline-bindings.md) 11 | 12 | - [Extending](extending/index.md) 13 | 14 | - [Data source](extending/data-source.md) 15 | - [Query](extending/query.md) 16 | - [Query input schema](extending/query-input-schema.md) 17 | - [Query output schema](extending/query-output-schema.md) 18 | - [Block registration](extending/block-registration.md) 19 | - [Block patterns](extending/block-patterns.md) 20 | - [Overrides](extending/overrides.md) 21 | - [Hooks](extending/hooks.md) 22 | 23 | - [Tutorials](tutorials/index.md) 24 | 25 | - [Airtable](tutorials/airtable.md) 26 | - [Google Sheets integration](tutorials/google-sheets.md) 27 | - [Shopify](tutorials/shopify.md) 28 | - [HTTP](tutorials/http.md) 29 | 30 | - [Local development](local-development.md) 31 | - [Troubleshooting](troubleshooting.md) 32 | -------------------------------------------------------------------------------- /docs/quickstart.md: -------------------------------------------------------------------------------- 1 | # Quickstart 2 | 3 | ## I want to explore the plugin without configuring anything. 4 | 5 | [Launch the plugin in WordPress Playground](https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/Automattic/remote-data-blocks/trunk/blueprint.json) and explore. An example remote data block ("Conference Event") has been registered. 6 | 7 | ## I want to create my own remote data block without writing any code. 8 | 9 | [Launch the plugin in WordPress Playground](https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/Automattic/remote-data-blocks/trunk/blueprint.settings.json) and use one of the supported services like Airtable, Google Sheets, and Shopify to configure remote data blocks for your use case. Follow our [tutorials](./tutorials/index.md) to create a data source and register a remote data block. 10 | 11 | ## I want to create my own remote data block against a custom API. 12 | 13 | If you're comfortable with writing some WordPress code, [our documentation will guide you](extending/index.md) through registering a remote data block that can work with your custom API. We also provide [working examples](https://github.com/Automattic/remote-data-blocks/blob/trunk/example/README.md) and [AI prompts](extending/ai-prompts.md) that help you get started quickly. 14 | -------------------------------------------------------------------------------- /docs/tutorials/http.md: -------------------------------------------------------------------------------- 1 | # Create a remote data block using an HTTP data source 2 | 3 | This page will walk you through registering a remote data block that loads data from a Zip code REST API. It will require you to commit code to a WordPress theme or plugin. 4 | 5 | ## Create the data source 6 | 7 | 1. Go to Settings > Remote Data Blocks in your WordPress admin. 8 | 2. Click on the "Connect new" button. 9 | 3. Choose "HTTP" from the dropdown menu as the data source type. 10 | 4. Fill in the following details: 11 | - Data Source Name: Zip Code API 12 | - URL: https://api.zippopotam.us/us/ 13 | 5. If your API requires authentication, enter those details. This API does not. 14 | 6. Save the data source and return the data source list. 15 | 7. In the Actions column, click the three-dot menu, then "Copy UUID" to copy the data source's UUID to your clipboard. 16 | 17 | ## Register the block 18 | 19 | In code, we'll define a query using the data source we just created. Follow the [Zip code block example](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/blocks/zip-code-block/zip-code-block.php), but remove the data source definition. In its place, use this code to load the data source we just created by its UUID: 20 | 21 | ```php 22 | $data_source = HttpDataSource::from_uuid( '{{ Data source UUID }}' ); 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/tutorials/index.md: -------------------------------------------------------------------------------- 1 | # Tutorials 2 | 3 | This section will guide you through configuring data sources in the plugin settings and via code. 4 | 5 | - [Airtable](airtable.md) 6 | - [Google Sheets integration](google-sheets.md) 7 | - [Shopify](shopify.md) 8 | - [HTTP](http.md) 9 | -------------------------------------------------------------------------------- /example/.cursor/rules/project-scope.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Project scope 3 | globs: 4 | alwaysApply: true 5 | --- 6 | 7 | - You are writing code that integrates with the Remote Data Blocks WordPress plugin. This plugin allows you to create Gutenberg blocks that display data from remote data sources, such as Airtable, Google Sheets, Shopify, or your own API. 8 | - You are not contributing to the plugin directly. You are writing code that will be used in a separate plugin or theme. 9 | - You do not need to develop custom Gutenberg blocks. Instead, you will write simple PHP code to describe how your API should be queried, then call registration functions provided by the Remote Data Blocks plugin. 10 | - Your goal is to configure and register a remote data block that displays remote data in an organized, visually appealing way. 11 | - The Remote Data Blocks plugin provides a default block pattern for displaying data, but it is very basic. You may need to create a custom block pattern to achieve your goal, but please ask before doing so. 12 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Example code and templates 2 | 3 | The example code and templates in this directory can help you get started with the Remote Data Blocks plugin. Note that many tasks can be performed in the UI without writing any code. However, other tasks require custom code, especially when you want to work with generic REST APIs or customize the block output or behavior. 4 | 5 | ## Block examples 6 | 7 | These blocks communicate with APIs that do not require authentication. Uncomment lines at the end of `remote-data-blocks.php` to enable them. They are roughly in order of complexity, starting with the simplest. 8 | 9 | - [Zip Code block](./blocks/zip-code-block/zip-code-block.php) 10 | - [Art block](./blocks/art-block/art-block.php) 11 | - [Shopify Mock Store block](./blocks/shopify-mock-store-block/shopify-mock-store-block.php) 12 | - [GitHub Markdown File block](./blocks/github-markdown-block/github-markdown-block.php) 13 | 14 | ## Templates 15 | 16 | These code templates require credentials and other customization to work. They are a useful starting point for exploration and are especially useful as context for AI agents. 17 | 18 | - [REST API block](templates/rest-api-block) 19 | - [REST API block from UI-created data source](templates/rest-api-block-from-ui-data-source) 20 | - [Airtable block](templates/airtable-block) 21 | - [Airtable map block](templates/airtable-map-block) 22 | - [Google Sheets block](templates/google-sheets-block) 23 | - [Shopify Product block](templates/shopify-product-block) 24 | - [Example child theme](templates/theme) 25 | -------------------------------------------------------------------------------- /example/blocks/github-markdown-block/inc/github-query-runner.php: -------------------------------------------------------------------------------- 1 | ensure_file_extension( $input_variables['file_path'] ); 23 | 24 | return parent::execute( $query, $input_variables ); 25 | } 26 | 27 | /** 28 | * @inheritDoc 29 | * 30 | * The API response is raw HTML, so we return an object construct containing 31 | * the HTML as a property. 32 | */ 33 | protected function deserialize_response( string $raw_response_data, array $input_variables ): array { 34 | return [ 35 | 'content' => $raw_response_data, 36 | 'path' => $input_variables['file_path'], 37 | ]; 38 | } 39 | 40 | private function ensure_file_extension( string $file_path ): string { 41 | return str_ends_with( $file_path, $this->default_file_extension ) ? $file_path : $file_path . $this->default_file_extension; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /example/blocks/github-markdown-block/inc/patterns/file-render.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |

5 | 6 |
7 | 8 | -------------------------------------------------------------------------------- /example/blocks/shopify-mock-store-block/shopify-mock-store-block.php: -------------------------------------------------------------------------------- 1 | [ 18 | '__version' => 1, 19 | 'access_token' => '', // No access token needed for the mock store. 20 | 'display_name' => 'Shopify Mock Store', 21 | 'store_name' => 'mock.shop', 22 | ], 23 | ] ); 24 | 25 | ShopifyIntegration::register_blocks_for_shopify_data_source( $shopify_data_source ); 26 | } 27 | add_action( 'init', __NAMESPACE__ . '\\register_shopify_mock_store_blocks' ); 28 | -------------------------------------------------------------------------------- /example/blocks/weather-block/patterns/weather-block-pattern.html: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 | 5 | 6 |

7 | 8 | 9 | 10 |

11 | 12 | 13 | 14 |

15 | 16 | 17 | 18 |

19 | 20 | -------------------------------------------------------------------------------- /example/templates/airtable-map-block/.gitignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /node_modules/ 3 | /package-lock.json 4 | -------------------------------------------------------------------------------- /example/templates/airtable-map-block/README.md: -------------------------------------------------------------------------------- 1 | # Example: "Leaflet Map" block 2 | 3 | This example illustrates the flexibility of the Remote Data Blocks plugin. Instead of registering a block via `register_remote_data_block`, this example builds a custom dynamic block that uses the [Leaflet library](https://leafletjs.com) to display a map with marked locations. 4 | 5 | The map locations are loaded from an Airtable base that contains longitude and latitude coordinates. Instead of using block bindings, this example creates a data source and a query and executes it manually in `render.php`. 6 | 7 | The result is a registered "Leaflet Map" block that renders remote data in the block editor and on the WordPress frontend. 8 | 9 |

A Leaflet Map block in the block editor

10 | 11 |

A Leaflet Map block in the WordPress frontend

12 | 13 | ## Build step 14 | 15 | Because the custom block uses JSX, it requires a build step: `npm run build`. 16 | 17 | If you want to adapt this example code in your own codebase, we recommend using [the `@wordpress/create-block` utility](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-create-block/) to scaffold your custom block. 18 | -------------------------------------------------------------------------------- /example/templates/airtable-map-block/airtable-map-block.php: -------------------------------------------------------------------------------- 1 | { 15 | // In the block editor, the document can be iframed. 16 | const parentDocument = 17 | document.querySelector( 'iframe[name="editor-canvas"]' )?.contentDocument ?? document; 18 | 19 | // Use an interval to make sure we get elements that might arrive "late" due 20 | // to client-side rendering or because they are rendered in the block editor. 21 | // 22 | // Using `ServerSideRender` allows us to rely on the markup generated by 23 | // `render.php`, which is good. But we don't have a way to know when the 24 | // render is finished, so we need to poll. 25 | const timer = setInterval( () => { 26 | const mapElement = parentDocument.querySelector( 27 | '.wp-block-example-leaflet-map[data-map-coordinates]' 28 | ); 29 | 30 | if ( mapElement ) { 31 | initMaps( [ mapElement ] ); 32 | clearInterval( timer ); 33 | } 34 | }, 100 ); 35 | 36 | return () => clearInterval( timer ); 37 | }, [] ); 38 | } 39 | 40 | export function Edit() { 41 | useMapInit(); 42 | 43 | // ServerSideRender allows us to reuse the markup generated by `render.php` 44 | // instead of duplicating the rendering logic in JavaScript. 45 | return ; 46 | } 47 | -------------------------------------------------------------------------------- /example/templates/airtable-map-block/src/leaflet-map/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Registers a new block provided a unique name and an object defining its behavior. 3 | * 4 | * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/ 5 | */ 6 | import { registerBlockType } from '@wordpress/blocks'; 7 | 8 | /** 9 | * Internal dependencies 10 | */ 11 | import metadata from './block.json'; 12 | import { Edit } from './edit'; 13 | 14 | registerBlockType( metadata.name, { 15 | ...metadata, 16 | edit: Edit, 17 | save: () => null, // A pure dynamic block only serializes its attributes. 18 | } ); 19 | -------------------------------------------------------------------------------- /example/templates/airtable-map-block/src/leaflet-map/view.js: -------------------------------------------------------------------------------- 1 | import domReady from '@wordpress/dom-ready'; 2 | 3 | /* global document, leaflet */ 4 | 5 | export function initMaps( mapElements ) { 6 | mapElements.forEach( element => { 7 | const data = element?.dataset.mapCoordinates ?? ''; 8 | 9 | let coordinates = []; 10 | try { 11 | coordinates = JSON.parse( data ) ?? []; 12 | } catch ( error ) {} 13 | 14 | delete element.dataset.mapCoordinates; 15 | 16 | const map = leaflet.map( element ).setView( [ coordinates[ 0 ].x, coordinates[ 0 ].y ], 25 ); 17 | const layerGroup = leaflet.layerGroup().addTo( map ); 18 | 19 | leaflet 20 | .tileLayer( 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 4 } ) 21 | .addTo( map ); 22 | 23 | coordinates 24 | .filter( location => location.x && location.y ) 25 | .forEach( location => { 26 | leaflet.marker( [ location.x, location.y ], { title: location.name } ).addTo( layerGroup ); 27 | } ); 28 | 29 | map.flyTo( [ coordinates[ 0 ].x, coordinates[ 0 ].y ] ); 30 | } ); 31 | } 32 | 33 | // When the document is ready, find all maps and initialize them with Leaflet. 34 | domReady( () => { 35 | initMaps( document.querySelectorAll( '.wp-block-example-leaflet-map[data-map-coordinates]' ) ); 36 | } ); 37 | -------------------------------------------------------------------------------- /example/templates/shopify-product-block/shopify-product-block.php: -------------------------------------------------------------------------------- 1 | [ 16 | '__version' => 1, 17 | 'access_token' => '{{ Access Token }}', 18 | 'display_name' => '{{ Shopify Store Display Name }}', 19 | 'store_name' => '{{ store-name.myshopify.com }}', 20 | ], 21 | ] ); 22 | 23 | ShopifyIntegration::register_blocks_for_shopify_data_source( $shopify_data_source ); 24 | } 25 | add_action( 'init', 'register_shopify_remote_data_block' ); 26 | -------------------------------------------------------------------------------- /example/templates/theme/README.md: -------------------------------------------------------------------------------- 1 | # Remote Data Blocks Example Theme 2 | 3 | This folder contains a simple example theme that provides custom styling of Remote Data Blocks via a `theme.json` file. It is a child theme of `twentytwentyfour` and delegates all rendering to the parent theme. 4 | -------------------------------------------------------------------------------- /example/templates/theme/functions.php: -------------------------------------------------------------------------------- 1 | get( 'Version' ) 21 | ); 22 | } 23 | add_action( 'wp_enqueue_scripts', __NAMESPACE__ . '\\remote_data_blocks_example_theme_enqueue_block_styles', 15, 0 ); 24 | add_action( 'enqueue_block_assets', __NAMESPACE__ . '\\remote_data_blocks_example_theme_enqueue_block_styles', 15, 0 ); 25 | -------------------------------------------------------------------------------- /example/templates/theme/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/remote-data-blocks/fce72063f3ad1b8c9023c103c0ef2947565f5d68/example/templates/theme/screenshot.png -------------------------------------------------------------------------------- /example/templates/theme/style-remote-data-blocks.css: -------------------------------------------------------------------------------- 1 | /** 2 | * This file may also contain CSS overrides that are difficult or impossible to 3 | * implement using `theme.json` alone. For example, each bound inner block of a 4 | * Remote Data Block has a class name corresponding to the field it is bound to. 5 | * 6 | * Therefore, a Remote Data Block named "Shopify Product" containing a paragraph 7 | * block bound to a field named `description` can be targeted with a selector: 8 | * 9 | * .wp-block-remote-data-blocks-shopify-product p.rdb-block-data-description { 10 | * /* styles here * / 11 | * } 12 | */ 13 | 14 | .wp-block-remote-data-blocks-shopify-product p.rdb-block-data-price { 15 | font-weight: 700; 16 | } 17 | -------------------------------------------------------------------------------- /example/templates/theme/style.css: -------------------------------------------------------------------------------- 1 | /*! 2 | * Theme Name: Remote Data Blocks Example Theme 3 | * Description: Example theme that provides styling for remote data blocks 4 | * Version: 1.0.0 5 | * Template: twentytwentyfour 6 | * Tags: remote-data-blocks 7 | * Text Domain: remote-data-blocks 8 | * Tested up to: 6.6 9 | * Requires at least: 6.6 10 | * Requires PHP: 8.1 11 | * License: GNU General Public License v2.0 12 | * License URI: https://www.gnu.org/licenses/gpl-2.0.html 13 | */ 14 | 15 | /* This file is not enqueued and exists only to provide the theme manifest. */ 16 | -------------------------------------------------------------------------------- /functions.php: -------------------------------------------------------------------------------- 1 | $block_config The block configuration. 16 | */ 17 | function register_remote_data_block( array $block_config ): bool|WP_Error { 18 | return ConfigRegistry::register_block( $block_config ); 19 | } 20 | -------------------------------------------------------------------------------- /inc/Config/DataSource/DataSourceInterface.php: -------------------------------------------------------------------------------- 1 | config['display_name']; 18 | } 19 | 20 | public function get_endpoint(): string { 21 | return $this->config['endpoint']; 22 | } 23 | 24 | public function get_request_headers(): array|WP_Error { 25 | return $this->get_or_call_from_config( 'request_headers' ) ?? []; 26 | } 27 | 28 | public function get_image_url(): ?string { 29 | return $this->config['image_url'] ?? null; 30 | } 31 | 32 | public static function from_uuid( string $uuid ): DataSourceInterface|WP_Error { 33 | $config = DataSourceCrud::get_config_by_uuid( $uuid ); 34 | 35 | if ( is_wp_error( $config ) ) { 36 | return $config; 37 | } 38 | 39 | return static::from_array( $config ); 40 | } 41 | 42 | /** 43 | * @inheritDoc 44 | */ 45 | public static function get_config_schema(): array { 46 | return ConfigSchemas::get_http_data_source_config_schema(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /inc/Config/DataSource/HttpDataSourceInterface.php: -------------------------------------------------------------------------------- 1 | config['request_method'] ?? 'POST'; 19 | } 20 | 21 | /** 22 | * Assemble the GraphQL query and variables into a GraphQL request body. 23 | */ 24 | public function get_request_body( array $input_variables ): array { 25 | return [ 26 | 'query' => $this->config['graphql_query'], 27 | 'variables' => empty( $input_variables ) ? [] : $input_variables, 28 | ]; 29 | } 30 | 31 | /** 32 | * @inheritDoc 33 | */ 34 | public static function get_config_schema(): array { 35 | return ConfigSchemas::get_graphql_query_config_schema(); 36 | } 37 | 38 | /** 39 | * GraphQL queries are typically made with POST requests, however, we do 40 | * want to cache the response to queries. Override the default HTTP behavior 41 | * for POST requests and allow caching. 42 | * 43 | * Caching policy for GraphQL mutations is separately handled and disabled. 44 | */ 45 | public function get_cache_ttl( array $input_variables ): int|null { 46 | // Return null for default cache TTL. 47 | return null; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /inc/Config/Query/HttpQueryInterface.php: -------------------------------------------------------------------------------- 1 | $input_variables The input variables for the current request. 14 | * @return WP_Error|array{ 15 | * metadata: array, 20 | * results: null|array, 27 | * } 28 | */ 29 | public function execute( HttpQueryInterface $query, array $input_variables ): array|WP_Error; 30 | 31 | /** 32 | * Execute the query multiple times and return processed and merged results. 33 | * 34 | * @param HttpQueryInterface $query The query to execute. 35 | * @param array $array_of_input_variables An array of input variables for each request. 36 | * @return WP_Error|array{ 37 | * metadata: array, 42 | * results: null|array, 49 | * } 50 | */ 51 | public function execute_batch( HttpQueryInterface $query, array $array_of_input_variables ): array|WP_Error; 52 | } 53 | -------------------------------------------------------------------------------- /inc/Editor/Assets/Assets.php: -------------------------------------------------------------------------------- 1 | true, 34 | ] 35 | ); 36 | 37 | if ( file_exists( REMOTE_DATA_BLOCKS__PLUGIN_DIRECTORY . sprintf( '/build/%s/index.css', $slug ) ) ) { 38 | wp_enqueue_style( 39 | sprintf( '%s-style', $handle ), 40 | plugins_url( sprintf( 'build/%s/index.css', $slug ), REMOTE_DATA_BLOCKS__PLUGIN_ROOT ), 41 | array_filter( 42 | $asset['dependencies'], 43 | function ( $style ) { 44 | return wp_style_is( $style, 'registered' ); 45 | } 46 | ), 47 | $asset['version'], 48 | ); 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /inc/Editor/BlockPatterns/templates/columns.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 | 6 |
%s
7 | 8 | 9 |
%s
10 | 11 |
12 | 13 |
14 | 15 | -------------------------------------------------------------------------------- /inc/Editor/BlockPatterns/templates/empty.html: -------------------------------------------------------------------------------- 1 | 2 |

The query used by this Remote Data Block has no output variables, so there is no data available for display.

3 | 4 | -------------------------------------------------------------------------------- /inc/Editor/BlockPatterns/templates/heading.html: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 | -------------------------------------------------------------------------------- /inc/Editor/BlockPatterns/templates/html.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /inc/Editor/BlockPatterns/templates/image.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | -------------------------------------------------------------------------------- /inc/Editor/BlockPatterns/templates/paragraph.html: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 | -------------------------------------------------------------------------------- /inc/Editor/PatternEditor/PatternEditor.php: -------------------------------------------------------------------------------- 1 | function ( bool $_allowed, string $meta_key, int $object_id ) { 18 | return current_user_can( 'edit_post_meta', $object_id ); 19 | }, 20 | 'show_in_rest' => true, 21 | 'single' => true, 22 | 'type' => 'string', 23 | ] ); 24 | } 25 | 26 | public static function enqueue_block_editor_assets(): void { 27 | $asset_file = REMOTE_DATA_BLOCKS__PLUGIN_DIRECTORY . '/build/pattern-editor/index.asset.php'; 28 | 29 | if ( ! file_exists( $asset_file ) ) { 30 | wp_die( 'The settings asset file is missing. Run `npm run build` to generate it.' ); 31 | } 32 | 33 | $asset = include $asset_file; 34 | 35 | wp_enqueue_script( 36 | 'remote-data-blocks-pattern-editor', 37 | plugins_url( 'build/pattern-editor/index.js', REMOTE_DATA_BLOCKS__PLUGIN_ROOT ), 38 | $asset['dependencies'], 39 | $asset['version'], 40 | [ 41 | 'in_footer' => true, 42 | ] 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /inc/ExampleApi/Data/ExampleApiData.php: -------------------------------------------------------------------------------- 1 | true ] ); 24 | } 25 | 26 | // If $api_data is *still* null, it could not be loaded. Store an error so 27 | // that we don't attempt endlessly. 28 | if ( is_null( self::$api_data ) || ! isset( self::$api_data['records'] ) ) { 29 | self::$api_data = new WP_Error( 'remote-data-blocks-missing-example-api-data', 'Could not load example API data' ); 30 | } 31 | 32 | return self::$api_data; 33 | } 34 | 35 | /** 36 | * Extract a single record from the example table data. 37 | * 38 | * @param string $item_id The ID of the record to extract. 39 | */ 40 | public static function get_item( string $item_id ): array|null|WP_Error { 41 | $items = self::get_items(); 42 | 43 | if ( is_wp_error( $items ) ) { 44 | return $items; 45 | } 46 | 47 | foreach ( $items['records'] as $item ) { 48 | if ( $item['id'] === $item_id ) { 49 | return $item; 50 | } 51 | } 52 | 53 | return null; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /inc/ExampleApi/Queries/ExampleApiQueryRunner.php: -------------------------------------------------------------------------------- 1 | [], 24 | 'response_data' => ExampleApiData::get_item( $input_variables['record_id'] ), 25 | ]; 26 | } 27 | 28 | return [ 29 | 'metadata' => [], 30 | 'response_data' => ExampleApiData::get_items(), 31 | ]; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /inc/Formatting/FieldFormatter.php: -------------------------------------------------------------------------------- 1 | getTextAttribute( NumberFormatter::CURRENCY_CODE ); 27 | return numfmt_format_currency( $format, (float) $value, $currency_code ); 28 | } 29 | 30 | /** 31 | * Format markdown as HTML. 32 | */ 33 | public static function format_markdown( string $value ): string { 34 | return Parsedown::instance()->text( $value ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /inc/HttpClient/RdbCacheMiddleware.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | // phpcs:ignore WordPress.NamingConventions.ValidVariableName.PropertyNotSnakeCase, SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingNativeTypeHint 10 | protected $httpMethods = [ 11 | 'GET' => true, 12 | 'POST' => true, 13 | ]; 14 | } 15 | -------------------------------------------------------------------------------- /inc/Integrations/GenericHttp/GenericHttpIntegration.php: -------------------------------------------------------------------------------- 1 | The block registration snippets. 13 | */ 14 | public static function get_code_snippets( array $data_source_config ): array { 15 | $snippets = []; 16 | $raw_snippet = file_get_contents( __DIR__ . '/templates/block_registration.template' ); 17 | 18 | $code = strtr( $raw_snippet, [ 19 | '{{DATA_SOURCE_UUID}}' => $data_source_config['uuid'], 20 | ] ); 21 | 22 | $snippets[] = new Snippet( 'Block registration', $code ); 23 | 24 | return $snippets; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /inc/Integrations/GenericHttp/templates/block_registration.template: -------------------------------------------------------------------------------- 1 | $data_source, 22 | 'input_schema' => [ 23 | /* Input schema */ 24 | ], 25 | 'output_schema' => [ 26 | /* Output schema */ 27 | ], 28 | ] ); 29 | 30 | register_remote_data_block( [ 31 | 'title' => $block_title, 32 | 'render_query' => [ 33 | 'query' => $query, 34 | ], 35 | ] ); 36 | } 37 | 38 | add_action( 'init', __NAMESPACE__ . '\\register_http_remote_data_block' ); 39 | -------------------------------------------------------------------------------- /inc/Integrations/GitHub/GitHubDataSource.php: -------------------------------------------------------------------------------- 1 | Types::integer(), 15 | 'display_name' => Types::string(), 16 | 'repo_owner' => Types::string(), 17 | 'repo_name' => Types::string(), 18 | 'ref' => Types::string(), 19 | ] ); 20 | } 21 | 22 | protected static function map_service_config( array $service_config ): array { 23 | return [ 24 | 'display_name' => $service_config['display_name'], 25 | 'endpoint' => sprintf( 26 | 'https://api.github.com/repos/%s/%s/git/trees/%s?recursive=1', 27 | $service_config['repo_owner'], 28 | $service_config['repo_name'], 29 | $service_config['ref'] 30 | ), 31 | 'request_headers' => [ 32 | 'Accept' => 'application/vnd.github+json', 33 | ], 34 | ]; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /inc/Integrations/Shopify/Patterns/product-teaser.html: -------------------------------------------------------------------------------- 1 | 2 |
3 |
4 |
5 |
6 | 7 | 8 | 9 |

10 |
11 | 12 | 13 | 14 |
15 |

16 | 17 | 18 | 19 |

20 |
21 |
22 |
23 | 24 | -------------------------------------------------------------------------------- /inc/Integrations/Shopify/Queries/GetProductById.graphql: -------------------------------------------------------------------------------- 1 | query GetProductById($id: ID!) { 2 | product(id: $id) { 3 | id 4 | descriptionHtml 5 | title 6 | featuredImage { 7 | url 8 | altText 9 | } 10 | priceRange { 11 | maxVariantPrice { 12 | amount 13 | } 14 | } 15 | variants(first: 10) { 16 | edges { 17 | node { 18 | id 19 | availableForSale 20 | image { 21 | url 22 | } 23 | sku 24 | title 25 | } 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /inc/Integrations/Shopify/Queries/SearchProducts.graphql: -------------------------------------------------------------------------------- 1 | query SearchProducts($search: String!, $limit: Int!, $cursor_next: String) { 2 | products( 3 | first: $limit 4 | after: $cursor_next 5 | query: $search 6 | sortKey: BEST_SELLING 7 | ) { 8 | pageInfo { 9 | hasNextPage 10 | hasPreviousPage 11 | startCursor 12 | endCursor 13 | } 14 | edges { 15 | node { 16 | id 17 | title 18 | descriptionHtml 19 | priceRange { 20 | maxVariantPrice { 21 | amount 22 | } 23 | } 24 | images(first: 1) { 25 | edges { 26 | node { 27 | originalSrc 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /inc/Integrations/Shopify/assets/shopify_logo_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Automattic/remote-data-blocks/fce72063f3ad1b8c9023c103c0ef2947565f5d68/inc/Integrations/Shopify/assets/shopify_logo_black.png -------------------------------------------------------------------------------- /inc/Integrations/Shopify/templates/block_registration.template: -------------------------------------------------------------------------------- 1 | \RemoteDataBlocks\Integrations\Airtable\AirtableDataSource::class, 22 | REMOTE_DATA_BLOCKS_GENERIC_HTTP_SERVICE => \RemoteDataBlocks\Integrations\GenericHttp\GenericHttpDataSource::class, 23 | REMOTE_DATA_BLOCKS_GITHUB_SERVICE => \RemoteDataBlocks\Integrations\GitHub\GitHubDataSource::class, 24 | REMOTE_DATA_BLOCKS_GOOGLE_SHEETS_SERVICE => \RemoteDataBlocks\Integrations\Google\Sheets\GoogleSheetsDataSource::class, 25 | REMOTE_DATA_BLOCKS_SHOPIFY_SERVICE => \RemoteDataBlocks\Integrations\Shopify\ShopifyDataSource::class, 26 | REMOTE_DATA_BLOCKS_MOCK_SERVICE => \RemoteDataBlocks\Tests\Mocks\MockDataSource::class, 27 | ]; 28 | -------------------------------------------------------------------------------- /inc/Logging/LogLevel.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | private static array $priority = [ 24 | self::DEBUG => 1, 25 | self::INFO => 2, 26 | self::NOTICE => 3, 27 | self::WARNING => 4, 28 | self::ERROR => 5, 29 | self::CRITICAL => 6, 30 | self::ALERT => 7, 31 | self::EMERGENCY => 8, 32 | ]; 33 | 34 | /** 35 | * Returns true if log level 1 is higher than or equal to log level 2, 36 | * otherwise false. 37 | * 38 | * @param string $level The level being logged. 39 | * @param string $threshold_level The threshold level to compare against. 40 | */ 41 | public static function meets_threshold( string $level, string $threshold_level ): bool { 42 | if ( ! isset( self::$priority[ $level ], self::$priority[ $threshold_level ] ) ) { 43 | return false; 44 | } 45 | 46 | return self::$priority[ $level ] >= self::$priority[ $threshold_level ]; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /inc/Logging/Logger.php: -------------------------------------------------------------------------------- 1 | namespace, $level, $message, $context ); 33 | 34 | if ( defined( 'WP_DEBUG' ) && constant( 'WP_DEBUG' ) && LogLevel::meets_threshold( $level, LogLevel::ERROR ) ) { 35 | // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log 36 | error_log( sprintf( '[%s] %s: %s', $this->namespace, $level, $message ) ); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /inc/Logging/QueryMonitor/RdbBlockBindingCollector.php: -------------------------------------------------------------------------------- 1 | 14 | */ 15 | class RdbBlockBindingCollector extends RdbLogCollector { 16 | /** 17 | * @var string 18 | */ 19 | public $id = 'remote-data-blocks-block-binding'; 20 | 21 | /** 22 | * @var string 23 | */ 24 | public string $log_type = 'block-binding'; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /inc/Logging/QueryMonitor/RdbLogOutputHtml.php: -------------------------------------------------------------------------------- 1 | $menu 18 | * @return array 19 | */ 20 | public function admin_menu( array $menu ): array { 21 | /** @var QM_Data_HTTP $data */ 22 | $data = $this->collector->get_data(); 23 | $label = $this->menu_title; 24 | 25 | if ( ! empty( $data->logs ) ) { 26 | $label = sprintf( '%s (%s)', $this->menu_title, number_format_i18n( count( $data->logs ) ) ); 27 | } 28 | 29 | $menu['qm-remote-data-blocks']['children'][ $this->collector->id() ] = $this->menu( [ 30 | 'id' => $this->collector->id(), 31 | 'title' => esc_html( $label ), 32 | ] ); 33 | 34 | return $menu; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /inc/Logging/QueryMonitor/RdbLogOutputRaw.php: -------------------------------------------------------------------------------- 1 | collector->get_data(); 18 | 19 | return $data->logs ?? []; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /inc/Logging/QueryMonitor/RdbMainCollector.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | class RdbMainCollector extends QM_Collector { 17 | /** 18 | * @var string 19 | */ 20 | public $id = 'remote-data-blocks'; 21 | 22 | /** 23 | * Intentionally do not call parent 24 | */ 25 | public function set_up(): void { 26 | $this->data->plugin = [ 27 | 'version' => defined( 'REMOTE_DATA_BLOCKS__PLUGIN_VERSION' ) ? constant( 'REMOTE_DATA_BLOCKS__PLUGIN_VERSION' ) : 'unknown', 28 | ]; 29 | } 30 | 31 | public function tear_down(): void {} 32 | 33 | /** 34 | * @return array 35 | */ 36 | public function get_concerned_actions(): array { 37 | return []; 38 | } 39 | 40 | /** 41 | * @return array 42 | */ 43 | public function get_concerned_filters(): array { 44 | return []; 45 | } 46 | 47 | /** 48 | * @return array 49 | */ 50 | public function get_concerned_constants(): array { 51 | return []; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /inc/Logging/QueryMonitor/RdbMainOutputHtml.php: -------------------------------------------------------------------------------- 1 | collector->get_data(); 26 | 27 | $this->before_non_tabular_output(); 28 | 29 | echo '
'; 30 | echo '

' . esc_html__( 'Plugin', 'remote-data-blocks' ) . '

'; 31 | echo ''; 32 | 33 | foreach ( array( 34 | 'version' => __( 'Version', 'remote-data-blocks' ), 35 | ) as $item => $name ) { 36 | echo ''; 37 | echo ''; 39 | echo ''; 40 | } 41 | 42 | echo '
' . esc_html( $name ) . ''; 38 | echo '' . esc_html( $data['plugin'][ $item ] ?? 'unknown' ) . '
'; 43 | echo '
'; 44 | 45 | $this->after_non_tabular_output(); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /inc/Logging/QueryMonitor/RdbValidationCollector.php: -------------------------------------------------------------------------------- 1 | collector->get_data(); 17 | 18 | if ( empty( $data->logs ) ) { 19 | $this->before_non_tabular_output(); 20 | 21 | $notice = __( 'No validation issues.', 'remote-data-blocks' ); 22 | echo $this->build_notice( $notice ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped 23 | 24 | $this->after_non_tabular_output(); 25 | 26 | return; 27 | } 28 | 29 | parent::output(); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /inc/Sanitization/SanitizerInterface.php: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests/integration/ 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | tests/inc/ 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, PlaywrightTestConfig } from '@playwright/test'; 2 | 3 | const baseConfig = 4 | require( '@wordpress/scripts/config/playwright.config.js' ) as PlaywrightTestConfig; // eslint-disable-line @typescript-eslint/no-var-requires 5 | 6 | const config = defineConfig( { 7 | ...baseConfig, 8 | testDir: './tests/e2e', 9 | } ); 10 | 11 | export default config; 12 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/block-editor/binding-sources/remote-data-binding.ts: -------------------------------------------------------------------------------- 1 | import { registerBlockBindingsSource } from '@wordpress/blocks'; 2 | 3 | registerBlockBindingsSource( { 4 | name: 'remote-data/binding', 5 | label: 'Remote Data Blocks', 6 | usesContext: [ 'remote-data-blocks/remoteData' ], 7 | getValues() { 8 | return {}; 9 | }, 10 | } ); 11 | -------------------------------------------------------------------------------- /src/block-editor/filters/addUsesContext.ts: -------------------------------------------------------------------------------- 1 | import { BlockConfiguration } from '@wordpress/blocks'; 2 | 3 | import { 4 | REMOTE_DATA_CONTEXT_KEY, 5 | SUPPORTED_CORE_BLOCKS, 6 | } from '@/blocks/remote-data-container/config/constants'; 7 | 8 | export function addUsesContext( 9 | settings: BlockConfiguration< RemoteDataInnerBlockAttributes >, 10 | name: string 11 | ) { 12 | if ( ! SUPPORTED_CORE_BLOCKS.includes( name ) ) { 13 | return settings; 14 | } 15 | 16 | const { usesContext = [] } = settings; 17 | 18 | if ( ! usesContext?.includes( REMOTE_DATA_CONTEXT_KEY ) ) { 19 | return { 20 | ...settings, 21 | usesContext: [ ...usesContext, REMOTE_DATA_CONTEXT_KEY ], 22 | }; 23 | } 24 | 25 | return settings; 26 | } 27 | -------------------------------------------------------------------------------- /src/block-editor/filters/index.ts: -------------------------------------------------------------------------------- 1 | import { addFilter } from '@wordpress/hooks'; 2 | 3 | import { addUsesContext } from '@/block-editor/filters/addUsesContext'; 4 | import { withBlockBindingShim } from '@/block-editor/filters/withBlockBinding'; 5 | 6 | /** 7 | * Use a filter to wrap the block edit component with our block binding HOC. 8 | * We are intentionally using the `blocks.registerBlockType` filter instead of 9 | * `editor.BlockEdit` so that we can make sure our HOC is applied after any 10 | * other HOCs from Core -- specifically this one, which injects the binding label 11 | * as the attribute value: 12 | * 13 | * https://github.com/WordPress/gutenberg/blob/f56dbeb9257c19acf6fbd8b45d87ae8a841624da/packages/block-editor/src/hooks/use-bindings-attributes.js#L159 14 | */ 15 | addFilter( 16 | 'blocks.registerBlockType', 17 | 'remote-data-blocks/withBlockBinding', 18 | withBlockBindingShim, 19 | 5 // Ensure this runs before core filters 20 | ); 21 | 22 | /** 23 | * Use a filter to inject usesContext to core block settings. 24 | */ 25 | addFilter( 'blocks.registerBlockType', 'remote-data-blocks/addUsesContext', addUsesContext, 10 ); 26 | -------------------------------------------------------------------------------- /src/block-editor/format-types/inline-binding/components/InlineBinding.scss: -------------------------------------------------------------------------------- 1 | 2 | .rdb-inline-binding_dropdown, 3 | .remote-data-blocks-inline-binding-dropdown { 4 | 5 | .components-popover__content { 6 | width: unset; 7 | min-width: 160px; 8 | max-width: 320px; 9 | } 10 | } 11 | 12 | .rdb-inline-binding_dropdown { 13 | 14 | .components-toolbar-group { 15 | display: flex; 16 | flex-direction: column; 17 | border-right: none; 18 | 19 | .components-dropdown-menu { 20 | 21 | .components-dropdown-menu__toggle { 22 | display: flex; 23 | flex-direction: row-reverse; 24 | justify-content: space-between; 25 | width: 100%; 26 | } 27 | } 28 | } 29 | } 30 | 31 | .remote-data-blocks-inline-field { 32 | 33 | span.components-menu-item__item { 34 | width: 100%; 35 | 36 | .remote-data-blocks-inline-field-choice { 37 | 38 | width: 100%; 39 | 40 | .components-base-control__field { 41 | display: flex; 42 | flex-direction: row; 43 | justify-content: space-between; 44 | } 45 | } 46 | 47 | } 48 | } 49 | 50 | -------------------------------------------------------------------------------- /src/block-editor/format-types/inline-binding/components/InlineBindingSelectExisting.tsx: -------------------------------------------------------------------------------- 1 | import { DropdownMenu, MenuGroup } from '@wordpress/components'; 2 | import { __ } from '@wordpress/i18n'; 3 | import { chevronRightSmall } from '@wordpress/icons'; 4 | 5 | import { FieldSelectionFromAvailableBindings } from '@/block-editor/format-types/inline-binding/components/InlineBindingSelection'; 6 | import { getBlocksConfig } from '@/utils/localized-block-data'; 7 | 8 | interface InlineBindingSelectExistingProps { 9 | onSelectField: ( data: FieldSelection, fieldValue: string ) => void; 10 | remoteData: RemoteData[]; 11 | } 12 | 13 | export function InlineBindingSelectExisting( props: InlineBindingSelectExistingProps ) { 14 | const blockConfigs = getBlocksConfig(); 15 | const { remoteData: remoteDatas } = props; 16 | 17 | return remoteDatas.length > 0 ? ( 18 | 28 | { () => 29 | remoteDatas.map( remoteData => ( 30 | 34 | 36 | props.onSelectField( { ...data, selectionPath: 'select_existing_tab' }, fieldValue ) 37 | } 38 | remoteData={ remoteData } 39 | /> 40 | 41 | ) ) 42 | } 43 | 44 | ) : undefined; 45 | } 46 | -------------------------------------------------------------------------------- /src/block-editor/format-types/inline-binding/hooks/useExistingRemoteData.ts: -------------------------------------------------------------------------------- 1 | import { BlockEditorStoreSelectors, store as blockEditorStore } from '@wordpress/block-editor'; 2 | import { useSelect } from '@wordpress/data'; 3 | 4 | import { getBlocksConfig } from '@/utils/localized-block-data'; 5 | import { migrateRemoteData } from '@/utils/remote-data'; 6 | 7 | // In contrast to `useRemoteData`, this hook is used to retrieve existing remote 8 | // from all blocks in the editors. This is useful when we want to display a list 9 | // of data that has already been fetched and stored in block attributes. 10 | export function useExistingRemoteData(): RemoteData[] { 11 | const { getBlocksByName, getBlocksByClientId } = useSelect< BlockEditorStoreSelectors >( 12 | blockEditorStore, 13 | [] 14 | ); 15 | 16 | return Object.keys( getBlocksConfig() ).flatMap( blockName => { 17 | const blocks = getBlocksByName( blockName ); 18 | 19 | return blocks 20 | .map( clientId => { 21 | const block = getBlocksByClientId< RemoteDataBlockAttributes >( clientId )[ 0 ]; 22 | return migrateRemoteData( block?.attributes?.remoteData ); 23 | } ) 24 | .filter( ( maybeRemoteData ): maybeRemoteData is RemoteData => Boolean( maybeRemoteData ) ); 25 | } ); 26 | } 27 | -------------------------------------------------------------------------------- /src/block-editor/format-types/inline-binding/index.ts: -------------------------------------------------------------------------------- 1 | import { registerFormatType } from '@wordpress/rich-text'; 2 | 3 | import { InlineBindingButton } from '@/block-editor/format-types/inline-binding/components/InlineBindingButton'; 4 | import { formatTypeSettings } from '@/block-editor/format-types/inline-binding/settings'; 5 | 6 | // Register the inline binding format type. 7 | registerFormatType( 'remote-data-blocks/inline-binding', { 8 | ...formatTypeSettings, 9 | edit: InlineBindingButton, 10 | } ); 11 | -------------------------------------------------------------------------------- /src/block-editor/format-types/inline-binding/settings.ts: -------------------------------------------------------------------------------- 1 | import { WPFormat } from '@wordpress/rich-text'; 2 | 3 | export const formatName = 'remote-data-blocks/inline-field'; 4 | 5 | export const formatTypeSettings: WPFormat = { 6 | attributes: { 7 | 'data-query': 'data-query', 8 | }, 9 | className: null, 10 | contentEditable: false, 11 | edit: () => null, // avoid circular import 12 | interactive: true, 13 | name: formatName, 14 | object: false, 15 | tagName: 'remote-data-blocks-inline-field', 16 | title: 'Block Bindings', 17 | } as WPFormat; 18 | -------------------------------------------------------------------------------- /src/block-editor/index.ts: -------------------------------------------------------------------------------- 1 | import '@/block-editor/binding-sources/remote-data-binding'; 2 | import '@/block-editor/filters'; 3 | import '@/block-editor/format-types/inline-binding'; 4 | -------------------------------------------------------------------------------- /src/blocks/remote-data-container/block.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schemas.wp.org/trunk/block.json", 3 | "apiVersion": 3, 4 | "name": "remote-data-blocks/container", 5 | "version": "0.1.0", 6 | "title": "Remote Data Container", 7 | "category": "widgets", 8 | "description": "A container and context provider for Remote Data Blocks", 9 | "example": {}, 10 | "providesContext": { 11 | "remote-data-blocks/remoteData": "remoteData" 12 | }, 13 | "supports": { 14 | "background": { 15 | "backgroundImage": true, 16 | "backgroundSize": true 17 | }, 18 | "color": {}, 19 | "html": false, 20 | "interactivity": true, 21 | "shadow": true, 22 | "spacing": { 23 | "margin": true, 24 | "padding": true 25 | }, 26 | "typography": { 27 | "fontSize": true, 28 | "lineHeight": true, 29 | "textAlign": true 30 | } 31 | }, 32 | "textdomain": "remote-data-blocks", 33 | "editorScript": [ "file:./index.js", "remote-data-blocks-block-editor" ], 34 | "editorStyle": "file:./index.css", 35 | "render": "file:./render.php", 36 | "style": "file:./style-index.css" 37 | } 38 | -------------------------------------------------------------------------------- /src/blocks/remote-data-container/components/EditErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from '@wordpress/element'; 2 | 3 | import { PlaceholderError } from './placeholders/PlaceholderError'; 4 | 5 | interface EditErrorBoundaryProps { 6 | blockTitle: string; 7 | children: React.ReactNode; 8 | } 9 | 10 | interface EditErrorBoundaryState { 11 | error: Error | null; 12 | } 13 | 14 | export class EditErrorBoundary extends Component< EditErrorBoundaryProps, EditErrorBoundaryState > { 15 | public state: EditErrorBoundaryState = { error: null }; 16 | 17 | public static getDerivedStateFromError( error: Error ): EditErrorBoundaryState { 18 | return { error }; 19 | } 20 | 21 | public render(): React.ReactNode { 22 | if ( this.state.error ) { 23 | return ( 24 | this.setState( { error: null } ) } 28 | /> 29 | ); 30 | } 31 | 32 | return this.props.children; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/blocks/remote-data-container/components/InnerBlocks.tsx: -------------------------------------------------------------------------------- 1 | import { InnerBlocks as CoreInnerBlocks } from '@wordpress/block-editor'; 2 | 3 | // This component wraps the Core InnerBlocks component to enable the renderAppender. 4 | export function InnerBlocks() { 5 | return ; 6 | } 7 | -------------------------------------------------------------------------------- /src/blocks/remote-data-container/components/item-list/ItemListField.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@wordpress/components'; 2 | 3 | import { 4 | createQueryInputsFromRemoteDataResults, 5 | getFirstRemoteDataResultValueByType, 6 | getRemoteDataResultValue, 7 | } from '@/utils/remote-data'; 8 | 9 | function createFieldSelection( 10 | field: string, 11 | item: RemoteDataApiResult, 12 | blockName: string 13 | ): FieldSelection { 14 | return { 15 | action: 'add_field_shortcode', 16 | remoteData: { 17 | blockName, 18 | queryInputs: createQueryInputsFromRemoteDataResults( [ item ] ), 19 | metadata: {}, 20 | }, 21 | selectedField: field, 22 | selectionPath: 'select_new_tab', 23 | type: 'field', 24 | }; 25 | } 26 | 27 | interface ItemListFieldProps { 28 | blockName: string; 29 | field: string; 30 | item: RemoteDataApiResult; 31 | mediaField?: string; 32 | onSelectField?: ( data: FieldSelection, fieldValue: string ) => void; 33 | } 34 | 35 | export function ItemListField( props: ItemListFieldProps ) { 36 | const { blockName, field, item, mediaField, onSelectField } = props; 37 | const value = getRemoteDataResultValue( item, field ); 38 | 39 | if ( field === mediaField ) { 40 | const imgAlt = getFirstRemoteDataResultValueByType( item, 'image_alt' ); 41 | return {; 42 | } 43 | 44 | if ( onSelectField ) { 45 | return ( 46 | 54 | ); 55 | } 56 | 57 | return value; 58 | } 59 | -------------------------------------------------------------------------------- /src/blocks/remote-data-container/components/modals/BaseModal.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Modal } from '@wordpress/components'; 2 | import { ModalProps } from '@wordpress/components/build-types/modal/types'; 3 | 4 | import { __ } from '@/utils/i18n'; 5 | 6 | export type BaseModalProps = Omit< ModalProps, 'onRequestClose' > & { 7 | children: JSX.Element; 8 | headerActions?: JSX.Element; 9 | headerImage?: string; 10 | onClose: () => void; 11 | }; 12 | 13 | export function BaseModal( props: BaseModalProps ) { 14 | return ( 15 | 19 | { props.headerImage && ( 20 | { 25 | ) } 26 | { props.headerActions } 27 | 28 | } 29 | onRequestClose={ props.onClose } 30 | size={ props.size ?? 'fill' } 31 | { ...props } 32 | > 33 | { props.children } 34 | 35 | ); 36 | } 37 | 38 | export interface ModalWithButtonTriggerProps extends BaseModalProps { 39 | buttonText: string; 40 | buttonVariant?: 'primary' | 'secondary' | 'tertiary' | 'link'; 41 | isOpen: boolean; 42 | onOpen: () => void; 43 | } 44 | 45 | export function ModalWithButtonTrigger( props: ModalWithButtonTriggerProps ) { 46 | const { buttonText, buttonVariant = 'primary', isOpen, onOpen, ...modalProps } = props; 47 | 48 | return ( 49 | <> 50 | 53 | 54 | { isOpen && } 55 | 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/blocks/remote-data-container/components/pattern-selection/PatternSelectionModal.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | BlockPattern, 3 | __experimentalBlockPatternsList as BlockPatternsList, 4 | } from '@wordpress/block-editor'; 5 | import { Modal } from '@wordpress/components'; 6 | import { __ } from '@wordpress/i18n'; 7 | 8 | interface PatternSelectionModalProps { 9 | supportedPatterns: BlockPattern[]; 10 | onClickPattern: ( pattern: BlockPattern ) => void; 11 | onClose: () => void; 12 | } 13 | 14 | export function PatternSelectionModal( props: PatternSelectionModalProps ) { 15 | return ( 16 | 22 |
23 | 28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/blocks/remote-data-container/components/placeholders/Placeholder.tsx: -------------------------------------------------------------------------------- 1 | import { IconType, Placeholder as PlaceholderComponent } from '@wordpress/components'; 2 | import { __ } from '@wordpress/i18n'; 3 | import { cloud } from '@wordpress/icons'; 4 | 5 | import { ItemSelectQueryType } from '@/blocks/remote-data-container/components/placeholders/ItemSelectQueryType'; 6 | 7 | export interface PlaceholderProps { 8 | blockConfig: BlockConfig; 9 | onSelect: ( input: RemoteDataQueryInput[] ) => void; 10 | } 11 | 12 | export function Placeholder( props: PlaceholderProps ) { 13 | const { blockConfig, onSelect } = props; 14 | const { instructions, settings } = blockConfig; 15 | 16 | const iconElement: IconType = ( settings.icon as IconType ) ?? cloud; 17 | 18 | return ( 19 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/blocks/remote-data-container/components/placeholders/PlaceholderError.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Icon, Placeholder, __experimentalHStack as HStack } from '@wordpress/components'; 2 | import { useState } from '@wordpress/element'; 3 | import { __, sprintf } from '@wordpress/i18n'; 4 | import { error as errorIcon } from '@wordpress/icons'; 5 | 6 | import './PlaceholderError.scss'; 7 | 8 | interface PlaceholderErrorProps { 9 | blockTitle: string; 10 | error: Error; 11 | onRetry: () => void; 12 | } 13 | 14 | export function PlaceholderError( { blockTitle, error, onRetry }: PlaceholderErrorProps ) { 15 | const [ showErrorDetails, setShowErrorDetails ] = useState< boolean >( false ); 16 | 17 | return ( 18 | 23 | 24 | 25 | 28 | 31 | 32 | { showErrorDetails && ( 33 | 38 | { error.message } 39 | 40 | ) } 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/blocks/remote-data-container/components/popovers/style.scss: -------------------------------------------------------------------------------- 1 | 2 | .remote-data-blocks-edit__input-popover-form { 3 | max-width: 350px; 4 | margin: 16px; 5 | 6 | .remote-data-blocks-edit__input { 7 | min-width: 300px; 8 | 9 | .components-text-control__input { 10 | border: none; 11 | } 12 | } 13 | 14 | .components-external-link__contents { 15 | text-decoration: none; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/blocks/remote-data-container/config/constants.ts: -------------------------------------------------------------------------------- 1 | import { getRestUrl } from '@/utils/localized-block-data'; 2 | import { getClassName } from '@/utils/string'; 3 | 4 | export const SUPPORTED_CORE_BLOCKS = [ 5 | 'core/button', 6 | 'core/heading', 7 | 'core/image', 8 | 'core/paragraph', 9 | ]; 10 | 11 | export const DISPLAY_QUERY_KEY = 'display'; 12 | export const REMOTE_DATA_CONTEXT_KEY = 'remote-data-blocks/remoteData'; 13 | export const REMOTE_DATA_REST_API_URL = getRestUrl(); 14 | 15 | export const CONTAINER_CLASS_NAME = getClassName( 'container' ); 16 | 17 | export const PAGINATION_CURSOR_VARIABLE_TYPE = 'ui:pagination_cursor'; 18 | export const PAGINATION_CURSOR_NEXT_VARIABLE_TYPE = 'ui:pagination_cursor_next'; 19 | export const PAGINATION_CURSOR_PREVIOUS_VARIABLE_TYPE = 'ui:pagination_cursor_previous'; 20 | export const PAGINATION_OFFSET_VARIABLE_TYPE = 'ui:pagination_offset'; 21 | export const PAGINATION_PAGE_VARIABLE_TYPE = 'ui:pagination_page'; 22 | export const PAGINATION_PER_PAGE_VARIABLE_TYPE = 'ui:pagination_per_page'; 23 | export const SEARCH_INPUT_VARIABLE_TYPE = 'ui:search_input'; 24 | 25 | export const BUTTON_TEXT_FIELD_TYPES = [ 'button_text' ]; 26 | export const BUTTON_URL_FIELD_TYPES = [ 'button_url' ]; 27 | export const HTML_FIELD_TYPES = [ 'html' ]; 28 | export const ID_FIELD_TYPES = [ 'id', 'id:list' ]; 29 | export const IMAGE_ALT_FIELD_TYPES = [ 'image_alt' ]; 30 | export const IMAGE_URL_FIELD_TYPES = [ 'image_url' ]; 31 | export const TEXT_FIELD_TYPES = [ 32 | 'currency_in_current_locale', 33 | 'email_address', 34 | 'integer', 35 | 'markdown', 36 | 'number', 37 | 'string', 38 | 'title', 39 | ]; 40 | -------------------------------------------------------------------------------- /src/blocks/remote-data-container/hooks/useModalState.ts: -------------------------------------------------------------------------------- 1 | import { useState } from '@wordpress/element'; 2 | 3 | export function useModalState( onOpen?: () => void, onClose?: () => void ) { 4 | const [ isOpen, setIsOpen ] = useState< boolean >( false ); 5 | 6 | function close(): void { 7 | onClose?.(); 8 | setIsOpen( false ); 9 | } 10 | 11 | function open(): void { 12 | onOpen?.(); 13 | setIsOpen( true ); 14 | } 15 | 16 | return { 17 | close, 18 | isOpen, 19 | open, 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/blocks/remote-data-container/hooks/useSearchVariables.ts: -------------------------------------------------------------------------------- 1 | import { SEARCH_INPUT_VARIABLE_TYPE } from '@/blocks/remote-data-container/config/constants'; 2 | import { useDebouncedState } from '@/hooks/useDebouncedState'; 3 | 4 | interface UseSearchVariables { 5 | hasSearchInput: boolean; 6 | searchInput: string; 7 | searchQueryInput: RemoteDataQueryInput; 8 | setSearchInput: ( searchInput: string ) => void; 9 | supportsSearch: boolean; 10 | } 11 | 12 | interface UseSearchVariablesInput { 13 | initialSearchInput?: string; 14 | inputVariables: InputVariable[]; 15 | searchInputDelayInMs?: number; 16 | } 17 | 18 | export function useSearchVariables( { 19 | initialSearchInput = '', 20 | inputVariables, 21 | searchInputDelayInMs = 500, 22 | }: UseSearchVariablesInput ): UseSearchVariables { 23 | const [ searchInput, setSearchInput ] = useDebouncedState< string >( 24 | searchInputDelayInMs, 25 | initialSearchInput 26 | ); 27 | 28 | const inputVariable = inputVariables?.find( input => input.type === SEARCH_INPUT_VARIABLE_TYPE ); 29 | const supportsSearch = Boolean( inputVariable ); 30 | const searchAllowsEmptyInput = supportsSearch && ! inputVariable?.required; 31 | const hasSearchInput = supportsSearch && Boolean( searchInput || searchAllowsEmptyInput ); 32 | 33 | return { 34 | hasSearchInput, 35 | searchInput, 36 | searchQueryInput: supportsSearch 37 | ? { [ inputVariable?.slug ?? '' ]: hasSearchInput ? searchInput : null } 38 | : {}, 39 | setSearchInput: supportsSearch ? setSearchInput : () => {}, 40 | supportsSearch, 41 | }; 42 | } 43 | -------------------------------------------------------------------------------- /src/blocks/remote-data-container/index.ts: -------------------------------------------------------------------------------- 1 | import { registerBlockType } from '@wordpress/blocks'; 2 | 3 | import { Edit } from '@/blocks/remote-data-container/edit'; 4 | import { Save } from '@/blocks/remote-data-container/save'; 5 | import { getBlocksConfig } from '@/utils/localized-block-data'; 6 | 7 | import './style.scss'; 8 | 9 | // Register a unique block definition for each of the context blocks. 10 | Object.values( getBlocksConfig() ).forEach( blockConfig => { 11 | registerBlockType< RemoteDataBlockAttributes >( blockConfig.name, { 12 | ...blockConfig.settings, 13 | attributes: { 14 | remoteData: { 15 | type: 'object', 16 | }, 17 | }, 18 | edit: Edit, 19 | save: Save, 20 | } ); 21 | } ); 22 | -------------------------------------------------------------------------------- /src/blocks/remote-data-container/render.php: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /src/blocks/remote-data-container/style.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * The following styles get applied both inside the editor and on the frontend. 3 | */ 4 | 5 | .rdb-block-label { 6 | background-color: var(--wp--custom--remote-data-blocks--block-label-background, inherit); 7 | color: var(--wp--custom--remote-data-blocks--block-label-color, inherit); 8 | font-weight: var(--wp--custom--remote-data-blocks--block-label-weight, 700); 9 | 10 | &::after { 11 | content: var(--wp--custom--remote-data-blocks--block-label-suffix-content, ":"); 12 | display: inline; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/blocks/remote-data-no-results/block.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schemas.wp.org/trunk/block.json", 3 | "apiVersion": 3, 4 | "name": "remote-data-blocks/no-results", 5 | "version": "0.1.0", 6 | "usesContext": [ "remote-data-blocks/remoteData" ], 7 | "title": "No Results", 8 | "description": "Contains the blocks to display when no remote data is found.", 9 | "category": "widgets", 10 | "example": {}, 11 | "attributes": { 12 | "mode": { 13 | "enum": [ "empty", "error" ], 14 | "default": "empty", 15 | "type": "string" 16 | } 17 | }, 18 | "supports": { 19 | "customClassName": false, 20 | "className": false, 21 | "html": false 22 | }, 23 | "variations": [ 24 | { 25 | "name": "remote-data-blocks/error", 26 | "title": "Error", 27 | "description": "Display an error message when the remote data fails to load.", 28 | "attributes": { 29 | "mode": "error" 30 | }, 31 | "isActive": [ "mode" ] 32 | } 33 | ], 34 | "textdomain": "remote-data-blocks", 35 | "editorScript": "file:./index.js", 36 | "editorStyle": "file:./index.css", 37 | "render": "file:./render.php", 38 | "style": "file:./style-index.css" 39 | } 40 | -------------------------------------------------------------------------------- /src/blocks/remote-data-no-results/edit.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { InnerBlocks, useBlockProps } from '@wordpress/block-editor'; 5 | import { BlockEditProps, Template } from '@wordpress/blocks'; 6 | import { Placeholder } from '@wordpress/components'; 7 | import { blockDefault } from '@wordpress/icons'; 8 | 9 | import { useRemoteDataContext } from '@/blocks/remote-data-container/hooks/useRemoteDataContext'; 10 | import { __ } from '@/utils/i18n'; 11 | 12 | import './editor.scss'; 13 | 14 | const NO_RESULTS_TEMPLATE: Template[] = [ 15 | [ 16 | 'core/paragraph', 17 | { 18 | content: __( 'No results found.' ), 19 | }, 20 | ], 21 | ]; 22 | 23 | const ERROR_FALLBACK_TEMPLATE: Template[] = [ 24 | [ 25 | 'core/paragraph', 26 | { 27 | content: __( 'Error loading results.' ), 28 | }, 29 | ], 30 | ]; 31 | 32 | export function Edit( props: BlockEditProps< RemoteDataNoResultsBlockAttributes > ): JSX.Element { 33 | const { context } = props; 34 | const { remoteData } = useRemoteDataContext( context ); 35 | const blockProps = useBlockProps(); 36 | 37 | if ( ! remoteData?.blockName ) { 38 | return ( 39 | 46 | ); 47 | } 48 | 49 | return ( 50 |
51 | 56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /src/blocks/remote-data-no-results/editor.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * The following styles get applied inside the editor only. 3 | */ 4 | -------------------------------------------------------------------------------- /src/blocks/remote-data-no-results/index.ts: -------------------------------------------------------------------------------- 1 | import { registerBlockType } from '@wordpress/blocks'; 2 | import { border, caution } from '@wordpress/icons'; 3 | 4 | import metadata from './block.json'; 5 | import { Edit } from './edit'; 6 | import { Save } from './save'; 7 | import './style.scss'; 8 | 9 | registerBlockType< RemoteDataNoResultsBlockAttributes >( metadata.name, { 10 | edit: Edit, 11 | icon: { 12 | src: border, 13 | }, 14 | save: Save, 15 | variations: [ 16 | { 17 | name: 'remote-data-blocks/error', 18 | title: 'Error', 19 | icon: { src: caution }, 20 | }, 21 | ], 22 | } ); 23 | -------------------------------------------------------------------------------- /src/blocks/remote-data-no-results/render.php: -------------------------------------------------------------------------------- 1 | context, $attributes ); 11 | 12 | // The fallback content should only be rendered if the query errors out, or if the query returns no results. 13 | if ( ! $should_render_fallback_content ) { 14 | return null; 15 | } 16 | 17 | echo wp_kses_post( $content ); 18 | -------------------------------------------------------------------------------- /src/blocks/remote-data-no-results/save.tsx: -------------------------------------------------------------------------------- 1 | import { InnerBlocks } from '@wordpress/block-editor'; 2 | 3 | export function Save() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/blocks/remote-data-no-results/style.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * The following styles get applied both inside the editor and on the frontend. 3 | */ 4 | 5 | -------------------------------------------------------------------------------- /src/blocks/remote-data-pagination/block.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schemas.wp.org/trunk/block.json", 3 | "apiVersion": 3, 4 | "name": "remote-data-blocks/pagination", 5 | "version": "0.1.0", 6 | "usesContext": [ "remote-data-blocks/remoteData" ], 7 | "title": "Pagination", 8 | "category": "widgets", 9 | "description": "Pagination controls for remote data blocks", 10 | "example": {}, 11 | "attributes": {}, 12 | "supports": { 13 | "customClassName": false, 14 | "className": false, 15 | "html": false 16 | }, 17 | "textdomain": "remote-data-blocks", 18 | "editorScript": "file:./index.js", 19 | "editorStyle": "file:./index.css", 20 | "render": "file:./render.php", 21 | "style": "file:./style-index.css" 22 | } 23 | -------------------------------------------------------------------------------- /src/blocks/remote-data-pagination/editor.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * The following styles get applied inside the editor only. 3 | */ 4 | -------------------------------------------------------------------------------- /src/blocks/remote-data-pagination/index.ts: -------------------------------------------------------------------------------- 1 | import { registerBlockType } from '@wordpress/blocks'; 2 | import { queryPagination } from '@wordpress/icons'; 3 | 4 | import metadata from './block.json'; 5 | import { Edit } from './edit'; 6 | import { Save } from './save'; 7 | import './style.scss'; 8 | 9 | registerBlockType< RemoteDataPaginationBlockAttributes >( metadata.name, { 10 | edit: Edit, 11 | icon: { 12 | src: queryPagination, 13 | }, 14 | save: Save, 15 | } ); 16 | -------------------------------------------------------------------------------- /src/blocks/remote-data-pagination/render.php: -------------------------------------------------------------------------------- 1 | 23 |
> 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 |
41 |
42 | -------------------------------------------------------------------------------- /src/blocks/remote-data-pagination/save.tsx: -------------------------------------------------------------------------------- 1 | import { InnerBlocks } from '@wordpress/block-editor'; 2 | 3 | export function Save() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /src/blocks/remote-data-pagination/style.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * The following styles get applied both inside the editor and on the frontend. 3 | */ 4 | 5 | .remote-data-pagination { 6 | color: var(--wp--custom--remote-data-blocks--pagination-link-disabled, #999); 7 | display: flex; 8 | justify-content: space-between; 9 | margin: 1rem 0; 10 | 11 | a { 12 | color: var(--wp--custom--remote-data-blocks--pagination-link, #000); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/blocks/remote-data-template/block.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schemas.wp.org/trunk/block.json", 3 | "apiVersion": 3, 4 | "name": "remote-data-blocks/template", 5 | "version": "0.1.0", 6 | "usesContext": [ "remote-data-blocks/remoteData" ], 7 | "title": "Item Template", 8 | "category": "widgets", 9 | "description": "Template block for displaying remote data collections", 10 | "example": {}, 11 | "supports": { 12 | "customClassName": false, 13 | "className": false, 14 | "html": false 15 | }, 16 | "textdomain": "remote-data-blocks", 17 | "editorScript": "file:./index.js", 18 | "render": "file:./render.php", 19 | "editorStyle": "file:./index.css" 20 | } 21 | -------------------------------------------------------------------------------- /src/blocks/remote-data-template/components/item-preview/ItemPreview.tsx: -------------------------------------------------------------------------------- 1 | import { __experimentalUseBlockPreview as useBlockPreview } from '@wordpress/block-editor'; 2 | import { BlockInstance } from '@wordpress/blocks'; 3 | import { memo } from '@wordpress/element'; 4 | 5 | interface ItemPreviewProps { 6 | blocks: BlockInstance[]; 7 | isHidden?: boolean; 8 | onSelect: () => void; 9 | } 10 | 11 | // Use the experimental block preview hook to render a preview of blocks when 12 | // they are not being actively edited. This preview is not interactive and are 13 | // not "real" blocks so they don't show up in the outline view. 14 | // 15 | // We hide the preview for the blocks that are being edited so they don't 16 | // duplicate. 17 | // 18 | // This is a mimick of the PostTemplate component from Gutenberg core. 19 | export function UnmemoizedItemPreview( props: ItemPreviewProps ) { 20 | const { blocks, isHidden = false, onSelect } = props; 21 | const blockPreviewProps = useBlockPreview( { blocks, props: {} } ); 22 | 23 | const style = { 24 | cursor: 'pointer', 25 | display: isHidden ? 'none' : undefined, 26 | listStyle: 'none', 27 | }; 28 | 29 | return ( 30 |
  • 39 | ); 40 | } 41 | 42 | export const ItemPreview = memo( UnmemoizedItemPreview ); 43 | -------------------------------------------------------------------------------- /src/blocks/remote-data-template/components/loop-template/LoopTemplateInnerBlocks.tsx: -------------------------------------------------------------------------------- 1 | import { useInnerBlocksProps } from '@wordpress/block-editor'; 2 | 3 | // Conditionally render inner blocks if this member of the loop is active. 4 | export function LoopTemplateInnerBlocks( { isActive }: { isActive: boolean } ) { 5 | const innerBlocksProps = useInnerBlocksProps(); 6 | 7 | if ( ! isActive ) { 8 | return null; 9 | } 10 | 11 | return
  • ; 12 | } 13 | -------------------------------------------------------------------------------- /src/blocks/remote-data-template/context/PreviewIndexContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from '@wordpress/element'; 2 | 3 | export const PreviewIndexContext = createContext< number >( 0 ); 4 | -------------------------------------------------------------------------------- /src/blocks/remote-data-template/edit.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * WordPress dependencies 3 | */ 4 | import { useBlockProps } from '@wordpress/block-editor'; 5 | import { BlockEditProps } from '@wordpress/blocks'; 6 | import { Placeholder } from '@wordpress/components'; 7 | import { __ } from '@wordpress/i18n'; 8 | 9 | import { useRemoteDataContext } from '@/blocks/remote-data-container/hooks/useRemoteDataContext'; 10 | import { LoopTemplate } from '@/blocks/remote-data-template/components/loop-template/LoopTemplate'; 11 | import { useGetInnerBlocks } from '@/blocks/remote-data-template/hooks/useGetInnerBlocks'; 12 | 13 | import './editor.scss'; 14 | 15 | export function Edit( props: BlockEditProps< RemoteDataTemplateBlockAttributes > ): JSX.Element { 16 | const { clientId, context, name } = props; 17 | const blockProps = useBlockProps(); 18 | 19 | const { remoteData } = useRemoteDataContext( context ); 20 | const getInnerBlocks = useGetInnerBlocks( name, clientId, remoteData?.blockName ); 21 | 22 | if ( ! remoteData?.blockName ) { 23 | return ( 24 |
    25 | 31 |
    32 | ); 33 | } 34 | 35 | // Don't render anything if there are no results. 36 | // Leave it to the no results block to handle this. 37 | if ( ! remoteData.results.length ) { 38 | return
    ; 39 | } 40 | 41 | return ; 42 | } 43 | -------------------------------------------------------------------------------- /src/blocks/remote-data-template/editor.scss: -------------------------------------------------------------------------------- 1 | .remote-data-blocks-loop-template { 2 | list-style: none; 3 | padding: 0; 4 | } 5 | -------------------------------------------------------------------------------- /src/blocks/remote-data-template/filters/index.ts: -------------------------------------------------------------------------------- 1 | import { addFilter } from '@wordpress/hooks'; 2 | 3 | import { withPreviewIndex } from './withPreviewIndex'; 4 | 5 | /** 6 | * Use a filter to wrap the block edit component and inject the preview index 7 | * when we are rendering the template block for collections. 8 | */ 9 | addFilter( 'editor.BlockEdit', 'remote-data-blocks/withPreviewIndex', withPreviewIndex ); 10 | -------------------------------------------------------------------------------- /src/blocks/remote-data-template/filters/withPreviewIndex.tsx: -------------------------------------------------------------------------------- 1 | import { BlockEditProps } from '@wordpress/blocks'; 2 | import { createHigherOrderComponent } from '@wordpress/compose'; 3 | import { useContext } from '@wordpress/element'; 4 | 5 | import { PreviewIndexContext } from '../context/PreviewIndexContext'; 6 | 7 | export const withPreviewIndex = createHigherOrderComponent( BlockEdit => { 8 | return ( props: BlockEditProps< RemoteDataInnerBlockAttributes > ) => { 9 | const previewIndex = useContext( PreviewIndexContext ); 10 | return ; 11 | }; 12 | }, 'withPreviewIndex' ); 13 | -------------------------------------------------------------------------------- /src/blocks/remote-data-template/hooks/useGetInnerBlocks.ts: -------------------------------------------------------------------------------- 1 | import { BlockEditorStoreSelectors, store as blockEditorStore } from '@wordpress/block-editor'; 2 | import { useSelect } from '@wordpress/data'; 3 | 4 | import { cloneBlockForPreview } from '@/utils/block-binding'; 5 | 6 | import type { BlockInstance } from '@wordpress/blocks'; 7 | 8 | export function useGetInnerBlocks( 9 | blockName: string, 10 | clientId: string, 11 | remoteDataBlockName?: string 12 | ) { 13 | const { getBlocks } = useSelect< BlockEditorStoreSelectors >( blockEditorStore, [ 14 | blockName, 15 | [ blockName, clientId ], 16 | ] ); 17 | 18 | return ( result: RemoteDataApiResult ): BlockInstance< RemoteDataInnerBlockAttributes >[] => { 19 | return getBlocks( clientId ).map( block => 20 | cloneBlockForPreview( block, result, remoteDataBlockName ?? blockName ) 21 | ); 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/blocks/remote-data-template/index.ts: -------------------------------------------------------------------------------- 1 | import { registerBlockType } from '@wordpress/blocks'; 2 | import { layout } from '@wordpress/icons'; 3 | 4 | import metadata from './block.json'; 5 | import { Edit } from './edit'; 6 | import './filters'; 7 | import { Save } from './save'; 8 | 9 | registerBlockType< RemoteDataTemplateBlockAttributes >( metadata.name, { 10 | edit: Edit, 11 | icon: { 12 | src: layout, 13 | }, 14 | save: Save, 15 | } ); 16 | -------------------------------------------------------------------------------- /src/blocks/remote-data-template/render.php: -------------------------------------------------------------------------------- 1 | ; 5 | } 6 | -------------------------------------------------------------------------------- /src/blocks/remote-html/block.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schemas.wp.org/trunk/block.json", 3 | "apiVersion": 3, 4 | "name": "remote-data-blocks/remote-html", 5 | "version": "0.1.0", 6 | "usesContext": [ "remote-data-blocks/remoteData" ], 7 | "title": "Remote HTML", 8 | "category": "widgets", 9 | "icon": "html", 10 | "description": "Remote data block for HTML content binding.", 11 | "example": {}, 12 | "attributes": { 13 | "content": { 14 | "type": "string", 15 | "source": "raw" 16 | } 17 | }, 18 | "supports": { 19 | "customClassName": false, 20 | "className": false, 21 | "html": false 22 | }, 23 | "textdomain": "remote-data-blocks", 24 | "editorScript": "file:./index.js", 25 | "render": "file:./render.php", 26 | "editorStyle": "file:./index.css" 27 | } 28 | -------------------------------------------------------------------------------- /src/blocks/remote-html/editor.scss: -------------------------------------------------------------------------------- 1 | // Styled after core/html. This overlay will allow clicking into the block 2 | // when sandboxed HTML is present and intercept all mouse events. 3 | // Without this, clicking onto this block does not select it in the editor. 4 | 5 | .wp-block-remote-data-blocks-remote-html { 6 | position: relative; 7 | 8 | .remote-data-block-html-overlay { 9 | position: absolute; 10 | width: 100%; 11 | height: 100%; 12 | top: 0; 13 | left: 0; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/blocks/remote-html/index.ts: -------------------------------------------------------------------------------- 1 | import { registerBlockType } from '@wordpress/blocks'; 2 | 3 | import metadata from './block.json'; 4 | import { Edit } from './edit'; 5 | import { Save } from './save'; 6 | 7 | registerBlockType( metadata.name, { 8 | edit: Edit, 9 | save: Save, 10 | } ); 11 | -------------------------------------------------------------------------------- /src/blocks/remote-html/render.php: -------------------------------------------------------------------------------- 1 | attributes['metadata']['bindings']['content']['args'] ?? []; 10 | 11 | ?> 12 | 13 |
    > 14 | 26 |
    27 | -------------------------------------------------------------------------------- /src/blocks/remote-html/save.tsx: -------------------------------------------------------------------------------- 1 | import { BlockSaveProps } from '@wordpress/blocks'; 2 | import { RawHTML } from '@wordpress/element'; 3 | 4 | interface RemoteDataHTMLSaveAttributes { 5 | content?: string | StringSeriablizable; 6 | saveContent?: string | StringSeriablizable; 7 | } 8 | 9 | export function Save( props: BlockSaveProps< RemoteDataHTMLSaveAttributes > ) { 10 | const { attributes } = props; 11 | 12 | const content = attributes?.saveContent ?? attributes?.content ?? ''; 13 | 14 | return { content?.toString() }; 15 | } 16 | -------------------------------------------------------------------------------- /src/config/constants.ts: -------------------------------------------------------------------------------- 1 | export const BLOCK_BINDING_SOURCE = 'remote-data/binding'; 2 | export const PATTERN_BLOCK_TYPE_POST_META_KEY: string = '_remote_data_blocks_block_type'; // Must match PatternEditor::$block_type_meta_key. 3 | export const PATTERN_OVERRIDES_BINDING_SOURCE = 'core/pattern-overrides'; 4 | export const PATTERN_OVERRIDES_CONTEXT_KEY = 'pattern/overrides'; 5 | export const TEXT_DOMAIN = 'remote-data-blocks'; 6 | -------------------------------------------------------------------------------- /src/data-sources/airtable/constants.ts: -------------------------------------------------------------------------------- 1 | export const AIRTABLE_STRING_TYPES = Object.freeze( 2 | new Set( [ 3 | 'singleLineText', 4 | 'multilineText', 5 | 'email', 6 | 'phoneNumber', 7 | 'barcode', 8 | 'singleSelect', 9 | 'date', 10 | 'dateTime', 11 | 'lastModifiedTime', 12 | 'createdTime', 13 | 'multipleRecordLinks', 14 | 'rollup', 15 | 'externalSyncSource', 16 | 'url', 17 | ] ) 18 | ); 19 | 20 | export const AIRTABLE_NUMBER_TYPES = Object.freeze( 21 | new Set( [ 'number', 'autoNumber', 'rating', 'duration', 'count', 'percent' ] ) 22 | ); 23 | 24 | export const AIRTABLE_USER_TYPES = Object.freeze( 25 | new Set( [ 'createdBy', 'lastModifiedBy', 'singleCollaborator' ] ) 26 | ); 27 | 28 | export const SUPPORTED_AIRTABLE_TYPES = Object.freeze( [ 29 | // String types 30 | 'singleLineText', 31 | 'multilineText', 32 | 'email', 33 | 'phoneNumber', 34 | 'barcode', 35 | 'singleSelect', 36 | 'multipleSelects', 37 | 'date', 38 | 'dateTime', 39 | 'lastModifiedTime', 40 | 'createdTime', 41 | 'multipleRecordLinks', 42 | 'rollup', 43 | 'externalSyncSource', 44 | 'url', 45 | // Number types 46 | 'number', 47 | 'autoNumber', 48 | 'rating', 49 | 'duration', 50 | 'count', 51 | 'percent', 52 | // User types 53 | 'createdBy', 54 | 'lastModifiedBy', 55 | 'singleCollaborator', 56 | // Markdown types 57 | 'richText', 58 | // Other types 59 | 'multipleCollaborators', 60 | 'currency', 61 | 'checkbox', 62 | 'multipleAttachments', 63 | 'formula', 64 | 'lookup', 65 | ] ); 66 | -------------------------------------------------------------------------------- /src/data-sources/airtable/types.ts: -------------------------------------------------------------------------------- 1 | export interface AirtableBase { 2 | id: string; 3 | name: string; 4 | permissionLevel: 'none' | 'read' | 'comment' | 'edit' | 'create'; 5 | } 6 | 7 | export interface AirtableBasesResult { 8 | offset?: string; 9 | bases: AirtableBase[]; 10 | } 11 | 12 | export interface AirtableBaseSchema { 13 | tables: AirtableTable[]; 14 | } 15 | 16 | export interface AirtableTable { 17 | id: string; 18 | name: string; 19 | primaryFieldId: string; 20 | fields: AirtableField[]; 21 | views: AirtableView[]; 22 | description: string | null; 23 | createTime: string; 24 | syncStatus: 'complete' | 'pending'; 25 | } 26 | 27 | /** 28 | * Represents an Airtable field configuration. 29 | * @see https://airtable.com/developers/web/api/model/table-model#fields 30 | */ 31 | export interface AirtableField { 32 | id: string; 33 | name: string; 34 | type: string; 35 | description: string | null; 36 | options?: { 37 | choices?: Array< { 38 | id: string; 39 | name: string; 40 | color?: string; 41 | } >; 42 | precision?: number; 43 | symbol?: string; 44 | format?: string; 45 | foreignTableId?: string; 46 | relationship?: 'many' | 'one'; 47 | symmetricColumnId?: string; 48 | result?: { 49 | type: string; 50 | options?: { 51 | precision?: number; 52 | symbol?: string; 53 | format?: string; 54 | }; 55 | }; 56 | }; 57 | } 58 | 59 | interface AirtableView { 60 | id: string; 61 | name: string; 62 | type: 'grid' | 'form' | 'calendar' | 'gallery' | 'kanban' | 'timeline' | 'block'; 63 | } 64 | 65 | export interface AirtableApiArgs { 66 | token: string; 67 | } 68 | 69 | export interface AirtableBaseOption { 70 | id: string; 71 | name: string; 72 | } 73 | -------------------------------------------------------------------------------- /src/data-sources/api-clients/auth.ts: -------------------------------------------------------------------------------- 1 | import apiFetch from '@wordpress/api-fetch'; 2 | 3 | import { REST_BASE_AUTH } from '@/data-sources/constants'; 4 | import { GoogleServiceAccountKey } from '@/types/google'; 5 | 6 | export async function getGoogleAuthTokenFromServiceAccount( 7 | serviceAccountKey: GoogleServiceAccountKey, 8 | scopes: string[] 9 | ): Promise< string > { 10 | const requestBody = { 11 | type: serviceAccountKey.type, 12 | scopes, 13 | credentials: serviceAccountKey, 14 | }; 15 | 16 | const response = await apiFetch< { token: string } >( { 17 | path: `${ REST_BASE_AUTH }/google/token`, 18 | method: 'POST', 19 | data: requestBody, 20 | } ); 21 | 22 | return response.token; 23 | } 24 | -------------------------------------------------------------------------------- /src/data-sources/components/CodeSnippet.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@wordpress/components'; 2 | import { __ } from '@wordpress/i18n'; 3 | import { copy } from '@wordpress/icons'; 4 | import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'; 5 | import php from 'react-syntax-highlighter/dist/esm/languages/prism/php'; 6 | import coy from 'react-syntax-highlighter/dist/esm/styles/prism/coy'; 7 | 8 | import { useDataSources } from '../hooks/useDataSources'; 9 | 10 | SyntaxHighlighter.registerLanguage( 'php', php ); 11 | 12 | const CodeSnippet = ( { code }: { code: string } ) => { 13 | const { showSnackbar } = useDataSources(); 14 | 15 | const handleCopy = () => { 16 | navigator.clipboard 17 | .writeText( code ) 18 | .then( () => { 19 | showSnackbar( 'success', __( 'Code copied to clipboard!', 'remote-data-blocks' ) ); 20 | } ) 21 | .catch( () => { 22 | showSnackbar( 'error', __( 'Failed to copy code', 'remote-data-blocks' ) ); 23 | } ); 24 | }; 25 | 26 | return ( 27 |
    34 | 48 | 49 | { code } 50 | 51 |
    52 | ); 53 | }; 54 | 55 | export default CodeSnippet; 56 | -------------------------------------------------------------------------------- /src/data-sources/components/CustomFormFieldToken.tsx: -------------------------------------------------------------------------------- 1 | import { FormTokenField } from '@wordpress/components'; 2 | import { FormTokenFieldProps } from '@wordpress/components/build-types/form-token-field/types'; 3 | import { useEffect, useRef, useState } from '@wordpress/element'; 4 | 5 | type CustomFormFieldTokenProps = FormTokenFieldProps & { 6 | customHelpText?: string | null; 7 | }; 8 | 9 | export const CustomFormFieldToken = ( props: CustomFormFieldTokenProps ) => { 10 | const [ isAbove, setIsAbove ] = useState( false ); 11 | const inputRef = useRef( null ); 12 | const { customHelpText, ...formTokenFieldProps } = props; 13 | 14 | const handlePosition = () => { 15 | if ( ! inputRef.current ) return; 16 | 17 | const { bottom, top } = ( inputRef.current as HTMLElement ).getBoundingClientRect(); 18 | const viewportHeight = window.innerHeight; 19 | const dropdownHeight = 200; 20 | 21 | // Determine whether there is more space above or below 22 | setIsAbove( viewportHeight - bottom < dropdownHeight && top > dropdownHeight ); 23 | }; 24 | 25 | useEffect( () => { 26 | const handleResize = () => handlePosition(); // Ensures stable reference for cleanup 27 | 28 | handlePosition(); // Calculate position on mount 29 | window.addEventListener( 'resize', handleResize ); // Recalculate on window resize 30 | 31 | return () => { 32 | window.removeEventListener( 'resize', handleResize ); 33 | }; 34 | }, [] ); 35 | 36 | return ( 37 |
    42 | 43 | { customHelpText &&

    { customHelpText }

    } 44 |
    45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /src/data-sources/components/DataSourceFormActions.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@wordpress/components'; 2 | import { __ } from '@wordpress/i18n'; 3 | 4 | interface DataSourceFormActionsProps { 5 | onSave: () => Promise< void >; 6 | isSaveDisabled: boolean; 7 | } 8 | 9 | export const DataSourceFormActions = ( { onSave, isSaveDisabled }: DataSourceFormActionsProps ) => { 10 | return ( 11 | 14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /src/data-sources/components/PasswordInputControl.tsx: -------------------------------------------------------------------------------- 1 | import { Button, __experimentalInputControl as InputControl } from '@wordpress/components'; 2 | import { useState } from '@wordpress/element'; 3 | import { __ } from '@wordpress/i18n'; 4 | import { seen, unseen } from '@wordpress/icons'; 5 | import { ComponentPropsWithoutRef } from 'react'; 6 | 7 | type PasswordInputControlProps = ComponentPropsWithoutRef< typeof InputControl >; 8 | 9 | const PasswordInputControl = ( { ...props }: PasswordInputControlProps ) => { 10 | const [ visible, setVisible ] = useState( false ); 11 | 12 | return ( 13 | setVisible( ! visible ) } 28 | /> 29 | } 30 | __next40pxDefaultSize 31 | { ...props } 32 | /> 33 | ); 34 | }; 35 | 36 | export default PasswordInputControl; 37 | -------------------------------------------------------------------------------- /src/data-sources/hooks/useGoogleAuth.ts: -------------------------------------------------------------------------------- 1 | import { useDebounce } from '@wordpress/compose'; 2 | import { useEffect, useCallback, useMemo } from '@wordpress/element'; 3 | 4 | import { getGoogleAuthTokenFromServiceAccount } from '@/data-sources/api-clients/auth'; 5 | import { useQuery } from '@/hooks/useQuery'; 6 | import { GoogleServiceAccountKey } from '@/types/google'; 7 | import { safeParseJSON } from '@/utils/string'; 8 | 9 | export const useGoogleAuth = ( serviceAccountKeyString: string, scopes: string[] ) => { 10 | const serviceAccountKey = useMemo( () => { 11 | return safeParseJSON< GoogleServiceAccountKey >( serviceAccountKeyString ); 12 | }, [ serviceAccountKeyString ] ); 13 | 14 | const queryFn = useCallback( async () => { 15 | if ( ! serviceAccountKey ) { 16 | return null; 17 | } 18 | return getGoogleAuthTokenFromServiceAccount( serviceAccountKey, scopes ); 19 | }, [ serviceAccountKey, scopes ] ); 20 | 21 | const { 22 | data: token, 23 | isLoading: fetchingToken, 24 | error: tokenError, 25 | refetch: fetchToken, 26 | } = useQuery( queryFn, { manualFetchOnly: true } ); 27 | 28 | const debouncedFetchToken = useDebounce( fetchToken, 500 ); 29 | // eslint-disable-next-line react-hooks/exhaustive-deps 30 | useEffect( debouncedFetchToken, [ serviceAccountKeyString ] ); 31 | 32 | return { token, fetchingToken, fetchToken, tokenError }; 33 | }; 34 | -------------------------------------------------------------------------------- /src/data-sources/http/types.ts: -------------------------------------------------------------------------------- 1 | export type HttpAuthTypes = 'bearer' | 'basic' | 'api-key' | 'none'; 2 | export type HttpApiKeyDestination = 'header' | 'queryparams'; 3 | 4 | export interface BaseHttpAuth { 5 | type: HttpAuthTypes; 6 | value: string; 7 | } 8 | 9 | export interface HttpBearerAuth extends BaseHttpAuth { 10 | type: 'bearer'; 11 | } 12 | 13 | export interface HttpBasicAuth extends BaseHttpAuth { 14 | type: 'basic'; 15 | } 16 | 17 | export type HttpAuth = HttpBearerAuth | HttpBasicAuth | HttpApiKeyAuth | HttpNoAuth; 18 | 19 | export interface HttpApiKeyAuth extends BaseHttpAuth { 20 | type: 'api-key'; 21 | key: string; 22 | add_to: HttpApiKeyDestination; 23 | } 24 | 25 | export interface HttpNoAuth extends BaseHttpAuth { 26 | type: 'none'; 27 | } 28 | -------------------------------------------------------------------------------- /src/data-sources/utils.tsx: -------------------------------------------------------------------------------- 1 | import { DataSourceConfig } from '@/data-sources/types'; 2 | import CheckIcon from '@/settings/icons/CheckIcon'; 3 | import ErrorIcon from '@/settings/icons/ErrorIcon'; 4 | 5 | export function getConnectionMessage( 6 | status: 'success' | 'error' | null, 7 | message: string 8 | ): JSX.Element { 9 | const StatusIcon = () => { 10 | if ( status === 'success' ) { 11 | return ; 12 | } 13 | 14 | if ( status === 'error' ) { 15 | return ; 16 | } 17 | 18 | return null; 19 | }; 20 | 21 | return ( 22 | 23 | { status && ( 24 | 25 | 26 | 27 | ) } 28 | { message } 29 | 30 | ); 31 | } 32 | 33 | export function getDataSourceName( config: DataSourceConfig | null ): string { 34 | return ( 35 | config?.display_name ?? config?.service_config.display_name ?? config?.service ?? 'Unknown' 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/dataviews/index.ts: -------------------------------------------------------------------------------- 1 | import { DataViews, filterSortAndPaginate } from '@wordpress/dataviews/wp'; 2 | 3 | /** 4 | * This file creates a global variable that provides DataViews so that it doesn't 5 | * have to be bundled multiple times for each block / script that uses it. 6 | * 7 | * More ideally, we would dynamically import() this module and avoid polluting 8 | * the global namespace, but our wrestling with Webpack was unsuccessful. 9 | * Improvements welcome! 10 | */ 11 | window.LockedPrivateDataViews = { 12 | filterSortAndPaginate, 13 | DataViews, 14 | }; 15 | -------------------------------------------------------------------------------- /src/hooks/useDebouncedState.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState } from '@wordpress/element'; 2 | 3 | export function useDebouncedState< T >( 4 | delayInMs: number, 5 | initialValue: T 6 | ): [ T, ( value: T ) => void ] { 7 | const timer = useRef< NodeJS.Timeout | null >( null ); 8 | const [ value, setValue ] = useState< T >( initialValue ); 9 | 10 | const debouncedSetValue = useCallback( ( newValue: T ) => { 11 | if ( timer.current ) { 12 | clearTimeout( timer.current ); 13 | } 14 | 15 | timer.current = setTimeout( () => { 16 | setValue( newValue ); 17 | }, delayInMs ); 18 | }, [] ); 19 | 20 | return [ value, debouncedSetValue ]; 21 | } 22 | -------------------------------------------------------------------------------- /src/hooks/useEditedPostAttribute.ts: -------------------------------------------------------------------------------- 1 | import { useSelect } from '@wordpress/data'; 2 | import { store as editorStore } from '@wordpress/editor'; 3 | 4 | // The @types/wordpress__editor package declares a module against best practice, 5 | // so we are unable to extend those types in our own declaration file like we do 6 | // for other @wordpress packages: 7 | // 8 | // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/fa6fad261048474f99b80698cae5170a6c37de0d/types/wordpress__editor/index.d.ts#L14-L15 9 | type GetEditedPostAttribute = < T >( attributeName: string ) => T | undefined; 10 | type PostAttributeSelector< T > = ( getEditedPostAttribute: GetEditedPostAttribute ) => T; 11 | 12 | interface EditorStoreSelectors { 13 | // Not all properties of Post | Page are included, so widen to string and 14 | // allow caller to provide the return type. 15 | getEditedPostAttribute: GetEditedPostAttribute; 16 | } 17 | 18 | /** 19 | * Provides access to post attributes. Inspect `wp.data.select('core/editor').getCurrentPost()` 20 | * to see what's available as a post attribute. 21 | */ 22 | export function useEditedPostAttribute< T >( selector: PostAttributeSelector< T > ): T { 23 | return useSelect< EditorStoreSelectors, T >( select => { 24 | const { getEditedPostAttribute } = select( editorStore ); 25 | 26 | return selector( getEditedPostAttribute ); 27 | }, [] ); 28 | } 29 | -------------------------------------------------------------------------------- /src/hooks/usePostMeta.ts: -------------------------------------------------------------------------------- 1 | import { useEntityProp } from '@wordpress/core-data'; 2 | 3 | type UseEntityPropReturnValue = [ 4 | // This is the post meta for the "saved" version of this post. It's not currently 5 | // updated in real-time. 6 | Record< string, unknown >, 7 | 8 | // We can use this function to update the post meta if needed. 9 | ( meta: Record< string, unknown > ) => void, 10 | 11 | // This is the "fullValue" of post meta from the REST API, containing "raw" 12 | // and "rendered" values. 13 | Record< string, unknown > 14 | ]; 15 | 16 | export interface UsePostMetaReturnValue { 17 | postMeta: UseEntityPropReturnValue[ 0 ]; 18 | updatePostMeta: UseEntityPropReturnValue[ 1 ]; 19 | } 20 | 21 | export function usePostMeta( postId: number, postType: string ): UsePostMetaReturnValue { 22 | const [ postMeta, updatePostMeta ] = useEntityProp( 23 | 'postType', 24 | postType, 25 | 'meta', 26 | postId 27 | ) as UseEntityPropReturnValue; 28 | 29 | return { postMeta, updatePostMeta }; 30 | } 31 | -------------------------------------------------------------------------------- /src/hooks/useQuery.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from '@wordpress/element'; 2 | 3 | type QueryFunction< T > = () => Promise< T >; 4 | 5 | interface QueryResult< T > { 6 | data: T | null; 7 | isLoading: boolean; 8 | error: Error | null; 9 | refetch: () => void; 10 | } 11 | 12 | interface QueryOptions { 13 | manualFetchOnly: boolean; 14 | } 15 | 16 | const defaultOptions: QueryOptions = { 17 | manualFetchOnly: false, 18 | }; 19 | 20 | export function useQuery< T >( 21 | queryFn: QueryFunction< T >, 22 | options: QueryOptions = defaultOptions 23 | ): QueryResult< T > { 24 | const [ data, setData ] = useState< T | null >( null ); 25 | const [ isLoading, setIsLoading ] = useState< boolean >( false ); 26 | const [ error, setError ] = useState< Error | null >( null ); 27 | 28 | const fetchData = useCallback( async () => { 29 | setData( null ); 30 | setIsLoading( true ); 31 | setError( null ); 32 | try { 33 | const result = await queryFn(); 34 | setData( result ); 35 | } catch ( err ) { 36 | setData( null ); 37 | setError( err instanceof Error ? err : new Error( 'An error occurred' ) ); 38 | } finally { 39 | setIsLoading( false ); 40 | } 41 | }, [ queryFn ] ); 42 | 43 | useEffect( () => { 44 | if ( options.manualFetchOnly ) { 45 | return; 46 | } 47 | 48 | void fetchData(); 49 | }, [ fetchData, options.manualFetchOnly ] ); 50 | 51 | const refetch = useCallback( () => { 52 | void fetchData(); 53 | }, [ fetchData ] ); 54 | 55 | return { data, isLoading, error, refetch }; 56 | } 57 | -------------------------------------------------------------------------------- /src/pattern-editor/config/constants.ts: -------------------------------------------------------------------------------- 1 | // Must match the post meta registered in PatternEditor class. 2 | export const PATTERN_BLOCK_TYPES_POST_META_KEY: string = '_remote_data_blocks_block_type'; 3 | -------------------------------------------------------------------------------- /src/pattern-editor/index.tsx: -------------------------------------------------------------------------------- 1 | import { registerPlugin } from '@wordpress/plugins'; 2 | 3 | import { PatternEditorSettingsPanel } from '@/pattern-editor/components/PatternEditorSettingsPanel'; 4 | 5 | registerPlugin( 'remote-data-blocks-settings', { 6 | render: PatternEditorSettingsPanel, 7 | icon: 'admin-settings', 8 | } ); 9 | -------------------------------------------------------------------------------- /src/settings/Notices.tsx: -------------------------------------------------------------------------------- 1 | import { SnackbarList } from '@wordpress/components'; 2 | import { useDispatch, useSelect } from '@wordpress/data'; 3 | import { 4 | store as noticesStore, 5 | NoticeStoreActions, 6 | NoticeStoreSelectors, 7 | WPNotice, 8 | } from '@wordpress/notices'; 9 | 10 | const Notices = () => { 11 | const { removeNotice } = useDispatch< NoticeStoreActions >( noticesStore ); 12 | const notices = useSelect< NoticeStoreSelectors, WPNotice[] >( 13 | select => select( noticesStore ).getNotices(), 14 | [] 15 | ); 16 | 17 | if ( notices.length === 0 ) { 18 | return null; 19 | } 20 | 21 | return ( 22 | { 25 | removeNotice( notice ); 26 | } } 27 | /> 28 | ); 29 | }; 30 | 31 | export default Notices; 32 | -------------------------------------------------------------------------------- /src/settings/icons/CheckIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SVG, Path } from '@wordpress/primitives'; 2 | 3 | const CheckIcon = () => ( 4 | 5 | 9 | 10 | ); 11 | 12 | export default CheckIcon; 13 | -------------------------------------------------------------------------------- /src/settings/icons/ErrorIcon.tsx: -------------------------------------------------------------------------------- 1 | import { Path, SVG } from '@wordpress/primitives'; 2 | 3 | const ErrorIcon = () => ( 4 | 5 | 6 | 7 | 13 | 14 | ); 15 | 16 | export default ErrorIcon; 17 | -------------------------------------------------------------------------------- /src/settings/icons/HttpIcon.tsx: -------------------------------------------------------------------------------- 1 | import { SVG } from '@wordpress/primitives'; 2 | 3 | const HttpIcon = ( 4 | 5 | 10 | 11 | ); 12 | 13 | export default HttpIcon; 14 | -------------------------------------------------------------------------------- /src/settings/index.tsx: -------------------------------------------------------------------------------- 1 | import domReady from '@wordpress/dom-ready'; 2 | import { createRoot } from '@wordpress/element'; 3 | 4 | import SettingsPage from '@/settings/SettingsPage'; 5 | import './index.scss'; 6 | 7 | domReady( () => { 8 | const el = document.getElementById( 'remote-data-blocks-settings' ); 9 | if ( ! el ) { 10 | return; 11 | } 12 | const root = createRoot( el ); 13 | root.render( ); 14 | } ); 15 | -------------------------------------------------------------------------------- /src/types/common.ts: -------------------------------------------------------------------------------- 1 | export interface StringIdName { 2 | name: string; 3 | id: string; 4 | } 5 | 6 | export interface NumberIdName { 7 | name: string; 8 | id: number; 9 | } 10 | -------------------------------------------------------------------------------- /src/types/input.ts: -------------------------------------------------------------------------------- 1 | export interface SelectOption< T = string > { 2 | label: string; 3 | value: T; 4 | disabled?: boolean; 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/errors.ts: -------------------------------------------------------------------------------- 1 | export abstract class DisplayableError extends Error { 2 | public abstract toString(): string; 3 | } 4 | 5 | export function ensureError( error: unknown ): Error { 6 | if ( error instanceof Error ) { 7 | return error; 8 | } 9 | 10 | if ( null === error || undefined === error ) { 11 | return new Error( 'An unknown error occurred' ); 12 | } 13 | 14 | if ( typeof error === 'string' ) { 15 | return new Error( error ); 16 | } 17 | 18 | if ( 'object' === typeof error && 'message' in error ) { 19 | return new Error( String( error.message ) ); 20 | } 21 | 22 | return new Error( String( error ) ); 23 | } 24 | -------------------------------------------------------------------------------- /src/utils/function.ts: -------------------------------------------------------------------------------- 1 | export function memoizeFn< T extends ( ...args: Parameters< T > ) => ReturnType< T > >( 2 | func: T 3 | ): T { 4 | const cache = new Map< string, ReturnType< T > >(); 5 | 6 | const stringify = ( obj: unknown ): string => { 7 | try { 8 | return JSON.stringify( obj ); 9 | } catch ( err ) { 10 | return String( obj ); 11 | } 12 | }; 13 | 14 | const memoized = ( ...args: Parameters< T > ): ReturnType< T > => { 15 | const key = Array.from( args ).map( stringify ).join( ',' ); 16 | 17 | if ( cache.has( key ) ) { 18 | // TypeScript does not narrow based on the result of Map#has. Workarounds 19 | // are complex and require a type overload or predicate. 20 | // https://github.com/microsoft/TypeScript/issues/13086 21 | // 22 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 23 | return cache.get( key )!; 24 | } 25 | 26 | const result = func( ...args ); 27 | 28 | // If the result is a Promise, ensure that it does not reject so that we 29 | // do not cache a bad result. Wrap the resolved value in a Promise to ensure 30 | // that it remains then-able. 31 | if ( result instanceof Promise ) { 32 | return result.then( () => { 33 | cache.set( key, result ); 34 | return result; 35 | } ) as ReturnType< T >; 36 | } 37 | 38 | cache.set( key, result ); 39 | return result; 40 | }; 41 | 42 | // Type assertion required to narrow type back to T. 43 | return memoized as T; 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/i18n.ts: -------------------------------------------------------------------------------- 1 | import { __ as unwrappedTranslate } from '@wordpress/i18n'; 2 | 3 | import { TEXT_DOMAIN } from '@/config/constants'; 4 | 5 | export function __( text: string ): string { 6 | return unwrappedTranslate( text, TEXT_DOMAIN ); 7 | } 8 | -------------------------------------------------------------------------------- /src/utils/localized-block-data.ts: -------------------------------------------------------------------------------- 1 | export function getBlockAvailableBindings( blockName: string ): AvailableBindings { 2 | return getBlockConfig( blockName )?.availableBindings ?? {}; 3 | } 4 | 5 | export function getBlockConfig( blockName: string ): BlockConfig | undefined { 6 | return window.REMOTE_DATA_BLOCKS?.config?.[ blockName ]; 7 | } 8 | 9 | export function getBlockDataSourceType( blockName?: string ): string { 10 | if ( ! blockName ) { 11 | return ''; 12 | } 13 | 14 | return getBlockConfig( blockName )?.dataSourceType ?? ''; 15 | } 16 | 17 | /** 18 | * Get the title of a remote data block. 19 | * 20 | * The title could be set in the block config or it could be the block name, without the remote-data-blocks/ prefix. 21 | * If the title is not found, or the block name is not found, then unknown is returned. 22 | * 23 | * @param blockName The name of the remote data block. 24 | * @returns The title of the remote data block. 25 | */ 26 | export function getBlockTitle( blockName?: string ): string { 27 | if ( ! blockName ) { 28 | return ''; 29 | } 30 | 31 | return ( 32 | getBlockConfig( blockName )?.settings?.title ?? blockName.replace( 'remote-data-blocks/', '' ) 33 | ); 34 | } 35 | 36 | export function getBlocksConfig(): BlocksConfig { 37 | return window.REMOTE_DATA_BLOCKS?.config ?? {}; 38 | } 39 | 40 | export function getRestUrl(): string { 41 | return window.REMOTE_DATA_BLOCKS?.rest_url ?? 'http://127.0.0.1:9999'; 42 | } 43 | 44 | /** 45 | * Return global `Tracks` properties to be sent with every event. 46 | */ 47 | export function getTracksGlobalProperties(): TracksGlobalProperties | undefined { 48 | return window.REMOTE_DATA_BLOCKS?.tracks_global_properties; 49 | } 50 | -------------------------------------------------------------------------------- /src/utils/object.ts: -------------------------------------------------------------------------------- 1 | export const isNonEmptyObj = ( obj: unknown ): boolean => 2 | typeof obj === 'object' && obj !== null && Object.keys( obj ).length > 0; 3 | 4 | export const constructObjectWithValues = < T >( 5 | keys: string[], 6 | defaultValue: T 7 | ): Record< string, T > => { 8 | return Object.fromEntries( keys.map( key => [ key, defaultValue ] ) ); 9 | }; 10 | -------------------------------------------------------------------------------- /src/utils/type-narrowing.ts: -------------------------------------------------------------------------------- 1 | export function isObjectWithStringKeys( value: unknown ): value is Record< string, unknown > { 2 | return typeof value === 'object' && value !== null && ! Array.isArray( value ); 3 | } 4 | 5 | export function removeNullValuesFromObject< ValueType >( 6 | obj: Record< string, ValueType | null > 7 | ): Record< string, ValueType > { 8 | return Object.fromEntries< ValueType >( 9 | Object.entries( obj ).filter( ( entry ): entry is [ string, ValueType ] => entry[ 1 ] !== null ) 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /tests/e2e/settings/activation.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@wordpress/e2e-test-utils-playwright'; 2 | 3 | test.describe( 'plugin activation', () => { 4 | test( 'should have the "Remote Data Blocks" menu item in sidebar', async ( { admin, page } ) => { 5 | await admin.visitAdminPage( '/' ); 6 | await page.locator( '#menu-settings' ).click(); 7 | 8 | const settingsMenuItem = page.locator( 9 | '.wp-has-current-submenu a[href="options-general.php?page=remote-data-blocks-settings"]' 10 | ); 11 | await expect( settingsMenuItem ).toBeVisible(); 12 | } ); 13 | } ); 14 | -------------------------------------------------------------------------------- /tests/inc/Config/ArraySerializableTest.php: -------------------------------------------------------------------------------- 1 | true, 12 | 'enum_value' => 'foo', 13 | 'string_value' => 'test', 14 | ]; 15 | 16 | public function test_from_array_valid_config(): void { 17 | $instance = MockSerializableClass::from_array( $this->sample_config ); 18 | $this->assertInstanceOf( MockSerializableClass::class, $instance ); 19 | } 20 | 21 | public function test_from_array_invalid_config(): void { 22 | $instance = MockSerializableClass::from_array( [ 'foo' => 'bar' ] ); 23 | $this->assertInstanceOf( WP_Error::class, $instance ); 24 | } 25 | 26 | public function test_to_array(): void { 27 | $instance = MockSerializableClass::from_array( $this->sample_config ); 28 | $config = $instance->to_array(); 29 | $expected_config = array_merge( 30 | $this->sample_config, 31 | [ '__class' => MockSerializableClass::class ] 32 | ); 33 | 34 | $this->assertSame( $expected_config, $config ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/inc/Config/HttpDataSourceTest.php: -------------------------------------------------------------------------------- 1 | 'Mock Data Source', 13 | 'endpoint' => 'https://example.com/api', 14 | 'request_headers' => [ 15 | 'x-user-id' => 123, 16 | ], 17 | ]; 18 | 19 | $mock_data_source = MockDataSource::create( $config ); 20 | 21 | $this->assertInstanceOf( MockDataSource::class, $mock_data_source ); 22 | $this->assertSame( 'https://example.com/api', $mock_data_source->get_endpoint() ); 23 | $this->assertSame( 123, $mock_data_source->get_request_headers()['x-api-user'] ); 24 | $this->assertSame( 123, $mock_data_source->to_array()['request_headers']['x-api-user'] ); 25 | } 26 | 27 | public function test_migrate_config_flags_invalid_header(): void { 28 | $config = [ 29 | 'display_name' => 'Mock Data Source', 30 | 'endpoint' => 'https://example.com/api', 31 | 'request_headers' => [ 32 | 'x-api-user' => 'not an integer', 33 | ], 34 | ]; 35 | 36 | $mock_data_source = MockDataSource::create( $config ); 37 | 38 | $this->assertInstanceOf( WP_Error::class, $mock_data_source ); 39 | $this->assertSame( 'invalid_x_api_user', $mock_data_source->get_error_code() ); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tests/inc/Editor/ConfigStoreTest.php: -------------------------------------------------------------------------------- 1 | assertNull( ConfigStore::get_data_source_type( 'block_name' ) ); 15 | } 16 | 17 | public function testGetDataSourceReturnsNullIfThereAreNoQueries(): void { 18 | ConfigStore::init(); 19 | ConfigStore::set_block_configuration( 'block_name', [ 'queries' => [] ] ); 20 | 21 | $this->assertNull( ConfigStore::get_data_source_type( 'block_name' ) ); 22 | } 23 | 24 | public function testGetDataSourceReturnsDataSource(): void { 25 | ConfigStore::init(); 26 | ConfigStore::set_block_configuration( 'airtable_remote_blocks', [ 27 | 'queries' => [ 28 | 'display' => HttpQuery::from_array( [ 29 | 'data_source' => AirtableDataSource::from_array( [ 30 | 'service_config' => [ 31 | '__version' => 1, 32 | 'access_token' => 'token', 33 | 'base' => [ 34 | 'id' => 'foo', 35 | ], 36 | 'display_name' => 'Name', 37 | 'tables' => [], 38 | ], 39 | ] ), 40 | 'output_schema' => [ 'type' => 'string' ], 41 | ] ), 42 | ], 43 | ] ); 44 | 45 | $this->assertEquals( 'airtable', ConfigStore::get_data_source_type( 'airtable_remote_blocks' ) ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /tests/inc/Integrations/Airtable/AirtableDataSourceTest.php: -------------------------------------------------------------------------------- 1 | data_source = AirtableDataSource::from_array( [ 15 | 'service_config' => [ 16 | '__version' => 1, 17 | 'access_token' => 'test_access_token', 18 | 'display_name' => 'Airtable Source', 19 | 'base' => [ 20 | 'id' => 'test_base_id', 21 | 'name' => 'Test Airtable Base', 22 | ], 23 | 'tables' => [], 24 | ], 25 | ] ); 26 | } 27 | 28 | public function test_get_display_name(): void { 29 | $this->assertSame( 30 | 'Airtable Source', 31 | $this->data_source->get_display_name() 32 | ); 33 | } 34 | 35 | public function test_get_endpoint(): void { 36 | $this->assertSame( 37 | 'https://api.airtable.com/v0/test_base_id', 38 | $this->data_source->get_endpoint() 39 | ); 40 | } 41 | 42 | public function test_get_request_headers(): void { 43 | $expected_headers = [ 44 | 'Authorization' => 'Bearer test_access_token', 45 | 'Content-Type' => 'application/json', 46 | ]; 47 | 48 | $this->assertSame( $expected_headers, $this->data_source->get_request_headers() ); 49 | } 50 | 51 | public function test_create(): void { 52 | $this->assertInstanceOf( AirtableDataSource::class, $this->data_source ); 53 | $this->assertSame( 'Airtable Source', $this->data_source->get_display_name() ); 54 | $this->assertSame( 'https://api.airtable.com/v0/test_base_id', $this->data_source->get_endpoint() ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/inc/Integrations/GenericHttp/GenericHttpDataSourceTest.php: -------------------------------------------------------------------------------- 1 | [ 13 | '__version' => 1, 14 | 'display_name' => 'Mock Data Source', 15 | 'endpoint' => 'http://example.com', 16 | ], 17 | ]; 18 | $data_source = GenericHttpDataSource::from_array( $config ); 19 | 20 | $this->assertEquals( 'generic-http', $data_source->get_service_name() ); 21 | } 22 | 23 | public function test_to_array_returns_correctly_mapped_values(): void { 24 | $config = [ 25 | 'service_config' => [ 26 | '__version' => 1, 27 | 'display_name' => 'Mock Data Source', 28 | 'endpoint' => 'http://example.com', 29 | ], 30 | ]; 31 | 32 | // By calling to_array, we can ensure that the config schema is correctly defined, 33 | // and that values haven't been sanitized due to them missing from the schema. 34 | $data_source = GenericHttpDataSource::from_array( $config ); 35 | $data_source_array = $data_source->to_array(); 36 | 37 | $this->assertEquals( 'generic-http', $data_source_array['service'] ); 38 | $this->assertEquals( 'http://example.com', $data_source_array['service_config']['endpoint'] ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/inc/Mocks/MockQuery.php: -------------------------------------------------------------------------------- 1 | $config['data_source'] ?? MockDataSource::create(), 17 | 'display_name' => 'Mock Query', 18 | 'endpoint' => $config['endpoint'] ?? null, 19 | 'input_schema' => $config['input_schema'] ?? [], 20 | 'output_schema' => $config['output_schema'] ?? [ 'type' => 'string' ], 21 | 'query_runner' => $config['query_runner'] ?? new MockQueryRunner(), 22 | ], $validator ?? new MockValidator() ); 23 | } 24 | 25 | public function preprocess_response( mixed $response_data, array $input_variables ): mixed { 26 | if ( null !== $this->response_data ) { 27 | return $this->response_data; 28 | } 29 | 30 | return $response_data; 31 | } 32 | 33 | public function set_output_schema( array $output_schema ): void { 34 | $this->config['output_schema'] = $output_schema; 35 | } 36 | 37 | public function set_request_method( string $method ): void { 38 | $this->config['request_method'] = $method; 39 | } 40 | 41 | public function set_request_body( array $body ): void { 42 | $this->config['request_body'] = $body; 43 | } 44 | 45 | public function set_response_data( mixed $data ): void { 46 | $this->response_data = $data; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /tests/inc/Mocks/MockQueryRunner.php: -------------------------------------------------------------------------------- 1 | */ 14 | private array $execute_call_inputs = []; 15 | 16 | public function setResults( array|WP_Error $results ): void { 17 | if ( $results instanceof WP_Error ) { 18 | $this->query_results = $results; 19 | return; 20 | } 21 | 22 | $this->query_results = [ 23 | 'is_collection' => false, 24 | 'results' => array_map( function ( $result ): array { 25 | return [ 26 | 'result' => $result, 27 | ]; 28 | }, $results ), 29 | ]; 30 | } 31 | 32 | public function execute( HttpQueryInterface $query, array $input_variables ): array|WP_Error { 33 | array_push( $this->execute_call_inputs, $input_variables ); 34 | return $this->query_results ?? new WP_Error( 'no-results', 'No results available.' ); 35 | } 36 | 37 | public function getLastExecuteCallInput(): array|null { 38 | return end( $this->execute_call_inputs ) ?? null; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/inc/Mocks/MockSerializableClass.php: -------------------------------------------------------------------------------- 1 | Types::boolean(), 12 | 'enum_value' => Types::enum( 'foo', 'bar' ), 13 | 'string_value' => Types::string(), 14 | ] ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/inc/Mocks/MockSerializableSubclass.php: -------------------------------------------------------------------------------- 1 | Types::boolean(), 11 | 'enum_value' => Types::enum( 'foo', 'bar' ), 12 | 'string_value' => Types::string(), 13 | 'extra_value' => Types::string(), 14 | ] ); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tests/inc/Mocks/MockTelemetry.php: -------------------------------------------------------------------------------- 1 | should_pass ) { 23 | return true; 24 | } 25 | 26 | return new WP_Error( 27 | 'mock_validation_error', 28 | 'Mock validation failed', 29 | [ 'status' => 400 ] 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/inc/WpdbStorage/DataEncryptionTest.php: -------------------------------------------------------------------------------- 1 | data_encryption = new DataEncryption(); 14 | } 15 | 16 | public function testEncryptAndDecrypt(): void { 17 | $original_value = 'sensitive data'; 18 | $encrypted_value = $this->data_encryption->encrypt( $original_value ); 19 | 20 | $this->assertNotEquals( $original_value, $encrypted_value ); 21 | $this->assertNotFalse( $encrypted_value ); 22 | 23 | $decrypted_value = $this->data_encryption->decrypt( $encrypted_value ); 24 | 25 | $this->assertSame( $original_value, $decrypted_value ); 26 | } 27 | 28 | public function testEncryptWithEmptyString(): void { 29 | $encrypted_value = $this->data_encryption->encrypt( '' ); 30 | 31 | $this->assertNotFalse( $encrypted_value ); 32 | $this->assertNotEmpty( $encrypted_value ); 33 | 34 | $decrypted_value = $this->data_encryption->decrypt( $encrypted_value ); 35 | 36 | $this->assertSame( '', $decrypted_value ); 37 | } 38 | 39 | public function testDecryptWithInvalidInput(): void { 40 | $invalid_input = 'not_encrypted_data'; 41 | $decrypted_value = $this->data_encryption->decrypt( $invalid_input ); 42 | 43 | $this->assertSame( $invalid_input, $decrypted_value ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/inc/bootstrap.php: -------------------------------------------------------------------------------- 1 | assertEquals( 'remote-data-blocks/block-name-with-slashes-andasterisks', $block_name ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tests/integration/bootstrap.php: -------------------------------------------------------------------------------- 1 | { 7 | it( 'should initialize with isOpen as false', () => { 8 | const { result } = renderHook( () => useModalState() ); 9 | expect( result.current.isOpen ).toBe( false ); 10 | } ); 11 | 12 | it( 'should open the modal', () => { 13 | const { result } = renderHook( () => useModalState() ); 14 | act( () => { 15 | result.current.open(); 16 | } ); 17 | expect( result.current.isOpen ).toBe( true ); 18 | } ); 19 | 20 | it( 'should close the modal', () => { 21 | const { result } = renderHook( () => useModalState() ); 22 | act( () => { 23 | result.current.open(); 24 | result.current.close(); 25 | } ); 26 | expect( result.current.isOpen ).toBe( false ); 27 | } ); 28 | 29 | it( 'should call onOpen when opening', () => { 30 | const onOpen = vi.fn(); 31 | const onClose = vi.fn(); 32 | const { result } = renderHook( () => useModalState( onOpen, onClose ) ); 33 | act( () => { 34 | result.current.open(); 35 | } ); 36 | expect( onOpen ).toHaveBeenCalledTimes( 1 ); 37 | expect( onClose ).not.toHaveBeenCalled(); 38 | } ); 39 | 40 | it( 'should call onClose when closing', () => { 41 | const onOpen = vi.fn(); 42 | const onClose = vi.fn(); 43 | const { result } = renderHook( () => useModalState( onOpen, onClose ) ); 44 | act( () => { 45 | result.current.open(); 46 | result.current.close(); 47 | } ); 48 | expect( onOpen ).toHaveBeenCalledTimes( 1 ); 49 | expect( onClose ).toHaveBeenCalledTimes( 1 ); 50 | } ); 51 | } ); 52 | -------------------------------------------------------------------------------- /tests/src/blocks/remote-data-template/components/loop-template/LoopTemplate.test.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, render } from '@testing-library/react'; 2 | import { afterEach, describe, expect, it } from 'vitest'; 3 | 4 | import { LoopTemplate } from '@/blocks/remote-data-template/components/loop-template/LoopTemplate'; 5 | 6 | describe( 'LoopTemplate', () => { 7 | const mockGetInnerBlocks = () => []; 8 | const mockRemoteData: RemoteData = { 9 | blockName: 'test/block', 10 | metadata: {}, 11 | queryInputs: [ {} ], 12 | queryKey: 'test-query', 13 | resultId: 'test-result', 14 | results: [ 15 | { 16 | uuid: 'foo', 17 | result: { 18 | id: { name: 'ID', type: 'id', value: '1' }, 19 | title: { name: 'Title', type: 'string', value: 'Test 1' }, 20 | }, 21 | }, 22 | { 23 | uuid: 'bar', 24 | result: { 25 | id: { name: 'ID', type: 'id', value: '2' }, 26 | title: { name: 'Title', type: 'string', value: 'Test 2' }, 27 | }, 28 | }, 29 | ], 30 | }; 31 | const expectedListItems = mockRemoteData.results.length + 1; // because of the memoized preview 32 | 33 | afterEach( cleanup ); 34 | 35 | it( 'renders a list when there are results', () => { 36 | const { container } = render( 37 | 38 | ); 39 | const list = container.querySelector( 'ul' ); 40 | expect( list?.nodeName ).toBe( 'UL' ); 41 | } ); 42 | 43 | it( 'renders the correct number of list items', () => { 44 | const { container } = render( 45 | 46 | ); 47 | const listItems = container.querySelectorAll( 'ul > li' ); 48 | expect( listItems.length ).toBe( expectedListItems ); 49 | } ); 50 | } ); 51 | -------------------------------------------------------------------------------- /tests/src/blocks/remote-data-template/components/loop-template/LoopTemplateInnerBlocks.test.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, render } from '@testing-library/react'; 2 | import { afterEach, describe, expect, it } from 'vitest'; 3 | 4 | import { LoopTemplateInnerBlocks } from '@/blocks/remote-data-template/components/loop-template/LoopTemplateInnerBlocks'; 5 | 6 | describe( 'LoopTemplateInnerBlocks', () => { 7 | afterEach( cleanup ); 8 | 9 | it( 'renders null when isActive is false', () => { 10 | const { container } = render( ); 11 | expect( container.firstChild ).toBeNull(); 12 | } ); 13 | 14 | it( 'renders a li when isActive is true', () => { 15 | const { container } = render( ); 16 | const element = container.firstChild; 17 | expect( element?.nodeName ).toBe( 'LI' ); 18 | } ); 19 | } ); 20 | -------------------------------------------------------------------------------- /tests/src/utils/errors.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | 3 | import { ensureError } from '@/utils/errors'; 4 | 5 | describe( 'ensureError', () => { 6 | it( 'should return the error if it is an instance of Error', () => { 7 | const error = new Error( 'Test error' ); 8 | expect( ensureError( error ) ).toBe( error ); 9 | } ); 10 | 11 | it( 'should return a new Error if the input is null', () => { 12 | expect( ensureError( null ) ).toEqual( new Error( 'An unknown error occurred' ) ); 13 | } ); 14 | 15 | it( 'should return a new Error if the input is undefined', () => { 16 | expect( ensureError( undefined ) ).toEqual( new Error( 'An unknown error occurred' ) ); 17 | } ); 18 | 19 | it( 'should return a new Error if the input is a string', () => { 20 | const message = 'This is an error message'; 21 | expect( ensureError( message ) ).toEqual( new Error( message ) ); 22 | } ); 23 | 24 | it( 'should return a new Error if the input is an object with a message property', () => { 25 | const errorObject = { message: 'Object error message' }; 26 | expect( ensureError( errorObject ) ).toEqual( new Error( 'Object error message' ) ); 27 | } ); 28 | 29 | it( 'should return a new Error for any other type of input', () => { 30 | const numberInput = 42; 31 | expect( ensureError( numberInput ).message ).toEqual( '42' ); 32 | } ); 33 | } ); 34 | -------------------------------------------------------------------------------- /tests/src/vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import * as matchers from '@testing-library/jest-dom/matchers'; 2 | import '@testing-library/jest-dom/vitest'; 3 | import { cleanup } from '@testing-library/react'; 4 | import { afterEach, expect } from 'vitest'; 5 | 6 | expect.extend( matchers ); 7 | 8 | afterEach( () => { 9 | cleanup(); 10 | } ); 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "baseUrl": "./", 5 | "esModuleInterop": true, 6 | "jsx": "react-jsx", 7 | "module": "es6", 8 | "moduleResolution": "bundler", 9 | "noImplicitAny": true, 10 | "noUncheckedIndexedAccess": true, 11 | "outDir": "./build/", 12 | "paths": { 13 | "@/*": [ "./src/*" ] 14 | }, 15 | "resolveJsonModule": true, 16 | "skipLibCheck": true, 17 | "strict": true, 18 | "target": "es6", 19 | "typeRoots": [ "./types", "./node_modules/@types" ], 20 | "sourceMap": true 21 | }, 22 | "include": [ "./types/*.d.ts", "./example/**/*", "./src/**/*", "./tests/**/*", "*.config.ts" ] 23 | } 24 | -------------------------------------------------------------------------------- /types/globals.d.ts: -------------------------------------------------------------------------------- 1 | import type { DataViews, filterSortAndPaginate } from '@wordpress/dataviews/wp'; 2 | 3 | declare global { 4 | var REMOTE_DATA_BLOCKS: LocalizedBlockData | undefined; 5 | var REMOTE_DATA_BLOCKS_SETTINGS: LocalizedSettingsData | undefined; 6 | var LockedPrivateDataViews: { 7 | filterSortAndPaginate: typeof filterSortAndPaginate; 8 | DataViews: typeof DataViews; 9 | }; 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /types/localized-block-data.d.ts: -------------------------------------------------------------------------------- 1 | type RemoteDataBinding = Pick< RemoteDataResultFields, 'name' | 'type' >; 2 | type AvailableBindings = Record< string, RemoteDataBinding >; 3 | 4 | /** 5 | * This corresponds directly to the input schema defined by a query. 6 | */ 7 | interface InputVariable { 8 | /** The stringified default value of the variable */ 9 | default_value?: string; 10 | /** The display friendly name of the variable */ 11 | name?: string; 12 | /** Whether the variable is required, or not in the query */ 13 | required: boolean; 14 | /** The slug of the variable in the query */ 15 | slug: string; 16 | /** The type of the variable in the query */ 17 | type: string; 18 | } 19 | 20 | interface InputVariableOverride { 21 | display_name?: string; 22 | help_text?: string; 23 | name: string; 24 | } 25 | 26 | interface BlockConfig { 27 | availableBindings: AvailableBindings; 28 | availableOverrides: InputVariableOverride[]; 29 | dataSourceType: string; 30 | instructions?: string; 31 | name: string; 32 | patterns: { 33 | default: string; 34 | inner_blocks?: string; 35 | }; 36 | selectors: { 37 | image_url?: string; 38 | inputs: InputVariable[]; 39 | name: string; 40 | query_key: string; 41 | type: string; 42 | }[]; 43 | settings: { 44 | category: string; 45 | description?: string; 46 | icon?: ReactElement | IconType | ComponentType; 47 | title: string; 48 | }; 49 | } 50 | 51 | interface BlocksConfig { 52 | [ blockName: string ]: BlockConfig; 53 | } 54 | 55 | interface LocalizedBlockData { 56 | config: BlocksConfig; 57 | rest_url: string; 58 | tracks_global_properties?: TracksGlobalProperties; 59 | } 60 | -------------------------------------------------------------------------------- /types/localized-settings.d.ts: -------------------------------------------------------------------------------- 1 | interface LocalizedSettingsData { 2 | branch: string; 3 | hash: string; 4 | version: string; 5 | } 6 | -------------------------------------------------------------------------------- /types/query-monitor.d.ts: -------------------------------------------------------------------------------- 1 | interface RemoteDataBlockLog { 2 | block_name: string; 3 | context: { 4 | query_key: string; 5 | query_inputs: RemoteDataQueryInput[]; 6 | }; 7 | filtered_trace: QueryMonitorTrace[]; 8 | level: string; 9 | message: string; 10 | } 11 | 12 | interface QueryMonitorData { 13 | 'remote-data-blocks-logs'?: RemoteDataBlockLog[]; 14 | } 15 | 16 | interface QueryMonitorTrace { 17 | file: string; 18 | line: number; 19 | function: string; 20 | class: string; 21 | type: string; 22 | id: string; 23 | display: string; 24 | calling_file: string; 25 | calling_line: number; 26 | } 27 | -------------------------------------------------------------------------------- /types/tracks.d.ts: -------------------------------------------------------------------------------- 1 | interface TracksGlobalProperties { 2 | plugin_version: string; 3 | 4 | // "Tracks" library properties. 5 | hosting_provider: string; 6 | is_vip_user: boolean; 7 | is_multisite: boolean; 8 | vip_env: string; 9 | vip_org: number; 10 | wp_version: string; 11 | _ui: string; // User ID 12 | _ut: string; // User Type 13 | } 14 | -------------------------------------------------------------------------------- /types/utils.d.ts: -------------------------------------------------------------------------------- 1 | type NullableKeys< T, K extends keyof T > = Omit< T, K > & { 2 | [ P in K ]: T[ P ] | null; 3 | }; 4 | -------------------------------------------------------------------------------- /types/wordpress__data/index.d.ts: -------------------------------------------------------------------------------- 1 | import type { StoreDescriptor } from '@wordpress/data/build-types/types'; 2 | 3 | declare module '@wordpress/data' { 4 | function useDispatch< StoreActions >( 5 | store: string | StoreDescriptor, 6 | dependencies?: unknown[] 7 | ): StoreActions; 8 | 9 | function useSelect< StoreSelectors, ReturnType = any >( 10 | mapFn: ( select: ( store: string | StoreDescriptor ) => StoreSelectors ) => ReturnType, 11 | dependencies?: unknown[] 12 | ): ReturnType; 13 | 14 | function useSelect< StoreSelectors >( 15 | store: string | StoreDescriptor, 16 | dependencies?: unknown[] 17 | ): StoreSelectors; 18 | } 19 | -------------------------------------------------------------------------------- /types/wordpress__notices/index.d.ts: -------------------------------------------------------------------------------- 1 | import '@wordpress/notices'; 2 | 3 | declare module '@wordpress/notices' { 4 | interface WPNoticeAction { 5 | label: string; 6 | url?: string; 7 | onClick?: () => void; 8 | } 9 | 10 | interface CreateNoticeOptions { 11 | context?: string; 12 | id?: string; 13 | isDismissible?: boolean; 14 | type?: 'default' | 'snackbar'; 15 | speak?: boolean; 16 | actions?: WPNoticeAction[]; 17 | icon?: string | null; 18 | explicitDismiss?: boolean; 19 | onDismiss?: () => void; 20 | __unstableHTML?: boolean; 21 | } 22 | 23 | interface WPNotice { 24 | id: string; 25 | status: string; 26 | content: string; 27 | spokenMessage: string | null; 28 | __unstableHTML?: boolean; 29 | isDismissible: boolean; 30 | actions: WPNoticeAction[]; 31 | type: 'default' | 'snackbar'; 32 | icon: string | null; 33 | explicitDismiss: boolean; 34 | onDismiss?: () => void; 35 | } 36 | 37 | interface CreateNoticeReturn { 38 | type: 'CREATE_NOTICE'; 39 | context: string; 40 | notice: WPNotice; 41 | } 42 | 43 | interface RemoveNoticeReturn { 44 | type: 'REMOVE_NOTICE'; 45 | context: string; 46 | id: string; 47 | } 48 | 49 | interface NoticeStoreActions { 50 | createSuccessNotice: ( content: string, options?: CreateNoticeOptions ) => CreateNoticeReturn; 51 | createErrorNotice: ( content: string, options?: CreateNoticeOptions ) => CreateNoticeReturn; 52 | removeNotice: ( id: string, context?: string ) => RemoveNoticeReturn; 53 | } 54 | 55 | interface NoticeStoreSelectors { 56 | getNotices: ( state: object = {}, context?: string ) => WPNotice[]; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /types/wordpress__rich-text/index.d.ts: -------------------------------------------------------------------------------- 1 | import { RichTextValue } from '@wordpress/rich-text'; 2 | import type { RichTextFormat as RichTextFormatFromDocBlock } from '@wordpress/rich-text/build-types/insert-object'; 3 | import type { WPFormat as WPFormatFromDocBlock } from '@wordpress/rich-text/build-types/register-format-type'; 4 | 5 | /** 6 | * The types provided by @wordpress/rich-text rely on incomplete docblocks. 7 | */ 8 | 9 | declare module '@wordpress/rich-text' { 10 | interface RichTextFormat extends RichTextFormatFromDocBlock { 11 | attributes: Record< string, string >; 12 | innerHTML: string; 13 | } 14 | 15 | interface WPFormat extends WPFormatFromDocBlock { 16 | attributes: Record< string, string >; 17 | contentEditable: boolean; 18 | object: boolean; 19 | } 20 | 21 | interface WPFormatEditProps { 22 | activeObjectAttributes: Record< string, string >; 23 | contentRef: React.MutableRefObject< HTMLElement >; 24 | isObjectActive: boolean; 25 | onChange: ( value: RichTextValue ) => void; 26 | onFocus: () => void; 27 | value: RichTextValue; 28 | } 29 | 30 | function insertObject( value: RichTextValue, format: RichTextFormat ): RichTextValue; 31 | } 32 | -------------------------------------------------------------------------------- /types/wordpress__server-side-render/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@wordpress/server-side-render' { 2 | function ServerSideRender< T extends object >( props: { 3 | attributes: T; 4 | block: string; 5 | } ): JSX.Element; 6 | 7 | // default export 8 | export = ServerSideRender; 9 | } 10 | -------------------------------------------------------------------------------- /vitest.config.mts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig( { 5 | resolve: { 6 | alias: { 7 | '@': path.resolve( __dirname, 'src/' ), 8 | }, 9 | }, 10 | test: { 11 | environment: 'happy-dom', 12 | exclude: [ '**/build/**', '**/node_modules/**', '**/vendor/**', '**/tests/e2e/**' ], 13 | setupFiles: [ './tests/src/vitest.setup.ts' ], 14 | coverage: { 15 | reporter: 'clover', 16 | reportsDirectory: './coverage/vitest', 17 | }, 18 | }, 19 | } ); 20 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const ForkTsCheckerWebpackPlugin = require( 'fork-ts-checker-webpack-plugin' ); 2 | 3 | const additionalScripts = { 4 | 'block-editor/index': './src/block-editor/index', 5 | 'dataviews/index': './src/dataviews/index', 6 | 'pattern-editor/index': './src/pattern-editor/index', 7 | 'settings/index': './src/settings/index', 8 | }; 9 | 10 | const { modernize, moduleConfig, scriptConfig } = require( './webpack.utils' ); 11 | 12 | // Add watchOptions configuration to reduce file watching load 13 | const watchOptions = { 14 | ignored: /node_modules/, 15 | aggregateTimeout: 300, 16 | }; 17 | 18 | module.exports = [ 19 | modernize( 20 | scriptConfig, 21 | additionalScripts, 22 | [ 23 | // we only need to fork one copy of ts-checker off here in these webpack exports 24 | new ForkTsCheckerWebpackPlugin(), 25 | ], 26 | watchOptions 27 | ), 28 | modernize( moduleConfig, {}, [], watchOptions ), 29 | ]; 30 | -------------------------------------------------------------------------------- /webpack.utils.js: -------------------------------------------------------------------------------- 1 | // The exports of `@wordpress/scripts/config/webpack.config` differ depending 2 | // on whether you pass the `--experimental-modules` flag. If you do, it exports 3 | // an array of two configurations instead of a single configuration object. 4 | const [ scriptConfig, moduleConfig ] = require( '@wordpress/scripts/config/webpack.config' ); 5 | const path = require( 'path' ); 6 | 7 | // This function modernizes the configuration object to support TypeScript. It 8 | // also allows for additional scripts to be added to the entry point. Blocks are 9 | // included by default, so this is only needed for non-block scripts. 10 | function modernize( config, additionalScripts = {}, additionalPlugins = [], watchOptions = {} ) { 11 | return { 12 | ...config, 13 | entry: { 14 | ...config.entry(), 15 | ...additionalScripts, 16 | }, 17 | module: { 18 | rules: config.module.rules.concat( [ 19 | { 20 | test: /\.tsx?$/, 21 | use: [ 22 | { 23 | loader: 'ts-loader', 24 | options: { 25 | transpileOnly: true, 26 | }, 27 | }, 28 | ], 29 | }, 30 | ] ), 31 | }, 32 | plugins: [ ...config.plugins, ...additionalPlugins ], 33 | resolve: { 34 | ...config.resolve, 35 | alias: { 36 | ...config.resolve.alias, 37 | '@': path.resolve( __dirname, 'src/' ), 38 | }, 39 | }, 40 | watchOptions: { ...config.watchOptions, ...watchOptions }, 41 | }; 42 | } 43 | 44 | exports.modernize = modernize; 45 | exports.moduleConfig = moduleConfig; 46 | exports.scriptConfig = scriptConfig; 47 | --------------------------------------------------------------------------------