diff --git a/src/Campaigns/Actions/RegisterCampaignBlocks.php b/src/Campaigns/Actions/RegisterCampaignBlocks.php
new file mode 100644
index 0000000000..6da9da1d24
--- /dev/null
+++ b/src/Campaigns/Actions/RegisterCampaignBlocks.php
@@ -0,0 +1,15 @@
+ function ($object) {
+ return get_post_meta($object['id'], 'campaignId', true);
+ },
+ 'update_callback' => function ($value, $object) {
+ return update_post_meta($object->ID, 'campaignId', (int) $value);
+ },
+ 'schema' => [
+ 'description' => 'Campaign ID',
+ 'type' => 'string',
+ 'context' => ['view', 'edit'],
+ ],
+ ]
+ );
+ }
+}
diff --git a/src/Campaigns/Blocks/CampaignTitleBlock/block.json b/src/Campaigns/Blocks/CampaignTitleBlock/block.json
new file mode 100644
index 0000000000..23643da1e8
--- /dev/null
+++ b/src/Campaigns/Blocks/CampaignTitleBlock/block.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "givewp/campaign-title-block",
+ "version": "1.0.0",
+ "title": "Campaign Title",
+ "category": "give",
+ "icon": "heading",
+ "description": "Displays the title of the campaign.",
+ "supports": {
+ "html": false
+ },
+ "attributes": {
+ "campaignId": {
+ "type": "integer"
+ },
+ "headingLevel": {
+ "type": "number",
+ "default": 1
+ }
+ },
+ "textdomain": "give",
+ "editorScript": "file:./../../../../build/Campaigns/Blocks/campaignTitleBlock.js",
+ "editorStyle": "file:./../../../../build/Campaigns/Blocks/campaignTitleBlock.css",
+ "render": "file:./render.php"
+}
diff --git a/src/Campaigns/Blocks/CampaignTitleBlock/edit.tsx b/src/Campaigns/Blocks/CampaignTitleBlock/edit.tsx
new file mode 100644
index 0000000000..db4a746bb7
--- /dev/null
+++ b/src/Campaigns/Blocks/CampaignTitleBlock/edit.tsx
@@ -0,0 +1,62 @@
+import {BlockControls, HeadingLevelDropdown, InspectorControls, useBlockProps} from '@wordpress/block-editor';
+import {BaseControl, Icon, PanelBody, TextareaControl} from '@wordpress/components';
+import ServerSideRender from '@wordpress/server-side-render';
+import {CampaignSelector} from '../shared/components/CampaignSelector';
+import useCampaign from '../shared/hooks/useCampaign';
+import {__} from '@wordpress/i18n';
+import {useSelect} from '@wordpress/data';
+import {external} from '@wordpress/icons';
+
+import './editor.scss';
+
+export default function Edit({attributes, setAttributes}) {
+ const blockProps = useBlockProps();
+ const {campaign, hasResolved} = useCampaign(attributes.campaignId);
+
+ const adminBaseUrl = useSelect(
+ (select) => select('core').getSite()?.url + '/wp-admin/edit.php?post_type=give_forms&page=give-campaigns',
+ []
+ );
+
+ const editCampaignUrl = `${adminBaseUrl}&id=${attributes.campaignId}&tab=settings`;
+
+ return (
+
+ );
+}
diff --git a/src/Campaigns/Blocks/CampaignTitleBlock/editor.scss b/src/Campaigns/Blocks/CampaignTitleBlock/editor.scss
new file mode 100644
index 0000000000..031ddc43f7
--- /dev/null
+++ b/src/Campaigns/Blocks/CampaignTitleBlock/editor.scss
@@ -0,0 +1,13 @@
+.givewp-campaign-title-block {
+ &__edit-campaign-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.125rem;
+
+ svg {
+ fill: currentColor;
+ height: 1.25rem;
+ width: 1.25rem;
+ }
+ }
+}
diff --git a/src/Campaigns/Blocks/CampaignTitleBlock/index.ts b/src/Campaigns/Blocks/CampaignTitleBlock/index.ts
new file mode 100644
index 0000000000..f376f33a5a
--- /dev/null
+++ b/src/Campaigns/Blocks/CampaignTitleBlock/index.ts
@@ -0,0 +1,8 @@
+import {registerBlockType} from '@wordpress/blocks';
+import metadata from './block.json';
+import Edit from './edit';
+
+// @ts-ignore
+registerBlockType(metadata.name, {
+ edit: Edit
+});
diff --git a/src/Campaigns/Blocks/CampaignTitleBlock/render.php b/src/Campaigns/Blocks/CampaignTitleBlock/render.php
new file mode 100644
index 0000000000..a351cf633a
--- /dev/null
+++ b/src/Campaigns/Blocks/CampaignTitleBlock/render.php
@@ -0,0 +1,23 @@
+getById($attributes['campaignId']);
+
+if (! $campaign) {
+ return;
+}
+
+$headingLevel = isset($attributes['headingLevel']) ? (int) $attributes['headingLevel'] : 1;
+$headingTag = 'h' . min(6, max(1, $headingLevel));
+?>
+
+< >
+title); ?>
+>
diff --git a/src/Campaigns/Blocks/shared/components/CampaignDropdown.tsx b/src/Campaigns/Blocks/shared/components/CampaignDropdown.tsx
new file mode 100644
index 0000000000..f7c543aef2
--- /dev/null
+++ b/src/Campaigns/Blocks/shared/components/CampaignDropdown.tsx
@@ -0,0 +1,48 @@
+import {PanelBody, SelectControl} from '@wordpress/components';
+import {InspectorControls} from '@wordpress/block-editor';
+import useCampaigns from '../hooks/useCampaigns';
+import {Campaign} from '@givewp/campaigns/admin/components/types';
+import {__} from '@wordpress/i18n';
+
+export default function CampaignDropdown({campaignId, setAttributes, placement = 'sidebar'}) {
+ const {campaigns, hasResolved} = useCampaigns();
+
+ const options = (() => {
+ if (!hasResolved) {
+ return [{label: __('Loading...', 'give'), value: ''}];
+ }
+
+ if (campaigns.length) {
+ const campaignOptions = campaigns.map((campaign) => ({
+ label: campaign.title,
+ value: campaign.id.toString(),
+ }));
+
+ return [{label: __('Select...', 'give'), value: ''}, ...campaignOptions];
+ }
+
+ return [{label: __('No campaigns found.', 'give'), value: ''}];
+ })();
+
+ const dropdown = (
+ setAttributes({campaignId: newValue ? parseInt(newValue) : null})}
+ />
+ );
+
+ if (placement === 'sidebar') {
+ return (
+
+
+ {dropdown}
+
+
+ );
+ }
+
+ return dropdown;
+}
diff --git a/src/Campaigns/Blocks/shared/components/CampaignSelector.tsx b/src/Campaigns/Blocks/shared/components/CampaignSelector.tsx
new file mode 100644
index 0000000000..2350e93e0a
--- /dev/null
+++ b/src/Campaigns/Blocks/shared/components/CampaignSelector.tsx
@@ -0,0 +1,28 @@
+import useCampaignId from '../hooks/useCampaignId';
+import CampaignDropdown from './CampaignDropdown';
+
+export function CampaignSelector({attributes, setAttributes, children}) {
+ const campaignId = useCampaignId(attributes, setAttributes);
+
+ return (
+ <>
+ {!campaignId && !attributes?.campaignId && (
+
+ )}
+
+ {!campaignId && (
+
+ )}
+
+ {attributes?.campaignId && children}
+ >
+ );
+}
diff --git a/src/Campaigns/Blocks/shared/hooks/useCampaign.ts b/src/Campaigns/Blocks/shared/hooks/useCampaign.ts
new file mode 100644
index 0000000000..c0219a44fb
--- /dev/null
+++ b/src/Campaigns/Blocks/shared/hooks/useCampaign.ts
@@ -0,0 +1,11 @@
+import {useEntityRecord} from '@wordpress/core-data';
+import {Campaign} from '@givewp/campaigns/admin/components/types';
+
+export default function useCampaign(campaignId) {
+ const data = useEntityRecord('givewp', 'campaign', campaignId);
+
+ return {
+ campaign: data?.record as Campaign,
+ hasResolved: data?.hasResolved,
+ };
+}
diff --git a/src/Campaigns/Blocks/shared/hooks/useCampaignId.ts b/src/Campaigns/Blocks/shared/hooks/useCampaignId.ts
new file mode 100644
index 0000000000..c44101de0f
--- /dev/null
+++ b/src/Campaigns/Blocks/shared/hooks/useCampaignId.ts
@@ -0,0 +1,18 @@
+import {useSelect} from '@wordpress/data';
+
+export default function useCampaignId(attributes, setAttributes) {
+ const campaignIdFromContext = useSelect((select) => {
+ const postType = select('core/editor').getCurrentPostType();
+
+ if (postType === 'give_campaign_page') {
+ return select('core/editor').getEditedPostAttribute('campaignId');
+ }
+ return null;
+ }, []);
+
+ if (campaignIdFromContext && campaignIdFromContext !== attributes?.campaignId) {
+ setAttributes({campaignId: campaignIdFromContext});
+ }
+
+ return campaignIdFromContext;
+}
diff --git a/src/Campaigns/Blocks/shared/hooks/useCampaigns.ts b/src/Campaigns/Blocks/shared/hooks/useCampaigns.ts
new file mode 100644
index 0000000000..3764ad139d
--- /dev/null
+++ b/src/Campaigns/Blocks/shared/hooks/useCampaigns.ts
@@ -0,0 +1,11 @@
+import {useEntityRecords} from '@wordpress/core-data';
+import {Campaign} from '@givewp/campaigns/admin/components/types';
+
+export default function useCampaigns() {
+ const data = useEntityRecords('givewp', 'campaign');
+
+ return {
+ campaigns: data?.records as Campaign[],
+ hasResolved: data?.hasResolved,
+ };
+}
diff --git a/src/Campaigns/ServiceProvider.php b/src/Campaigns/ServiceProvider.php
index 729ea3719e..c6c7f6282c 100644
--- a/src/Campaigns/ServiceProvider.php
+++ b/src/Campaigns/ServiceProvider.php
@@ -47,6 +47,7 @@ public function boot(): void
$this->registerMigrations();
$this->registerRoutes();
$this->registerCampaignEntity();
+ $this->registerCampaignBlocks();
$this->setupCampaignForms();
}
@@ -148,4 +149,13 @@ private function setupCampaignForms()
Hooks::addAction('givewp_donation_form_created', AddCampaignFormFromRequest::class, 'visualFormBuilder');
Hooks::addAction('givewp_campaign_created', CreateDefaultCampaignForm::class);
}
+
+ /**
+ * @unreleased
+ */
+ private function registerCampaignBlocks()
+ {
+ Hooks::addAction('rest_api_init', Actions\RegisterCampaignIdRestField::class);
+ Hooks::addAction('init', Actions\RegisterCampaignBlocks::class);
+ }
}
diff --git a/wordpress-scripts-webpack.config.js b/wordpress-scripts-webpack.config.js
index f8dced076f..ffd188d3b5 100644
--- a/wordpress-scripts-webpack.config.js
+++ b/wordpress-scripts-webpack.config.js
@@ -1,6 +1,7 @@
/**
* External Dependencies
*/
+const fs = require('fs');
const path = require('path');
/**
@@ -63,6 +64,7 @@ module.exports = {
campaignEntity: srcPath('Campaigns/resources/entity.ts'),
campaignDetails: srcPath('Campaigns/resources/admin/campaign-details.tsx'),
adminBlocks: path.resolve(process.cwd(), 'blocks', 'load.js'),
+ ...getDynamicEntries(srcPath('Campaigns/Blocks'), 'Campaigns/Blocks/'),
},
};
@@ -75,3 +77,22 @@ module.exports = {
function srcPath(relativePath) {
return path.resolve(process.cwd(), 'src', relativePath);
}
+
+function getDynamicEntries(basePath, destinationPath = '') {
+ const entries = {};
+ const directories = fs.readdirSync(basePath, {withFileTypes: true});
+
+ directories.forEach((dir) => {
+ if (dir.isDirectory()) {
+ const blockPath = path.join(basePath, dir.name);
+ const indexPath = path.join(blockPath, 'index.ts');
+ const entryKey = dir.name.charAt(0).toLowerCase() + dir.name.slice(1);
+
+ if (fs.existsSync(indexPath)) {
+ entries[`${destinationPath}${entryKey}`] = indexPath;
+ }
+ }
+ });
+
+ return entries;
+}