- Published on
SPFx Command Set — Adding Custom Toolbar Buttons
A List View Command Set adds buttons to the SharePoint list toolbar and right-click context menu. The button can be always visible, appear only when items are selected, or change its label based on how many items are selected.
This article builds a practical bulk-edit command set — a "Edit Selected" button that opens a panel form allowing the user to update a field on all selected items at once.
📦 Scaffold the Extension
yo @microsoft/sharepoint
When prompted:
- What type of client-side component? → Extension
- Which type of client-side extension? → ListView Command Set
- What is your Command Set name? →
BulkEdit - Framework? → No JavaScript framework (we will add React manually)
⚙️ Command Set Lifecycle
| Method | When it fires | What to do |
|---|---|---|
onInit() | Once on load | Set up PnPjs, initialise state |
onListViewUpdated(event) | Every time selection changes | Show/hide/enable/disable buttons |
onExecute(event) | When a button is clicked | Perform the action |
The onListViewUpdated method is where you make commands context-sensitive — checking event.selectedRows to enable a button only when the right items are selected.
🧩 Command Set Manifest
The manifest declares the buttons (called "commands") and their default labels. Located at src/extensions/bulkEdit/BulkEditCommandSet.manifest.json:
{
"id": "your-component-id",
"alias": "BulkEditCommandSet",
"componentType": "Extension",
"extensionType": "ListViewCommandSet",
"version": "*",
"manifestVersion": 2,
"items": {
"BULK_EDIT_STATUS": {
"title": { "default": "Edit Status" },
"iconImageUrl": "https://spoprod-a.akamaihd.net/files/fabric/assets/item-types/16/genericfile.png",
"type": "command"
}
}
}
🧩 Command Set Class
src/extensions/bulkEdit/BulkEditCommandSet.ts
import { override } from '@microsoft/decorators';
import { Log } from '@microsoft/sp-core-library';
import {
BaseListViewCommandSet,
Command,
IListViewCommandSetExecuteEventParameters,
IListViewCommandSetListViewUpdatedParameters,
RowAccessor
} from '@microsoft/sp-listview-extensibility';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { FluentProvider, webLightTheme } from '@fluentui/react-components';
import { spfi, SPFx, SPFI } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import BulkEditPanel from './components/BulkEditPanel';
export default class BulkEditCommandSet
extends BaseListViewCommandSet<{}> {
private _sp: SPFI;
private _panelContainer: HTMLDivElement;
@override
public onInit(): Promise<void> {
this._sp = spfi().using(SPFx(this.context));
// Create a persistent container for the panel outside the list view DOM
this._panelContainer = document.createElement('div');
document.body.appendChild(this._panelContainer);
return Promise.resolve();
}
@override
public onListViewUpdated(event: IListViewCommandSetListViewUpdatedParameters): void {
const bulkEditCommand: Command = this.tryGetCommand('BULK_EDIT_STATUS');
if (bulkEditCommand) {
// Show button only when 1 or more items are selected
const count = event.selectedRows.length;
bulkEditCommand.visible = count > 0;
bulkEditCommand.title = count === 1
? 'Edit Status'
: `Edit Status (${count} items)`;
}
}
@override
public onExecute(event: IListViewCommandSetExecuteEventParameters): void {
if (event.itemId === 'BULK_EDIT_STATUS') {
const selectedIds = event.selectedRows.map(
(row: RowAccessor) => row.getValueByName('ID') as string
);
this._openBulkEditPanel(selectedIds);
}
}
private _openBulkEditPanel(selectedIds: string[]): void {
ReactDOM.render(
React.createElement(
FluentProvider,
{ theme: webLightTheme },
React.createElement(BulkEditPanel, {
sp: this._sp,
listName: this.context.pageContext.list?.title ?? '',
selectedIds,
onDismiss: () => this._closeBulkEditPanel(),
onSaved: () => {
this._closeBulkEditPanel();
// Reload the list view to reflect changes
window.location.reload();
}
})
),
this._panelContainer
);
}
private _closeBulkEditPanel(): void {
ReactDOM.unmountComponentAtNode(this._panelContainer);
}
public onDispose(): void {
ReactDOM.unmountComponentAtNode(this._panelContainer);
document.body.removeChild(this._panelContainer);
super.onDispose();
}
}
🧩 Bulk Edit Panel Component
src/extensions/bulkEdit/components/BulkEditPanel.tsx
import * as React from 'react';
import {
DrawerBody,
DrawerHeader,
DrawerHeaderTitle,
InlineDrawer,
Button,
Field,
Dropdown,
Option,
MessageBar,
Spinner,
makeStyles,
tokens
} from '@fluentui/react-components';
import { DismissRegular } from '@fluentui/react-icons';
import { SPFI } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
const useStyles = makeStyles({
actions: {
display: 'flex',
gap: tokens.spacingHorizontalS,
paddingTop: tokens.spacingVerticalL
}
});
interface IBulkEditPanelProps {
sp: SPFI;
listName: string;
selectedIds: string[];
onDismiss: () => void;
onSaved: () => void;
}
const STATUS_OPTIONS = ['Not Started', 'In Progress', 'Blocked', 'Completed'];
const BulkEditPanel: React.FC<IBulkEditPanelProps> = ({
sp, listName, selectedIds, onDismiss, onSaved
}) => {
const styles = useStyles();
const [newStatus, setNewStatus] = React.useState('');
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const handleSave = async () => {
if (!newStatus) return;
setSaving(true);
setError(null);
try {
// Update all selected items in sequence
for (const id of selectedIds) {
await sp.web.lists
.getByTitle(listName)
.items
.getById(parseInt(id))
.update({ Status: newStatus });
}
onSaved();
} catch (err) {
setError('Failed to update some items. Please try again.');
setSaving(false);
}
};
return (
<InlineDrawer open={true} position="end" size="small">
<DrawerHeader>
<DrawerHeaderTitle
action={
<Button
appearance="subtle"
icon={<DismissRegular />}
onClick={onDismiss}
/>
}
>
Edit Status — {selectedIds.length} item{selectedIds.length !== 1 ? 's' : ''}
</DrawerHeaderTitle>
</DrawerHeader>
<DrawerBody>
{error && (
<MessageBar intent="error">{error}</MessageBar>
)}
<Field label="New Status" required>
<Dropdown
placeholder="Select a status..."
value={newStatus}
onOptionSelect={(_, data) => setNewStatus(data.optionValue ?? '')}
>
{STATUS_OPTIONS.map(opt => (
<Option key={opt} value={opt}>{opt}</Option>
))}
</Dropdown>
</Field>
<div className={styles.actions}>
<Button
appearance="primary"
onClick={handleSave}
disabled={!newStatus || saving}
>
{saving ? <Spinner size="tiny" /> : `Update ${selectedIds.length} item${selectedIds.length !== 1 ? 's' : ''}`}
</Button>
<Button onClick={onDismiss} disabled={saving}>Cancel</Button>
</div>
</DrawerBody>
</InlineDrawer>
);
};
export default BulkEditPanel;
🚀 Register the Command Set on a List
Via PnP PowerShell:
Add-PnPCustomAction `
-Title "BulkEditStatus" `
-Name "BulkEditStatus" `
-Location "ClientSideExtension.ListViewCommandSet.CommandBar" `
-ClientSideComponentId "your-component-id-from-manifest.json" `
-ClientSideComponentProperties '{}' `
-List "Projects"
The Location controls where the button appears:
CommandBar— main toolbar at the top of the list viewContextMenu— right-click context menu on item rowsCommandBar,ContextMenu— both (requires separateAdd-PnPCustomActioncalls)
🚀 Deploy
npm run build
npm run bundle -- --ship
npm run package-solution -- --ship
📂 GitHub Source
View full SPFx project on GitHub:SPFx command set — bulk edit selected list items with panel form
✅ Summary
onListViewUpdatedfires on every selection change — use it to setcommand.visibleandcommand.titlebased onevent.selectedRows.length.- Render panels into a
<div>appended todocument.body— not into the list view DOM, which SharePoint manages and may replace. - Unmount and remove the panel container in
onDisposeto avoid memory leaks. - Use
row.getValueByName('ID')to get the list item ID from the selected rows. - Register on
CommandBarfor the toolbar,ContextMenufor right-click, or both via separateAdd-PnPCustomActioncalls.
Happy coding!
Author
Ravichandran@Hi_Ravichandran
