مساله

در برنامه‌ام به فرمی نیاز داشتم که بتوانم در آن، کاربران را به یک نقش تخصیص دهم. در این فرم ادمین باید بتواند:

  • کاربر مورد نظر خود را جستجو کند
  • بتواند چند کاربر را انتخاب و اضافه کند
  • بتواند چند کاربر را انتخاب و حذف کند
  • بتواند همه کاربران را انتخاب کند و حذف و اضافه انجام دهد

🔔 آیتم Select Many قابلیت چند انتخابی و جستجو را دارد، اما نیاز است که کاربر منو کشویی را باز کند و تک تک نفرات را انتخاب کند.

Shuttle گزینه خوبی برای انتخاب چندتایی است، ولی قابلیت جستجو ندارد.


ساختار shuttle

  • shuttle از دو المنت select برای نمایش مقادیر استفاده می‌کند.
  • id ایجاد شده برای این دو المنت براساس نام آیتم و پسوندهای left و right است.

🔔 این دو select از پسوندهای left و right استفاده می‌کنند، که در حالت چپچین معنای درستی دارند ولی در حالت راستچین خیر. بنابراین در این مطلب من از ستون اول (شروع) و دوم (پایان) برای نام بردن از آن‌ها استفاده ‌می‌کنم؛ اما در کد، من هم از پسوند left و right استفاده می‌کنم.


پیاده سازی

ایجاد فیلتر

در صفحه مورد نظرمان (مثلا صفحه 5) سه آیتم نیاز داریم:

  1. P5_USER_ID: آیتمی از نوع shuttle. به sort آن نیاز نداریم، بنابراین آن را حذف می‌کنیم:
  • Settings | Show Controls: Moving Only
  1. P5_SEARCH_TEXT_L: آیتمی برای جستجو در ستون اول مقادیر (left)
  2. P5_SEARCH_TEXT_R آیتمی برای جستجو در ستون دوم مقادیر (right)

دکمه پاک کردن جستجو

برای پاک کردن مقادیر یک آیتم، از دستور زیر استفاده می‌کنیم:

apex.item('نام آیتم مورد نظر').setValue('')

یا دستور کوتاهتر:

$s('نام آیتم مورد نظر')

در این دستور چون مقداری برای پارامتر دوم آن ننوشته‌ایم، مقدار خالی در نظر گرفته می‌شود.

برای امکان ایجاد حذف متن در باکس‌های جستجو، برای هرکدام از آیتم‌های جستجو تغییرات زیر را انجام می‌دهیم:

  • Appearance | Template Options
    • Advanced | Item Post Text: Display as Block
  • Advanced | Post Text:
<span 
    onclick="$s('نام آیتم مورد نظر')"
    style="cursor: pointer;" 
    class="fa fa-trash-o" 
    title="پاک کردن"
    aria-label="پاک کردن جستجو"
    aria-hidden="true">
</span>

Dynamic Action

می‌خواهیم با تایپ کردن کاربر، فیلتر انجام شود. بنابراین از دو رویداد زیر استفاده می‌کنیم:

  • Change: برای زمان تایپ کردن اصلا مناسب نیست و فعال نمی‌شود. اما زمانی که باکس focus را از دست می‌دهد و یا متن به صورت کامل پاک می‌شود فعال می‌شود.
  • Key Release: این رویداد پس از رها کردن کلید فعال خواهد شد.

بنابراین برای هر دو باکس مربوط به جستجو، این DAها را اضافه کرده و برای هرکدام یک action از نوع Execute JavaScript Code اضافه می‌کنیم:

در داخل Execute JavaScript Code از دو متد با امضا زیر استفاده می‌کنیم:

  • پارامتر اول: نام آیتم جستجو
  • پارامتر دوم: نام آیتم شاتل

برای ستون اول (filter left side)

item_Shuttle_FilterLeftSide(
    "P5_SEARCH_TEXT_L",
    "P5_USER_ID"
);

برای ستون دوم (filter right side)

item_Shuttle_FilterRightSide(
    "P5_SEARCH_TEXT_R",
    "P5_USER_ID"
);

متدهای فیلتر

در Attribute صفحه کدهای زیر را می‌نویسیم

Page | JavaScript | Function and Global Variable Declaration

در دو متد ابتدایی، پسوند _LEFT یا _RIGHT به نام آیتم وصل می‌شوند تا به id این ستون‌ها برسیم. سپس متد اصلی یعنی item_Shuttle_Filter را فراخوانی می‌کنیم.

/**
 * فیلتر کردن مقادیر ستون چپ (اول)
 *  
 * @param {string} searchItemName   > نام آیتم جستجو    
 * @param {string} shuttleName      > نام شاتل        
 */
function item_Shuttle_FilterLeftSide(searchItemName, shuttleName) {
    let leftSide = shuttleName + '_LEFT';
    item_Shuttle_Filter(searchItemName, leftSide);
}

/**
 * فیلتر کردن مقادیر ستون راست (دوم)
 *  
 * @param {string} searchItemName   > نام آیتم جستجو    
 * @param {string} shuttleName      > نام شاتل        
 */
function item_Shuttle_FilterRightSide(searchItemName, shuttleName) {
    let rightSide = shuttleName + '_RIGHT';
    item_Shuttle_Filter(searchItemName, rightSide);
}

item_Shuttle_Filter: وظیفه این متد مخفی کردن گزینه‌های موجود در لیست هست. با استفاده از حلقه، تمام گزینه‌ها بررسی می‌شوند و موارد غیرمشابه مخفی می‌شوند.

/**
 * جستجو در شاتل چپ یا راست
 * توسط متدهای دیگر فراخوانی می‌شود
 * 
 * @param {string} searchItemName   > نام آیتم جستجو    
 * @param {string} shuttleSideName  > نام ستون شاتل (_LEFT, _RIGHT)
 */
function item_Shuttle_Filter(searchItemName, shuttleSideName) {
    const searchText = apex.item(searchItemName).getValue().toLowerCase();
    const selectEl = document.getElementById(shuttleSideName);
    const options = selectEl.options;

    for (let i = 0; i < options.length; i++) {
        const optionText = options[i].text.toLowerCase();
        options[i].hidden = !optionText.includes(searchText);

        // options[i].style.display = optionText.includes(searchText) ? "" : "none";
    }
}

💡 برای این کار می‌توان از دو روش استفاده کرد:

  1. hidden
<option value="305" hidden="">کاربر 305</option>
  1. display none
<option value="21" style="display: none;">کاربر 21</option>

بازنویسی رویداد دکمه‌ها

مشکل بعدی این هست که دکمه‌های Move All و Remove All هم گزینه‌های مخفی و هم غیر مخفی را انتقال می‌دهند. پس آن‌ها را بازنویسی می‌کنیم.

شناسه دکمه‌ها

id مورد استفاده برای دکمه‌ها، نام آیتم شاتل + پسوند _MOVE_ALL یا _REMOVE_ALL است. move all button remove all button

متدها

در خصوصیات صفحه:

Page | JavaScript | Function and Global Variable Declaration

  • item_Shuttle_MoveAllButton: بازنویسی رویداد دکمه Move All
  • item_Shuttle_RemoveAllButton: بازنویسی رویداد دکمه Remove All
/**
 * فعال کردن انتقال از لیست یک به دو
 * move all بازنویسی دکمه
 * @param {string} shuttleName 
 */
function item_Shuttle_MoveLeftToRight(shuttleName) {
    //ltr: left to right
    item_Shuttle_MoveAll(shuttleName, 'ltr');
}

/**
 * فعال کردن انتقال از لیست دو به یک
 * remove all بازنویسی دکمه
 * @param {string} shuttleName 
 */
function item_Shuttle_MoveRightToLeft(shuttleName) {  
    //rtl: right to left
    item_Shuttle_MoveAll(shuttleName, 'rtl');
}

item_Shuttle_MoveAll: در این متد، رویداد کلیک بر روی این دکمه‌ها را بازنویسی می‌کنیم. پارامتر moveType جهت انتقال و نوع دکمه را مشخص می‌کند؛ از لیست چپ به راست، یا راست به چپ.
بدین ترتیب source و target را مشخص می‌کنیم (ایجاد id) و در نهایت با استفاده از یک حلقه موارد hidden را نادیده گرفته و انتقال انجام می‌شود.


/**
 * در زمان فیلتر کردن ستون چپ یا راست move all, remove all بازنویسی دکمه 
 * در این حالت مقادیری که فیلتر شده‌اند نباید به ستون کناری منتقل شوند
 * @param {string} shuttleName  > نام شاتل      
 * @param {string} moveType     > سمت حرکت (ltr, rtl)

 */
function item_Shuttle_MoveAll(shuttleName, moveType) {
    //ltr: left to right
    //rtl: right to left
    const moveAllBtn = document.getElementById(shuttleName + (moveType == 'ltr' ? '_MOVE_ALL' : '_REMOVE_ALL'));

    moveAllBtn.addEventListener("click",
        function (e) {
            e.preventDefault();
            e.stopImmediatePropagation();

            const source = document.getElementById(shuttleName + (moveType == 'ltr' ? '_LEFT' : '_RIGHT'));
            const target = document.getElementById(shuttleName + (moveType == 'ltr' ? '_RIGHT' : '_LEFT'));
              
           
            for (let i = source.options.length - 1; i >= 0; i--) {
                const option = source.options[i];
                if (!option.hidden) {
                    target.add(option);
                }
            }
        }, true); // capture phase
}

فراخوانی در صفحه

و در صفحه این متدها را فراخوانی می‌کنیم:

Page | JavaScript | Execute when Page Loads
بدین ترتیب با load شدن صفحه، رویداد دکمه‌ها را بازنویسی می‌کنیم.

let shuttleId = 'P5_USER_ID';

item_Shuttle_MoveAllButton(shuttleId);
item_Shuttle_RemoveAllButton(shuttleId);

💡 نامگذاری متدهای من

من از پیشوند item برای این متدها استفاده می‌کنم و بعد نام آیتم (در اینجا shuttle) و در نهایت کاری که می‌خواهم انجام دهم.

این کار برای دسته بندی فایل‌های JS من هست. در نهایت تمام فایل‌های JS را در site.js ادغام می‌کنم و داخل محیط production استفاده می‌کنم.

site_item.js