ساخت یک کنترل سفارشی
هدف
نوشتن یک کنترل سفارشی — یک کامپوننت 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;
}
از این شکل چهار قاعده برمیآید، و کنترلی که هر یک را نقض کند ناقص است:
- کنترل، کنترلشده است.
valueرا رندر میکند و ویرایشها را از راهonChangeگزارش میدهد. مقدار ثبتشده را بهعنوان منبع حقیقت در state خودش نگه نمیدارد و رکورد را ذخیره نمیکند — ذخیرهسازی از آنِ فرم است. - کنترل بهجای فرض کردن، فراداده فیلدش را میخواند. برچسب، نشانهٔ اجباری بودن،
بیشینهٔ طول، جدول مقصد یک جستجوی رکورد و نام یک مجموعه گزینه همگی در
fieldMetadataمیرسند. کنترلی که برچسب را سفتوسخت در کد بنویسد کنترلی است که دقیقاً یک بار میتوان از آن استفاده کرد. - کنترل تنها از طریق
contextبه پلتفرم میرسد. این همان سطحParswareRuntimeاست که یک برنامه دریافت میکند —context.dataبرای خواندن وcontext.logبرای ثبت لاگ. هیچ فراخوانی HTTP مستقیمی در کار نیست. - کنترل بهجای پرتاب استثنا، افت کیفیت میدهد. کنترل درون فرم شخص دیگری سوار میشود؛ یک استثنا کل رکورد را با خود میبرد. برای یک API نبودهٔ مرورگر، یک نوع مقدار غیرمنتظره و یک مقدار خالی محافظ بگذارید.
گامها
1. ساخت پروژه
پروژه درست مانند پروژهٔ یک برنامهٔ سفارشی React برپا میشود — package.json،
tsconfig.json، index.html، src/vite-env.d.ts، vite.config.ts — گام ۱ از
ساخت یک برنامهٔ سفارشی React را ببینید. Runtime SDK
و بستهساز از همین سایت راهنما دانلود میشوند و با نشانیشان نصب میشوند:
{
"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
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. نوشتن کنترل
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 | یک فیلد بولی |
| یک رشتهٔ تاریخ/زمان ISO | DateTime | یک فیلد تاریخ/زمان |
| یک شناسهٔ رکورد | Lookup | یک فیلد جستجوی رکورد |
| یک کلید فضای ذخیرهسازی شیء | File، Image | یک فیلد فایل یا تصویر |
فیلد مجموعه گزینه خانوادهٔ جداگانهای ندارد: بهعنوان Text مشتق میشود، پس کنترلی برای
آن Text (یا Custom) را اعلام میکند.
اعلام نوعی که کنترل واقعاً نمیتواند از پس آن برآید رایجترین علت کنترلی است که «در
توسعه کار میکند و روی فرم واقعی میشکند» — طراح آن را برای فیلدی پیشنهاد میدهد که
شکل value آن را هرگز انتظار نداشته است.
5. هر وضعیت مقدار را مدیریت کنید
value در چهار وضعیت میرسد و هرکدام به پاسخی سنجیده نیاز دارد:
- خالی (
undefined،nullیا'') — یک رکورد تازه، یا فیلدی که پاک شده است. ویرایشگر خالی را رندر کنید، نه یک ویرایشگر شکسته. - یک مقدار ذخیرهشده — رکوردی موجود که دوباره باز میشود. آن را رندر کنید؛ کنترلی که فقط آنچه کاربر در همین نشست تایپ کرده را نشان میدهد، دادههای ذخیرهشده را بیسروصدا پنهان میکند.
- مقداری با نوع غیرمنتظره — کنترل به نوع فیلدی بسته شده که از پس آن برنمیآید. بهجای پرتاب استثنا یک جایگزین خنثی رندر کنید.
- مقداری که از بیرون تغییر کرده — ممکن است فرم بازنشانی یا بارگذاری دوباره شود.
بهجای کش کردن مقدار ثبتشده در state، به prop به نام
valueواکنش نشان دهید.
6. آزمودن کنترل
کامپوننت را مستقیماً با یک runtime ساختگی رندر کنید و روی قرارداد ادعا بگذارید:
اینکه برای یک value مشخص چه رندر میکند، و چه چیزی به onChange میدهد.
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 را هم اضافه کنید.