إنتقل إلى المحتوى الرئيسي

بناء عنصر تحكم مخصص

الهدف​

كتابة عنصر تحكم مخصص — وهو مكوّن React قابل لإعادة الاستخدام يحل محل المحرّر المدمج لحقل في نموذج مبني على البيانات — بدءًا من مجلد فارغ وصولًا إلى عنصر تحكم يستطيع المصمم ربطه بحقل.

عنصر التحكم المخصص هو الأداة الصحيحة حين يحتاج حقل إلى محرّر لا تقدّمه المنصة: لوحة توقيع، أو منتقي ألوان، أو تقييم بالنجوم، أو خريطة، أو قارئ باركود. وهو ليس الأداة المناسبة لشاشة كاملة — فتلك مهمة تطبيق React مخصص.

المتطلبات المسبقة​

  • Node.js 18 أو أحدث وnpm.
  • إلمام بـ React 18 وTypeScript.
  • وجود ناشر وحل في البيئة التي سيُثبَّت فيها عنصر التحكم. بادئة الناشر (مثل par) جزء من الاسم المنطقي لعنصر التحكم.
  • قراءة بناء تطبيق React مخصص تفيد هنا: فعنصر التحكم يُستضاف بالطريقة نفسها التي يُستضاف بها التطبيق، والاختلاف في العقد فقط.

عقد عنصر التحكم​

عنصر التحكم المخصص هو مكوّن React واحد خصائصه ثابتة تحددها المنصة. ويعرضه Model-Driven App Player بدلًا من محرّر الحقل الأصلي، ويزوّده بالخصائص الأربع كلها:

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. ولا يحتفظ بالقيمة المعتمَدة في حالته الداخلية بوصفها مصدر الحقيقة، ولا يحفظ السجل — فالنموذج هو مالك عملية الحفظ.
  2. يقرأ عنصر التحكم بيانات وصف حقله بدل افتراضها. فالتسمية، وعلامة الإلزام، والطول الأقصى، والجدول الهدف لحقل البحث، واسم مجموعة الخيارات، كلها تصل في fieldMetadata. وعنصر التحكم الذي يكتب التسمية داخل شيفرته هو عنصر تحكم يصلح للاستخدام مرة واحدة فقط.
  3. لا يصل عنصر التحكم إلى المنصة إلا عبر context. وهو الواجهة ParswareRuntime نفسها التي يحصل عليها التطبيق — context.data للقراءة، وcontext.log للتسجيل. ولا استدعاءات HTTP مباشرة.
  4. يتدهور عنصر التحكم بلطف بدل أن يطلق استثناءً. فهو مُركَّب داخل نموذج يخص جهة أخرى؛ والاستثناء يُسقط السجل بأكمله معه. احترس من واجهة متصفح مفقودة، ومن نوع قيمة غير متوقع، ومن قيمة فارغة.

الخطوات​

1. إنشاء المشروع​

يُهيَّأ المشروع تمامًا كمشروع تطبيق React مخصص — package.json وtsconfig.json وindex.html وsrc/vite-env.d.ts وvite.config.ts — انظر الخطوة 1 من بناء تطبيق 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 — بدل أن يعتمد ذلك على مكان تثبيت الأداة. وهو يستدعي ملف الأداة مباشرةً بدل غلاف التنفيذ parsware-pack، لأن تشغيل الغلاف عملية 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حقل نصي
سلسلة نصية طويلة (عنوان بيانات، أو JSON، أو نص منسّق)MultilineTextحقل نصي متعدد الأسطر
رقمNumberحقل صحيح أو عشري
قيمة منطقيةBooleanحقل منطقي
سلسلة تاريخ/وقت بصيغة ISODateTimeحقل تاريخ/وقت
معرّف سجلLookupحقل بحث
مفتاح في تخزين الكائناتFile، Imageحقل ملف أو صورة

ولا تملك حقول مجموعات الخيارات عائلة خاصة بها: فهي تُشتق بوصفها Text، لذا يعلن عنصر التحكم الخاص بها Text (أو Custom).

وإعلان نوع لا يستطيع عنصر التحكم التعامل معه فعليًا هو السبب الأشيع لعنصر تحكم "يعمل في التطوير وينكسر على نموذج حقيقي" — إذ سيعرضه المصمم لحقل لم يتوقع عنصر التحكم يومًا شكل قيمته.

5. عالج كل حالات القيمة​

تصل value في أربع حالات، ولكل منها جواب مقصود:

  • فارغة (undefined أو null أو '') — سجل جديد، أو حقل أُفرغ. اعرض المحرّر الفارغ، لا محرّرًا معطوبًا.
  • قيمة مخزّنة — سجل قائم يُعاد فتحه. اعرضها؛ فعنصر التحكم الذي لا يعرض سوى ما كتبه المستخدم في هذه الجلسة يخفي البيانات المحفوظة بصمت.
  • قيمة من نوع غير متوقع — رُبط عنصر التحكم بنوع حقل لا يتعامل معه. اعرض بديلًا محايدًا بدل إطلاق استثناء.
  • قيمة تغيّرت من الخارج — قد يعيد النموذج ضبط نفسه أو تحميله. تفاعل مع الخاصية 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 — هو إعداد التطبيق نفسه، في الخطوة 6 من بناء تطبيق 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 في الخطوة 2)، فلا يمكن أن تنحرف عن vite.config.ts.

وversion هو إصدار npm الخاص بعنصر التحكم. وهو ما يراه المصمم في جدول عناصر التحكم المخصصة، لذا فتشغيل npm version patch قبل التحزيم هو ما يجعل البناء قابلًا للتمييز.

8. احزمه​

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 لديك، والنطاق والوحدة وملف الدخول من mf-manifest.json الذي تقدمه إضافة federation عند ضبط manifest: true، فلا يبقى ما تملؤه. إذا كان عنصر التحكم مثبتًا من قبل فأزله أولًا، وعندما تنتهي ارفع الحزمة، فهي تحل محل تسجيل خادم التطوير.

من ذلك الحين يحمّل النموذج عنصر التحكم مباشرة من جهازك: احفظ تغييرًا وحدّث النموذج لتراه، دون بناء ولا حزم ولا رفع. لا يُستبدل التغيير والنموذج مفتوح: فالتحديث السريع (fast refresh) في خادم التطوير لا يصل إلى React الخاص بالـ Shell، لذا تتوقف إعادة التحميل الساخنة التي تحصل عليها في صفحة التطوير الخاصة بعنصر التحكم عند حدود الـ Shell.

ويجب أن يسمح أمران بذلك. لا يحمّل الـ Shell الكود إلا من أصول على قائمة السماح لديه (REMOTE_ALLOWED_ORIGINS للنشر، وVITE_REMOTE_ALLOWED_ORIGINS لـ Shell يعمل بـ npm run dev)، فيجب أن يكون أصل خادم التطوير لديك عليها. ويجب أن يقبل خادم التطوير الطلبات من عنوان المنصة، وهو ما يفعله Vite افتراضيًا لـ localhost ويحتاج غير ذلك إلى server.cors. ترفض المنصة خادم التطوير في بيئتي الاختبار والإنتاج: عنصر التحكم المسجل بهذه الطريقة ليست له حزمة، فلن ينتقل كوده مع الحل، وهذه بالضبط المشكلة التي وُجد الحزم لحلها.

بعد ذلك يسجّل المصمم عنصر التحكم (تسجيل عنصر تحكم مخصص) ويربطه بحقل في مصمم النماذج (ربط عنصر تحكم مخصص بحقل). والبيان الذي ينتجه ذلك التسجيل محدّد في بيان عنصر التحكم المخصص.

ما يجري خلف الكواليس​

عندما يعرض Model-Driven App Player نموذجًا، يقرأ ارتباط كل حقل. والحقل المرتبط بعنصر تحكم مخصص يُحلّ إلى تسجيل ذلك العنصر في البيئة الحالية، فيحمّل الـ Player وحدة Module Federation البعيدة الخاصة به — مشاركًا نسختي React وRuntime SDK لدى الـ Shell، تمامًا كما يفعل مع تطبيق React مخصص — ثم يركّب المكوّن المعروض بدلًا من المحرّر المدمج، ممرّرًا إليه القيمة الحالية للحقل، ودالة onChange موصولة بمخزن تعديلات النموذج، وبيانات وصف الحقل، والـ runtime نفسه الذي يحصل عليه التطبيق، وتخرج استدعاءاته باسم الشخص الذي يستخدم النموذج. والقيم التي يعتمدها عنصر التحكم تصبح جزءًا من تعديلات النموذج المعلّقة وتُكتب عند حفظ السجل، فيشارك عنصر التحكم في التحقق والحفظ كأي محرّر أصلي.

استكشاف الأخطاء​

  • لا يظهر عنصر التحكم في قائمة عناصر التحكم لدى مصمم النماذج — نوع عنصر تحكم الحقل غير مدرج في قائمة يحل محل الخاصة بعنصر التحكم، أو أن عنصر التحكم مسجَّل في بيئة غير البيئة التي يجري التصميم فيها.
  • "Invalid hook call" عند تركيب عنصر التحكم — نسختان من React. تحقّق من singleton: true على react/react-dom ومن resolve.dedupe في vite.config.ts.
  • تختفي التعديلات عند حفظ السجل — لم يستدعِ عنصر التحكم onChange قط، أو استدعاها بقيمة لا يستطيع نوع بيانات الحقل تخزينها. فالنموذج يحفظ ما أُبلغ به، لا ما يعرضه عنصر التحكم.
  • يعرض عنصر التحكم محرّرًا فارغًا على سجل له قيمة — عنصر التحكم يعرض حالته الخاصة بدل الخاصية value. يجب أن يكون عنصر التحكم مُدارًا من الخارج.
  • يخفق عرض النموذج بأكمله — أطلق عنصر التحكم استثناءً أثناء العرض. عنصر التحكم مُركَّب داخل صفحة تخص جهة أخرى؛ فاحترس من واجهة متصفح مفقودة ومن نوع قيمة غير متوقع بدل الافتراض.
  • تتعذّر إيجاد الوحدة المعروضة — يجب أن تطابق الوحدة المسجَّلة مفتاح exposes تمامًا، بما في ذلك ./ في بدايته (./Control).

سيناريوهات ذات صلة​

  • بناء تطبيق React مخصص.
  • استخدام Runtime SDK.
  • تسجيل عنصر تحكم مخصص.
  • ربط عنصر تحكم مخصص بحقل.