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

ساخت یک برنامهٔ سفارشی React

هدف​

نوشتن یک برنامهٔ سفارشی React — یک برنامهٔ React دست‌نویس که درون پوستهٔ پلتفرم Parsware اجرا می‌شود — از یک پوشهٔ خالی تا بسته‌ای که سازنده بارگذاری می‌کند.

برنامهٔ سفارشی React برای صفحه‌ای است که جدول‌ها، فرم‌ها و نماها نمی‌توانند بیانش کنند: یک تابلوی اختصاصی، یک ویزارد، یک کنسول یکپارچه‌سازی. هر چیزی که یک برنامهٔ مدل‌محور رایگان می‌گیرد (ورود، محیط جاری، دسترسی به داده، ثبت لاگ)، برنامهٔ سفارشی React هم می‌گیرد — از راه Runtime SDK.

پیش‌نیازها​

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

یک برنامهٔ سفارشی React چگونه میزبانی می‌شود​

برنامهٔ سفارشی React نه iframe است و نه صفحه‌ای جدا. یک ریموت Module Federation است: بسته‌ای که پوستهٔ پلتفرم آن را می‌گیرد و در درخت React خودش سوار می‌کند.

  1. برنامه React پوسته را به اشتراک می‌گذارد. react، react-dom و @parsware/runtime-sdk به‌عنوان singleton مشترک اعلام می‌شوند، پس برنامه از همان نسخه‌هایی استفاده می‌کند که پوسته پیش‌تر بار کرده است. دو نسخهٔ React در یک صفحه همهٔ هوک‌ها را می‌شکند.
  2. برنامه دقیقاً یک کامپوننت ارائه می‌کند. پوسته یک ماژول را بار می‌کند، طبق قرارداد ./App، و خروجی پیش‌فرض آن را رندر می‌کند. هیچ prop‌ای نمی‌گیرد.
  3. برنامه از راه Runtime SDK به پلتفرم می‌رسد. نه توکنی، نه شناسهٔ محیطی و نه نشانی‌ای برای پیکربندی: useParswareRuntime() همهٔ این‌ها را از پیش متصل به او می‌دهد.

گام‌ها​

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

دو بستهٔ 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 چیزی را می‌گوید که بیلد نمی‌تواند بداند: نام منطقی‌ای که برنامه با آن نصب می‌شود، نامی که مردم در پوسته می‌بینند، و اسکوپ‌هایی که درخواست می‌کند (گام ۴ را ببینید).
  • 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.

۲. پیکربندی 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 متوقف می‌شود: نام کانتینر، ماژول ارائه‌شده و فایل ورودی از همین فایل خوانده می‌شوند، پس هیچ‌وقت دو بار تایپ نمی‌شوند و نمی‌توانند از این پیکربندی جدا بیفتند.

نام کانتینر را همان نام منطقی برنامه بگذارید. دو برنامه با یک نام در مرورگر روی هم نوشته می‌شوند.

۳. نوشتن کامپوننت برنامه​

خروجی پیش‌فرض ماژول ارائه‌شده همان چیزی است که پوسته سوار می‌کند:

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 آمده است.

۴. درخواست اسکوپ‌های لازم​

requestedScopes می‌گوید برنامه چه نوع فراخوانی‌هایی انجام می‌دهد: modelapps.read برای خواندن رکوردها، modelapps.write برای تغییرشان، storage.read و storage.write برای فایل‌ها. وقتی کسی برنامه را باز می‌کند، پوسته این فهرست را با آنچه نشست خودِ آن شخص دارد اشتراک می‌گیرد و نتیجه را به‌صورت runtime.scopes به برنامه می‌دهد.

آن را بخوانید تا آنچه به کار نمی‌آید کنار گذاشته شود:

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

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

۵. بسته‌بندی​

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 یا نام منطقی بی‌پیشوند، پس خطا در ترمینال شما دیده می‌شود نه به‌صورت بارگذاری ردشده.

۶. آزمودن​

کامپوننت را با Vitest و Testing Library زیر یک runtime ساختگی رندر کنید:

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

به آزمون‌ها پیکربندی جداگانه بدهید. Vitest به‌جای vite.config.ts فایل vitest.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 } });

در runtime ساختگی فقط آنچه برنامه صدا می‌زند باید واقعی باشد:

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 اجرایشان کنید. افزون بر حالت عادی، یک محیط خالی و یک فراخوانی ردشده را هم پوشش دهید.

۷. بارگذاری دوبارهٔ زنده هنگام نوشتن​

بسته‌بندی برای هر تغییر چرخه‌ای کند است. در یک محیط توسعه می‌توان برنامه را به‌جای آن روی سرور توسعهٔ در حال اجرای شما ثبت کرد (حالت 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 نیست (گام ۱).
  • Cannot resolve entry module index.html — Vite به index.html گام ۱ نیاز دارد، هرچند پوسته هرگز آن را بار نمی‌کند.
  • «Invalid hook call» همین که برنامه باز می‌شود — دو نسخهٔ React. react و react-dom باید در shared به‌صورت singleton: true باشند.
  • useParswareRuntime() خطا می‌دهد — کامپوننت بیرون از یک فراهم‌کنندهٔ runtime رندر شده است. در پوسته این اتفاق نمی‌افتد؛ در آزمون، مانند گام ۶ آن را درون ParswareRuntimeProvider بگذارید.
  • یک فراخوانی رد می‌شود — کسی که از برنامه استفاده می‌کند اجازهٔ آن کار را ندارد؛ نقش امنیتی او تصمیم می‌گیرد. runtime.scopes را بررسی کنید و آنچه را نمی‌تواند کنار بگذارید.
  • ماژول ارائه‌شده پیدا نمی‌شود — ماژول باید دقیقاً با کلید exposes یکی باشد، با ./ آغازینش.

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

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