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

بناء تطبيق React مخصص

الهدف​

كتابة تطبيق React مخصص — تطبيق React مكتوب يدويًا يعمل داخل واجهة منصة Parsware — من مجلد فارغ حتى الحزمة التي يرفعها المصمم.

التطبيق المخصص لشاشة لا تستطيع الجداول والنماذج والعروض التعبير عنها: لوحة مصممة خصيصًا، أو معالج خطوات، أو وحدة تكامل. كل ما يحصل عليه التطبيق الذي يعتمد على النموذج مجانًا (تسجيل الدخول، والبيئة الحالية، والوصول إلى البيانات، والتسجيل) يحصل عليه التطبيق المخصص أيضًا، عبر Runtime SDK.

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

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

كيف يُستضاف التطبيق المخصص​

التطبيق المخصص ليس إطار iframe وليس صفحة مستقلة. إنه وحدة بعيدة في Module Federation: حزمة تجلبها واجهة المنصة وتركّبها في شجرة React الخاصة بها.

  1. يشارك التطبيق React الخاص بالواجهة. تُعلَن react وreact-dom و@parsware/runtime-sdk singletons مشتركة، فيستخدم التطبيق النسخ التي حمّلتها الواجهة مسبقًا. نسختان من React في صفحة واحدة تكسران كل hook.
  2. يعرض التطبيق مكوّنًا واحدًا بالضبط. تحمّل الواجهة وحدة واحدة، بحسب العرف ./App، وتعرض تصديرها الافتراضي. ولا يتلقى أي props.
  3. يصل التطبيق إلى المنصة عبر Runtime SDK. لا رمز، ولا معرّف بيئة، ولا عنوان يُضبط: useParswareRuntime() يسلّمه كل ذلك مربوطًا مسبقًا.

الخطوات​

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

حزمتا Parsware تنزيلان من موقع المساعدة هذا: Runtime SDK الذي يستورده التطبيق، وأداة التحزيم التي تحوّل البناء إلى الملف الذي يرفعه المصمم. ثبّتهما بعنوانيهما، كأي حزمة tarball في npm:

package.json
{
"name": "sales-dashboard",
"private": true,
"version": "1.0.0",
"type": "module",
"parsware": {
"kind": "CustomApp",
"logicalName": "par_sales_dashboard",
"displayName": "Sales Dashboard",
"description": "Tables and their record counts.",
"requestedScopes": ["modelapps.read"]
},
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"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": {
"@module-federation/vite": "^1.20.3",
"@parsware/pack": "https://docs.parsware.com/sdk/parsware-pack-1.0.0.tgz",
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^6.0.5",
"typescript": "^7.0.2",
"vite": "^8.2.0"
}
}
  • version هو إصدار التطبيق نفسه. يعرضه مركز الإدارة بجانب التطبيق المثبت، فارفعه قبل كل رفع.
  • parsware يقول ما لا يستطيع البناء معرفته: الاسم المنطقي الذي يُثبَّت به التطبيق، والاسم الذي يراه الناس في الواجهة، والنطاقات (scopes) التي يطلبها (انظر الخطوة 4).
  • pack يستدعي ملف أداة التحزيم بدلًا من أمرها parsware-pack، لأن تشغيل أمر هو exec، وقد يرفض macOS تشغيل ملف جاء من تنزيل.

وبجانبه tsconfig.json، وindex.html يبني منه Vite، وتعريفات الأنواع للملفات المستوردة:

tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
index.html
<!doctype html>
<html>
<head><meta charset="utf-8" /><title>Sales Dashboard</title></head>
<body></body>
</html>
src/vite-env.d.ts
/// <reference types="vite/client" />

لا يوجد index.html إلا لأن Vite يحتاج مدخلًا يبني منه. ولا تحمّله الواجهة أبدًا.

ثم npm install.

2. ضبط Module Federation​

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

export default defineConfig({
plugins: [
react(),
federation({
name: 'par_sales_dashboard', // the container's name: unique per environment
filename: 'remoteEntry.js', // what the Shell fetches
exposes: { './App': './src/App.tsx' },
shared: {
react: { singleton: true, requiredVersion: '^18.3.1' },
'react-dom': { singleton: true, requiredVersion: '^18.3.1' },
'@parsware/runtime-sdk': { singleton: true },
},
manifest: true, // writes dist/mf-manifest.json, which the packer reads
}),
],
// Module Federation's shared-scope negotiation needs modern output.
build: { target: 'esnext' },
});

manifest: true ليس اختياريًا. هو معطّل افتراضيًا، ومن دونه تتوقف أداة التحزيم برسالة No mf-manifest.json: فاسم الحاوية والوحدة المعروضة وملف المدخل تُقرأ من ذلك الملف، فلا تُكتب مرتين أبدًا ولا يمكن أن تنحرف عن هذا الضبط.

سمِّ الحاوية بالاسم المنطقي للتطبيق. تطبيقان بالاسم نفسه يكتب أحدهما فوق الآخر في المتصفح.

3. كتابة مكوّن التطبيق​

التصدير الافتراضي للوحدة المعروضة هو ما تركّبه الواجهة:

src/App.tsx
import { useEffect, useState } from 'react';
import { useParswareRuntime, type EntityDto } from '@parsware/runtime-sdk';

interface Row { entity: EntityDto; count: number }

export default function App() {
// 1. The runtime for the environment the Shell opened this app in.
const runtime = useParswareRuntime();
const [rows, setRows] = useState<Row[] | null>(null);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let cancelled = false;

// 2. Read through the SDK: no token, no environment id, no address.
(async () => {
try {
const entities = await runtime.data.listEntities();
const counted = await Promise.all(entities.map(async (entity) => {
const page = await runtime.data.queryRecords(entity.logicalName, { pageSize: 1 });
return { entity, count: page.totalCount };
}));
if (!cancelled) setRows(counted);
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
runtime.log('error', 'Sales dashboard failed to load', { message });
if (!cancelled) setError(message);
}
})();

return () => { cancelled = true; }; // 3. Never set state after the app is closed.
}, [runtime]);

if (error) return <p role="alert">Could not load data: {error}</p>;
if (rows === null) return <p>Loading…</p>;

return (
<section>
<h2>Sales Dashboard</h2>
<p>{runtime.user.name} in {runtime.environmentName}</p>
<table>
<tbody>
{rows.map(({ entity, count }) => (
<tr key={entity.id}><td>{entity.displayCollectionName}</td><td>{count}</td></tr>
))}
</tbody>
</table>
</section>
);
}

كل ما يقدّمه runtime — الاستعلامات، والاستعلامات المحفوظة، والواجهات البرمجية المخصصة، والتخزين، والسجل — موجود في استخدام Runtime SDK.

4. طلب النطاقات التي يحتاجها​

يسمّي requestedScopes نوع الاستدعاءات التي يجريها التطبيق: modelapps.read لقراءة السجلات، وmodelapps.write لتغييرها، وstorage.read وstorage.write للملفات. وحين يفتح أحدهم التطبيق، تقاطع الواجهة هذه القائمة مع ما تحمله جلسة ذلك الشخص، وتسلّم النتيجة للتطبيق بوصفها runtime.scopes.

اقرأها لتستبعد ما لا يمكن استخدامه:

const canEdit = runtime.scopes.includes('modelapps.write');
{canEdit && <button onClick={save}>Save</button>}

كل استدعاء يجريه التطبيق يتم باسم الشخص الذي يستخدمه، فدوره الأمني يقرر ما يحق لكل استدعاء قراءته وتغييره، تمامًا كما في التطبيق الذي يعتمد على النموذج. لا يستطيع التطبيق أبدًا أن يفعل أكثر مما يستطيعه الشخص الذي فتحه.

5. تحزيمه​

npm run pack # builds, then writes package/<logicalName>-<version>.zip
npm run pack -- --no-build # package an existing build
npm run pack -- --module ./App # when the remote exposes more than one module

تعرض أداة التحزيم ما قرأته وأين كتبت الملف:

Packaged par_sales_dashboard 1.0.0
scope par_sales_dashboard
module ./App
entry remoteEntry.js
files 18

Upload it in the Admin Control Center: open the solution, then Custom Apps → New.

يحوي الأرشيف البناء كله وparsware.json مركّبًا من المصدرين أعلاه. وهو التسجيل كله: يرفعه المصمم ولا يكتب شيئًا (تسجيل تطبيق React مخصص). ثم تحفظ المنصة الشيفرة وتقدّمها من عنوانها هي، لذا يحمل الحل التطبيق معه: إذا صُدِّر واستُورد في Test ثبّت تطبيقًا يعمل، دون أي شيء خارجي يجب إبقاؤه قيد التشغيل.

ترفض أداة التحزيم ما كانت المنصة سترفضه لاحقًا، مثل غياب كتلة parsware أو اسم منطقي بلا بادئة، فيظهر الخطأ في الطرفية لا في صورة رفع مرفوض.

6. اختباره​

اعرض المكوّن تحت runtime بديل باستخدام Vitest وTesting Library:

npm install -D vitest jsdom @testing-library/react @testing-library/jest-dom

أعطِ الاختبارات ضبطًا خاصًا بها. يقرأ Vitest الملف vitest.config.ts بدلًا من vite.config.ts، وهذا يُبقي Module Federation خارجها: فوحداته الافتراضية للمتصفح، لا لاختبار الوحدات.

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

// globals: Testing Library clears the page between tests only when Vitest's globals are on.
export default defineConfig({ plugins: [react()], test: { environment: 'jsdom', globals: true } });

لا يلزم أن يكون حقيقيًا في البديل إلا ما يستدعيه التطبيق:

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

const runtime = {
environmentName: 'Development',
user: { id: 'u1', name: 'Dev User', email: 'dev@example.com', tenantId: 't1', roles: [] },
scopes: ['modelapps.read'],
log: () => {},
data: {
listEntities: async () => [{ id: 'e1', logicalName: 'par_order', displayName: 'Order',
displayCollectionName: 'Orders', primaryFieldLogicalName: 'par_name' }],
queryRecords: async () => ({ items: [], totalCount: 42, pageNumber: 1, pageSize: 1,
totalPages: 42, hasNextPage: true, hasPreviousPage: false }),
},
} as unknown as ParswareRuntime;

it('shows each table with its record count', async () => {
render(<ParswareRuntimeProvider runtime={runtime}><App /></ParswareRuntimeProvider>);
expect(await screen.findByText('Orders')).toBeInTheDocument();
expect(screen.getByText('42')).toBeInTheDocument();
});

شغّلها بالأمر npx vitest run. غطِّ بيئة فارغة واستدعاءً مرفوضًا إلى جانب الحالة العادية.

7. إعادة التحميل الفوري أثناء الكتابة​

التحزيم مع كل تغيير حلقة بطيئة. في بيئة تطوير يمكن بدلًا من ذلك تسجيل التطبيق على خادم التطوير العامل لديك (وضع Hosted)، فتبقى إعادة التحميل الفوري. لا توجد شاشة لذلك: إنه استدعاء واحد، POST /environments/{environment}/solutions/{solution}/custom-apps/hosted، يذكر عنوان remoteEntry.js لديك. وترفضه المنصة في أي مكان آخر، لأن التطبيق الذي تقيم شيفرته على جهازك تطبيق لا يستطيع حله أن يحمله. ويجب أن تسمح الواجهة أيضًا بعنوان خادم التطوير (VITE_REMOTE_ALLOWED_ORIGINS).

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

حين يفتح أحدهم التطبيق، تطلب الواجهة من Identity App Context Token قصير العمر يسمّي التطبيق والبيئة، مع النطاقات التي طلبها التطبيق مقصورة على نطاقات ذلك الشخص؛ وتلك القائمة المقصورة هي runtime.scopes. ثم تجلب الواجهة remoteEntry.js من عنوان المنصة نفسها، وتدع الوحدة البعيدة ترتبط بـ React وRuntime SDK المحمّلين مسبقًا، وتعرض المكوّن المعروض داخل runtime مربوط بالبيئة. تخرج استدعاءات SDK بتسجيل دخول الشخص نفسه، فيتحقق الخادم من كل واحد منها مقابل دوره الأمني.

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

  • No mf-manifest.json عند التحزيم — أضف manifest: true إلى federation({ … }).
  • Cannot find module … for side-effect import of './x.css' — الملف src/vite-env.d.ts غير موجود (الخطوة 1).
  • Cannot resolve entry module index.html — يحتاج Vite إلى index.html من الخطوة 1، وإن كانت الواجهة لا تحمّله أبدًا.
  • "Invalid hook call" فور فتح التطبيق — نسختان من React. يجب أن تكون react وreact-dom singleton: true في shared.
  • useParswareRuntime() يرمي خطأ — عُرض المكوّن خارج مزوّد runtime. لا يحدث هذا في الواجهة؛ وفي الاختبار غلّفه بـ ParswareRuntimeProvider كما في الخطوة 6.
  • رُفض استدعاء — لا يحق للشخص الذي يستخدم التطبيق فعل ذلك؛ دوره الأمني هو الذي يقرر. تحقق من runtime.scopes واستبعد ما لا يستطيعه.
  • تعذّر العثور على الوحدة المعروضة — يجب أن تطابق الوحدة مفتاح exposes تمامًا، بما في ذلك ./ في أولها.

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

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