From f36b14a8599d7cc0d2c97a4f0e174151a634ea76 Mon Sep 17 00:00:00 2001
From: Matt <1169490+caddoo@users.noreply.github.com>
Date: Mon, 23 Mar 2026 11:40:00 +1300
Subject: [PATCH] Add campaign url presets feature
---
plugins/Referrers/API.php | 94 +++++++++++++
.../Referrers/tests/Integration/APITest.php | 84 ++++++++++++
.../src/CampaignBuilder/CampaignBuilder.vue | 124 +++++++++++++++++-
3 files changed, 301 insertions(+), 1 deletion(-)
create mode 100644 plugins/Referrers/tests/Integration/APITest.php
diff --git a/plugins/Referrers/API.php b/plugins/Referrers/API.php
index 445398b7601..e3c1047dc1a 100644
--- a/plugins/Referrers/API.php
+++ b/plugins/Referrers/API.php
@@ -14,7 +14,9 @@
use Piwik\Archive;
use Piwik\Common;
use Piwik\DataTable;
+use Piwik\Db;
use Piwik\Metrics;
+use Piwik\Option;
use Piwik\Piwik;
use Piwik\Plugins\Actions\ArchivingHelper;
use Piwik\Plugins\Referrers\Columns\Metrics\VisitorsFromReferrerPercent;
@@ -749,6 +751,93 @@ public function getNumberOfDistinctWebsitesUrls($idSite, string $period, string
return $this->getNumeric(Archiver::METRIC_DISTINCT_URLS_RECORD_NAME, $idSite, $period, $date, $segment);
}
+ /**
+ * @unsanitized
+ */
+ public function getCampaignUrlPresets($idSite, string $search = '')
+ {
+ Piwik::checkUserHasViewAccess($idSite);
+
+ if (!empty($search)) {
+ $optionName = $this->getCampaignUrlPresetsOptionName($idSite);
+ $sql = sprintf(
+ "SELECT option_value FROM `%s` WHERE option_name = '%s' AND option_value LIKE '%%%s%%'",
+ Common::prefixTable('option'),
+ $optionName,
+ $search
+ );
+ $optionValue = Db::fetchOne($sql);
+ } else {
+ $optionValue = Option::get($this->getCampaignUrlPresetsOptionName($idSite));
+ }
+
+ $presets = json_decode($optionValue, true);
+ if (!is_array($presets)) {
+ return [];
+ }
+
+ return Common::unsanitizeInputValues(array_slice(array_reverse($presets), 0, 5));
+ }
+
+ public function saveCampaignUrlPreset(
+ $idSite,
+ string $websiteUrl = '',
+ string $generatedUrl = '',
+ string $campaignName = '',
+ string $campaignKeyword = '',
+ string $campaignSource = '',
+ string $campaignMedium = '',
+ string $campaignId = '',
+ string $campaignContent = '',
+ string $campaignGroup = '',
+ string $campaignPlacement = ''
+ ) {
+ Piwik::checkUserHasViewAccess($idSite);
+
+ $optionName = $this->getCampaignUrlPresetsOptionName($idSite);
+ $presets = json_decode(Option::get($optionName), true);
+ if (!is_array($presets)) {
+ $presets = [];
+ }
+
+ $presets[] = [
+ 'websiteUrl' => $websiteUrl,
+ 'generatedUrl' => $generatedUrl,
+ 'campaignName' => $campaignName,
+ 'campaignKeyword' => $campaignKeyword,
+ 'campaignSource' => $campaignSource,
+ 'campaignMedium' => $campaignMedium,
+ 'campaignId' => $campaignId,
+ 'campaignContent' => $campaignContent,
+ 'campaignGroup' => $campaignGroup,
+ 'campaignPlacement' => $campaignPlacement,
+ 'login' => Piwik::getCurrentUserLogin(),
+ 'savedAt' => date('Y-m-d H:i:s'),
+ ];
+
+ Option::set($optionName, json_encode($presets));
+
+ return Common::unsanitizeInputValues(array_slice(array_reverse($presets), 0, 5));
+ }
+
+ public function deleteCampaignUrlPreset($idSite, int $index)
+ {
+ Piwik::checkUserHasViewAccess($idSite);
+
+ $optionName = $this->getCampaignUrlPresetsOptionName($idSite);
+ $presets = json_decode(Option::get($optionName), true);
+ if (!is_array($presets)) {
+ return [];
+ }
+
+ unset($presets[$index]);
+ $presets = array_values($presets);
+
+ Option::set($optionName, json_encode($presets));
+
+ return Common::unsanitizeInputValues(array_slice(array_reverse($presets), 0, 5));
+ }
+
private function getNumeric(string $name, $idSite, string $period, string $date, ?string $segment)
{
Piwik::checkUserHasViewAccess($idSite);
@@ -756,6 +845,11 @@ private function getNumeric(string $name, $idSite, string $period, string $date,
return $archive->getDataTableFromNumeric($name);
}
+ private function getCampaignUrlPresetsOptionName($idSite): string
+ {
+ return 'Referrers.campaignUrlPresets.' . (int) $idSite;
+ }
+
/**
* Removes idsubdatatable_in_db metadata from a DataTable. Used by Social tables since
* they use fake subtable IDs.
diff --git a/plugins/Referrers/tests/Integration/APITest.php b/plugins/Referrers/tests/Integration/APITest.php
new file mode 100644
index 00000000000..76bee79a003
--- /dev/null
+++ b/plugins/Referrers/tests/Integration/APITest.php
@@ -0,0 +1,84 @@
+siteId = Fixture::createWebsite('2014-01-01 01:02:03');
+ $this->api = API::getInstance();
+ }
+
+ public function tearDown(): void
+ {
+ Option::delete($this->getPresetOptionName());
+
+ parent::tearDown();
+ }
+
+ public function testSaveCampaignUrlPresetReturnsSavedPreset(): void
+ {
+ $result = $this->api->saveCampaignUrlPreset(
+ $this->siteId,
+ 'https://example.com/landing-page',
+ 'https://example.com/landing-page?mtm_campaign=Spring+sale',
+ 'Spring sale',
+ 'shoes',
+ 'newsletter',
+ 'email',
+ 'spring-2026',
+ 'hero-banner',
+ 'vip',
+ 'top-slot'
+ );
+
+ $this->assertIsArray($result);
+ $this->assertCount(1, $result);
+ $this->assertSame('Spring sale', $result[0]['campaignName']);
+ $this->assertSame('newsletter', $result[0]['campaignSource']);
+ }
+
+ public function provideContainerConfig()
+ {
+ return [
+ 'Piwik\Access' => new FakeAccess(true),
+ ];
+ }
+
+ private function getPresetOptionName(): string
+ {
+ return 'Referrers.campaignUrlPresets.' . $this->siteId;
+ }
+}
diff --git a/plugins/Referrers/vue/src/CampaignBuilder/CampaignBuilder.vue b/plugins/Referrers/vue/src/CampaignBuilder/CampaignBuilder.vue
index 0dcaea150b8..75484a9c645 100644
--- a/plugins/Referrers/vue/src/CampaignBuilder/CampaignBuilder.vue
+++ b/plugins/Referrers/vue/src/CampaignBuilder/CampaignBuilder.vue
@@ -135,15 +135,55 @@
>
+