Parsware
All articles

Parsware Platform

Build a custom React app

When a screen needs something tables, forms and views can't say, you write it in React. A custom app is built on your own machine with two downloads from the help site, packed into one file, uploaded once, and opened from the Shell like any other app. We built a visitor board for reception.

The Visitor board custom app open in the Shell: eight visitor cards in company order, under the heading Visitor board, Development, next to the title "Build a custom React app"

Reception wants a board on the screen behind the desk: everyone who's visiting, by company, in big cards. A view could list them, but not like that. When a screen needs to be drawn the way somebody imagines it, rather than assembled from tables, forms and views, you write it in React. That's a custom app.

A custom app runs inside the Parsware Shell. Not in a frame, not on another site: the Shell loads it, hands it the signed-in person and the environment, and it draws itself in the Shell's own page. Everything it reads goes through the Runtime SDK, so it has no token to handle and no address to configure.

Two downloads

You build on your own machine, with Node.js and npm. Parsware's part is two packages, both downloads on the help site:

  • @parsware/runtime-sdk, which the app imports to reach the platform;
  • @parsware/pack, which turns the build into the one file you upload.

They go in package.json like any other dependency, by address:

{
  "name": "visitor-board",
  "version": "1.0.0",
  "type": "module",
  "parsware": {
    "kind": "CustomApp",
    "logicalName": "nw_visitor_board",
    "displayName": "Visitor board",
    "description": "Who is in the building today.",
    "requestedScopes": ["modelapps.read"]
  },
  "scripts": {
    "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",
    "@vitejs/plugin-react": "^6.0.5",
    "typescript": "^7.0.2",
    "vite": "^8.2.0"
  }
}

The parsware block is the part only you know: the name the app installs under (it starts with the nw prefix of the Visitors solution's publisher), the name people see, and what kind of calls it makes. modelapps.read is enough to read records.

Share the Shell's React

The app is a Module Federation remote: a bundle the Shell loads into its own page. That's set in vite.config.ts:

federation({
  name: 'nw_visitor_board',
  filename: 'remoteEntry.js',
  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,
}),
  • singleton: true means the app uses the React and the SDK the Shell already loaded. Two copies of React on one page break every hook.
  • exposes names the one component the Shell renders.
  • manifest: true writes a file the packer reads, so the app's container name, module and entry file are never typed again anywhere.

The app

import { useEffect, useState } from 'react';
import { useParswareRuntime, type RecordDto } from '@parsware/runtime-sdk';
import './board.css';

export default function App() {
  const runtime = useParswareRuntime();
  const [visitors, setVisitors] = useState<RecordDto[] | null>(null);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let cancelled = false;
    runtime.data
      .runQuery('nw_visitor', {
        select: ['nw_name', 'nw_company'],
        order: [{ field: 'nw_company', descending: false }, { field: 'nw_name', descending: false }],
        page: { number: 1, size: 50 },
      })
      .then((result) => { if (!cancelled) setVisitors(result.records?.items ?? []); })
      .catch((e: unknown) => { if (!cancelled) setError(e instanceof Error ? e.message : String(e)); });
    return () => { cancelled = true; };
  }, [runtime]);

  return (
    <section className="board">
      <h1>Visitor board</h1>
      <p className="board-sub">{runtime.environmentName} · {runtime.user.name}</p>
      {error && <p role="alert">Could not load visitors: {error}</p>}
      {visitors && (
        <ul className="board-cards">
          {visitors.map((v) => (
            <li key={v.id}>
              <strong>{String(v.values.nw_name)}</strong>
              <span>{String(v.values.nw_company ?? '')}</span>
            </li>
          ))}
        </ul>
      )}
    </section>
  );
}

useParswareRuntime() is the whole connection to Parsware. runtime.data.runQuery sends the same query document the platform's own grids send; runtime.environmentName and runtime.user say where it was opened and by whom. There's no address, no environment id and no token in the code, which is why the same build works in Development, Test and Production.

Pack it

$ npm run pack

Packaged nw_visitor_board 1.0.0
  scope   nw_visitor_board
  module  ./App
  entry   remoteEntry.js
  files   18
  sha256  d1ec51048f4b787ba1b855bfc7c2edfe17487eef878534469d34316ed2fa0c54

  …isitor-boardpackage
w_visitor_board-1.0.0.zip  (88.7 KiB)

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

The packer builds the app, reads what it needs from the build itself, and writes package/nw_visitor_board-1.0.0.zip. That one file is the whole registration.

Upload it

In the Admin Control Center, open the Visitors solution, then Custom Apps, New, and choose the file:

The New custom app panel in the Visitors solution with nw_visitor_board-1.0.0.zip chosen

There's nothing to type: the package already says what the app is called, what it exposes and what it asks for. Register custom app, and it's listed with its version:

The Custom Apps list of the Visitors solution: Visitor board, nw_visitor_board, version 1.0.0

Open it

In the Shell, the Visitor board is on the home page with the environment's other apps. Open it:

The Visitor board open in the Shell: the heading Visitor board, Development and the signed-in name, and eight cards, each a visitor's name and company

Eight cards, by company, read as the person who opened the board. Somebody whose role can't read visitors would get the refusal instead, in the app's own words. An app can never do more than the person using it.

What we fixed

Building this app the way a customer would, outside our own repository, found that a customer couldn't:

  • The SDK and the packer weren't published anywhere. They're now downloads on the help site, built from the same source the Shell uses, so the version you install is the one the Shell shares.
  • The help page for building a custom app didn't work when followed. It missed a setting the packer needs, a file Vite needs, and had a test that didn't compile. It's rewritten, and every file in it was checked by building an app from the page alone, in all seven languages.
  • The packer sent apps to the wrong tab. Its closing line said Custom Controls whatever it had packed; it now names the right one.

Good to know

  • Raise version before every upload. Uploading the same logical name replaces the app, and the version in the list is how you know which build is installed.
  • The solution carries the code. Export the Visitors solution and the board goes with it, stored by the platform and served from its own address.
  • While you're writing it, a development environment can load the app straight from your dev server instead, with hot reload. The help page has the one call that sets that up.

Try it

Build the board from the snippets above, change the cards to show what your reception would want, pack it and upload it to a solution of your own.

Next: The Runtime SDK.