ساخت یک برنامهٔ سفارشی React
هدف
نوشتن یک برنام هٔ سفارشی React — یک برنامهٔ React دستنویس که درون پوستهٔ پلتفرم Parsware اجرا میشود — از یک پوشهٔ خالی تا بستهای که سازنده بارگذاری میکند.
برنامهٔ سفارشی React برای صفحهای است که جدولها، فرمها و نماها نمیتوانند بیانش کنند: یک تابلوی اختصاصی، یک ویزارد، یک کنسول یکپارچهسازی. هر چیزی که یک برنامهٔ مدلمحور رایگان میگیرد (ورود، محیط جاری، دسترسی به داده، ثبت لاگ)، برنامهٔ سفارشی React هم میگیرد — از راه Runtime SDK.
پیشنیازها
- Node.js نسخهٔ ۱۸ یا بالاتر و npm.
- آشنایی با React 18 و TypeScript.
- یک ناشر و یک راهحل در محیطی که برنامه در آن نصب میشود. پیشوند ناشر (برای مثال
par) آغاز نام منطقی برنامه است.
یک برنامهٔ سفارشی React چگونه میزبانی میشود
برنامهٔ سفارشی React نه iframe است و نه صفحهای جدا. یک ریموت Module Federation است: بستهای که پوستهٔ پلتفرم آن را میگیرد و در درخت React خودش سوار میکند.
- برنامه React پوسته را به اشتراک میگذارد.
react،react-domو@parsware/runtime-sdkبهعنوان singleton مشترک اعلام میشوند، پس برنامه از همان نسخههایی استفاده میکند که پوسته پیشتر بار کرده است. دو نسخهٔ React در یک صفحه همهٔ هوکها را میشکند. - برنامه دقیقاً یک کامپوننت ارائه میکند. پوسته یک ماژول را بار میکند، طبق قرارداد
./App، و خروجی پیشفرض آن را رندر میکند. هیچ propای نمیگیرد. - برنامه از راه Runtime SDK به پلتفرم میرسد. نه توکنی، نه شناسهٔ محیطی و نه نشانیای برای
پیکربندی:
useParswareRuntime()همهٔ اینها را از پیش متصل به او میدهد.
گامها
۱. ساخت پروژه
دو بستهٔ Parsware از همین سایت راهنما دانلود میشوند: Runtime SDK که برنامه آن را وارد میکند، و بستهساز که خروجی بیلد را به فایلی تبدیل میکند که سازنده بارگذاری میکند. آنها را مثل هر tarball دیگر npm با نشانیشان نصب کنید:
{
"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 از آن میسازد، و اعلان نوعها برای
فایلهای واردشده:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
<!doctype html>
<html>
<head><meta charset="utf-8" /><title>Sales Dashboard</title></head>
<body></body>
</html>
/// <reference types="vite/client" />
index.html فقط برای این است که Vite نقطهٔ شروعی برای ساختن لازم دارد. پوسته هرگز آن را بار
نمیکند.
سپس npm install.
۲. پیکربندی Module Federation
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 متوقف میشود: نام کانتینر، ماژول ارائهشده و فایل ورودی از همین فایل خوانده
میشوند، پس هیچوقت دو بار تایپ نمیشوند و نمیتوانند از این پیکربندی جدا بیفتند.
نام کانتینر را همان نام منطقی برنامه بگذارید. دو برنامه با یک نام در مرورگر روی هم نوشته میشوند.
۳. نوشتن کامپوننت برنامه
خروجی پیشفرض م اژول ارائهشده همان چیزی است که پوسته سوار میکند:
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 را از آنها بیرون نگه میدارد: ماژولهای مجازی آن برای مرورگرند،
نه برای آزمون واحد.
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 ساختگی فقط آنچه برنامه صدا میزند باید واقعی باشد:
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).