graph TD
subgraph BID["select-bid — one monolithic matrix module"]
MT["MatrixTable\nentry point"]
subgraph LAYOUT["Table structure"]
TH["TableHeader"]
SG["SortableGroup"]
TB["TableBody"]
SC["SortableCategory"]
SI["SortableItem"]
CR["CellRow / TotalRow"]
end
subgraph EMBEDDED["Bid concepts hardcoded in view logic"]
ST{{"status === 'Draft'\nstatus === 'Confirmation'\n(scattered across 5 files)"}}
TK{{"translate('negotiations.setup\n.common.tabs.lots.*')\n(8 call sites in rendering code)"}}
SER{{"withoutDisabledValues()\nbaseline · info · entry_bid\nnegotiation PATCH wrapper\n(LotsController contract)"}}
end
subgraph INSEP["Bid-specific rendering — no separation point"]
CELL["Editable cells\n(bid input formats)"]
RULES["Permission-gating\n(bid rules wrapper)"]
PROG["Import progress indicator"]
WARN["Warning messages"]
ACTS["Item action icons"]
end
end
MT --> LAYOUT
MT --> EMBEDDED
MT --> INSEP
SC --> ST
SG --> ST
SI --> ST
TH --> TK
TB --> TK
SC --> TK
MT --> SER
The matrix table was a closed system. The table layout components (SortableCategory, SortableGroup, SortableItem) reached directly into bid workflow vocabulary, branching on the string "Draft" to decide whether to show delete icons and on "Confirmation" to decide whether to show add buttons. Every user-visible label was fetched via translate("negotiations.setup.common.tabs.lots.*") — a namespace that only exists in the bid app's locale files. The serialization utility that prepared data for the Rails PATCH endpoint knew about baseline, info, and entry_bid value-type keys and the negotiation wrapper the controller expected. None of these concerns had seams between them. Extracting the layout engine without also pulling out the bid logic was not possible; a second consumer would have had to copy the entire module.
graph LR
subgraph JB["@justicebid/jb-ui · shared package"]
direction TB
SCAFFOLD["Table scaffold\nTableHeader · TableBody · SortableGroup\nSortableCategory · SortableItem · CellRow · TotalRow"]
INFRA["Column resize · DnD reordering\nScrollbar sync · Expand/collapse state\nWarning dialog"]
CTX["MatrixTableContext\ndata · actions · slots · labels"]
SLOT_DEF["8 typed slot render-props\ncell · itemActions · itemProperties\nitemPropertyHeaders · warningMessage\nrulesWrapper · progressIndicator\naddPropertyCell"]
TYPES["MatrixWorkflowState\n canModifyStructure: boolean\n isConfirmation: boolean\n──────────────────────\nMatrixTableLabels\n all display strings typed"]
end
subgraph BID["select-bid · consumer"]
direction TB
CONT["MatrixTableContainer"]
ADAPT["Adapters\nmapBidNegotiationToWorkflowState\n status → canModifyStructure / isConfirmation\nmapBidMatrixToMatrixData\n pass-through (types already aligned)"]
LBLS["labels bundle\nI18n.t() per key"]
SLOTS["8 slot implementations\nBidCell · BidItemActions · BidRulesWrapper\nBidWarningMessage · BidProgressIndicator\nBidItemProperties · BidAddPropertyCell\nBidItemPropertyHeaders"]
SER["withoutDisabledValues()\nstrips disabled cells\nshapes Rails PATCH body"]
end
CONT --> ADAPT
CONT --> LBLS
CONT --> SLOTS
CONT -- MatrixTableProps --> JB
JB -. slot calls .-> SLOTS
The extraction introduced a hard boundary: the shared layer owns the layout engine and its infrastructure but knows nothing about bid domain concepts. Every place where the old code branched on a status string is replaced by a semantic boolean (canModifyStructure, isConfirmation) that the host computes and passes in; the shared view never sees the raw string "Draft". Every hardcoded locale key is replaced by a field on the MatrixTableLabels type — the shared view reads labels.addGroup, the bid container populates it from I18n.t(...). The eight places where bid-specific rendering needed to happen are exposed as typed render-prop slots. The bid app implements each slot with its own components; the shared layer only calls them by name.
The slot interface is not just a type boundary — it changes the control flow. The shared view hands execution to the host at specific points and gets back rendered output. This sequence shows how a single item row negotiates permission state before deciding which icons to display:
sequenceDiagram
participant V as MatrixTableView (shared)
participant RW as BidRulesWrapper (bid slot)
participant MR as ManualUpdateRulesWrapper (bid permission system)
participant IA as BidItemActions (bid slot)
V ->> RW: slots.rulesWrapper({ currentStepName: "lots", children })
RW ->> MR: delegates to bid permission system
MR -->> RW: { disabledByRule: boolean, wrap: fn }
RW -->> V: renders children({ disabledByRule, wrap })
V ->> IA: slots.itemActions({ canModifyStructure, canDelete,<br/>disabledByRule, wrap, ... })
Note over IA: edit icon — shown when !readOnlyTitles<br/>trash icon — shown when canModifyStructure<br/> && !disabledByRule && !readOnlyTitles
IA -->> V: rendered action icons
The shared view never imports ManualUpdateRulesWrapper, never knows what disabledByRule means, and never decides which icons to render. It only knows that rulesWrapper exists, that it produces a render-prop, and that it should pass the result through to itemActions.
graph LR
subgraph SHARED["@justicebid/jb-ui — stays in shared"]
direction TB
L1["Table layout engine\nheader · body · group · category · item · total rows"]
L2["Column width tracking & resize handles"]
L3["Drag-and-drop contexts\n(groups horizontal · categories/items vertical)"]
L4["Sticky header · scrollbar sync"]
L5["Expand/collapse state (ItemExpandProvider)"]
L6["Warning dialog (WarningProvider)"]
L7["MatrixTableContext\nslot interface · data shape · actions"]
L8["MatrixWorkflowState\ncanModifyStructure · isConfirmation · sealed\npublished_or_farther"]
L9["MatrixTableLabels\nall 14 display strings typed"]
L10["Storybook fixtures & stub slot implementations\nfor isolated component development"]
end
subgraph CONSUMER["select-bid — stays in bid app"]
direction TB
R1["BidCell\n(editable cell types: numeric, text, etc.)"]
R2["BidItemActions\n(pencil · trash · edit-properties modal)"]
R3["BidRulesWrapper\n(ManualUpdateRulesWrapper integration)"]
R4["BidWarningMessage\n(bid-specific error text)"]
R5["BidProgressIndicator\n(import progress bar)"]
R6["BidItemProperties · BidItemPropertyHeaders\n(property column cells & headers)"]
R7["BidAddPropertyCell\n(add description column button/modal)"]
R8["mapBidNegotiationToWorkflowState\nstatus string → semantic booleans"]
R9["mapBidMatrixToMatrixData\n(pass-through — types are already aligned)"]
R10["withoutDisabledValues()\nstrips disabled cells\nbaseline/info/entry_bid PATCH shaping\nnegotiation wrapper key"]
R11["labels bundle assembly\nI18n.t() for each MatrixTableLabels field"]
R12["MatrixTableContainer\nwires API data · adapters · slots · labels"]
end
The shared layer holds everything a second consumer would reuse unchanged: the layout, the interaction patterns, the type contracts. The bid layer holds everything that encodes a Select-specific decision: how cells are edited, which icons appear, how permissions are checked, how data is serialised for the Rails backend, and what the user-facing strings say. Nothing in the shared layer imports from select-bid; the dependency arrow points in one direction only.
This unlocks straightforward reuse: a Rate Review consumer could wire up the same shared table scaffold with its own slot implementations and a labels bundle, without touching any bid-specific code.