Skip to content

Commit f1e3367

Browse files
authored
Merge pull request #2058 from pnp/copilot/fix-etag-errors-attachments
Fix ETag conflict errors when using ListItemAttachments with DynamicForm
2 parents 8805050 + 54f515f commit f1e3367

File tree

8 files changed

+163
-6
lines changed

8 files changed

+163
-6
lines changed

docs/documentation/docs/controls/DynamicForm.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,56 @@ The `DynamicForm` can be configured with the following properties:
120120
| thumbnailFieldButtons | IStyle | styles for button when field type is 'Thumbnail' |
121121
| selectedFileContainer | IStyle | styles for File Selection Control |
122122

123+
## Public Methods
124+
125+
The `DynamicForm` control exposes the following public methods that can be called using a ref:
126+
127+
### updateETag(itemData: any): void
128+
129+
Updates the ETag stored in the component's state. This is useful when the list item has been modified externally (e.g., by adding/removing attachments using the ListItemAttachments control) and you need to update the ETag to prevent 412 conflict errors on save.
130+
131+
**Parameters:**
132+
- `itemData` - The updated item data containing the new ETag (typically from SharePoint REST API response with `odata.etag` property)
133+
134+
**Example:**
135+
136+
```TypeScript
137+
import * as React from 'react';
138+
import { DynamicForm } from '@pnp/spfx-controls-react/lib/DynamicForm';
139+
import { ListItemAttachments } from '@pnp/spfx-controls-react/lib/ListItemAttachments';
140+
141+
export class MyComponent extends React.Component {
142+
private dynamicFormRef = React.createRef<DynamicForm>();
143+
144+
private onAttachmentChange = (itemData: any): void => {
145+
// Update the DynamicForm's ETag when attachments are modified
146+
if (this.dynamicFormRef.current) {
147+
this.dynamicFormRef.current.updateETag(itemData);
148+
}
149+
}
150+
151+
public render() {
152+
return (
153+
<div>
154+
<ListItemAttachments
155+
listId={this.props.listId}
156+
itemId={this.props.itemId}
157+
context={this.props.context}
158+
onAttachmentChange={this.onAttachmentChange}
159+
/>
160+
<DynamicForm
161+
ref={this.dynamicFormRef}
162+
context={this.props.context}
163+
listId={this.props.listId}
164+
listItemId={this.props.itemId}
165+
respectETag={true}
166+
/>
167+
</div>
168+
);
169+
}
170+
}
171+
```
172+
123173
## How to use styles property
124174

125175
Property styles of Dynamic Form gives you a set of properties which you can use to modify styles.

docs/documentation/docs/controls/ListItemAttachments.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,5 +63,53 @@ The `ListItemAttachments` control can be configured with the following propertie
6363
| description | string | no | Description text to display on the placeholder, below the main text and icon. |
6464
| disabled | boolean | no | Specifies if the control is disabled or not. |
6565
| openAttachmentsInNewWindow | boolean | no | Specifies if the attachment should be opened in a separate browser tab. Use this property set to `true` if you plan to use the component in Microsoft Teams. |
66+
| onAttachmentChange | (itemData: any) => void | no | Callback function invoked when attachments are added or removed. Receives the updated item data including the new ETag. This is useful when using the control within a form (like DynamicForm) that tracks ETags for optimistic concurrency control. |
67+
68+
## Usage with DynamicForm
69+
70+
When using `ListItemAttachments` within a `DynamicForm` or any component that uses ETags for optimistic concurrency control, you should use the `onAttachmentChange` callback to update the ETag when attachments are modified:
71+
72+
```TypeScript
73+
import * as React from 'react';
74+
import { DynamicForm } from '@pnp/spfx-controls-react/lib/DynamicForm';
75+
import { ListItemAttachments } from '@pnp/spfx-controls-react/lib/ListItemAttachments';
76+
77+
export class MyFormComponent extends React.Component<any, any> {
78+
private dynamicFormRef = React.createRef<DynamicForm>();
79+
80+
/**
81+
* Callback invoked when attachments are added or removed
82+
* Updates the ETag in DynamicForm to prevent 412 conflicts
83+
*/
84+
private onAttachmentChange = (itemData: any): void => {
85+
if (this.dynamicFormRef.current) {
86+
this.dynamicFormRef.current.updateETag(itemData);
87+
}
88+
}
89+
90+
public render(): React.ReactElement {
91+
return (
92+
<div>
93+
<ListItemAttachments
94+
listId={listId}
95+
itemId={itemId}
96+
context={this.props.context}
97+
onAttachmentChange={this.onAttachmentChange}
98+
/>
99+
100+
<DynamicForm
101+
ref={this.dynamicFormRef}
102+
context={this.props.context}
103+
listId={listId}
104+
listItemId={itemId}
105+
respectETag={true}
106+
/>
107+
</div>
108+
);
109+
}
110+
}
111+
```
112+
113+
This prevents 412 ETag conflict errors when saving the form after adding or removing attachments.
66114

67115
![](https://telemetry.sharepointpnp.com/sp-dev-fx-controls-react/wiki/controls/ListItemAttachments)

src/controls/dynamicForm/DynamicForm.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,21 @@ export class DynamicFormBase extends React.Component<
121121
this._customFormatter = new CustomFormattingHelper(this._formulaEvaluation);
122122
}
123123

124+
/**
125+
* Updates the ETag stored in the component's state.
126+
* This is useful when the list item has been modified externally (e.g., by adding/removing attachments)
127+
* and you need to update the ETag to prevent 412 conflict errors on save.
128+
*
129+
* @param itemData - The updated item data containing the new ETag
130+
*/
131+
public updateETag(itemData: any): void { // eslint-disable-line @typescript-eslint/no-explicit-any
132+
if (itemData && itemData["odata.etag"]) {
133+
this.setState({
134+
etag: itemData["odata.etag"]
135+
});
136+
}
137+
}
138+
124139
/**
125140
* Lifecycle hook when component is mounted
126141
*/

src/controls/listItemAttachments/IListItemAttachmentsProps.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,9 @@ export interface IListItemAttachmentsProps {
1616
* Description text to display on the placeholder, below the main text and icon
1717
*/
1818
description?:string;
19+
/**
20+
* Callback function to notify parent components when attachments are modified and the item ETag changes
21+
* @param itemData - The updated item data including the new ETag
22+
*/
23+
onAttachmentChange?: (itemData: any) => void; // eslint-disable-line @typescript-eslint/no-explicit-any
1924
}

src/controls/listItemAttachments/IUploadAttachmentProps.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,9 @@ export interface IUploadAttachmentProps {
1010
fireUpload?: boolean;
1111
onAttachmentUpload: (file?: File) => void;
1212
onUploadDialogClosed: () => void;
13+
/**
14+
* Callback function to notify parent components when attachments are modified and the item ETag changes
15+
* @param itemData - The updated item data including the new ETag
16+
*/
17+
onAttachmentChange?: (itemData: any) => void; // eslint-disable-line @typescript-eslint/no-explicit-any
1318
}

src/controls/listItemAttachments/ListItemAttachments.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,20 @@ export class ListItemAttachments extends React.Component<IListItemAttachmentsPro
7777

7878
public async uploadAttachments(itemId: number): Promise<void> {
7979
if (this.state.filesToUpload) {
80+
let updatedItem: any = null; // eslint-disable-line @typescript-eslint/no-explicit-any
8081
for (const file of this.state.filesToUpload) {
81-
await this._spservice.addAttachment(
82+
updatedItem = await this._spservice.addAttachment(
8283
this.props.listId,
8384
itemId,
8485
file.name,
8586
file,
8687
this.props.webUrl);
8788
}
89+
90+
// Notify parent component of the ETag change (use the last updated item)
91+
if (updatedItem && this.props.onAttachmentChange) {
92+
this.props.onAttachmentChange(updatedItem);
93+
}
8894
}
8995
return new Promise<void>((resolve, reject) => {
9096
this.setState({
@@ -201,7 +207,12 @@ export class ListItemAttachments extends React.Component<IListItemAttachmentsPro
201207

202208
try {
203209
if (this.state.itemId) {
204-
await this._spservice.deleteAttachment(file.FileName, this.props.listId, this.state.itemId, this.props.webUrl);
210+
const updatedItem = await this._spservice.deleteAttachment(file.FileName, this.props.listId, this.state.itemId, this.props.webUrl);
211+
212+
// Notify parent component of the ETag change
213+
if (updatedItem && this.props.onAttachmentChange) {
214+
this.props.onAttachmentChange(updatedItem);
215+
}
205216
}
206217
else {
207218
const filesToUpload = this.state.filesToUpload;
@@ -254,6 +265,7 @@ export class ListItemAttachments extends React.Component<IListItemAttachmentsPro
254265
onAttachmentUpload={this._onAttachmentUpload}
255266
fireUpload={this.state.fireUpload}
256267
onUploadDialogClosed={() => this.setState({ fireUpload: false })}
268+
onAttachmentChange={this.props.onAttachmentChange}
257269
/>
258270

259271
{

src/controls/listItemAttachments/UploadAttachment.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,12 @@ export class UploadAttachment extends React.Component<IUploadAttachmentProps, IU
6666

6767
try {
6868
if(this.props.itemId && this.props.itemId > 0){
69-
await this._spservice.addAttachment(this.props.listId, this.props.itemId, file.name, file, this.props.webUrl);
69+
const updatedItem = await this._spservice.addAttachment(this.props.listId, this.props.itemId, file.name, file, this.props.webUrl);
70+
71+
// Notify parent component of the ETag change
72+
if (updatedItem && this.props.onAttachmentChange) {
73+
this.props.onAttachmentChange(updatedItem);
74+
}
7075
}
7176

7277
this.props.onAttachmentUpload(file);

src/services/SPService.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -393,15 +393,24 @@ export default class SPService implements ISPService {
393393
* @param listId
394394
* @param itemId
395395
* @param webUrl
396+
* @returns Updated list item with new ETag
396397
*/
397-
public async deleteAttachment(fileName: string, listId: string, itemId: number, webUrl?: string): Promise<void> {
398+
public async deleteAttachment(fileName: string, listId: string, itemId: number, webUrl?: string): Promise<any> { // eslint-disable-line @typescript-eslint/no-explicit-any
398399
try {
399400
const spOpts: ISPHttpClientOptions = {
400401
headers: { "X-HTTP-Method": 'DELETE', }
401402
};
402403
const webAbsoluteUrl = !webUrl ? this._webAbsoluteUrl : webUrl;
403404
const apiUrl = `${webAbsoluteUrl}/_api/web/lists(@listId)/items(@itemId)/AttachmentFiles/getByFileName(@fileName)/RecycleObject?@listId=guid'${encodeURIComponent(listId)}'&@itemId=${encodeURIComponent(String(itemId))}&@fileName='${encodeURIComponent(fileName.replace(/'/g, "''"))}'`;
404405
await this._context.spHttpClient.post(apiUrl, SPHttpClient.configurations.v1, spOpts);
406+
407+
// Fetch the updated item to get the new ETag
408+
const itemApiUrl = `${webAbsoluteUrl}/_api/web/lists(@listId)/items(@itemId)?@listId=guid'${encodeURIComponent(listId)}'&@itemId=${encodeURIComponent(String(itemId))}`;
409+
const itemData = await this._context.spHttpClient.get(itemApiUrl, SPHttpClient.configurations.v1);
410+
if (itemData.ok) {
411+
return await itemData.json();
412+
}
413+
return null;
405414
} catch (error) {
406415
console.dir(error);
407416
return Promise.reject(error);
@@ -416,8 +425,9 @@ export default class SPService implements ISPService {
416425
* @param fileName
417426
* @param file
418427
* @param webUrl
428+
* @returns Updated list item with new ETag
419429
*/
420-
public async addAttachment(listId: string, itemId: number, fileName: string, file: File, webUrl?: string): Promise<void> {
430+
public async addAttachment(listId: string, itemId: number, fileName: string, file: File, webUrl?: string): Promise<any> { // eslint-disable-line @typescript-eslint/no-explicit-any
421431
try {
422432
// Remove special characters in FileName
423433
//Updating the escape characters for filename as per the doucmentations
@@ -436,7 +446,14 @@ export default class SPService implements ISPService {
436446
const webAbsoluteUrl = !webUrl ? this._webAbsoluteUrl : webUrl;
437447
const apiUrl = `${webAbsoluteUrl}/_api/web/lists(@listId)/items(@itemId)/AttachmentFiles/add(FileName=@fileName)?@listId=guid'${encodeURIComponent(listId)}'&@itemId=${encodeURIComponent(String(itemId))}&@fileName='${encodeURIComponent(fileName.replace(/'/g, "''"))}'`;
438448
await this._context.spHttpClient.post(apiUrl, SPHttpClient.configurations.v1, spOpts);
439-
return;
449+
450+
// Fetch the updated item to get the new ETag
451+
const itemApiUrl = `${webAbsoluteUrl}/_api/web/lists(@listId)/items(@itemId)?@listId=guid'${encodeURIComponent(listId)}'&@itemId=${encodeURIComponent(String(itemId))}`;
452+
const itemData = await this._context.spHttpClient.get(itemApiUrl, SPHttpClient.configurations.v1);
453+
if (itemData.ok) {
454+
return await itemData.json();
455+
}
456+
return null;
440457
} catch (error) {
441458
return Promise.reject(error);
442459
}

0 commit comments

Comments
 (0)