Skip to content

feat(component): Field builder component #777

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
section: Component groups
subsection: Helpers
id: Field Builder
source: react
propComponents: ['FieldBuilder']
---

import { FunctionComponent, useState } from 'react';
import { FieldBuilder } from '@patternfly/react-component-groups/dist/dynamic/FieldBuilder';
import { MinusCircleIcon } from '@patternfly/react-icons';


## Examples

### Basic Field Builder

This is a basic field builder!

```js file="./FieldBuilderExample.tsx"

```

### Field Builder Select

This is a field builder with Select components!

```js file="./FieldBuilderSelectExample.tsx"

```
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React, { useState } from 'react';
import {
Form,
TextInput,
} from '@patternfly/react-core';
import { FieldBuilder } from '@patternfly/react-component-groups/dist/dynamic/FieldBuilder';

interface Contact {
name: string;
email: string;
}

export const FieldBuilderExample: React.FunctionComponent = () => {
const [ contacts, setContacts ] = useState<Contact[]>([
{ name: '', email: '' }
]);

// Handle adding a new contact row
const handleAddContact = (event: React.MouseEvent) => {
// eslint-disable-next-line no-console
console.log('Add button clicked:', event.currentTarget);
const newContacts = [ ...contacts, { name: '', email: '' } ];
setContacts(newContacts);
};

// Handle removing a contact row
const handleRemoveContact = (event: React.MouseEvent, index: number) => {
// eslint-disable-next-line no-console
console.log('Remove button clicked:', event.currentTarget, 'for index:', index);
const newContacts = contacts.filter((_, i) => i !== index);
setContacts(newContacts);
};

// Handle updating contact data
const handleContactChange = (index: number, field: keyof Contact, value: string) => {
const updatedContacts = [ ...contacts ];
updatedContacts[index] = { ...updatedContacts[index], [field]: value };
setContacts(updatedContacts);
};

// Custom announcement for adding rows
const customAddAnnouncement = (rowNumber: number, rowGroupLabelPrefix: string) => `New ${rowGroupLabelPrefix.toLowerCase()} ${rowNumber} added.`;

// Custom announcement for removing rows
const customRemoveAnnouncement = (rowNumber: number, rowGroupLabelPrefix: string) => {
const removedIndex = rowNumber - 1;
const removedContact = contacts[removedIndex];
if (removedContact?.name) {
return `Removed ${rowGroupLabelPrefix.toLowerCase()} ${removedContact.name}.`;
}
return `${rowGroupLabelPrefix} ${rowNumber} removed.`;
};

// Custom aria-label for remove buttons
const customRemoveAriaLabel = (rowNumber: number, rowGroupLabelPrefix: string) => {
const contactIndex = rowNumber - 1;
const contact = contacts[contactIndex];
if (contact?.name) {
return `Remove ${rowGroupLabelPrefix.toLowerCase()} ${contact.name}`;
}
return `Remove ${rowGroupLabelPrefix.toLowerCase()} in row ${rowNumber}`;
};

return (
<Form>
<FieldBuilder
label=""
labelInfo=""
isRequired
firstColumnLabel="Name"
secondColumnLabel="Email"
rowCount={contacts.length}
onAddRow={handleAddContact}
onRemoveRow={handleRemoveContact}
onAddRowAnnouncement={customAddAnnouncement}
onRemoveRowAnnouncement={customRemoveAnnouncement}
removeButtonAriaLabel={customRemoveAriaLabel}
addButtonContent="Add contact"
>
{({ focusRef, firstColumnAriaLabel, secondColumnAriaLabel }, index) => [
<TextInput
key="name"
ref={focusRef}
type="text"
value={contacts[index]?.name || ''}
placeholder="Enter full name"
onChange={(_event, value) => handleContactChange(index, 'name', value)}
aria-label={firstColumnAriaLabel}
isRequired
/>,
<TextInput
key="email"
type="email"
value={contacts[index]?.email || ''}
placeholder="[email protected]"
onChange={(_event, value) => handleContactChange(index, 'email', value)}
aria-label={secondColumnAriaLabel}
isRequired
/>
]}
</FieldBuilder>
</Form>
);
};

export default FieldBuilderExample;
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
import React, { useState } from 'react';
import {
Form,
Select,
SelectOption,
SelectList,
MenuToggle,
MenuToggleElement,
} from '@patternfly/react-core';
import { FieldBuilder } from '@patternfly/react-component-groups/dist/dynamic/FieldBuilder';

interface TeamMember {
department: string;
role: string;
}

export const FieldBuilderSelectExample: React.FunctionComponent = () => {
const [ teamMembers, setTeamMembers ] = useState<TeamMember[]>([
{ department: '', role: '' }
]);

// State for managing which select dropdowns are open
const [ departmentOpenStates, setDepartmentOpenStates ] = useState<boolean[]>([ false ]);
const [ roleOpenStates, setRoleOpenStates ] = useState<boolean[]>([ false ]);

// Handle adding a new team member row
const handleAddTeamMember = (event: React.MouseEvent) => {
// eslint-disable-next-line no-console
console.log('Add button clicked:', event.currentTarget);
const newTeamMembers = [ ...teamMembers, { department: '', role: '' } ];
setTeamMembers(newTeamMembers);
// Add new open states for the selects
setDepartmentOpenStates([ ...departmentOpenStates, false ]);
setRoleOpenStates([ ...roleOpenStates, false ]);
};

// Handle removing a team member row
const handleRemoveTeamMember = (event: React.MouseEvent, index: number) => {
// eslint-disable-next-line no-console
console.log('Remove button clicked:', event.currentTarget, 'for index:', index);
const newTeamMembers = teamMembers.filter((_, i) => i !== index);
setTeamMembers(newTeamMembers);
// Remove corresponding open states
setDepartmentOpenStates(departmentOpenStates.filter((_, i) => i !== index));
setRoleOpenStates(roleOpenStates.filter((_, i) => i !== index));
};

// Handle updating team member data
const handleTeamMemberChange = (index: number, field: keyof TeamMember, value: string) => {
const updatedTeamMembers = [ ...teamMembers ];
updatedTeamMembers[index] = { ...updatedTeamMembers[index], [field]: value };
setTeamMembers(updatedTeamMembers);
};

// Handle department select open/close
const handleDepartmentToggle = (index: number) => {
const newOpenStates = [ ...departmentOpenStates ];
newOpenStates[index] = !newOpenStates[index];
setDepartmentOpenStates(newOpenStates);
};

// Handle role select open/close
const handleRoleToggle = (index: number) => {
const newOpenStates = [ ...roleOpenStates ];
newOpenStates[index] = !newOpenStates[index];
setRoleOpenStates(newOpenStates);
};

// Handle department selection
const handleDepartmentSelect = (index: number, _event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
handleTeamMemberChange(index, 'department', value as string);
const newOpenStates = [ ...departmentOpenStates ];
newOpenStates[index] = false;
setDepartmentOpenStates(newOpenStates);
};

// Handle role selection
const handleRoleSelect = (index: number, _event: React.MouseEvent<Element, MouseEvent> | undefined, value: string | number | undefined) => {
handleTeamMemberChange(index, 'role', value as string);
const newOpenStates = [ ...roleOpenStates ];
newOpenStates[index] = false;
setRoleOpenStates(newOpenStates);
};

// Custom announcement for adding rows
const customAddAnnouncement = (rowNumber: number, rowGroupLabelPrefix: string) => `New ${rowGroupLabelPrefix.toLowerCase()} ${rowNumber} added.`;

// Custom announcement for removing rows
const customRemoveAnnouncement = (rowNumber: number, rowGroupLabelPrefix: string) => {
const removedIndex = rowNumber - 1;
const removedTeamMember = teamMembers[removedIndex];
if (removedTeamMember?.department && removedTeamMember?.role) {
return `Removed ${rowGroupLabelPrefix.toLowerCase()} ${removedTeamMember.role} from ${removedTeamMember.department}.`;
}
return `${rowGroupLabelPrefix} ${rowNumber} removed.`;
};

// Custom aria-label for remove buttons
const customRemoveAriaLabel = (rowNumber: number, rowGroupLabelPrefix: string) => {
const teamMemberIndex = rowNumber - 1;
const teamMember = teamMembers[teamMemberIndex];
if (teamMember?.department && teamMember?.role) {
return `Remove ${rowGroupLabelPrefix.toLowerCase()} ${teamMember.role} from ${teamMember.department}`;
}
return `Remove ${rowGroupLabelPrefix.toLowerCase()} in row ${rowNumber}`;
};

const departmentOptions = [
{ label: 'Choose a department', value: '', disabled: true },
{ label: 'Engineering', value: 'engineering' },
{ label: 'Marketing', value: 'marketing' },
{ label: 'Sales', value: 'sales' },
{ label: 'Human Resources', value: 'hr' },
{ label: 'Finance', value: 'finance' }
];

const roleOptions = [
{ label: 'Choose a role', value: '', disabled: true },
{ label: 'Manager', value: 'manager' },
{ label: 'Senior', value: 'senior' },
{ label: 'Junior', value: 'junior' },
{ label: 'Intern', value: 'intern' },
{ label: 'Contractor', value: 'contractor' }
];

return (
<Form>
<FieldBuilder
label=""
labelInfo=""
isRequired
firstColumnLabel="Department"
secondColumnLabel="Role"
rowCount={teamMembers.length}
onAddRow={handleAddTeamMember}
onRemoveRow={handleRemoveTeamMember}
onAddRowAnnouncement={customAddAnnouncement}
onRemoveRowAnnouncement={customRemoveAnnouncement}
removeButtonAriaLabel={customRemoveAriaLabel}
rowGroupLabelPrefix="Team member"
addButtonContent="Add team member"
>
{({ focusRef, firstColumnAriaLabel, secondColumnAriaLabel }, index) => [
<Select
key="department"
id={`department-select-${index}`}
isOpen={departmentOpenStates[index] || false}
selected={teamMembers[index]?.department || ''}
onSelect={(event, value) => handleDepartmentSelect(index, event, value)}
onOpenChange={(isOpen) => {
const newOpenStates = [ ...departmentOpenStates ];
newOpenStates[index] = isOpen;
setDepartmentOpenStates(newOpenStates);
}}
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={(element) => {
// Handle both the toggle ref and focus ref
if (typeof toggleRef === 'function') {
toggleRef(element);
} else if (toggleRef && 'current' in toggleRef && toggleRef.current !== element) {
(toggleRef as React.MutableRefObject<MenuToggleElement | null>).current = element;
}
focusRef(element);
}}
onClick={() => handleDepartmentToggle(index)}
isExpanded={departmentOpenStates[index] || false}
aria-label={firstColumnAriaLabel}
style={{ width: '100%' }}
>
{teamMembers[index]?.department ?
departmentOptions.find(opt => opt.value === teamMembers[index]?.department)?.label || 'Choose a department'
: 'Choose a department'}
</MenuToggle>
)}
shouldFocusToggleOnSelect
>
<SelectList>
{departmentOptions.map((option, optionIndex) => (
<SelectOption
key={optionIndex}
value={option.value}
isDisabled={option.disabled}
>
{option.label}
</SelectOption>
))}
</SelectList>
</Select>,
<Select
key="role"
id={`role-select-${index}`}
isOpen={roleOpenStates[index] || false}
selected={teamMembers[index]?.role || ''}
onSelect={(event, value) => handleRoleSelect(index, event, value)}
onOpenChange={(isOpen) => {
const newOpenStates = [ ...roleOpenStates ];
newOpenStates[index] = isOpen;
setRoleOpenStates(newOpenStates);
}}
toggle={(toggleRef: React.Ref<MenuToggleElement>) => (
<MenuToggle
ref={toggleRef}
onClick={() => handleRoleToggle(index)}
isExpanded={roleOpenStates[index] || false}
aria-label={secondColumnAriaLabel}
style={{ width: '100%' }}
>
{teamMembers[index]?.role ?
roleOptions.find(opt => opt.value === teamMembers[index]?.role)?.label || 'Choose a role'
: 'Choose a role'}
</MenuToggle>
)}
shouldFocusToggleOnSelect
>
<SelectList>
{roleOptions.map((option, optionIndex) => (
<SelectOption
key={optionIndex}
value={option.value}
isDisabled={option.disabled}
>
{option.label}
</SelectOption>
))}
</SelectList>
</Select>
]}
</FieldBuilder>
</Form>
);
};

export default FieldBuilderSelectExample;
Loading
Loading