Skip to content

Commit d0aa815

Browse files
authored
[PLAY-3208] MultiLevelSelect: Ability to Customize Single-Select Display (#6598)
**What does this PR do?** A clear and concise description with your runway ticket url. [PLAY-3208](https://runway.powerhrg.com/backlog_items/PLAY-3208?from=active_sprint) Adds customizable display formatting to the React `MultiLevelSelect` kit’s single-select variant. Introduces a new `formatSelectedDisplay` prop that: - Accepts the selected item and hierarchy context. - Provides `ancestors`, ordered from the root through the immediate parent. - Provides `path`, containing the ancestors plus the selected item. - Requires a string return value for display in the input. - Changes only the displayed text; selected data, callbacks, and submitted values remain unchanged. - Works for both interactive selections and initial values supplied through `selectedIds`. - Preserves the selected item’s label when no formatter is provided. **Screenshots:** Screenshots to visualize your addition/change **How to test?** Steps to confirm the desired behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See addition/change #### Checklist: - [ ] **LABELS** Add a label: `enhancement`, `bug`, `improvement`, `new kit`, `deprecated`, or `breaking`. See [Changelog & Labels](https://github.com/powerhome/playbook/wiki/Changelog-&-Labels) for details. - [ ] **DEPLOY** I have added the `milano` label to show I'm ready for a review. - [ ] **TESTS** I have added test coverage to my code. - [ ] **PLAYGROUND** I have added and tested Playground metadata and overrides for all kits and props updated in my code. - [ ] **SEMVER** I have added a `minor`, `major`, or `patch` label for release. - [ ] **RC** I have added an `inactive RC` label if not an active RC.
1 parent 040b5b5 commit d0aa815

12 files changed

Lines changed: 363 additions & 6 deletions

File tree

docs/PLAYGROUND_CONFIG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ The base layer can infer simple things like component name, schema defaults, and
132132
| `groups` | Sections in the props panel. Use camelCase prop names. Props not listed still appear under `Other` unless hidden. |
133133
| `presets` | Feature pills. Each preset can set `props`, `children`, `structureMode`, or `dataPreset`. |
134134
| `conditionals` | Disable controls until requirements are met. |
135+
| `editableFunctionProps` | Render selected function props as editable code fields instead of handler preset dropdowns. |
135136
| `hints` | Info/warning/error banners above the preview. |
136137
| `hiddenProps` | Props to omit from the props panel. |
137138
| `emitEmptyStringProps` | Prop names for which an explicitly empty string is a meaningful value. Emits `prop=""` instead of omitting the prop when enabled and cleared. |

playbook-website/app/javascript/components/Website/src/pages/KitShow/Tabs/Playground/PropControl.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,9 +536,37 @@ const FunctionControl: React.FC<ExtendedPropControlProps> = ({
536536
onChange,
537537
definition,
538538
info,
539+
playgroundConfig,
539540
}) => {
540541
const displayValue = getEffectiveDisplayValue(value, definition, "");
541542
const currentValue = String(displayValue ?? "");
543+
544+
if (playgroundConfig?.editableFunctionProps?.includes(name)) {
545+
const exampleFunction = playgroundConfig.presets?.find(
546+
(preset) => preset.props?.[name],
547+
)?.props?.[name];
548+
549+
return (
550+
<PropControlRow
551+
alignItems="start"
552+
filled={isFilledDisplayValue(currentValue)}
553+
info={info}
554+
label={<PropControlLabel name={name} />}
555+
>
556+
<PropsPanelTextarea
557+
dialogTitle={formatPropName(name)}
558+
exampleFormat={String(exampleFunction ?? currentValue)}
559+
exampleLanguage="jsx"
560+
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
561+
onChange(name, { value: e.target.value, enabled: true });
562+
}}
563+
placeholder={`Enter ${name} function...`}
564+
value={currentValue}
565+
/>
566+
</PropControlRow>
567+
);
568+
}
569+
542570
const functionOptions = FUNCTION_PRESETS.map((preset) => ({
543571
id: preset.value || "none",
544572
label: preset.label,

playbook-website/app/javascript/components/Website/src/pages/KitShow/Tabs/Playground/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,4 +213,6 @@ export interface PlaygroundConfig {
213213
externalImports?: string[];
214214
/** Prop names for which an explicit empty string is a meaningful value and should be emitted as `prop=""` instead of omitted. */
215215
emitEmptyStringProps?: string[];
216+
/** Function props that should use an editable code field instead of handler presets. */
217+
editableFunctionProps?: string[];
216218
}

playbook/app/pb_kits/playbook/pb_multi_level_select/_multi_level_select.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,19 @@ interface MultiLevelSelectComponent extends React.ForwardRefExoticComponent<
4949
Options: typeof MultiLevelSelectOptions;
5050
}
5151

52+
type MultiLevelSelectItem = { [key: string]: any };
53+
type SelectedDisplayContext = {
54+
ancestors: MultiLevelSelectItem[];
55+
path: MultiLevelSelectItem[];
56+
};
57+
5258
type MultiLevelSelectProps = {
5359
aria?: { [key: string]: string };
5460
className?: string;
5561
data?: { [key: string]: string };
5662
disabled?: boolean;
5763
error?: string;
64+
formatSelectedDisplay?: (item: MultiLevelSelectItem, context: SelectedDisplayContext) => string;
5865
htmlOptions?: { [key: string]: string | number | boolean | (() => void) };
5966
id?: string;
6067
inputDisplay?: "pills" | "none";
@@ -105,6 +112,7 @@ const MultiLevelSelect = forwardRef<HTMLInputElement, MultiLevelSelectProps>(
105112
data = {},
106113
disabled = false,
107114
error,
115+
formatSelectedDisplay,
108116
htmlOptions = {},
109117
id,
110118
inputDisplay = "pills",
@@ -175,6 +183,26 @@ const MultiLevelSelect = forwardRef<HTMLInputElement, MultiLevelSelectProps>(
175183
item: [],
176184
});
177185

186+
const getSelectedDisplay = (
187+
item: MultiLevelSelectItem,
188+
data: MultiLevelSelectItem[],
189+
) => {
190+
const ancestors: MultiLevelSelectItem[] = [];
191+
let parentId = item.parent_id;
192+
193+
while (parentId) {
194+
const parent = filterFormattedDataById(data, parentId)[0];
195+
if (!parent) break;
196+
197+
ancestors.unshift(parent);
198+
parentId = parent.parent_id;
199+
}
200+
201+
return formatSelectedDisplay
202+
? formatSelectedDisplay(item, { ancestors, path: [...ancestors, item] })
203+
: item.label;
204+
};
205+
178206
const arrowDownElementId = `arrow_down_${id}`;
179207
const arrowUpElementId = `arrow_up_${id}`;
180208
// Control id for label htmlFor: use suffix to avoid conflict with outer div's id
@@ -771,7 +799,9 @@ const MultiLevelSelect = forwardRef<HTMLInputElement, MultiLevelSelectProps>(
771799
: placeholderText
772800
}
773801
required={required}
774-
value={singleSelectedItem.value || filterItem}
802+
value={singleSelectedItem.item.length
803+
? getSelectedDisplay(singleSelectedItem.item[0], formattedData)
804+
: singleSelectedItem.value || filterItem}
775805
/>
776806
</div>
777807

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
import React from "react";
2+
import MultiLevelSelect from "../_multi_level_select";
3+
4+
const treeData = [
5+
{
6+
label: "HQ",
7+
value: "hQ",
8+
id: "hq1",
9+
},
10+
{
11+
label: "Philadelphia",
12+
value: "philadelphia",
13+
id: "phl1",
14+
expanded: true,
15+
children: [
16+
{
17+
label: "Marketing & Sales PHL",
18+
value: "marketingAndSalesPhl",
19+
id: "marketingPHL1",
20+
},
21+
{
22+
label: "Installation Office PHL",
23+
value: "installationOfficePhl",
24+
id: "installationPHL1",
25+
},
26+
{
27+
label: "Warehouse PHL",
28+
value: "warehousePhl",
29+
id: "warehousePHL1",
30+
},
31+
]
32+
},
33+
{
34+
label: "New Jersey",
35+
value: "newJersey",
36+
id: "nj1",
37+
children: [
38+
{
39+
label: "New Jersey",
40+
value: "newJersey",
41+
id: "nj11",
42+
children: [
43+
{
44+
label: "Marketing & Sales NJ",
45+
value: "marketingAndSalesNj",
46+
id: "marketingNJ1",
47+
},
48+
{
49+
label: "Installation Office NJ",
50+
value: "installationOfficeNj",
51+
id: "installationNJ1",
52+
},
53+
{
54+
label: "Warehouse NJ",
55+
value: "warehouseNj",
56+
id: "warehouseNJ1",
57+
},
58+
],
59+
},
60+
{
61+
label: "Princeton",
62+
value: "princeton",
63+
id: "princeton1",
64+
children: [
65+
{
66+
label: "Marketing & Sales Princeton",
67+
value: "marketingAndSalesPrinceton",
68+
id: "marketingPR1",
69+
},
70+
{
71+
label: "Installation Office Princeton",
72+
value: "installationOfficePrinceton",
73+
id: "installationPR1",
74+
},
75+
{
76+
label: "Warehouse Princeton",
77+
value: "warehousePrinceton",
78+
id: "warehousePR1",
79+
},
80+
]
81+
},
82+
]
83+
},
84+
];
85+
86+
const treeData2 = [
87+
{
88+
label: "HQ",
89+
value: "hQ",
90+
id: "hq2",
91+
},
92+
{
93+
label: "Philadelphia",
94+
value: "philadelphia",
95+
id: "phl2",
96+
expanded: true,
97+
children: [
98+
{
99+
label: "Marketing & Sales PHL",
100+
value: "marketingAndSalesPhl",
101+
id: "marketingPHL2",
102+
},
103+
{
104+
label: "Installation Office PHL",
105+
value: "installationOfficePhl",
106+
id: "installationPHL2",
107+
},
108+
{
109+
label: "Warehouse PHL",
110+
value: "warehousePhl",
111+
id: "warehousePHL2",
112+
},
113+
]
114+
},
115+
{
116+
label: "New Jersey",
117+
value: "newJersey",
118+
id: "nj2",
119+
children: [
120+
{
121+
label: "New Jersey",
122+
value: "newJersey",
123+
id: "nj22",
124+
children: [
125+
{
126+
label: "Marketing & Sales NJ",
127+
value: "marketingAndSalesNj",
128+
id: "marketingNJ2",
129+
},
130+
{
131+
label: "Installation Office NJ",
132+
value: "installationOfficeNj",
133+
id: "installationNJ2",
134+
},
135+
{
136+
label: "Warehouse NJ",
137+
value: "warehouseNj",
138+
id: "warehouseNJ2",
139+
},
140+
],
141+
},
142+
{
143+
label: "Princeton",
144+
value: "princeton",
145+
id: "princeton2",
146+
children: [
147+
{
148+
label: "Marketing & Sales Princeton",
149+
value: "marketingAndSalesPrinceton",
150+
id: "marketingPR2",
151+
},
152+
{
153+
label: "Installation Office Princeton",
154+
value: "installationOfficePrinceton",
155+
id: "installationPR2",
156+
},
157+
{
158+
label: "Warehouse Princeton",
159+
value: "warehousePrinceton",
160+
id: "warehousePR2",
161+
},
162+
]
163+
},
164+
]
165+
},
166+
];
167+
168+
const MultiLevelSelectFormatSelectedDisplay = (props) => (
169+
<>
170+
<MultiLevelSelect
171+
formatSelectedDisplay={(_, { path }) =>
172+
path.map(({ label }) => label).join(" / ")
173+
}
174+
treeData={treeData}
175+
variant="single"
176+
{...props}
177+
/>
178+
<br />
179+
<MultiLevelSelect
180+
formatSelectedDisplay={(item, { ancestors }) => {
181+
const parent = ancestors[ancestors.length - 1];
182+
return parent ? `${item.label} (${parent.label})` : item.label;
183+
}}
184+
treeData={treeData2}
185+
variant="single"
186+
{...props}
187+
/>
188+
</>
189+
);
190+
191+
export default MultiLevelSelectFormatSelectedDisplay;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Use `formatSelectedDisplay` to customize the string shown in the input after a single-select option is chosen. The formatter receives the selected item and a context object containing `ancestors` (ordered from the root through the immediate parent) and `path` (the ancestors plus the selected item). This changes only the displayed string; selection callbacks and form values remain unchanged.

playbook/app/pb_kits/playbook/pb_multi_level_select/docs/_playground.json

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
"name",
3232
"placeholder",
3333
"selectedIds",
34-
"inputName"
34+
"inputName",
35+
"formatSelectedDisplay"
3536
]
3637
},
3738
{
@@ -48,7 +49,8 @@
4849
"props": [
4950
"inputDisplay",
5051
"variant",
51-
"wrapped"
52+
"wrapped",
53+
"pillColor"
5254
]
5355
},
5456
{
@@ -92,6 +94,20 @@
9294
"onSelect": "(selectedNode) => console.log('Selected Node', selectedNode)"
9395
}
9496
},
97+
{
98+
"name": "Custom Single Select Display",
99+
"structureMode": "standard",
100+
"props": {
101+
"variant": "single",
102+
"label": "Pick one team",
103+
"placeholder": "Choose...",
104+
"selectedIds": [
105+
"initiative1"
106+
],
107+
"formatSelectedDisplay": "(_, { path }) => path.map(({ label }) => label).join(' / ')",
108+
"onSelect": "(selectedNode) => console.log('Selected Node', selectedNode)"
109+
}
110+
},
95111
{
96112
"name": "Selected IDs",
97113
"structureMode": "standard",
@@ -246,6 +262,11 @@
246262
}
247263
],
248264
"conditionals": {
265+
"formatSelectedDisplay": {
266+
"requires": {
267+
"variant": "single"
268+
}
269+
},
249270
"requiredIndicator": {
250271
"requires": "label"
251272
}
@@ -266,6 +287,11 @@
266287
"message": "Single Select uses radios and should receive at most one selectedIds value because only one node can be selected.",
267288
"type": "info"
268289
},
290+
"custom_single_select_display": {
291+
"presetName": "Custom Single Select Display",
292+
"message": "formatSelectedDisplay receives the selected item plus ancestors and path context, and must return the string shown in the single-select input. Edit the function in Content to choose which hierarchy details to display.",
293+
"type": "info"
294+
},
269295
"selected_ids": {
270296
"presetName": "Selected IDs",
271297
"message": "selectedIds checks matching treeData node ids on load. In multi mode, multiple ids can be passed.",
@@ -319,6 +345,9 @@
319345
"editable": false,
320346
"default": ""
321347
},
348+
"editableFunctionProps": [
349+
"formatSelectedDisplay"
350+
],
322351
"customProps": {
323352
"treeData": {
324353
"type": "array",

0 commit comments

Comments
 (0)