پرش به مطلب اصلی

ساخت یک کنترل سفارشی

هدف​

نوشتن یک کنترل سفارشی — یک کامپوننت React قابل استفادهٔ دوباره که جایگزین ویرایشگر داخلی یک فیلد روی فرم Model-Driven می‌شود — از یک پوشهٔ خالی تا کنترلی که سازنده می‌تواند آن را به یک فیلد ببندد.

کنترل سفارشی ابزار درست وقتی است که فیلدی به ویرایشگری نیاز دارد که پلتفرم عرضه‌اش نمی‌کند: یک تختهٔ امضا، یک انتخابگر رنگ، یک امتیازدهی ستاره‌ای، یک نقشه، یک اسکنر بارکد. این ابزارِ یک صفحهٔ کامل نیست — آن یک برنامهٔ سفارشی React است.

پیش‌نیازها​

  • Node.js نسخهٔ 18 یا بالاتر و npm.
  • آشنایی با React 18 و TypeScript.
  • یک ناشر و یک راه‌حل در محیطی که کنترل در آن نصب خواهد شد. پیشوند ناشر (برای مثال par) بخشی از نام منطقی کنترل است.
  • خواندن ساخت یک برنامهٔ سفارشی React کمک می‌کند: میزبانی یک کنترل دقیقاً مانند میزبانی یک برنامه است و تنها قرارداد فرق می‌کند.

قرارداد کنترل​

یک کنترل سفارشی یک کامپوننت React است که props آن را پلتفرم تثبیت کرده است. Model-Driven App Player آن را به‌جای ویرایشگر بومی فیلد رندر می‌کند و هر چهار prop را تأمین می‌کند:

interface ParswareControlProps {
/** The bound field's current value. Its type follows the field's data type. */
value: unknown;
/** Commit a new value back to the form. */
onChange: (next: unknown) => void;
/** The bound field's metadata — type, label, required flag, constraints. */
fieldMetadata: FieldMetadata;
/** The same auth/data/logging context a Custom React App receives. */
context: RuntimeContext;
}

از این شکل چهار قاعده برمی‌آید، و کنترلی که هر یک را نقض کند ناقص است:

  1. کنترل، کنترل‌شده است. value را رندر می‌کند و ویرایش‌ها را از راه onChange گزارش می‌دهد. مقدار ثبت‌شده را به‌عنوان منبع حقیقت در state خودش نگه نمی‌دارد و رکورد را ذخیره نمی‌کند — ذخیره‌سازی از آنِ فرم است.
  2. کنترل به‌جای فرض کردن، فراداده فیلدش را می‌خواند. برچسب، نشانهٔ اجباری بودن، بیشینهٔ طول، جدول مقصد یک جستجوی رکورد و نام یک مجموعه گزینه همگی در fieldMetadata می‌رسند. کنترلی که برچسب را سفت‌وسخت در کد بنویسد کنترلی است که دقیقاً یک بار می‌توان از آن استفاده کرد.
  3. کنترل تنها از طریق context به پلتفرم می‌رسد. این همان سطح ParswareRuntime است که یک برنامه دریافت می‌کند — context.data برای خواندن و context.log برای ثبت لاگ. هیچ فراخوانی HTTP مستقیمی در کار نیست.
  4. کنترل به‌جای پرتاب استثنا، افت کیفیت می‌دهد. کنترل درون فرم شخص دیگری سوار می‌شود؛ یک استثنا کل رکورد را با خود می‌برد. برای یک API نبودهٔ مرورگر، یک نوع مقدار غیرمنتظره و یک مقدار خالی محافظ بگذارید.

گام‌ها​

1. ساخت پروژه​

پروژه درست مانند پروژهٔ یک برنامهٔ سفارشی React برپا می‌شود — package.json، tsconfig.json، index.html، src/vite-env.d.ts، vite.config.ts — گام ۱ از ساخت یک برنامهٔ سفارشی React را ببینید. Runtime SDK و بسته‌ساز از همین سایت راهنما دانلود می‌شوند و با نشانی‌شان نصب می‌شوند:

package.json (excerpt)
{
"name": "signature-pad",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test": "vitest run",
"pack": "node node_modules/@parsware/pack/src/cli.mjs"
},
"dependencies": {
"@parsware/runtime-sdk": "https://docs.parsware.com/sdk/parsware-runtime-sdk-1.0.0.tgz",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@parsware/pack": "https://docs.parsware.com/sdk/parsware-pack-1.0.0.tgz"
}
}

اسکریپت pack همان چیزی است که گام ۸ اجرا می‌کند. تعریف آن در اینجا یعنی بسته‌ساز در هر پروژه و روی هر ماشین به یک شکل فراخوانی می‌شود — npm run pack — نه بسته به اینکه ابزار کجا نصب شده باشد. این اسکریپت به‌جای shim اجرایی parsware-pack مستقیم فایل بسته‌ساز را صدا می‌زند، چون اجرای shim یک exec است و exec ممکن است به دلایلی بی‌ربط به پروژهٔ شما رد شود — بیشتر اوقات Gatekeeper در macOS، که فایل‌های بیرون‌آمده از یک آرشیو دانلودی را قرنطینه می‌کند و بعد bad interpreter: Operation not permitted می‌دهد.

2. پیکربندی Module Federation​

vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { federation } from '@module-federation/vite';

// The control reuses the Shell's React and Runtime SDK instances; its bundle ships
// no copy of either. Without `singleton: true`, a second React would break hooks.
const sharedSingletons = {
react: { singleton: true, requiredVersion: '^18.3.1' },
'react-dom': { singleton: true, requiredVersion: '^18.3.1' },
'@parsware/runtime-sdk': { singleton: true },
};

export default defineConfig({
plugins: [
react(),
federation({
name: 'par_signature_pad', // the container's name: unique per environment
filename: 'remoteEntry.js',
exposes: { './Control': './src/Control.tsx' },
shared: sharedSingletons,
manifest: true, // writes dist/mf-manifest.json, which the packer reads
}),
],
build: { target: 'esnext' },
});

طبق قرارداد، یک کنترل ./Control را در معرض می‌گذارد، آنجا که یک برنامه ./App را. هیچ‌کس آن را جایی تایپ نمی‌کند: بسته‌ساز آن را از mf-manifest.json می‌خواند، و به همین دلیل manifest: true اختیاری نیست.

3. نوشتن کنترل​

src/Control.tsx
import { useEffect, useRef, useState, type PointerEvent } from 'react';
import type { ParswareControlProps } from '@parsware/runtime-sdk';
import './index.css';

/**
* A signature pad bound to a text field. The committed value is a PNG data URL, so
* the field it binds to is an ordinary text field — the control decides what the
* text means, the platform stores it unchanged.
*/
export default function Control({ value, onChange, fieldMetadata, context }: ParswareControlProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const drawing = useRef(false);
const [hasInk, setHasInk] = useState(false);

// 1. Announce the mount through the platform's log — a control reaches platform
// services only through `context`.
useEffect(() => {
context.log('debug', 'Signature pad mounted', { field: fieldMetadata.logicalName });
}, [context, fieldMetadata.logicalName]);

// 2. Begin a stroke. A missing 2D context is a no-op rather than a throw, so the
// control degrades on a canvas-less environment instead of breaking the form.
const startStroke = (e: PointerEvent<HTMLCanvasElement>) => {
const ctx = canvasRef.current?.getContext('2d');
if (!ctx) return;
drawing.current = true;
const rect = e.currentTarget.getBoundingClientRect();
ctx.beginPath();
ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);
};

// 3. Commit the drawing to the form when the stroke ends. `onChange` is the only
// way a value reaches the record — the control never saves anything itself.
const endStroke = () => {
if (!drawing.current) return;
drawing.current = false;
const dataUrl = canvasRef.current?.toDataURL('image/png');
if (dataUrl) onChange(dataUrl);
};

// 4. Clearing is an edit like any other: wipe the canvas, commit an empty value.
const clear = () => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
setHasInk(false);
onChange('');
};

const signed = typeof value === 'string' && value.length > 0;

return (
<div className="signature-pad">
<canvas
ref={canvasRef}
width={320}
height={120}
role="img"
aria-label={`${fieldMetadata.displayName} signature canvas`}
onPointerDown={startStroke}
onPointerUp={endStroke}
onPointerLeave={endStroke}
/>
<div className="signature-pad__row">
<span>
{signed || hasInk ? 'Signed' : 'Not signed'}
{/* The required marker comes from metadata, not from a hard-coded assumption. */}
{fieldMetadata.isRequired && !signed && !hasInk ? ' — required' : ''}
</span>
<button type="button" onClick={clear}>Clear</button>
</div>
{/* Re-opening a saved record must show the stored signature. */}
{signed && <img src={value as string} alt={`${fieldMetadata.displayName} signature`} />}
</div>
);
}

4. تصمیم بگیرید کنترل جایگزین چه نوع فیلدهایی می‌شود​

هنگام ثبت، یک کنترل جایگزینِ خود را اعلام می‌کند — یعنی خانواده‌های نوع دادهٔ فیلدی که می‌تواند جایشان را بگیرد (Text، MultilineText، Number، Boolean، DateTime، Lookup، File، Image، Custom). طراح فرم این کنترل را تنها برای فیلدهایی پیشنهاد می‌دهد که نوع کنترلشان در آن فهرست باشد، پس یک انتخابگر تاریخ هرگز برای یک فیلد بولی پیشنهاد نمی‌شود.

این‌ها نام کنترل‌های داخلی نیستند

یک جای‌گذاری در فرم، کنترل داخلی را انتخاب می‌کند — TextArea، Toggle، Dropdown، Date، Time. اما substitutesFor واژگان دیگر و کوتاه‌تری دارد: خانواده‌ای که نوع دادهٔ فیلد به آن تعلق دارد. این دو در چهار نام مشترک‌اند (Text، DateTime، Lookup، Custom) و همین آن‌ها را آسان‌اشتباه می‌کند. اعلام TextArea هنگام ثبت با خطای /substitutesFor/1 enum رد می‌شود.

فهرست را بر پایهٔ آنچه کنترل واقعاً می‌خواند و می‌نویسد انتخاب کنید:

کنترل چه می‌نویسدجایگزین چه می‌شودآن را به چه ببندید
یک رشتهٔ کوتاهTextیک فیلد متنی
یک رشتهٔ بلند (data URL، JSON، متن قالب‌بندی‌شده)MultilineTextیک فیلد متنی چندخطی
یک عددNumberیک فیلد صحیح یا اعشاری
یک مقدار بولیBooleanیک فیلد بولی
یک رشتهٔ تاریخ/زمان ISODateTimeیک فیلد تاریخ/زمان
یک شناسهٔ رکوردLookupیک فیلد جستجوی رکورد
یک کلید فضای ذخیره‌سازی شیءFile، Imageیک فیلد فایل یا تصویر

فیلد مجموعه گزینه خانوادهٔ جداگانه‌ای ندارد: به‌عنوان Text مشتق می‌شود، پس کنترلی برای آن Text (یا Custom) را اعلام می‌کند.

اعلام نوعی که کنترل واقعاً نمی‌تواند از پس آن برآید رایج‌ترین علت کنترلی است که «در توسعه کار می‌کند و روی فرم واقعی می‌شکند» — طراح آن را برای فیلدی پیشنهاد می‌دهد که شکل value آن را هرگز انتظار نداشته است.

5. هر وضعیت مقدار را مدیریت کنید​

value در چهار وضعیت می‌رسد و هرکدام به پاسخی سنجیده نیاز دارد:

  • خالی (undefined، null یا '') — یک رکورد تازه، یا فیلدی که پاک شده است. ویرایشگر خالی را رندر کنید، نه یک ویرایشگر شکسته.
  • یک مقدار ذخیره‌شده — رکوردی موجود که دوباره باز می‌شود. آن را رندر کنید؛ کنترلی که فقط آنچه کاربر در همین نشست تایپ کرده را نشان می‌دهد، داده‌های ذخیره‌شده را بی‌سروصدا پنهان می‌کند.
  • مقداری با نوع غیرمنتظره — کنترل به نوع فیلدی بسته شده که از پس آن برنمی‌آید. به‌جای پرتاب استثنا یک جایگزین خنثی رندر کنید.
  • مقداری که از بیرون تغییر کرده — ممکن است فرم بازنشانی یا بارگذاری دوباره شود. به‌جای کش کردن مقدار ثبت‌شده در state، به prop به نام value واکنش نشان دهید.

6. آزمودن کنترل​

کامپوننت را مستقیماً با یک runtime ساختگی رندر کنید و روی قرارداد ادعا بگذارید: اینکه برای یک value مشخص چه رندر می‌کند، و چه چیزی به onChange می‌دهد.

src/Control.test.tsx
import { expect, it, vi } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ParswareRuntime } from '@parsware/runtime-sdk';
import Control from './Control';

// Only what the control calls needs to be real.
const mockRuntime = () => ({ log: () => {} }) as unknown as ParswareRuntime;

const fieldMetadata = {
logicalName: 'par_signature',
displayName: 'Signature',
dataType: 0, // Text
isRequired: true,
} as never;

it('renders a stored signature when the record already has one', () => {
render(
<Control
value="data:image/png;base64,AAA"
onChange={() => {}}
fieldMetadata={fieldMetadata}
context={mockRuntime()}
/>,
);

expect(screen.getByAltText('Signature signature')).toBeInTheDocument();
});

it('commits an empty value when cleared', async () => {
const onChange = vi.fn();
render(<Control value="data:image/png;base64,AAA" onChange={onChange} fieldMetadata={fieldMetadata} context={mockRuntime()} />);

await userEvent.click(screen.getByRole('button', { name: 'Clear' }));

expect(onChange).toHaveBeenCalledWith('');
});

حالتِ «اجباری اما خالی» را هم بیازمایید: نشانه‌ای که یک فیلد اجباری نمایش می‌دهد بخشی از قولی است که کنترل به فرم می‌دهد. برپایی آزمون — vitest، jsdom، Testing Library و یک vitest.config.ts که Module Federation را کنار می‌گذارد — همان برپایی برنامه است، در گام ۶ از ساخت یک برنامهٔ سفارشی React؛ برای کلیک، @testing-library/user-event را هم اضافه کنید.

7. آنچه باندلر نمی‌تواند بداند را اعلام کنید​

یک بلوک parsware به package.json کنترل بیفزایید. این بلوک دقیقاً همان سه چیزی را نگه می‌دارد که بیلد راهی برای دانستنشان ندارد — نام منطقی با پیشوند ناشر که کنترل زیر آن نصب می‌شود، نامی که در پالت فیلدها دیده می‌شود، و خانواده‌های نوع‌دادهٔ فیلدی که می‌تواند جای آن‌ها بنشیند:

{
"version": "1.0.0",
"parsware": {
"logicalName": "par_signature_pad",
"displayName": "Signature Pad",
"description": "Capture a hand-drawn signature on a text field.",
"substitutesFor": ["Text", "Custom"]
}
}

نام کانتینر، ماژول ارائه‌شده و فایل ورودی عمداً اینجا نیستند. آن‌ها از mf-manifest.json خودِ بیلد خوانده می‌شوند (همان manifest: true گام ۲) تا نتوانند از vite.config.ts جدا بیفتند.

version نسخهٔ npm خودِ کنترل است. همان چیزی است که سازنده در جدول کنترل‌های سفارشی می‌بیند، پس اجرای npm version patch پیش از بسته‌سازی راهی است که یک بیلد قابل تشخیص شود.

۸. بسته‌اش کنید​

npm run pack # بیلد می‌کند و سپس package/<logicalName>-<version>.zip را می‌نویسد

آرشیو، خروجی بیلد را به‌همراه یک parsware.json که از دو منبع بالا ساخته شده نگه می‌دارد. همان یک فایل، کل ثبت است: سازنده آن را در مرکز کنترل مدیریت بارگذاری می‌کند و چیز دیگری پر نمی‌کند.

npm run pack -- --no-build # بسته‌سازی از یک بیلد موجود
npm run pack -- --module ./Grid # وقتی ریموت بیش از یک ماژول ارائه می‌کند

آن -- مالِ npm است، نه بسته‌ساز: همان چیزی است که نمی‌گذارد npm پرچم را برای خودش بردارد و آن را به اسکریپت می‌رساند.

بسته‌ساز هر چیزی را که پلتفرم بعداً رد می‌کرد، همان‌جا رد می‌کند — بلوک parsware غایب، نام منطقی‌ای که هرگز از بررسی پیشوند نمی‌گذرد، نام یک کنترل درون‌ساخته آنجا که خانوادهٔ نوع‌داده باید باشد — تا اشتباه در ترمینال شما نام برده شود، نه به‌صورت یک بارگذاری ردشده کشف شود.

بستهٔ ذخیره‌شده از نشانی خودِ پلتفرم سرو می‌شود، پس راهکار کد کنترل را با خود می‌برد: خروجی گرفتن از آن و وارد کردنش در محیط آزمون، یک کنترل کارآمد نصب می‌کند، نه اشاره‌ای به دستگاه شما. نه نشانی‌ای برای مجاز کردن هست و نه نشانی‌ای که کهنه شود.

بارگذاری داغ هنگام نوشتن کنترل​

بسته‌سازی برای هر تغییر، چرخهٔ کندی است. در یک محیط توسعه می‌توانید کنترل را در عوض به سرور توسعهٔ در حال اجرایش ثبت کنید: در راه‌حل، کنترل‌های سفارشی، جدید، استفاده از سرور توسعهٔ در حال اجرا، و نشانی‌ای را که npm run dev چاپ می‌کند بنویسید. پلتفرم نام کنترل را از package.json شما و scope، ماژول و فایل ورودی را از mf-manifest.jsonی می‌خواند که افزونهٔ federation با manifest: true ارائه می‌کند، پس چیز دیگری برای پر کردن نیست. اگر کنترل از پیش نصب است، اول آن را بردارید؛ وقتی کارتان تمام شد بسته را بارگذاری کنید که جای ثبتِ سرور توسعه را می‌گیرد.

از آن پس فرم کنترل را مستقیم از دستگاه شما بارگذاری می‌کند: تغییری را ذخیره کنید و فرم را تازه کنید تا آن را ببینید، بی‌ساخت، بی‌بسته‌سازی و بی‌بارگذاری. تغییر تا وقتی فرم باز است جایگزین نمی‌شود: fast refresh سرور توسعهٔ شما به React پوستهٔ Shell نمی‌رسد، پس بارگذاری داغی که روی صفحهٔ توسعهٔ خودِ کنترل دارید، در مرز Shell می‌ایستد.

دو چیز باید اجازه بدهند. Shell تنها از originهایی که در فهرست مجازش هستند کد بارگذاری می‌کند (REMOTE_ALLOWED_ORIGINS برای یک استقرار، VITE_REMOTE_ALLOWED_ORIGINS برای Shellی که با npm run dev اجرا می‌شود)، پس origin سرور توسعهٔ شما باید در آن باشد. و سرور توسعهٔ شما باید درخواست‌ها را از نشانی پلتفرم بپذیرد؛ Vite این کار را به‌طور پیش‌فرض برای localhost انجام می‌دهد و در غیر این صورت server.cors لازم است. پلتفرم سرور توسعه را در محیط آزمون و تولید نمی‌پذیرد: کنترلی که این‌گونه ثبت شود بسته‌ای ندارد، پس کدش با راه‌حل جابه‌جا نمی‌شود — و این دقیقاً همان مشکلی است که بسته‌بندی برای حل آن وجود دارد.

سپس یک سازنده کنترل را ثبت می‌کند (ثبت یک کنترل سفارشی) و آن را در طراح فرم به یک فیلد می‌بندد (بستن یک کنترل سفارشی به یک فیلد). مانیفستی که این ثبت تولید می‌کند در مانیفست کنترل سفارشی مشخص شده است.

پشت صحنه چه می‌گذرد​

وقتی Model-Driven App Player یک فرم را رندر می‌کند، اتصال هر فیلد را می‌خواند. فیلدی که به یک کنترل سفارشی بسته شده به ثبت آن کنترل در محیط جاری رفع می‌شود، و Player remote مربوط به Module Federation آن کنترل را بارگذاری می‌کند — با به اشتراک گذاشتن نمونه‌های React و Runtime SDK پوسته، دقیقاً همان‌گونه که برای یک برنامهٔ سفارشی React انجام می‌دهد — و کامپوننت در معرض قرارگرفته را به‌جای ویرایشگر داخلی سوار می‌کند و مقدار جاری فیلد، یک onChange سیم‌کشی‌شده به بافر ویرایش فرم، فراداده فیلد و یک همان runtimeی را به آن می‌دهد که یک برنامه می‌گیرد و فراخوانی‌هایش به نام کسی انجام می‌شود که فرم را به کار می‌برد. مقادیری که کنترل ثبت می‌کند بخشی از ویرایش‌های در انتظار فرم می‌شوند و هنگام ذخیرهٔ رکورد نوشته می‌شوند، بنابراین کنترل مانند هر ویرایشگر بومی دیگری در اعتبارسنجی و ذخیره‌سازی مشارکت می‌کند.

رفع اشکال​

  • کنترل در فهرست کنترل‌های طراح فرم دیده نمی‌شود — نوع کنترل فیلد در فهرست جایگزین چه می‌شود آن کنترل نیست، یا کنترل در محیطی غیر از محیطی که در آن طراحی می‌کنید ثبت شده است.
  • «Invalid hook call» هنگام سوار شدن کنترل — دو نسخهٔ React. مقدار singleton: true روی react/react-dom و resolve.dedupe را در vite.config.ts بررسی کنید.
  • ویرایش‌ها هنگام ذخیرهٔ رکورد ناپدید می‌شوند — کنترل هرگز onChange را صدا نزده، یا آن را با مقداری صدا زده که نوع دادهٔ فیلد نمی‌تواند ذخیره‌اش کند. فرم آنچه به آن گفته شده را ذخیره می‌کند، نه آنچه کنترل نمایش می‌دهد.
  • کنترل روی رکوردی که مقدار دارد ویرایشگر خالی نشان می‌دهد — کنترل به‌جای prop به نام value دارد state خودش را رندر می‌کند. کنترل باید کنترل‌شده باشد.
  • کل فرم رندر نمی‌شود — کنترل در حین رندر استثنا پرتاب کرده است. یک کنترل درون صفحهٔ شخص دیگری سوار می‌شود؛ به‌جای فرض کردن، برای یک API نبودهٔ مرورگر و یک نوع مقدار غیرمنتظره محافظ بگذارید.
  • ماژول در معرض قرارگرفته پیدا نمی‌شود — ماژول ثبت‌شده باید دقیقاً با کلید exposes یکسان باشد، از جمله ./ ابتدایی (./Control).

سناریوهای مرتبط​

  • ساخت یک برنامهٔ سفارشی React.
  • استفاده از Runtime SDK.
  • ثبت یک کنترل سفارشی.
  • بستن یک کنترل سفارشی به یک فیلد.