فهرست منبع

Home: Recommended friends shows an empty state instead of vanishing (#5898)

* Home: Recommended friends shows an empty state instead of vanishing

On a new or small site there is nobody to recommend, so the members'
home page's only right-column block rendered nothing and left a blank
375px column next to the feed.

- browse_recommendations_friends/_subscriptions take an empty_state
  param: with nothing to suggest they render recommendations_empty.html
  ("No suggestions yet", why, and a "Find people" button to the home
  page of the default profile type, or the first active profile module).
- The home page block turns it on (install SQL; migration for existing
  sites in the PR). Other uses keep their behaviour.
- Language keys added in English and Russian with tools/add_lang_key.php.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Recommendations empty state: test the Find people link against fixed inputs

The test took its expected URL from the helper it was checking, so a
helper that ignored the default profile type, or returned no URL, still
passed. The module list is now _getRecommendationsEmptyStateModules(),
and the tests set the inputs and assert the destination directly:

- the configured default profile type wins over module order;
- with no default, or an unknown one, the first profile module is used;
- with no profile modules there is no URL and no button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko 4 روز پیش
والد
کامیت
9a578e1cac

+ 1 - 1
install/sql/system.sql

@@ -6386,7 +6386,7 @@ INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `
 ('sys_home', 1, 'system', '_sys_page_block_system_title_login', '_sys_page_block_title_login', '', '', 11, '', '', 0, 0, 1, '', 'service', 'a:3:{s:6:\"module\";s:6:\"system\";s:6:\"method\";s:10:\"login_form\";s:5:\"class\";s:17:\"TemplServiceLogin\";}', '', '', 0, '', 0, '', 0, 0, 0, 0, 0),
 ('sys_home', 1, 'system', '', '_sys_page_block_title_forgot_password', '', '', 13, '', '', 0, 0, 1, '', 'service', 'a:4:{s:6:\"module\";s:6:\"system\";s:6:\"method\";s:15:\"forgot_password\";s:6:\"params\";a:0:{}s:5:\"class\";s:19:\"TemplServiceAccount\";}', '', '', 0, '', 0, '', 0, 0, 0, 0, 0),
 ('sys_home', 2, 'system', '', '_sys_page_block_title_profile_menu', '', '', 0, '', '', 0, 0, 2147483644, '0', 'service', 'a:4:{s:6:\"module\";s:6:\"system\";s:6:\"method\";s:12:\"profile_menu\";s:6:\"params\";a:0:{}s:5:\"class\";s:20:\"TemplServiceProfiles\";}', '', '', 0, '', 0, '', 1, 0, 0, 0, 0),
-('sys_home', 4, 'system', '_sys_page_block_title_sys_recom_friends', '_sys_page_block_title_recom_friends', '', '', 11, '', '', 1, 0, 2147483644, '0', 'service', 'a:4:{s:6:\"module\";s:6:\"system\";s:6:\"method\";s:30:\"browse_recommendations_friends\";s:6:\"params\";a:2:{i:0;i:0;i:1;a:1:{s:8:\"per_page\";i:3;}}s:5:\"class\";s:20:\"TemplServiceProfiles\";}', '', '', 0, '', 0, '', 1, 0, 1, 0, 0),
+('sys_home', 4, 'system', '_sys_page_block_title_sys_recom_friends', '_sys_page_block_title_recom_friends', '', '', 11, '', '', 1, 0, 2147483644, '0', 'service', 'a:4:{s:6:\"module\";s:6:\"system\";s:6:\"method\";s:30:\"browse_recommendations_friends\";s:6:\"params\";a:2:{i:0;i:0;i:1;a:2:{s:8:\"per_page\";i:3;s:11:\"empty_state\";b:1;}}s:5:\"class\";s:20:\"TemplServiceProfiles\";}', '', '', 0, '', 0, '', 1, 0, 1, 0, 0),
 ('sys_home', 4, 'system', '_sys_page_block_title_sys_recom_subscriptions', '_sys_page_block_title_recom_subscriptions', '', '', 11, '', '', 1, 0, 2147483644, '0', 'service', 'a:4:{s:6:\"module\";s:6:\"system\";s:6:\"method\";s:36:\"browse_recommendations_subscriptions\";s:6:\"params\";a:0:{}s:5:\"class\";s:20:\"TemplServiceProfiles\";}', '', '', 0, '', 0, '', 1, 0, 0, 0, 0),
 ('sys_home', 4, 'system', '_sys_page_block_title_sys_invitations', '_sys_page_block_title_invitations', '', '', 11, '', '', 1, 0, 2147483644, '0', 'service', 'a:4:{s:6:\"module\";s:6:\"system\";s:6:\"method\";s:18:\"browse_invitations\";s:6:\"params\";a:0:{}s:5:\"class\";s:20:\"TemplServiceProfiles\";}', '', '', 0, '', 0, '', 1, 0, 0, 0, 0);
 

+ 3 - 0
modules/boonex/english/data/langs/system/en.xml

@@ -4653,4 +4653,7 @@
     <string name="_sys_redirect_confirmation"><![CDATA[<div class="bx-def-font-h2 bx-def-margin-sec-bottom">You're going to a link outside {1}</div><div class="bx-def-font-small">Link: {0}</div><div class="bx-def-margin-sec-top">Are you sure you want to follow this link?</div>]]></string>
     <string name="_sys_redirect_confirmation_harmful"><![CDATA[<div class="bx-def-font-h2 bx-def-margin-sec-bottom">You're going to a potentially <b>harmful</b> link {1}</div><div class="bx-def-font-small">Link: {0}</div><div class="bx-def-margin-sec-top">Are you sure you want to follow this link?</div>]]></string>
     <string name="_sys_leave_page_confirmation"><![CDATA[<div class="bx-def-font-h2">Leave the page?</div><div class="bx-def-margin-sec-top">If you do it, your changes will not be saved.</div>]]></string>
+    <string name="_sys_recom_friends_empty_action"><![CDATA[Find people]]></string>
+    <string name="_sys_recom_friends_empty_text"><![CDATA[Suggestions appear as you connect with people.]]></string>
+    <string name="_sys_recom_friends_empty_title"><![CDATA[No suggestions yet]]></string>
 </resources>

+ 3 - 0
modules/boonex/russian/data/langs/system/ru.xml

@@ -4566,4 +4566,7 @@
   <string name="_sys_redirect_confirmation"><![CDATA[<div class="bx-def-font-h2 bx-def-margin-sec-bottom">Вы переходите по ссылке за пределы сайта "{1}"</div><div class="bx-def-font-small">Ссылка: {0}</div><div class="bx-def-margin-sec-top">Вы действительно хотите перейти по этой ссылке?</div>]]></string>
   <string name="_sys_redirect_confirmation_harmful"><![CDATA[<div class="bx-def-font-h2 bx-def-margin-sec-bottom">Вы переходите по <b>опасной</b> ссылке за пределы сайта "{1}"</div><div class="bx-def-font-small">Ссылка: {0}</div><div class="bx-def-margin-sec-top">Вы действительно хотите перейти по этой ссылке?</div>]]></string>
   <string name="_sys_leave_page_confirmation"><![CDATA[<div class="bx-def-font-h2">Покинуть страницу?</div><div class="bx-def-margin-sec-top">Если Вы это сделаете, ваши изменения не будут сохранены.</div>]]></string>
+  <string name="_sys_recom_friends_empty_action"><![CDATA[Найти людей]]></string>
+  <string name="_sys_recom_friends_empty_text"><![CDATA[Предложения появятся, когда вы начнёте общаться с людьми.]]></string>
+  <string name="_sys_recom_friends_empty_title"><![CDATA[Пока нет предложений]]></string>
 </resources>

+ 7 - 0
template/recommendations_empty.html

@@ -0,0 +1,7 @@
+<div class="bx-recommendations-empty">
+    <p class="text-base font-medium text-gray-900 dark:text-gray-100">__title__</p>
+    <p class="mt-1 text-sm text-gray-600 dark:text-gray-400">__text__</p>
+    <bx_if:show_action>
+        <a class="bx-btn bx-btn-small mt-4" href="__url__">__action__</a>
+    </bx_if:show_action>
+</div>

+ 54 - 1
template/scripts/BxBaseServiceProfiles.php

@@ -1142,6 +1142,7 @@ class BxBaseServiceProfiles extends BxDol
 
         $aParams = array_merge([
             'empty_message' => false,
+            'empty_state' => false,
             'start' => 0,
             'per_page' => 0
         ], $aParams);
@@ -1177,11 +1178,63 @@ class BxBaseServiceProfiles extends BxDol
         }
 
         $sCode = $oRecommendation->getCode($iProfileId, $aParams);
-        if(!$sCode && $aParams['empty_message'])
+        if(!$sCode && $aParams['empty_state'])
+            $sCode = $this->_getRecommendationsEmptyState();
+        else if(!$sCode && $aParams['empty_message'])
             $sCode = MsgBox(_t('_Empty'));
 
         return $sCode;
     }
+
+    /**
+     * Empty state for a recommendations block that has nothing to suggest yet (a new or small site): it says why and
+     * links to a page where people can be found, so the block, and the page column it sits in, doesn't just vanish.
+     */
+    protected function _getRecommendationsEmptyState()
+    {
+        $sUrl = $this->_getRecommendationsEmptyStateUrl();
+
+        return BxDolTemplate::getInstance()->parseHtmlByName('recommendations_empty.html', [
+            'title' => _t('_sys_recom_friends_empty_title'),
+            'text' => _t('_sys_recom_friends_empty_text'),
+            'bx_if:show_action' => [
+                'condition' => !empty($sUrl),
+                'content' => [
+                    'url' => $sUrl,
+                    'action' => _t('_sys_recom_friends_empty_action')
+                ]
+            ]
+        ]);
+    }
+
+    /**
+     * Home page of the first profile module in _getRecommendationsEmptyStateModules() that has one.
+     */
+    protected function _getRecommendationsEmptyStateUrl()
+    {
+        foreach($this->_getRecommendationsEmptyStateModules() as $sModule) {
+            $oModule = BxDolModule::getInstance($sModule);
+            if($oModule && !empty($oModule->_oConfig->CNF['URL_HOME']))
+                return bx_absolute_url(BxDolPermalinks::getInstance()->permalink($oModule->_oConfig->CNF['URL_HOME']));
+        }
+
+        return '';
+    }
+
+    /**
+     * Active profile modules, with the default profile type (Settings > Accounts) first when it is one of them.
+     */
+    protected function _getRecommendationsEmptyStateModules()
+    {
+        $aModules = bx_srv('system', 'get_modules_by_type', ['profile', ['name_as_key' => true]]);
+        $aModules = is_array($aModules) ? array_keys($aModules) : [];
+
+        $sDefault = getParam('sys_account_default_profile_type');
+        if($sDefault && in_array($sDefault, $aModules))
+            $aModules = array_values(array_unique(array_merge([$sDefault], $aModules)));
+
+        return $aModules;
+    }
     
     public function serviceSetMembership($mixedProfileId, $iAclLevelId, $iAclLevelDuration = 0)
     {

+ 10 - 0
tests/units/install/SystemSqlDefaultsTest.php

@@ -32,4 +32,14 @@ class SystemSqlDefaultsTest extends \PHPUnit\Framework\TestCase
         $this->assertStringContainsString('fill="currentColor"', $aMatch[1], 'The badge draws in the footer link colour, so it reads in light and dark.');
         $this->assertDoesNotMatchRegularExpression('/(fill|stroke)="#/', $aMatch[1], 'No fixed colour that would vanish on one of the two schemes.');
     }
+
+    public function testHomeRecommendedFriendsShowsAnEmptyState(): void
+    {
+        $bFound = preg_match("/\\('sys_home', 4, 'system', '_sys_page_block_title_sys_recom_friends', [^\\n]*?'service', '([^\\n]*?)', /", self::systemSql(), $aMatch);
+
+        $this->assertSame(1, $bFound, 'system.sql adds Recommended friends to the right column of the home page.');
+        $aContent = unserialize(stripslashes($aMatch[1]));
+        $this->assertSame('browse_recommendations_friends', $aContent['method']);
+        $this->assertTrue($aContent['params'][1]['empty_state'] ?? false, 'With nothing to suggest, the block explains why instead of leaving the column blank.');
+    }
 }

+ 114 - 0
tests/units/template/scripts/BxBaseServiceProfilesRecommendationsTest.php

@@ -0,0 +1,114 @@
+<?php
+
+/**
+ * "Recommended friends" on the members' home page: with nothing to suggest (a new or small site) the block shows an
+ * empty state with a link to find people, instead of rendering nothing and leaving its page column blank.
+ */
+class BxBaseServiceProfilesRecommendationsTest extends BxDolTestCase
+{
+    // No profile has this id, so there is nothing to recommend to it.
+    private const NO_RECOMMENDATIONS_PROFILE = 2147483000;
+
+    private function browse(array $aParams)
+    {
+        return BxDolService::call('system', 'browse_recommendations_friends', [self::NO_RECOMMENDATIONS_PROFILE, $aParams], 'TemplServiceProfiles');
+    }
+
+    private function callProtected($oObject, string $sMethod, array $aArgs = [])
+    {
+        return (new ReflectionMethod($oObject, $sMethod))->invokeArgs($oObject, $aArgs);
+    }
+
+    private function withDefaultProfileType(string $sValue, callable $fTest)
+    {
+        $sPrior = getParam('sys_account_default_profile_type');
+        setParam('sys_account_default_profile_type', $sValue);
+        try {
+            return $fTest();
+        }
+        finally {
+            setParam('sys_account_default_profile_type', $sPrior);
+        }
+    }
+
+    /**
+     * Active profile modules that have a home page, each with the URL the link should point to.
+     */
+    private function profileHomes(): array
+    {
+        $aResult = [];
+        foreach (array_keys(bx_srv('system', 'get_modules_by_type', ['profile', ['name_as_key' => true]]) ?: []) as $sModule) {
+            $oModule = BxDolModule::getInstance($sModule);
+            if ($oModule && !empty($oModule->_oConfig->CNF['URL_HOME']))
+                $aResult[$sModule] = BX_DOL_URL_ROOT . BxDolPermalinks::getInstance()->permalink($oModule->_oConfig->CNF['URL_HOME']);
+        }
+
+        return $aResult;
+    }
+
+    public function testNothingToRecommendRendersNothingByDefault(): void
+    {
+        $this->assertEmpty($this->browse(['per_page' => 3]), 'Blocks without empty_state keep hiding themselves.');
+    }
+
+    public function testEmptyStateExplainsWhy(): void
+    {
+        $sHtml = $this->browse(['per_page' => 3, 'empty_state' => true]);
+
+        $this->assertStringContainsString('bx-recommendations-empty', $sHtml);
+        $this->assertStringContainsString(_t('_sys_recom_friends_empty_title'), $sHtml);
+        $this->assertStringContainsString(_t('_sys_recom_friends_empty_text'), $sHtml);
+    }
+
+    public function testEmptyStateWinsOverTheGenericEmptyMessage(): void
+    {
+        $sHtml = $this->browse(['empty_state' => true, 'empty_message' => true]);
+
+        $this->assertStringContainsString('bx-recommendations-empty', $sHtml);
+        $this->assertStringNotContainsString('bx-msg-box', $sHtml);
+    }
+
+    public function testFindPeopleGoesToTheDefaultProfileType(): void
+    {
+        $aHomes = $this->profileHomes();
+        if (!$aHomes)
+            $this->markTestSkipped('No active profile module with a home page is installed.');
+
+        // The last module, so the default has to beat the module order to be picked.
+        $sDefault = array_key_last($aHomes);
+
+        $sHtml = $this->withDefaultProfileType($sDefault, fn() => $this->browse(['empty_state' => true]));
+
+        $this->assertStringContainsString('href="' . $aHomes[$sDefault] . '"', $sHtml);
+        $this->assertStringContainsString(_t('_sys_recom_friends_empty_action'), $sHtml);
+    }
+
+    public function testWithoutAUsableDefaultTheFirstProfileModuleIsUsed(): void
+    {
+        $aHomes = $this->profileHomes();
+        if (!$aHomes)
+            $this->markTestSkipped('No active profile module with a home page is installed.');
+
+        $sFirst = $aHomes[array_key_first($aHomes)];
+        foreach (['', 'bx_no_such_module'] as $sDefault) {
+            $sUrl = $this->withDefaultProfileType($sDefault, fn() => $this->callProtected(new BxTemplServiceProfiles(), '_getRecommendationsEmptyStateUrl'));
+            $this->assertSame($sFirst, $sUrl, "Default profile type '$sDefault'");
+        }
+    }
+
+    public function testNoProfileModulesMeansNoButton(): void
+    {
+        $oService = new class extends BxTemplServiceProfiles {
+            protected function _getRecommendationsEmptyStateModules()
+            {
+                return [];
+            }
+        };
+
+        $this->assertSame('', $this->callProtected($oService, '_getRecommendationsEmptyStateUrl'));
+
+        $sHtml = $this->callProtected($oService, '_getRecommendationsEmptyState');
+        $this->assertStringContainsString(_t('_sys_recom_friends_empty_title'), $sHtml);
+        $this->assertStringNotContainsString('bx-btn', $sHtml, 'With nowhere to send people, there is no button.');
+    }
+}