From 83e737021091e8863ce72663819a29822522d195 Mon Sep 17 00:00:00 2001 From: Antanina Druzhkina Date: Tue, 28 Apr 2026 02:18:06 +0400 Subject: [PATCH] #1556: Dynamic form - show comment history for Append Only Multiline field --- src/controls/dynamicForm/DynamicForm.tsx | 42 ++++++++++++++++--- .../dynamicField/DynamicField.styles.ts | 17 ++++++++ .../dynamicForm/dynamicField/DynamicField.tsx | 17 +++++++- .../dynamicField/IDynamicFieldProps.ts | 7 ++++ src/services/ISPService.ts | 27 ++++++++++++ src/services/SPService.ts | 25 ++++++++++- src/services/SPServiceMock.ts | 5 ++- 7 files changed, 132 insertions(+), 8 deletions(-) diff --git a/src/controls/dynamicForm/DynamicForm.tsx b/src/controls/dynamicForm/DynamicForm.tsx index dd741af5f..18d6b19ef 100644 --- a/src/controls/dynamicForm/DynamicForm.tsx +++ b/src/controls/dynamicForm/DynamicForm.tsx @@ -37,7 +37,7 @@ import { IInstalledLanguageInfo, IItemUpdateResult, IList, ITermInfo, ChoiceFiel import { cloneDeep, isEqual } from "lodash"; import { ICustomFormatting, ICustomFormattingBodySection, ICustomFormattingNode } from "../../common/utilities/ICustomFormatting"; import SPservice from "../../services/SPService"; -import { IRenderListDataAsStreamClientFormResult } from "../../services/ISPService"; +import { IAppendOnlyNoteHistoryEntry, IClientFormTextFieldInfo, IRenderExtendedListFormDataResultNotesField, IRenderExtendedListFormDataResultStatic, IRenderListDataAsStreamClientFormResult } from "../../services/ISPService"; import { ISPField, IUploadImageResult } from "../../common/SPEntities"; import { FormulaEvaluation } from "../../common/utilities/FormulaEvaluation"; import { Context } from "../../common/utilities/FormulaEvaluation.types"; @@ -695,6 +695,21 @@ export class DynamicFormBase extends React.Component< } } + // Reload append-only history after save + if (listItemId && this.state.fieldCollection.some(f => f.isAppendOnly)) { + const updatedExtendedInfo = await this._spService.getExtendedListFormData(listId, listItemId, this.webURL); + this.setState(prevState => ({ + fieldCollection: prevState.fieldCollection.map(field => + field.isAppendOnly + ? { ...field, + notesAppendOnlyHistory: updatedExtendedInfo[field.columnInternalName], + newValue: '', + value: '' } + : field + ) + })); + } + this.setState({ isSaving: false, etag: newETag, @@ -1059,6 +1074,7 @@ export class DynamicFormBase extends React.Component< let item = null; const isEditingItem = listItemId !== undefined && listItemId !== null && listItemId !== 0; let etag: string | undefined = undefined; + let extendedInfo: (IRenderExtendedListFormDataResultStatic & IRenderExtendedListFormDataResultNotesField) | undefined = undefined; if (isEditingItem) { const spListItem = spList.items.getById(listItemId); @@ -1076,6 +1092,13 @@ export class DynamicFormBase extends React.Component< if (respectETag !== false) { etag = item["odata.etag"]; } + + const appendOnlyFields = listInfo.ClientForms.Edit[contentTypeName] + .filter(field => field.FieldType === 'Note' && (field as IClientFormTextFieldInfo).AppendOnly); + + if (appendOnlyFields.length > 0) { + extendedInfo = await this._spService.getExtendedListFormData(listId, listItemId, this.webURL); + } } // Build the field collection @@ -1087,7 +1110,8 @@ export class DynamicFormBase extends React.Component< listId, listItemId, disabledFields, - customIcons + customIcons, + extendedInfo ); const sortedFields = this.props.fieldOrder?.length > 0 @@ -1133,7 +1157,7 @@ export class DynamicFormBase extends React.Component< * @returns */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - private async buildFieldCollection(listInfo: IRenderListDataAsStreamClientFormResult, contentTypeName: string, item: any, numberFields: ISPField[], listId: string, listItemId: number, disabledFields: string[], customIcons: { [key: string]: string }): Promise { + private async buildFieldCollection(listInfo: IRenderListDataAsStreamClientFormResult, contentTypeName: string, item: any, numberFields: ISPField[], listId: string, listItemId: number, disabledFields: string[], customIcons: { [key: string]: string }, extendedInfo: (IRenderExtendedListFormDataResultStatic & IRenderExtendedListFormDataResultNotesField) | undefined): Promise { const { useModernTaxonomyPicker } = this.props; const tempFields: IDynamicFieldProps[] = []; let order: number = 0; @@ -1158,6 +1182,7 @@ export class DynamicFormBase extends React.Component< let stringValue = null; const subPropertyValues: Record = {}; let richText = false; + let appendOnly = false; let dateFormat: DateFormat | undefined; let principalType = ""; let cultureName: string; @@ -1167,7 +1192,7 @@ export class DynamicFormBase extends React.Component< // eslint-disable-next-line @typescript-eslint/no-explicit-any const selectedTags: any = []; let choiceType: ChoiceFieldFormatType | undefined; - + let notesAppendOnlyHistory: IAppendOnlyNoteHistoryEntry[] | undefined; let fieldName = field.InternalName; if (fieldName.startsWith('_x') || fieldName.startsWith('_')) { fieldName = `OData_${fieldName}`; @@ -1205,6 +1230,11 @@ export class DynamicFormBase extends React.Component< // Setup Note, Number and Currency fields if (field.FieldType === "Note") { richText = field.RichText; + appendOnly = field.AppendOnly; + if (field.AppendOnly) { + notesAppendOnlyHistory = extendedInfo?.[field.InternalName]; + value = ''; + } } if (field.FieldType === "Number" || field.FieldType === "Currency") { const numberField = numberFields.find(f => f.InternalName === field.InternalName); @@ -1486,6 +1516,7 @@ export class DynamicFormBase extends React.Component< hiddenFieldName: hiddenName, Order: order, isRichText: richText, + isAppendOnly: appendOnly, dateFormat: dateFormat, firstDayOfWeek: defaultDayOfWeek, listItemId: listItemId, @@ -1496,7 +1527,8 @@ export class DynamicFormBase extends React.Component< showAsPercentage: showAsPercentage, customIcon: customIcons ? customIcons[field.InternalName] : undefined, useModernTaxonomyPickerControl: useModernTaxonomyPicker, - choiceType: choiceType + choiceType: choiceType, + notesAppendOnlyHistory: notesAppendOnlyHistory }); // This may not be necessary now using RenderListDataAsStream diff --git a/src/controls/dynamicForm/dynamicField/DynamicField.styles.ts b/src/controls/dynamicForm/dynamicField/DynamicField.styles.ts index 4f931b3c3..e19791437 100644 --- a/src/controls/dynamicForm/dynamicField/DynamicField.styles.ts +++ b/src/controls/dynamicForm/dynamicField/DynamicField.styles.ts @@ -25,6 +25,10 @@ export const getFieldStyles = ( thumbnailFieldButtons: 'thumbnailFieldButtons', selectedFileContainer: 'selectedFileContainer', fieldRequired: 'fieldRequired', + appendOnlyHistoryContainer: 'appendOnlyHistoryContainer', + appendOnlyHistoryEntry: 'appendOnlyHistoryEntry', + appendOnlyHistoryAuthor: 'appendOnlyHistoryAuthor', + appendOnlyHistoryDate: 'appendOnlyHistoryDate', }; const fieldDisplayNoPadding_style: IStyle = { @@ -131,6 +135,19 @@ export const getFieldStyles = ( globalClassNames.thumbnailFieldButtons, { display: 'flex' }, ], + appendOnlyHistoryContainer: [globalClassNames.appendOnlyHistoryContainer, { selectors: { p: { margin: 0 } } }], + appendOnlyHistoryEntry: [ + globalClassNames.appendOnlyHistoryEntry, + { display: 'flex', gap: 4, fontSize: 12, padding: '4px 0' }, + ], + appendOnlyHistoryAuthor: [ + globalClassNames.appendOnlyHistoryAuthor, + { fontWeight: 600 }, + ], + appendOnlyHistoryDate: [ + globalClassNames.appendOnlyHistoryDate, + { color: palette.themePrimary, cursor: 'pointer' }, + ], errormessage: [ globalClassNames.errormessage, { diff --git a/src/controls/dynamicForm/dynamicField/DynamicField.tsx b/src/controls/dynamicForm/dynamicField/DynamicField.tsx index e570a67be..2c18cf3b3 100644 --- a/src/controls/dynamicForm/dynamicField/DynamicField.tsx +++ b/src/controls/dynamicForm/dynamicField/DynamicField.tsx @@ -83,6 +83,7 @@ export class DynamicFieldBase extends React.Component; - case 'Note': + case 'Note': { + const notesHistory: JSX.Element = isAppendOnly && notesAppendOnlyHistory?.length > 0 + ?
{notesAppendOnlyHistory.map((comment) => ( +
+ {comment.createdTitle} + ({comment.createdTime}) + : + +
+ ))}
+ : null; if (isRichText) { const noteValue = valueToDisplay !== undefined ? valueToDisplay : defaultValue; return
@@ -165,6 +177,7 @@ export class DynamicFieldBase extends React.Component { this.onChange(newText); return newText; }} isEditMode={!disabled} /> + {notesHistory} {descriptionEl} {errorTextEl}
; @@ -186,9 +199,11 @@ export class DynamicFieldBase extends React.Component + {notesHistory} {descriptionEl} ; } + } case 'Choice': { let choiceControl: JSX.Element = undefined; diff --git a/src/controls/dynamicForm/dynamicField/IDynamicFieldProps.ts b/src/controls/dynamicForm/dynamicField/IDynamicFieldProps.ts index 44208562d..80ac04c77 100644 --- a/src/controls/dynamicForm/dynamicField/IDynamicFieldProps.ts +++ b/src/controls/dynamicForm/dynamicField/IDynamicFieldProps.ts @@ -3,6 +3,7 @@ import { IDropdownOption } from "@fluentui/react/lib/Dropdown"; import { IStyle, IStyleFunctionOrObject, Theme } from '@fluentui/react'; import { IFilePickerResult } from '../../filePicker'; import { ChoiceFieldFormatType } from '@pnp/sp/fields'; +import { IAppendOnlyNoteHistoryEntry } from '../../../services/ISPService'; export type DateFormat = 'DateTime' | 'DateOnly'; export type FieldChangeAdditionalData = IFilePickerResult; @@ -87,6 +88,7 @@ export interface IDynamicFieldProps { // Related to various field types options?: IDropdownOption[]; isRichText?: boolean; + isAppendOnly?: boolean; dateFormat?: DateFormat; firstDayOfWeek: number; principalType?: string; @@ -98,6 +100,7 @@ export interface IDynamicFieldProps { customIcon?: string; orderBy?: string; choiceType?: ChoiceFieldFormatType; + notesAppendOnlyHistory?: IAppendOnlyNoteHistoryEntry[]; /** Used for customize component styling */ styles?:IStyleFunctionOrObject; } @@ -124,4 +127,8 @@ export interface IDynamicFieldStyles { thumbnailFieldButtons:IStyle; selectedFileContainer:IStyle; fieldRequired:IStyle; + appendOnlyHistoryContainer:IStyle; + appendOnlyHistoryEntry:IStyle; + appendOnlyHistoryAuthor:IStyle; + appendOnlyHistoryDate:IStyle; } diff --git a/src/services/ISPService.ts b/src/services/ISPService.ts index 680c75cd0..485c3b504 100644 --- a/src/services/ISPService.ts +++ b/src/services/ISPService.ts @@ -240,6 +240,24 @@ export interface IRenderListDataAsStreamClientFormResult { FormRenderModes: IClientFormRenderModeByContentType; } +export interface IRenderExtendedListFormDataResultStatic { + ListData: Record; + ListSchema: { New: IClientFormInfoByContentType; Edit: IClientFormInfoByContentType }; +} + +export interface IRenderExtendedListFormDataResultNotesField { + [fieldName: string]: IAppendOnlyNoteHistoryEntry[]; +} + +export interface IAppendOnlyNoteHistoryEntry { + value: string; + versionId: number; + createdEmail: string; + createdTitle: string; + createdId: number; + createdTime: string; +} + export interface ISPService { /** * Get the lists from SharePoint @@ -271,6 +289,15 @@ export interface ISPService { */ getAdditionalListFormFieldInfo(listId: string, webUrl?: string): Promise; + /** + * Retrieves extended list form data for a list item, including append-only note history. + * Calls RenderExtendedListFormData with options=30 to include version history. + * @param listId - The GUID of the SharePoint list + * @param itemId - The ID of the list item + * @param webUrl - Optional web URL; defaults to the current web + */ + getExtendedListFormData(listId: string, itemId: number, webUrl?: string): Promise; + /** * Get the views from lists or libraries * @params listId, orderBy, onViewsRetrived diff --git a/src/services/SPService.ts b/src/services/SPService.ts index 6dd506945..cfa847aa8 100644 --- a/src/services/SPService.ts +++ b/src/services/SPService.ts @@ -4,7 +4,7 @@ import filter from 'lodash/filter'; import find from 'lodash/find'; import { ISPContentType, ISPField, ISPList, ISPLists, IUploadImageResult, ISPViews } from "../common/SPEntities"; import { SPHelper, urlCombine } from "../common/utilities"; -import { IContentTypesOptions, IFieldsOptions, ILibsOptions, IRenderListDataAsStreamClientFormResult, ISPService, LibsOrderBy } from "./ISPService"; +import { IContentTypesOptions, IFieldsOptions, ILibsOptions, IRenderExtendedListFormDataResultStatic, IRenderExtendedListFormDataResultNotesField, IRenderListDataAsStreamClientFormResult, ISPService, LibsOrderBy } from "./ISPService"; import {orderBy } from '../controls/viewPicker/IViewPicker'; interface ICachedListItems { @@ -809,6 +809,29 @@ export default class SPService implements ISPService { } } + /** + * Retrieves extended list form data for a list item, including append-only note history. + * Calls RenderExtendedListFormData with options=30 to include version history. + * @param listId - The GUID of the SharePoint list + * @param itemId - The ID of the list item + * @param webUrl - Optional web URL; defaults to the current web + */ + async getExtendedListFormData(listId: string, itemId: number, webUrl?: string): Promise { + try { + const webAbsoluteUrl = !webUrl ? this._context.pageContext.web.absoluteUrl : webUrl; + const apiRequestPath = `/_api/web/lists(guid'${listId}')/RenderExtendedListFormData(itemId=${itemId},formId='editform',mode='2',options=30,cutoffVersion=0)`; + + const apiUrl = urlCombine(webAbsoluteUrl, apiRequestPath, false); + const response = await this._context.spHttpClient.post(apiUrl, SPHttpClient.configurations.v1, {}); + const { value } = await response.json(); + const result = JSON.parse(value) as IRenderExtendedListFormDataResultStatic & IRenderExtendedListFormDataResultNotesField + return result; + } catch (error) { + console.dir(error); + return Promise.reject(error); + } + } + private _filterListItemsFieldValuesAsText(items: any[], internalColumnName: string, filterText: string | undefined, substringSearch: boolean): any[] { // eslint-disable-line @typescript-eslint/no-explicit-any const lowercasedFilterText = filterText.toLowerCase(); diff --git a/src/services/SPServiceMock.ts b/src/services/SPServiceMock.ts index 4a143ec24..11713dbe7 100644 --- a/src/services/SPServiceMock.ts +++ b/src/services/SPServiceMock.ts @@ -1,4 +1,4 @@ -import { ISPService, ILibsOptions, IFieldsOptions, IContentTypesOptions, IRenderListDataAsStreamClientFormResult } from "./ISPService"; +import { ISPService, ILibsOptions, IFieldsOptions, IContentTypesOptions, IRenderListDataAsStreamClientFormResult, IRenderExtendedListFormDataResultNotesField, IRenderExtendedListFormDataResultStatic } from "./ISPService"; import { ISPContentType, ISPField, ISPLists, ISPViews } from "../common/SPEntities"; import {orderBy } from '../controls/viewPicker/IViewPicker'; @@ -16,6 +16,9 @@ export default class SPServiceMock implements ISPService { public getAdditionalListFormFieldInfo(listId: string, webUrl?: string): Promise { throw new Error("Method not implemented."); } + public getExtendedListFormData(listId: string, itemId: number, webUrl?: string): Promise { + throw new Error("Method not implemented."); + } public getFields(options?: IFieldsOptions): Promise { throw new Error("Method not implemented."); }