np-dlms-components
v0.2.0
Published
Shared components for DLMS applications
Downloads
277
Readme
np-dlms-components
Shared React components for DLMS apps.
This README is the complete user manual. If you just want the short version — install, import, register — read USAGE.md instead.
Contents
- What this package is
- Install
- Set it up in your app
- How to read this manual
- Component list at a glance
- Text and number inputs
- Chips and file capture
- Dropdowns and multi-selects
- Spectrometer components
- Chemistry panels
- Layout components
- The Data Table
- Helpers you can import
- Common recipes
- Troubleshooting
- Upgrading from 0.1.x
- For maintainers
1. What this package is
DLMS forms are not written by hand. Somebody designs a form in the form builder, and the builder saves a JSON description of it. Later, an app loads that JSON and draws the real form on screen.
The JSON refers to each field by a type name — "NpUnitInput", "RsSimpleTable", and so on. The form engine looks up that type name in a list of registered components and draws whatever it finds.
This package is that list of components for the field types the stock form-engine packages do not provide.
Two things follow from this, and they explain most of the surprises people hit:
- If a type name is missing from the list, the form breaks. The engine has no fallback. It throws
Type 'X' is not found!and the whole section fails to render — not just that one field. So register everything you might need, even components you think a form is not using. - Type names are a contract. A form saved last year says
"RsSpectrometerReading". If you keep that type name, old forms keep working with no edits and no data migration. That is why some components here deliberately reuse a name instead of inventing a new one.
Everything in this package is ported from the nowpurchase-form-builder repo, not written here. See For maintainers if you need to change something.
2. Install
npm install np-dlms-componentsYour app must also have these installed. They are peer dependencies — this package expects your app to supply them, so that you and this package share one copy of React, one copy of rsuite, and so on.
npm install react react-dom @react-form-builder/core rsuite rsuite-table react-use-websocket react-number-formatTrusted authenticated API origins
Components that attach a stored authentication token refuse to send it to an
unknown origin. Same-origin URLs and the configured npBaseUrl / dlmsBaseUrl
are trusted automatically. Add any intentional additional API origin explicitly:
window.__DLMS_CONFIG = {
...window.__DLMS_CONFIG,
trustedAuthOrigins: ["https://api.example.com"],
};Use origins only (scheme + host + optional port), and never add an origin that is not controlled by your organisation.
What each one is for:
| Package | Needed for | Can I skip it? |
| --- | --- | --- |
| react, react-dom | everything | No |
| @react-form-builder/core | everything (define, prop types) | No |
| rsuite | dropdowns, tag picker, uploader, unit inputs | No in practice |
| rsuite-table | simpleTable only | Ships with rsuite anyway |
| react-use-websocket | rsSpectrometerReading only | Only if you never register it |
| react-number-format | npUnitNumber only | Only if you never register it |
Because these are peer dependencies, npm will warn you if a version is wrong rather than quietly installing a second copy. Take those warnings seriously — two copies of React in one app cause errors that are very hard to read.
3. Set it up in your app
There are three steps, and all three are required.
Step 1 — import the stylesheet, once
import "np-dlms-components/styles.css";Put this in your app entry file (main.tsx, index.tsx, App.tsx — wherever you already import global CSS). Once for the whole app, not once per component.
If you skip this, the components still work but look wrong — unstyled boxes, no colours, no spacing.
You also need rsuite's own stylesheet, which your app should already be loading:
import "rsuite/dist/rsuite.min.css";This package deliberately does not bundle rsuite's CSS. If it did, you would get two copies fighting each other.
Step 2 — import the components you want
import {
npInput,
npUnitInput,
npUnitNumber,
rsChipInput,
rsCameraCapture,
rsDropdown,
rsDropdownV2,
rsTagPicker,
rsSpectrometerReading,
rsElementalCells,
formAccordion,
actualChemItem,
spectroReadingMtc,
collapsibleSection,
simpleTable,
} from "np-dlms-components";Step 3 — register them with the form engine
Add them to the array of components you hand to the form viewer, alongside the stock ones:
import { rsInput, rsTextArea /* …all the stock ones… */ } from "@react-form-builder/components-rsuite";
export const ALL_FORM_COMPONENTS = [
// stock components
rsInput,
rsTextArea,
// …
// this package
npInput,
npUnitInput,
npUnitNumber,
rsChipInput,
rsCameraCapture,
rsDropdown,
rsDropdownV2,
rsTagPicker,
rsSpectrometerReading,
rsElementalCells,
formAccordion,
actualChemItem,
spectroReadingMtc,
collapsibleSection,
simpleTable,
];One important exception: rsDropdown
rsDropdown uses the same type name as the stock dropdown: "RsDropdown". Two components cannot claim one type name.
So if you register this one, remove the stock rsDropdown from your list:
import { rsDropdown as stockDropdown } from "@react-form-builder/components-rsuite";
import { rsDropdown } from "np-dlms-components";
const ALL_FORM_COMPONENTS = [
// …everything else, but NOT stockDropdown…
rsDropdown, // ours wins
];Every other component in this package either has its own unique type name, or (in the case of rsSpectrometerReading) replaces something only this package ever provided. rsDropdown is the only one that collides with a stock component.
4. How to read this manual
Each component below has:
- What it is — one or two plain sentences.
- When to use it — and when not to.
- Type name — the string that appears in saved form JSON.
- Props — a table of settings. Every prop is optional unless the description says otherwise.
A note on two words that appear throughout:
- Prop — a setting on a field. The form builder writes props into the saved JSON; you rarely set them by hand.
- Bound /
value— the prop that holds the field's actual answer. It is connected to the form's data by itsdataKey, so whatever ends up invalueis what gets submitted.
Props marked read-only display never contribute anything to the submitted data.
5. Component list at a glance
| Export | Type name | One-line summary |
| --- | --- | --- |
| npInput | NpInput | Plain text input. |
| npUnitInput | NpUnitInput | Text input with a unit like Kg pinned to the right. |
| npUnitNumber | NpUnitNumber | The same, for numbers. |
| rsChipInput | RsChipInput | Type-and-Enter chips, saved as one comma-separated string. |
| rsCameraCapture | RsCameraCapture | Upload button that opens the phone camera. |
| rsDropdown | RsDropdown | Fixed stock dropdown (same type name as stock). |
| rsDropdownV2 | RsDropdownV2 | Async dropdown: search, paging, cascades. Opt-in. |
| rsTagPicker | RsTagPickerV2 | Async multi-select. |
| rsSpectrometerReading | RsSpectrometerReading | Live spectrometer readings over a WebSocket. |
| rsElementalCells | RsElementalCells | Read-only grid of elements with target ranges. |
| actualChemItem | ActualChemItem | Editable actual-chemistry grid. |
| spectroReadingMtc | RsSpectroReadingMtc | Actual chemistry pulled from the MTC API. |
| formAccordion | FormAccordion | Grey collapsible panel. |
| collapsibleSection | RsCollapsibleSection | Collapsible section card. |
| simpleTable | RsSimpleTable | Read-only data table, optionally API-backed. |
Also exported as plain React components, for use outside a form: FormAccordion, CollapsibleSection, SimpleTable, SpectroAdditionDilution.
6. Text and number inputs
npInput — plain text input
What it is: a simple styled text box.
When to use it: when you want the DLMS look without any rsuite behaviour. For most ordinary text fields the stock RsInput is the better choice; this exists for screens that need the specific DLMS input styling.
Type name: NpInput
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| label | text | "" | Text shown above the box. |
| placeholder | text | "Enter value..." | Grey hint inside the empty box. |
| value | text | "" | The answer. Bound to the form data. |
| disabled | yes/no | false | Greyed out, cannot be edited or focused. |
| readOnly | yes/no | false | Can be focused and copied, but not changed. |
| onChange | event | — | Fires when the text changes. |
npUnitInput — text input with a unit
What it is: a normal text input with a fixed piece of text pinned to the right edge, like 100 ⋯ Kg.
The single most important thing about it: the unit is presentation only. It is drawn beside the real input, not inside it. The field submits exactly what a normal input would submit — 100, not "100 Kg". Nobody has to strip the unit back off later.
When to use it: whenever a number or code has a fixed unit the operator should see but never type. Weight in Kg, temperature in °C, pressure in bar.
When not to use it: when the unit can change per row or per selection. This unit is fixed at design time.
Type name: NpUnitInput
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| label | text | "" | Text shown above the box. |
| placeholder | text | "" | Grey hint inside the empty box. |
| value | text | "" | The answer. The unit is not part of it. |
| endAdornment | text | "" | The unit pinned to the right, e.g. "Kg". Leave empty and it looks like a normal input. |
| disabled | yes/no | false | Greyed out. |
| readOnly | yes/no | false | Visible but not editable. |
| onChange | event | — | Fires when the text changes. |
The unit stays visible even when the field is empty, so the operator knows what is expected before typing.
npUnitNumber — number input with a unit
What it is: the same idea, for numeric fields. Built on react-number-format, so it also gives you thousand separators, decimal limits, and so on.
Type name: NpUnitNumber
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| label | text | "" | Text shown above the box. |
| placeholder | text | "" | Grey hint. |
| value | number | — | The answer, as a number. |
| endAdornment | text | "" | The unit pinned to the right, e.g. "Kg". |
| allowNegative | yes/no | true | Allow a minus sign. |
| decimalScale | number | — | Maximum digits after the decimal point. |
| fixedDecimalScale | yes/no | false | Always pad to decimalScale digits (5 shows as 5.00). |
| prefix | text | — | Text glued to the front of the number itself, e.g. "₹". |
| suffix | text | — | Text glued to the end of the number itself, e.g. "%". |
| thousandSeparator | text | — | e.g. "," to show 1,000. |
| disabled | yes/no | false | Greyed out. |
| readOnly | yes/no | false | Visible but not editable. |
| onChange | event | — | Fires when the number changes. |
suffix vs endAdornment — these are different, and people mix them up.
| | suffix | endAdornment |
| --- | --- | --- |
| Attached to | the number text | the field, on the right edge |
| Visible when empty? | No | Yes |
| Moves as you type? | Yes, it sits right after the digits | No, it stays pinned right |
| Part of the value? | No (it is formatting) | No |
| Typical use | %, ₹ | Kg, °C |
You can use both at once. They were kept independent on purpose.
7. Chips and file capture
rsChipInput — comma-separated chips
What it is: the operator types a value and presses Enter or comma; it turns into a removable chip. The whole set is saved as one comma-separated string, not an array.
When to use it: free-form lists where the values are not from a master list — batch codes, remarks, tags typed by hand.
When not to use it: picking several items from a known list. Use rsTagPicker for that.
Type name: RsChipInput
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| label | text | — | Text shown above the box. |
| placeholder | text | "Type and press Enter or comma..." | Grey hint. |
| size | rsuite size | — | xs, sm, md, lg. |
| value | text | "" | The chips, as one comma-separated string. |
| allowDuplicates | yes/no | false | Allow the same chip twice. |
| maxChips | number | 0 | Maximum chips. 0 means unlimited. |
| disabled | yes/no | false | Greyed out. |
| readOnly | yes/no | — | Chips visible, cannot add or remove. |
| onChange | event | — | Fires when chips change. |
rsCameraCapture — photo capture
What it is: rsuite's Uploader with a camera-icon Capture button instead of the usual file button.
Why it exists: on Android, opening the camera reliably means letting the browser's own <input type="file"> do the work. This component wires that up correctly, which hand-rolled camera buttons usually get wrong.
When to use it: any shop-floor photo — a heat, a defect, an MTC sheet.
Type name: RsCameraCapture
The important props:
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| label | text | — | Text shown above the button. |
| allowGallery | yes/no | false | false opens the camera directly. true lets the phone offer camera or gallery. |
| action | text | "/" | Upload URL. |
| accept | text | "image/*" | Which file types are allowed. |
| autoUpload | yes/no | true | Upload immediately after capture. |
| multiple | yes/no | false | Allow more than one file. |
| fileList | list | — | The captured files. Bound to the form data. |
| listType | choice | — | text, picture-text, or picture. |
| removable | yes/no | false | Show a remove button on each file. |
| method, name, timeout, withCredentials, disableMultipart | — | — | Passed straight to rsuite's Uploader. |
| onChange, onSuccess, onError, onProgress, onRemove, onUpload, onPreview, onReupload | events | — | Passed straight to rsuite's Uploader. |
Set allowGallery: false for shop-floor evidence photos. It forces a fresh photo instead of letting someone attach an old one from the gallery.
8. Dropdowns and multi-selects
These three all look similar. Here is how to choose:
| Use this | When |
| --- | --- |
| the stock RsDropdown | A short fixed list typed into the form. |
| rsDropdown (ours) | You want the stock dropdown, but with the search bug fixed, on every existing form at once. |
| rsDropdownV2 | Options come from an API: searching, paging, or one dropdown filtering another. |
| rsTagPicker | Same as V2, but the operator picks several values. |
rsDropdown — the fixed stock dropdown
What it is: the stock dropdown with two bugs fixed:
- Backspace-to-empty did nothing. Clear the search box and the stock version never asked for options again, so you were stuck with the last search's results. This one always reloads, including on an empty search.
- The required-field asterisk drifted out of sync.
Type name: RsDropdown — the same as stock. Remove the stock one from your list (see Step 3).
When to use it: you want every existing form to pick up the fix with no re-authoring. The risk is that it changes behaviour everywhere at once.
rsDropdownV2 — the async dropdown
What it is: a dropdown built for options that come from an API.
Type name: RsDropdownV2 — a separate, new type name. Existing "RsDropdown" forms are completely unaffected. A form opts in per-field, which makes this the safe choice.
What it fixes over v1, in plain terms:
- No more duplicate options. V1 added each new page onto the end of the list without checking, so reopening a dropdown could show
A B C D A B C D. - Opening or searching replaces the list instead of adding to it.
- Cascades actually update. If a child dropdown is filtered by a parent field, changing the parent now clears the child's stale options instead of keeping the previous parent's list.
- No page-ordering races. Which page replaces and which page appends is decided per request, so an open and a scroll happening together cannot leave you with the wrong list.
- Search is debounced, so typing does not fire one request per keystroke.
Type name: RsDropdownV2
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| label | text | "Select" | Text shown above the box. |
| value | text | — | The selected option's value. Bound to the form data. |
| placeholder | text | — | Grey hint when nothing is selected. |
| data | list | 3 samples | A fixed list of { label, value }. Ignored in practice when onLoadData is set. |
| onLoadData | event | — | This is what makes it async. Called on open, on search, and on scroll-to-end. |
| preload | yes/no | false | Fetch the first page immediately, before the operator opens the menu. |
| cleanable | yes/no | true | Show the ✕ to clear the selection. |
| creatable | yes/no | false | Let the operator add a value that is not in the list. |
| groupBy | text | "" | Group options by this field. |
| size | rsuite size | — | xs, sm, md, lg. |
| placement | rsuite placement | — | Which side the menu opens on. |
| disableVirtualized | yes/no | — | Turn off virtual scrolling. Only for very short lists. |
| disabled | yes/no | false | Greyed out. |
| readOnly | yes/no | false | Selection visible, cannot be changed. |
| onChange, onSelect, onSearch, onOpen, onClose, onClean, onCreate | events | — | Standard rsuite picker events. |
rsDropdown takes the same props. The difference is behaviour and type name, not configuration.
rsTagPicker — the async multi-select
What it is: the multi-select version of rsDropdownV2.
Why it exists: the stock tag picker throws onLoadData away and only ever shows a fixed list. So a master-data multi-select simply never fetched anything.
Type name: RsTagPickerV2 — a separate type. Stock "RsTagPicker" forms are untouched.
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| label | text | "Select" | Text shown above the box. |
| value | list of text | — | The selected values. Bound to the form data. |
| data | list | 3 samples | A fixed list of { label, value }. |
| onLoadData | event | — | Called on open, on search, and on scroll-to-end. |
| itemsKey | text | — | See "the companion list" below. |
| foldFields | text (JSON) | — | See "the companion list" below. |
| preload | yes/no | false | Fetch the first page immediately. |
| cleanable | yes/no | true | Show the ✕ to clear everything. |
| creatable | yes/no | false | Allow typed-in values. |
| size, placement, disableVirtualized, disabled, readOnly | — | — | As above. |
| onChange, onSelect, onSearch, onOpen, onClose, onClean, onCreate | events | — | Standard rsuite picker events. |
The companion list (itemsKey and foldFields)
A multi-select stores IDs: ["12", "47"]. That is all the form data holds. When you later print a report, 12 and 47 are useless — you wanted the names.
Set itemsKey and the picker writes a second, parallel field holding the readable records:
// itemsKey: "materials_items", foldFields: "[\"grade\",\"supplier\"]"
"materials": ["12", "47"],
"materials_items": [
{ "id": "12", "label": "FERRO MANGANESE", "grade": "LS223", "supplier": "ACME" },
{ "id": "47", "label": "FERRO SILICON", "grade": "LS110", "supplier": "BOLT" }
]itemsKeyis the field name to write into.foldFieldsis a JSON string listing which extra fields to copy from each record.
Why this is trustworthy: the visible menu is volatile — searching or paging replaces it entirely. If the companion list were rebuilt from whatever is on screen, a selection that scrolled off the current page would silently lose its name and fall back to the raw ID. So the picker keeps a private cache of every option it has ever shown, and rebuilds from that. Membership and order always come from the current selection; only the labels come from the cache. This is the pickerOptionCache helper, which is exported if you need it.
Leave itemsKey unset and nothing extra is written. Plain tag pickers are unaffected.
9. Spectrometer components
rsSpectrometerReading — live readings
What it is: connects to a spectrometer over a WebSocket and shows each reading as a grid of element boxes, colour-coded against the target chemistry. Under each reading it shows the Addition Dilution Suggestion — what to add to bring the heat into spec.
Type name: RsSpectrometerReading
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| label | text | "Spectrometer Reading" | Heading above the readings. |
| url | text | — | The WebSocket URL to connect to. |
| columnsPerRow | number | 4 | How many element boxes per row. |
| showConnectionStatus | yes/no | true | Show the Connected / Closed chip. |
| elements | text | "C,Si,Mn,S,P,Cr,Cu,Ni" | Comma-separated elements to display, in order. |
| showAdditionDilution | yes/no | true | Show the Addition Dilution block under each reading. |
| additionDilutionTitle | text | "Addition Dilution Suggestion" | Heading for that block. |
| value | object | {} | The flattened readings, saved with the submission. |
| onChange | event | — | Fires when new readings arrive. |
The colour of each element box has four states, and the order they are checked in matters:
- Green — in range.
- Amber — outside the range but within tolerance. Checked before red, so a borderline element reads amber, not red.
- Red — out of range and out of tolerance. An arrow points up if the value is above the range, down if below (taken from the sign of the deviation).
- Grey — no range configured for that element.
How readings are numbered: within each sample_type, and only when that type repeats. A Final + Bath + Bath frame reads Reading / Reading 1 / Reading 2 — not Reading / Reading 2 / Reading 3. This matches the design; the 0.1.x component numbered globally and got it wrong.
What it saves: the component flattens every reading into the form data using keys shaped like:
<prefix>__elements__<Symbol>__value
<prefix>__elements__<Symbol>__deviation
<prefix>__elements__<Symbol>__min
<prefix>__elements__<Symbol>__maxso a report template can read any single element directly.
SpectroAdditionDilution — the suggestion block on its own
Normally you do not touch this: rsSpectrometerReading renders it for you, one per reading. It is exported as a plain React component in case you need the same block somewhere else.
import { SpectroAdditionDilution } from "np-dlms-components";
<SpectroAdditionDilution data={reading.spectro_add_dil} title="Addition Dilution Suggestion" />It reads the raw spectro_add_dil object, groups the suggested materials by where they go, and collapses cleanly when there is nothing to suggest.
rsElementalCells — element cells with target ranges
What it is: a read-only display — one small card per element, showing the symbol and value on a blue body, with the target range on a grey strip underneath.
When to use it: showing the grade's target chemistry next to where the operator is working. It never collects input and never contributes to the submitted data.
Type name: RsElementalCells
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| json | object | — | The payload to render. Bind it to data the form already has. |
| url | text | — | Fetch the payload from here instead. If both are set, url wins. |
| auth | text | "none" | Auth mode for the url fetch: none, django, or dlms. |
| elementsKey | text | "grade_master__elements" | Where the element list sits inside the payload. |
| valueKey | text | "target" | Which field of each element to show as the big value. |
| minCellWidth | number | 114 | Cell width in pixels — not a column count. |
| emptyText | text | "No elemental data" | Shown when there is nothing to display. |
minCellWidth is a width, not a count. The row fits as many cells of that width as the space allows, then wraps to the next line. So the same field looks right on a phone and on a wide desktop with no configuration change. At the design's 728px width, the default 114 gives exactly six across.
The payload it expects looks like this — each element carrying element, target, min and max:
{ "data": { "main": { "data": {
"grade_master__elements": [
{ "element": "C", "target": "3.5000000", "min": "3.4000000", "max": "3.6000000" },
{ "element": "P", "target": "0.0250000", "min": "", "max": "0.0400000" }
]
}}}}An empty min is normal — that element renders as 0.04 max rather than a range.
10. Chemistry panels
actualChemItem — editable actual chemistry
What it is: a grid where the operator records the actual chemistry, usually filled in from a spectrometer reading and then adjusted by hand.
Where it saves: one field per element, named actual_chem__<element>.
Type name: ActualChemItem
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| header | text | "Actual Chemistry" | Panel title. |
| defaultOpen | yes/no | true | Start expanded. |
| spectroData | object | — | The spectrometer reading that seeds the values. |
| formRef | object | — | The live form, so it can write the actual_chem__* fields. |
| panelColor | text | "transparent" | Panel background. |
| headerPadding | text | — | CSS padding for the header. |
| labelColor, labelSize, labelTracking | text | — | Title styling. |
spectroReadingMtc — actual chemistry from the MTC API
What it is: the same idea, but it fetches heat readings and the target chemistry from the MTC API instead of being fed a reading.
Type name: RsSpectroReadingMtc
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| header | text | "Actual Chemistry" | Panel title. |
| heatIdField | text | — | Name of the form field holding the heat ID. |
| partIdField | text | — | Name of the form field holding the part ID. |
| apiBase | text | "" | Base URL for the MTC API. |
| writePrefix | text | "actual_chem__" | Prefix for the fields it writes. |
| editable | yes/no | true | Let the operator correct the fetched values. |
| defaultOpen | yes/no | true | Start expanded. |
| panelColor | text | "transparent" | Panel background. |
| headerPadding | text | — | CSS padding for the header. |
| formRef | object | — | The live form, so it can read the ID fields and write results. |
heatIdField and partIdField take the name of a field, not a value. The component reads the current value out of the form at fetch time, so it follows whatever the operator selects.
11. Layout components
Both of these draw a collapsible box. They are not interchangeable:
| | formAccordion | collapsibleSection |
| --- | --- | --- |
| Looks like | a grey inner panel | a normal section card |
| Styling | many colour/padding props | none — follows the theme |
| Use for | a sub-group inside a section | a whole section that collapses |
formAccordion
Type name: FormAccordion
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| header | text | "Section" | Title in the header bar. |
| defaultOpen | yes/no | true | Start expanded. |
| children | fields | — | What goes inside. |
| panelColor | text | "#F2F2F2" | Body background. |
| panelPadding | text | — | CSS padding for the body. |
| headerPadding | text | — | CSS padding for the header. |
| headerBackground | text | — | Header background. |
| headerBorderBottom | text | — | CSS border under the header. |
| labelColor, labelSize, labelTracking | text | — | Title styling. |
collapsibleSection
Type name: RsCollapsibleSection
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| header | text | "Section" | Title. Rendered as a real <h4>, so the theme decides its font and colour. |
| defaultOpen | yes/no | true | Start expanded. |
| chevronPosition | text | "right" | "right" for Title ⌄, "left" for ⌄ Title. |
| children | fields | — | What goes inside. |
This one deliberately has no styling props. It uses theme tokens, so it tracks whichever theme the form is using.
12. The Data Table
simpleTable — read-only data table
What it is: a real HTML table for displaying data: specs, reference values, computed results. Column formatting, totals row, sticky header, and optionally rows fetched from an API.
What it is not: the builder's editable Table (the repeater, from add_table). That one is for data entry, where each cell is a form field and the operator adds and removes rows. This one is read-only presentation. They are different things with confusingly similar names.
Type name: RsSimpleTable
Display props
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| header | text | "" | Title above the table. |
| columns | list | 4 sample columns | Column definitions — see below. |
| rows | list | [] | Static rows, as { columnKey: value }. |
| rowHeight | number | 44 | Row height in pixels. |
| headerHeight | number | 40 | Header height in pixels. |
| bordered | yes/no | true | Outer border. |
| cellBordered | yes/no | true | Lines between cells. |
| hover | yes/no | true | Highlight the row under the cursor. |
| striped | yes/no | false | Alternate row shading. |
| stickyHeader | yes/no | false | Header stays put while the body scrolls. |
| maxHeight | number | 0 | Maximum height in pixels before scrolling. 0 means no limit. |
| showTotals | yes/no | false | Add a totals row at the bottom. |
| totalLabel | text | "Total" | Label for that row. |
| emptyText | text | "—" | Shown in a cell with no value. |
Defining columns
"columns": [
{ "key": "element", "label": "Element", "flexGrow": 1, "align": "left" },
{ "key": "qty", "label": "Qty", "width": 90, "align": "right",
"decimals": 2, "suffix": " Kg", "total": "sum" }
]| Key | What it does |
| --- | --- |
| key | Required. Which field of each row this column shows. |
| label | Header text. |
| align | left, center, or right. |
| width | Fixed width in pixels. |
| flexGrow | Share of the leftover space. Use instead of width. |
| decimals | Round to this many decimal places. |
| prefix / suffix | Text before / after the value, e.g. " Kg". |
| className | Extra CSS class on this column's cells. |
| total | sum, avg, min, max, or count — needs showTotals: true. |
Styling is deliberately not configurable here. Instead, every cell gets a stable class you can target from the theme or the node's custom CSS:
.np-dt-col-<key>on body cells.np-dt-head-<key>on header cells
So conditional formatting — highlighting out-of-spec values, for instance — is written as CSS, not as table props.
API-backed rows
Set request and response and the table fetches its own rows.
| Prop | Type | Default | What it does |
| --- | --- | --- | --- |
| request | object | — | What to fetch. See the shape below. |
| response | object | — | How to read the answer. |
| trigger | text | "onload" | When to fetch: onload, button, or field. |
| buttonLabel | text | "Load data" | Button text when trigger is "button". |
| formRef | object | — | The live form, so field-sourced values can be read. |
| value | list | — | The fetched rows, saved with the submission. |
The three triggers:
onload— fetch once when the form opens, and again if the request changes.button— fetch only when the operator clicks the button. Good for slow or expensive endpoints.field— fetch whenever a field the request depends on changes. Good for "show me the spec for the grade the operator just picked".
The request object is the shared request contract — the same one every other API-backed part of a DLMS form uses:
"request": {
"base": "https://example.com",
"url": "/api/c/grades/{grade_id}/",
"method": "GET",
"auth": "django",
"path_params": [{ "key": "grade_id", "source": "field", "field": "prelim__mssg" }],
"query_params": [{ "key": "client", "source": "field", "field": "prelim__client" }],
"headers": { "X-Some-Header": "value" },
"payload": { "template": { "fields": [] }, "bindings": [] }
},
"response": {
"rows_path": "data.results",
"row_map": [{ "source": "element__symbol", "column": "element" }]
}Things worth knowing:
{placeholder}in the URL is filled frompath_params, and only frompath_params. A raw form field name is not a placeholder source — it has to be mapped through a param first. Values are URL-encoded, so a value containing/cannot break out of its path segment.- Query params are always the query string, whatever the HTTP method.
- Missing values block the fetch. If a placeholder or a required field is still empty, the table does not fire. Requesting a literal
{grade_id}would be a 404 at best and the wrong record at worst. - When a dependency is cleared, the table clears. It does not keep showing rows loaded for the previous parent value.
rows_pathis a dot-path into the response —"data.results". Leave it empty if the response is already the array;dataandresultsare also tried automatically.row_maprenames API fields to your column keys. Skip it if they already match.
Fetched rows are saved with the submission
This surprises people, so it is worth being explicit.
When the table fetches rows, it writes them into the form data. The printed report renders from stored submission data, so what the table writes at fill time is what appears on the PDF. It is a snapshot of what the operator actually saw, not a cache to be re-resolved later. Re-fetching at print time could show different numbers than the person who signed the sheet ever saw.
Two guards on that write:
- Static rows are never saved. They already live in the template; copying them into every submission would bloat the payload and mark a pristine form as dirty the moment it opens.
- A failed fetch never saves. On a submitted sheet whose API is down, writing the empty error result would overwrite a good stored snapshot with nothing — exactly what a snapshot exists to prevent.
13. Helpers you can import
If you are writing your own picker override, these are the pieces rsDropdownV2 and rsTagPicker use internally:
import {
createOptionCache,
useOptionCache,
isRealLabel,
buildItemsForSelection,
sameItems,
} from "np-dlms-components";| Helper | What it does |
| --- | --- |
| createOptionCache() | Makes a cache that remembers every option a picker has shown. |
| useOptionCache() | The React hook version — one cache per component instance, so each row of a repeater gets its own. |
| isRealLabel(option) | Tells you whether an option has a genuine label or just an ID standing in for one. |
| buildItemsForSelection(...) | Builds the ${itemsKey} companion array from a selection, resolving labels from the cache. |
| sameItems(a, b) | Compares two companion arrays by value, so you can avoid pointless writes. |
You can also import the plain React components, for use outside a form:
import {
FormAccordion,
CollapsibleSection,
SimpleTable,
SpectroAdditionDilution,
} from "np-dlms-components";14. Common recipes
An API-backed dropdown
Handle onLoadData. It is called with the current search text and a callback — call the callback with your options.
onLoadData: async (searchKeyword, loadCallback, start) => {
const res = await fetch(`/api/materials?q=${encodeURIComponent(searchKeyword)}&start=${start}`);
const json = await res.json();
loadCallback(json.results.map((r) => ({ label: r.name, value: String(r.id) })));
};Two rules that matter:
valuemust be a string. The picker stores strings. A numeric7will not match its option"7", and the selected label will not show.- Always call the callback, even with an empty array. Not calling it leaves the dropdown spinning forever.
One dropdown filtering another (a cascade)
Use rsDropdownV2 for the child, and read the parent's value inside onLoadData:
onLoadData: async (kw, cb, start) => {
const gradeId = form.data.grade;
if (!gradeId) return cb([]); // parent not chosen yet
const res = await fetch(`/api/parts?grade=${gradeId}&q=${kw}&start=${start}`);
cb((await res.json()).results.map(toOption));
};Use V2, not v1. V1 keeps the previous parent's options when the parent changes — that is the bug V2 exists to fix.
Showing the grade spec next to the work
Use rsElementalCells. If the form already loaded the grade master, bind json:
json → return form.data.gradeMaster;If not, set url and leave json unbound. url wins if you set both.
A table that reloads when the operator picks a grade
Set trigger: "field" and reference the grade field from path_params or query_params. The table refetches on every change, and clears itself if the grade is cleared.
15. Troubleshooting
Type 'X' is not found! and the whole section is blank
The type name in the saved form JSON is not in your registered component list.
- Check
Xagainst the component list and register the matching export. - Remember the whole section dies, not just the one field — so the broken field may not be the one you were looking at.
The components look unstyled
You did not import the stylesheet, or you imported it inside a component instead of at the app entry:
import "np-dlms-components/styles.css";You also need rsuite/dist/rsuite.min.css.
The dropdown spins forever
Your onLoadData never called its callback. Call it even on an error or an empty result:
try { cb(await load()); }
catch { cb([]); }The dropdown shows the right options but not the selected label
Your option values are numbers. Make them strings: value: String(r.id).
The tag picker shows raw IDs instead of names in the report
You did not set itemsKey, so no companion list was written. See the companion list.
The Data Table never fetches
Work down this list:
- Is
request.urlset? - Is
trigger"button"? Then nothing happens until the button is clicked. - Is a
{placeholder}unresolved, or a field-sourced param still empty? The table blocks on purpose. Fill the field it depends on. - Is
formRefbound? Without it, field-sourced params cannot read anything. - Check the browser console. The table logs
[Data Table] …explaining why no auth header was sent.
The Data Table gets a 401
request.auth is "none", or the token is missing from storage. The console warning names the reason. If your app has a fetch interceptor that adds Authorization, it wins for the hosts it covers — check that the table's host is one of them.
An element box is the wrong colour
Check the order of the four states in rsSpectrometerReading. Amber (in tolerance) is checked before red on purpose. Grey means no range is configured for that element.
Readings are numbered oddly
They are numbered within each sample_type, and only when that type repeats. Reading / Reading 1 / Reading 2 for a Final + Bath + Bath frame is correct.
Two copies of React, or hooks errors
A peer dependency version does not match. Check npm ls react rsuite and make sure there is exactly one copy of each.
16. Upgrading from 0.1.x
Version 0.2.0 adds components and updates two existing ones. No form JSON needs to change.
New: npUnitInput, npUnitNumber, rsElementalCells, SpectroAdditionDilution.
Updated:
rsSpectrometerReadingnow shows the Addition Dilution Suggestion under each reading, numbers readings within each sample type, and fixes the sample-type chip class. Same type name, so old forms pick it up automatically. SetshowAdditionDilution: falseif you do not want the new block.simpleTablemoved to the shared request contract. It gained path placeholders, structured JSON payloads, proper auth handling, and submission snapshots. If you had a table configured with the old flatapiUrl/paramsprops, re-save it from the builder so the newrequest/responseobjects are emitted.
What you must do:
- Install
react-number-format— it is a new peer dependency, needed bynpUnitNumber. - Register the new components. Register them even if you think no form uses them yet; an unregistered type takes the whole section down.
One behaviour change to be aware of: an API-backed simpleTable now writes its fetched rows into the submitted data. That is intentional — reports render from stored data — but it does mean submissions get larger.
17. For maintainers
Nothing here is written here. Every component is ported from nowpurchase-form-builder (branch origin/staging), from src/components/shared/ plus the matching define block in src/config/. Each file's header names its exact source.
So: fix bugs upstream in the builder, then re-port here. Do not fork. A fix applied only in this package will be silently overwritten by the next port.
The port is mechanical and deliberately kept that way, so diff stays useful:
.jsx→.tsx, with// @ts-nocheckon line 1- CSS filenames lowercased to match this package (
SimpleTable.css→simpleTable.css) .jsdropped from relative imports- the header comment reworded, since this package is the destination those headers point at
Nothing else changes. If a diff against upstream shows anything beyond that list, it is drift and should be fixed.
src/config/vendor/ holds byte-identical copies of shared logic — request, apiUrlTemplate and dropdownMap from form-engine-simplified v0.8.2, plus authHeader, elementalCells, readingLabels and additionDilution from the builder. Never edit these; re-copy them. They are vendored so this package carries no dependency on the private library, and keeping them byte-identical is what lets diff prove there is no drift. Their tests live upstream and are not duplicated here.
Build
npm install
npm run build # tsup → dist/
npm run typecheck # tsc --noEmit
npm run dev # tsup --watchdist/ is the only thing published. Adding a new component means: add the file, add it to src/index.ts, add any new external package to both peerDependencies and tsup.config.ts's external list, and document it here.
