feat: Update dynamic poll web part to version 1.3.0 with enhanced field mapping and configurable title

- Bump version in package.json to 1.3.0
- Add new properties for field mapping in DynamicPollWebPart.manifest.json
- Enhance DynamicPollWebPart.ts to support dynamic field mapping for polls and answers
- Implement field loading and default application logic in PollDataService.ts
- Update PollTypes.ts to include new interfaces for field mapping
- Modify DynamicPollCollection.tsx to display configurable web part title
- Update styles in DynamicPoll.module.scss for improved UI
- Add localization strings for new properties in en-us.js and de-de.js
- Update tests to reflect changes in data structure and functionality
- Validate project structure and ensure all new features are accounted for
This commit is contained in:
Torsten Brendgen
2026-08-27 00:13:58 +02:00
parent f87c49a91a
commit 6bcf7c5020
15 changed files with 423 additions and 99 deletions
+30 -5
View File
@@ -6,6 +6,9 @@ Ein eigenstaendiges, mit SharePoint Server Subscription Edition kompatibles SPFx
- alle aktiven Umfragen anhand von Aktiv-Flag sowie optionalem Start- und Enddatum
- umschaltbare Darstellung mehrerer aktiver Umfragen als Accordion oder Slider mit Punktenavigation
- konfigurierbarer WebPart-Titel
- vollstaendige Spaltenzuordnung im Property Pane
- Content-Type-Hub-taugliche Verbindung zwischen Umfragen und Antworten ueber einen stabilen Textschluessel
- genau eine Antwort je angemeldetem Benutzer und Umfrage
- unmittelbare Ergebnisanzeige mit Stimmen und Prozentwerten
- optional sichtbare Ergebnisse vor der eigenen Abstimmung
@@ -30,8 +33,8 @@ In der SharePoint Management Shell auf einem SharePoint-Server ausfuehren:
Das Skript ist idempotent und legt standardmaessig diese Listen an:
- `Polls`: `Title`, `Question`, `Options` (mehrzeiliger Nur-Text), `StartDate`, `EndDate`, `IsActive`
- `Poll Answers`: `Title` (Benutzerschluessel), `Poll` (Lookup), `Answer`
- `Polls`: `PollKey`, `Title`, `Question`, `Options` (mehrzeiliger Nur-Text), `StartDate`, `EndDate`, `IsActive`
- `Poll Answers`: `PollKey` (Text), `UserKey` (Text), `Answer`
Die Benutzer des WebParts benoetigen Leserechte auf `Polls` sowie Lese- und Hinzufuegerechte auf `Poll Answers`. Fuer die Ergebnisberechnung muessen sie die vorhandenen Antworten lesen koennen.
@@ -47,7 +50,27 @@ Anderer Termin
Leerzeilen und doppelte Eintraege werden ignoriert. `IsActive` aktivieren; Start- und Enddatum koennen leer bleiben. Sind mehrere Umfragen gleichzeitig aktiv, werden sie nach Startdatum sortiert und entsprechend dem im Property Pane gewählten Accordion- oder Slider-Modus angezeigt.
Danach das WebPart auf einer modernen Seite einfuegen und im Property Pane die Listen `Polls` und `Poll Answers` auswaehlen.
Danach das WebPart auf einer modernen Seite einfuegen, im Property Pane die Listen `Polls` und `Poll Answers`
auswaehlen und die Spalten zuordnen. Bekannte Standardnamen werden automatisch vorbelegt.
## Verwendung mit dem Inhaltstyp-Katalog
Die beiden Listen duerfen aus zentral verteilten Inhaltstypen und Websitespalten aufgebaut werden. Fuer die
Beziehung wird absichtlich kein Lookup verwendet: Der Poll-Schluessel enthaelt einen eindeutigen, dauerhaften
Textwert und derselbe Wert wird beim Abstimmen in die zugeordnete Textspalte der Antwortliste geschrieben. Ein Lookup verweist
auf die ID einer konkreten Liste und kann deshalb nicht verlaesslich ueber den Content Type Hub auf andere
Websitesammlungen verteilt werden.
Im Inhaltstyp-Katalog sollten dafuer zwei getrennte Websitespalten angelegt werden: eine eindeutige/indexierte
Schluesselspalte fuer die Poll-Liste und eine nicht eindeutige Verweisspalte fuer die Antwortliste. Die
Verweisspalte darf nicht eindeutig sein, weil mehrere Benutzer dieselbe Umfrage beantworten. Interne Namen
wie `PollDefinitionKey` und `PollReferenceKey` sind daher ebenso moeglich und werden im Property Pane zugeordnet.
Die internen Spaltennamen duerfen abweichen. Im Property Pane lassen sich Umfrageschluessel, Titel, Frage,
Antwortoptionen, Zeitfenster, Aktiv-Flag sowie Umfrageschluessel, Benutzer und Antwort der Antwortliste frei
zuordnen. Fuer vorhandene Installationen bleibt ein bestehendes `Poll`-Lookup lesbar: In diesem Fall muss als
Umfrageschluessel `ID` und als Antwortschluessel das Lookup `Poll` ausgewaehlt werden. Empfohlen ist die
Migration auf die beiden Textspalten `PollKey`.
## Paket bauen
@@ -60,6 +83,8 @@ Das fertige Paket liegt unter `sharepoint/solution/dynamic-poll-web-part.sppkg`.
## Hinweise
Der Benutzerschluessel wird in der `Title`-Spalte der Antwortliste gespeichert. Die doppelte Stimmabgabe wird vor dem Schreiben geprueft. Zwei exakt gleichzeitige Requests desselben Benutzers lassen sich ohne serverseitige Eindeutigkeitsregel dennoch nicht vollstaendig ausschliessen.
Der Benutzerschluessel wird in der zugeordneten Spalte (standardmaessig `UserKey`) gespeichert. Die doppelte Stimmabgabe wird vor dem Schreiben geprueft. Zwei exakt gleichzeitige Requests desselben Benutzers lassen sich ohne serverseitige Eindeutigkeitsregel dennoch nicht vollstaendig ausschliessen.
Nach einem Upgrade von der bisherigen Choice-Spalte muss das Provisionierungsskript erneut ausgefuehrt werden. Es legt das neue Feld `OptionsText` mit dem Anzeigenamen `Options` an, uebernimmt vorhandene Choice-Werte und blendet die alte Spalte als `Options (legacy)` in den Formularen aus. Das WebPart kann waehrend der Umstellung weiterhin das alte Choice-Feld lesen.
Nach einem Upgrade muss das Provisionierungsskript erneut ausgefuehrt werden. Es legt `PollKey`, `UserKey` und
das mehrzeilige `OptionsText` an, uebernimmt vorhandene Werte und blendet das alte Poll-Lookup sowie die alte
Choice-Spalte in den Formularen aus. Das Skript legt bei neuen Installationen kein Lookup mehr an.
+1 -1
View File
@@ -4,7 +4,7 @@
"name": "dynamic-poll-web-part",
"title": "dynamic-poll-web-part",
"id": "900239c9-b6c7-495d-a386-2d08a81a66f3",
"version": "1.1.0.0",
"version": "1.3.0.0",
"includeClientSideAssets": true,
"skipFeatureDeployment": true
},
+41 -2
View File
@@ -34,6 +34,7 @@ try {
}
Ensure-Field -List $polls -InternalName 'Question' -SchemaXml '<Field Type="Text" DisplayName="Question" Name="Question" StaticName="Question" Required="TRUE" />'
Ensure-Field -List $polls -InternalName 'PollKey' -SchemaXml '<Field Type="Text" DisplayName="Poll key" Name="PollKey" StaticName="PollKey" Required="FALSE" MaxLength="255" EnforceUniqueValues="TRUE" Indexed="TRUE" />'
$legacyOptionsField = $null
try { $legacyOptionsField = $polls.Fields.GetFieldByInternalName('Options') } catch { }
@@ -71,6 +72,13 @@ try {
Ensure-Field -List $polls -InternalName 'EndDate' -SchemaXml '<Field Type="DateTime" DisplayName="End date" Name="EndDate" StaticName="EndDate" Format="DateTime" />'
Ensure-Field -List $polls -InternalName 'IsActive' -SchemaXml '<Field Type="Boolean" DisplayName="Active" Name="IsActive" StaticName="IsActive"><Default>0</Default></Field>'
foreach ($item in $polls.Items) {
if ([string]::IsNullOrWhiteSpace([string]$item['PollKey'])) {
$item['PollKey'] = ('Poll-{0}' -f $item.ID)
$item.SystemUpdate($false)
}
}
$answers = $web.Lists.TryGetList($AnswerListTitle)
if (-not $answers) {
$listId = $web.Lists.Add($AnswerListTitle, 'Votes submitted through the Dynamic Poll web part.', [Microsoft.SharePoint.SPListTemplateType]::GenericList)
@@ -79,10 +87,41 @@ try {
$answers.Update()
}
$pollListId = $polls.ID.ToString('B')
Ensure-Field -List $answers -InternalName 'Poll' -SchemaXml (('<Field Type="Lookup" DisplayName="Poll" Name="Poll" StaticName="Poll" List="{0}" ShowField="Title" Required="TRUE" />' -f $pollListId))
Ensure-Field -List $answers -InternalName 'PollKey' -SchemaXml '<Field Type="Text" DisplayName="Poll key" Name="PollKey" StaticName="PollKey" Required="FALSE" MaxLength="255" Indexed="TRUE" />'
Ensure-Field -List $answers -InternalName 'UserKey' -SchemaXml '<Field Type="Text" DisplayName="User key" Name="UserKey" StaticName="UserKey" Required="FALSE" MaxLength="255" Indexed="TRUE" />'
Ensure-Field -List $answers -InternalName 'Answer' -SchemaXml '<Field Type="Text" DisplayName="Answer" Name="Answer" StaticName="Answer" Required="TRUE" MaxLength="255" />'
# A lookup cannot be distributed safely through the Content Type Hub because its target list ID
# belongs to one site collection. Keep an existing legacy lookup readable, but stop requiring it.
$legacyPollField = $null
try { $legacyPollField = $answers.Fields.GetFieldByInternalName('Poll') } catch { }
if ($legacyPollField -and $legacyPollField.Type -eq [Microsoft.SharePoint.SPFieldType]::Lookup) {
$legacyPollField.Required = $false
$legacyPollField.ShowInNewForm = $false
$legacyPollField.ShowInEditForm = $false
$legacyPollField.ShowInDisplayForm = $false
$legacyPollField.Update()
}
foreach ($answerItem in $answers.Items) {
$changed = $false
if ($legacyPollField -and $legacyPollField.Type -eq [Microsoft.SharePoint.SPFieldType]::Lookup) {
if ([string]::IsNullOrWhiteSpace([string]$answerItem['PollKey']) -and $null -ne $answerItem[$legacyPollField.Id]) {
$lookup = New-Object Microsoft.SharePoint.SPFieldLookupValue([string]$answerItem[$legacyPollField.Id])
$pollItem = $polls.GetItemById($lookup.LookupId)
$answerItem['PollKey'] = [string]$pollItem['PollKey']
$changed = $true
}
}
if ([string]::IsNullOrWhiteSpace([string]$answerItem['UserKey']) -and
-not [string]::IsNullOrWhiteSpace([string]$answerItem['Title'])) {
$answerItem['UserKey'] = [string]$answerItem['Title']
$changed = $true
}
if ($changed) { $answerItem.SystemUpdate($false) }
}
Write-Host ('Dynamic Poll lists are ready at {0}' -f $web.Url) -ForegroundColor Green
Write-Host ('Poll list: {0} ({1})' -f $polls.Title, $polls.ID)
Write-Host ('Answer list: {0} ({1})' -f $answers.Title, $answers.ID)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dynamic-poll-web-part",
"version": "1.1.0",
"version": "1.3.0",
"private": true,
"main": "lib/index.js",
"engines": { "node": ">=6.9.0 <9.0.0" },
@@ -16,8 +16,19 @@
},
"officeFabricIconFontName": "PollResults",
"properties": {
"title": "Dynamic Poll",
"pollListId": "",
"answerListId": "",
"pollKeyField": "",
"pollTitleField": "",
"pollQuestionField": "",
"pollOptionsField": "",
"pollStartField": "",
"pollEndField": "",
"pollActiveField": "",
"answerPollKeyField": "",
"answerUserKeyField": "",
"answerValueField": "",
"showResultsBeforeVote": false,
"displayMode": "slider",
"refreshSeconds": 60
+165 -6
View File
@@ -3,17 +3,28 @@ import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import {
BaseClientSideWebPart, IPropertyPaneConfiguration, IPropertyPaneDropdownOption,
PropertyPaneDropdown, PropertyPaneSlider, PropertyPaneToggle
PropertyPaneDropdown, PropertyPaneSlider, PropertyPaneTextField, PropertyPaneToggle
} from '@microsoft/sp-webpart-base';
import * as strings from 'DynamicPollWebPartStrings';
import DynamicPollCollection, { IDynamicPollCollectionProps } from './components/DynamicPollCollection';
import styles from './components/DynamicPoll.module.scss';
import { PollDataService } from './PollDataService';
import { IListOption, IPollSnapshot, PollDisplayMode } from './PollTypes';
import { IListFieldOption, IListOption, IPollFieldMapping, IPollSnapshot, PollDisplayMode } from './PollTypes';
export interface IDynamicPollWebPartProps {
title: string;
pollListId: string;
answerListId: string;
pollKeyField: string;
pollTitleField: string;
pollQuestionField: string;
pollOptionsField: string;
pollStartField: string;
pollEndField: string;
pollActiveField: string;
answerPollKeyField: string;
answerUserKeyField: string;
answerValueField: string;
showResultsBeforeVote: boolean;
refreshSeconds: number;
displayMode: PollDisplayMode;
@@ -22,7 +33,11 @@ export interface IDynamicPollWebPartProps {
export default class DynamicPollWebPart extends BaseClientSideWebPart<IDynamicPollWebPartProps> {
private service: PollDataService;
private lists: IListOption[] = [];
private pollFields: IListFieldOption[] = [];
private answerFields: IListFieldOption[] = [];
private loadingLists: boolean = false;
private loadingPollFields: boolean = false;
private loadingAnswerFields: boolean = false;
private requestVersion: number = 0;
private refreshHandle: number | undefined;
private currentRefreshSeconds: number = 0;
@@ -30,7 +45,13 @@ export default class DynamicPollWebPart extends BaseClientSideWebPart<IDynamicPo
protected onInit(): Promise<void> {
this.service = new PollDataService(this.context);
return super.onInit().then((): Promise<void> => this.loadLists());
return super.onInit().then((): Promise<void> => this.loadLists())
.then((): Promise<void> => {
const requests: Array<Promise<void>> = [];
if (this.properties.pollListId) { requests.push(this.loadFields(this.properties.pollListId, true)); }
if (this.properties.answerListId) { requests.push(this.loadFields(this.properties.answerListId, false)); }
return Promise.all(requests).then((): void => undefined);
});
}
public render(): void {
@@ -49,6 +70,24 @@ export default class DynamicPollWebPart extends BaseClientSideWebPart<IDynamicPo
protected onPropertyPaneConfigurationStart(): void {
if (!this.lists.length && !this.loadingLists) { this.loadLists(); }
if (this.properties.pollListId && !this.pollFields.length && !this.loadingPollFields) {
this.loadFields(this.properties.pollListId, true);
}
if (this.properties.answerListId && !this.answerFields.length && !this.loadingAnswerFields) {
this.loadFields(this.properties.answerListId, false);
}
}
protected onPropertyPaneFieldChanged(propertyPath: string, oldValue: any, newValue: any): void { // tslint:disable-line:no-any
super.onPropertyPaneFieldChanged(propertyPath, oldValue, newValue);
if (newValue === oldValue) { return; }
if (propertyPath === 'pollListId') {
this.resetPollMappings();
this.loadFields(String(newValue || ''), true);
} else if (propertyPath === 'answerListId') {
this.resetAnswerMappings();
this.loadFields(String(newValue || ''), false);
}
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
@@ -56,14 +95,43 @@ export default class DynamicPollWebPart extends BaseClientSideWebPart<IDynamicPo
for (let i: number = 0; i < this.lists.length; i++) {
options.push({ key: this.lists[i].id, text: this.lists[i].title });
}
const pollKeyOptions: IPropertyPaneDropdownOption[] = this.typedFieldOptions(this.pollFields,
['Text', 'Number', 'Integer', 'Counter', 'Computed'], false);
const pollTextOptions: IPropertyPaneDropdownOption[] = this.typedFieldOptions(this.pollFields,
['Text', 'Note', 'Choice', 'Computed'], false);
const pollOptionsOptions: IPropertyPaneDropdownOption[] = this.typedFieldOptions(this.pollFields,
['Text', 'Note', 'Choice', 'MultiChoice'], false);
const pollDateOptions: IPropertyPaneDropdownOption[] = this.typedFieldOptions(this.pollFields, ['DateTime'], false);
const pollBooleanOptions: IPropertyPaneDropdownOption[] = this.typedFieldOptions(this.pollFields, ['Boolean'], false);
const answerPollKeyOptions: IPropertyPaneDropdownOption[] = this.typedFieldOptions(this.answerFields,
['Text', 'Note', 'Lookup', 'Number', 'Integer'], true);
const answerUserOptions: IPropertyPaneDropdownOption[] = this.typedFieldOptions(this.answerFields,
['Text', 'Note'], true);
const answerValueOptions: IPropertyPaneDropdownOption[] = this.typedFieldOptions(this.answerFields,
['Text', 'Note', 'Choice'], true);
return { pages: [{
header: { description: strings.PropertyPaneDescription },
groups: [{
groupName: strings.DataGroupName,
groupFields: [
PropertyPaneTextField('title', { label: strings.TitleFieldLabel }),
PropertyPaneDropdown('pollListId', { label: strings.PollListLabel, options: options, disabled: this.loadingLists }),
PropertyPaneDropdown('answerListId', { label: strings.AnswerListLabel, options: options, disabled: this.loadingLists })
]
}, {
groupName: strings.FieldMappingGroupName,
groupFields: [
PropertyPaneDropdown('pollKeyField', { label: strings.PollKeyFieldLabel, options: pollKeyOptions, disabled: this.loadingPollFields }),
PropertyPaneDropdown('pollTitleField', { label: strings.PollTitleFieldLabel, options: pollTextOptions, disabled: this.loadingPollFields }),
PropertyPaneDropdown('pollQuestionField', { label: strings.PollQuestionFieldLabel, options: pollTextOptions, disabled: this.loadingPollFields }),
PropertyPaneDropdown('pollOptionsField', { label: strings.PollOptionsFieldLabel, options: pollOptionsOptions, disabled: this.loadingPollFields }),
PropertyPaneDropdown('pollStartField', { label: strings.PollStartFieldLabel, options: pollDateOptions, disabled: this.loadingPollFields }),
PropertyPaneDropdown('pollEndField', { label: strings.PollEndFieldLabel, options: pollDateOptions, disabled: this.loadingPollFields }),
PropertyPaneDropdown('pollActiveField', { label: strings.PollActiveFieldLabel, options: pollBooleanOptions, disabled: this.loadingPollFields }),
PropertyPaneDropdown('answerPollKeyField', { label: strings.AnswerPollKeyFieldLabel, options: answerPollKeyOptions, disabled: this.loadingAnswerFields }),
PropertyPaneDropdown('answerUserKeyField', { label: strings.AnswerUserKeyFieldLabel, options: answerUserOptions, disabled: this.loadingAnswerFields }),
PropertyPaneDropdown('answerValueField', { label: strings.AnswerValueFieldLabel, options: answerValueOptions, disabled: this.loadingAnswerFields })
]
}, {
groupName: strings.BehaviorGroupName,
groupFields: [
@@ -90,7 +158,7 @@ export default class DynamicPollWebPart extends BaseClientSideWebPart<IDynamicPo
return Promise.resolve();
}
if (showLoading) { this.renderStatus(strings.LoadingMessage, false); }
return this.service.getSnapshots(this.properties.pollListId, this.properties.answerListId, this.userKey)
return this.service.getSnapshots(this.properties.pollListId, this.properties.answerListId, this.userKey, this.fieldMapping)
.then((snapshots: IPollSnapshot[]): void => {
if (this.disposed || version !== this.requestVersion) { return; }
if (!snapshots.length) { this.renderStatus(strings.NoPollMessage, false); return; }
@@ -104,6 +172,7 @@ export default class DynamicPollWebPart extends BaseClientSideWebPart<IDynamicPo
pollNavigationLabel: strings.PollNavigationLabel
};
const element: React.ReactElement<IDynamicPollCollectionProps> = React.createElement(DynamicPollCollection, {
title: this.properties.title || strings.DefaultTitle,
snapshots: snapshots,
displayMode: this.properties.displayMode === 'accordion' ? 'accordion' : 'slider',
showResultsBeforeVote: this.properties.showResultsBeforeVote === true,
@@ -119,7 +188,8 @@ export default class DynamicPollWebPart extends BaseClientSideWebPart<IDynamicPo
}
private vote(pollId: number, answer: string): Promise<void> {
return this.service.vote(this.properties.pollListId, this.properties.answerListId, pollId, answer, this.userKey)
return this.service.vote(this.properties.pollListId, this.properties.answerListId, pollId, answer,
this.userKey, this.fieldMapping)
.then((): Promise<void> => this.loadSnapshot(false));
}
@@ -136,6 +206,80 @@ export default class DynamicPollWebPart extends BaseClientSideWebPart<IDynamicPo
});
}
private loadFields(listId: string, pollList: boolean): Promise<void> {
if (!listId) {
if (pollList) { this.pollFields = []; } else { this.answerFields = []; }
this.refreshPropertyPane();
return Promise.resolve();
}
if (pollList) { this.loadingPollFields = true; } else { this.loadingAnswerFields = true; }
this.refreshPropertyPane();
return this.service.getFields(listId).then((fields: IListFieldOption[]): void => {
if (pollList) { this.pollFields = fields; this.applyPollDefaults(); }
else { this.answerFields = fields; this.applyAnswerDefaults(); }
}).catch((error: Error): void => {
if (pollList) { this.pollFields = []; } else { this.answerFields = []; }
if (window.console) { console.error('[DynamicPoll] Fields could not be loaded.', error); }
}).then((): void => {
if (pollList) { this.loadingPollFields = false; } else { this.loadingAnswerFields = false; }
this.refreshPropertyPane();
this.render();
});
}
private applyPollDefaults(): void {
this.properties.pollKeyField = this.validOrDefault(this.properties.pollKeyField, this.pollFields, ['PollKey', 'ID']);
this.properties.pollTitleField = this.validOrDefault(this.properties.pollTitleField, this.pollFields, ['Title'], true);
this.properties.pollQuestionField = this.validOrDefault(this.properties.pollQuestionField, this.pollFields, ['Question', 'Title']);
this.properties.pollOptionsField = this.validOrDefault(this.properties.pollOptionsField, this.pollFields, ['OptionsText', 'Options']);
this.properties.pollStartField = this.validOrDefault(this.properties.pollStartField, this.pollFields, ['StartDate'], true);
this.properties.pollEndField = this.validOrDefault(this.properties.pollEndField, this.pollFields, ['EndDate'], true);
this.properties.pollActiveField = this.validOrDefault(this.properties.pollActiveField, this.pollFields, ['IsActive']);
}
private applyAnswerDefaults(): void {
this.properties.answerPollKeyField = this.validOrDefault(this.properties.answerPollKeyField,
this.answerFields, ['PollKey', 'Poll']);
this.properties.answerUserKeyField = this.validOrDefault(this.properties.answerUserKeyField,
this.answerFields, ['UserKey', 'Title']);
this.properties.answerValueField = this.validOrDefault(this.properties.answerValueField, this.answerFields, ['Answer']);
}
private validOrDefault(current: string, fields: IListFieldOption[], candidates: string[], optional?: boolean): string {
const names: string[] = fields.map((field: IListFieldOption): string => field.internalName);
if (current && names.indexOf(current) >= 0) { return current; }
for (let i: number = 0; i < candidates.length; i++) {
if (names.indexOf(candidates[i]) >= 0) { return candidates[i]; }
}
return optional ? '' : '';
}
private fieldOptions(fields: IListFieldOption[], required: boolean): IPropertyPaneDropdownOption[] {
const options: IPropertyPaneDropdownOption[] = required ? [] : [{ key: '', text: strings.NoFieldOption }];
for (let i: number = 0; i < fields.length; i++) {
options.push({ key: fields[i].internalName, text: fields[i].title + ' (' + fields[i].internalName + ')' });
}
return options;
}
private typedFieldOptions(fields: IListFieldOption[], types: string[], writable: boolean): IPropertyPaneDropdownOption[] {
return this.fieldOptions(fields.filter((field: IListFieldOption): boolean =>
types.indexOf(field.type) >= 0 && (!writable || !field.readOnly)), false);
}
private resetPollMappings(): void {
this.pollFields = [];
this.properties.pollKeyField = ''; this.properties.pollTitleField = '';
this.properties.pollQuestionField = ''; this.properties.pollOptionsField = '';
this.properties.pollStartField = ''; this.properties.pollEndField = ''; this.properties.pollActiveField = '';
}
private resetAnswerMappings(): void {
this.answerFields = [];
this.properties.answerPollKeyField = ''; this.properties.answerUserKeyField = '';
this.properties.answerValueField = '';
}
private ensureRefreshTimer(): void {
const seconds: number = this.normalizedRefreshSeconds();
if (seconds === this.currentRefreshSeconds && (seconds === 0 || this.refreshHandle !== undefined)) { return; }
@@ -158,7 +302,22 @@ export default class DynamicPollWebPart extends BaseClientSideWebPart<IDynamicPo
return String(user.email || user.loginName || user.displayName || '').trim();
}
private isConfigured(): boolean {
return !!(this.service && this.properties.pollListId && this.properties.answerListId && this.userKey);
return !!(this.service && this.properties.pollListId && this.properties.answerListId && this.userKey &&
this.properties.pollKeyField && this.properties.pollQuestionField && this.properties.pollOptionsField &&
this.properties.pollActiveField && this.properties.answerPollKeyField &&
this.properties.answerUserKeyField && this.properties.answerValueField);
}
private get fieldMapping(): IPollFieldMapping {
const answerKey: IListFieldOption = this.answerFields.filter((field: IListFieldOption): boolean =>
field.internalName === this.properties.answerPollKeyField)[0];
return {
pollKeyField: this.properties.pollKeyField || '', pollTitleField: this.properties.pollTitleField || '',
pollQuestionField: this.properties.pollQuestionField || '', pollOptionsField: this.properties.pollOptionsField || '',
pollStartField: this.properties.pollStartField || '', pollEndField: this.properties.pollEndField || '',
pollActiveField: this.properties.pollActiveField || '', answerPollKeyField: this.properties.answerPollKeyField || '',
answerPollKeyFieldType: answerKey ? answerKey.type : 'Text',
answerUserKeyField: this.properties.answerUserKeyField || '', answerValueField: this.properties.answerValueField || ''
};
}
private refreshPropertyPane(): void {
if (this.context && this.context.propertyPane) { this.context.propertyPane.refresh(); }
+98 -75
View File
@@ -1,11 +1,9 @@
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { calculatePollResults, findUserAnswer, parsePollOptionLines, selectActivePolls } from './PollCore';
import { IListOption, IPoll, IPollAnswer, IPollSnapshot } from './PollTypes';
import { IListFieldOption, IListOption, IPoll, IPollAnswer, IPollFieldMapping, IPollSnapshot } from './PollTypes';
export class PollDataService {
private optionsFieldNames: { [listId: string]: string } = {};
public constructor(private context: WebPartContext) { }
public getLists(): Promise<IListOption[]> {
@@ -15,42 +13,58 @@ export class PollDataService {
})));
}
public getSnapshots(pollListId: string, answerListId: string, userKey: string): Promise<IPollSnapshot[]> {
return this.getActivePolls(pollListId).then((polls: IPoll[]): Promise<IPollSnapshot[]> => {
const requests: Array<Promise<IPollSnapshot>> = polls.map((poll: IPoll): Promise<IPollSnapshot> =>
this.getAnswers(answerListId, poll.id).then((answers: IPollAnswer[]): IPollSnapshot => {
const results = calculatePollResults(poll.options, answers);
return {
poll: poll,
results: results,
totalVotes: results.reduce((total, result): number => total + result.votes, 0),
userAnswer: findUserAnswer(answers, userKey)
};
}));
return Promise.all(requests);
});
public getFields(listId: string): Promise<IListFieldOption[]> {
const url: string = this.webUrl + "/_api/web/lists(guid'" + this.validateGuid(listId) + "')/fields" +
'?$select=InternalName,Title,TypeAsString,ReadOnlyField,Hidden&$filter=Hidden eq false&$orderby=Title';
return this.getJson(url).then((payload: any): IListFieldOption[] => this.results(payload) // tslint:disable-line:no-any
.map((field: any): IListFieldOption => ({ // tslint:disable-line:no-any
internalName: String(field.InternalName || ''),
title: String(field.Title || field.InternalName || ''),
type: String(field.TypeAsString || ''),
readOnly: field.ReadOnlyField === true
})).filter((field: IListFieldOption): boolean => !!field.internalName));
}
public vote(pollListId: string, answerListId: string, pollId: number, answer: string, userKey: string): Promise<void> {
return this.getActivePolls(pollListId).then((polls: IPoll[]): Promise<void> => {
const poll: IPoll = polls.filter((candidate: IPoll): boolean => candidate.id === pollId)[0];
if (!poll || poll.options.indexOf(answer) < 0) {
return Promise.reject(new Error('POLL_NOT_ACTIVE'));
}
return this.saveVote(answerListId, pollId, answer, userKey);
});
}
private saveVote(answerListId: string, pollId: number, answer: string, userKey: string): Promise<void> {
return this.getAnswers(answerListId, pollId).then((answers: IPollAnswer[]): Promise<void> => {
if (findUserAnswer(answers, userKey)) { return Promise.reject(new Error('ALREADY_VOTED')); }
return this.getListEntityType(answerListId).then((entityType: string): Promise<void> => {
const body: any = { // tslint:disable-line:no-any
'__metadata': { 'type': entityType },
'Title': userKey,
'PollId': pollId,
'Answer': answer
public getSnapshots(pollListId: string, answerListId: string, userKey: string,
mapping: IPollFieldMapping): Promise<IPollSnapshot[]> {
return Promise.all([this.getActivePolls(pollListId, mapping), this.getAnswers(answerListId, mapping)])
.then((values: [IPoll[], IPollAnswer[]]): IPollSnapshot[] => values[0].map((poll: IPoll): IPollSnapshot => {
const answers: IPollAnswer[] = values[1].filter((candidate: IPollAnswer): boolean =>
candidate.pollKey.toLocaleLowerCase() === poll.key.toLocaleLowerCase());
const results = calculatePollResults(poll.options, answers);
return {
poll: poll,
results: results,
totalVotes: results.reduce((total, result): number => total + result.votes, 0),
userAnswer: findUserAnswer(answers, userKey)
};
}));
}
public vote(pollListId: string, answerListId: string, pollId: number, answer: string,
userKey: string, mapping: IPollFieldMapping): Promise<void> {
return this.getActivePolls(pollListId, mapping).then((polls: IPoll[]): Promise<void> => {
const poll: IPoll = polls.filter((candidate: IPoll): boolean => candidate.id === pollId)[0];
if (!poll || poll.options.indexOf(answer) < 0) { return Promise.reject(new Error('POLL_NOT_ACTIVE')); }
return this.saveVote(answerListId, poll, answer, userKey, mapping);
});
}
private saveVote(answerListId: string, poll: IPoll, answer: string, userKey: string,
mapping: IPollFieldMapping): Promise<void> {
return this.getAnswers(answerListId, mapping).then((answers: IPollAnswer[]): Promise<void> => {
const pollAnswers: IPollAnswer[] = answers.filter((candidate: IPollAnswer): boolean =>
candidate.pollKey.toLocaleLowerCase() === poll.key.toLocaleLowerCase());
if (findUserAnswer(pollAnswers, userKey)) { return Promise.reject(new Error('ALREADY_VOTED')); }
return this.getListEntityType(answerListId).then((entityType: string): Promise<void> => {
const body: any = { '__metadata': { 'type': entityType } }; // tslint:disable-line:no-any
body[mapping.answerUserKeyField] = userKey;
body[mapping.answerValueField] = answer;
if (mapping.answerPollKeyFieldType === 'Lookup') {
body[mapping.answerPollKeyField + 'Id'] = poll.id;
} else {
body[mapping.answerPollKeyField] = poll.key;
}
const url: string = this.webUrl + "/_api/web/lists(guid'" + this.validateGuid(answerListId) + "')/items";
return this.context.spHttpClient.post(url, SPHttpClient.configurations.v1, {
headers: this.writeHeaders, body: JSON.stringify(body)
@@ -59,47 +73,40 @@ export class PollDataService {
});
}
private getActivePolls(listId: string): Promise<IPoll[]> {
return this.getOptionsFieldName(listId).then((optionsField: string): Promise<IPoll[]> => {
const url: string = this.webUrl + "/_api/web/lists(guid'" + this.validateGuid(listId) + "')/items" +
'?$select=Id,Title,Question,' + optionsField +
',StartDate,EndDate,IsActive&$filter=IsActive eq 1&$orderby=StartDate desc&$top=100';
return this.getJson(url).then((payload: any): IPoll[] => { // tslint:disable-line:no-any
const polls: IPoll[] = this.results(payload).map((item: any): IPoll => ({ // tslint:disable-line:no-any
id: Number(item.Id),
title: String(item.Title || ''),
question: String(item.Question || item.Title || ''),
options: this.toStringArray(item[optionsField]),
startDate: this.toDate(item.StartDate),
endDate: this.toDate(item.EndDate),
isActive: item.IsActive === true || item.IsActive === 1
}));
return selectActivePolls(polls, new Date());
});
});
}
private getOptionsFieldName(listId: string): Promise<string> {
const cleanListId: string = this.validateGuid(listId);
if (this.optionsFieldNames[cleanListId]) { return Promise.resolve(this.optionsFieldNames[cleanListId]); }
const url: string = this.webUrl + "/_api/web/lists(guid'" + cleanListId + "')/fields" +
'?$select=InternalName';
return this.getJson(url).then((payload: any): string => { // tslint:disable-line:no-any
const names: string[] = this.results(payload).map((field: any): string => String(field.InternalName || '')); // tslint:disable-line:no-any
const fieldName: string = names.indexOf('OptionsText') >= 0 ? 'OptionsText' :
(names.indexOf('Options') >= 0 ? 'Options' : '');
if (!fieldName) { throw new Error('The poll options field is missing.'); }
this.optionsFieldNames[cleanListId] = fieldName;
return fieldName;
});
}
private getAnswers(listId: string, pollId: number): Promise<IPollAnswer[]> {
private getActivePolls(listId: string, mapping: IPollFieldMapping): Promise<IPoll[]> {
const fields: string[] = this.uniqueFields(['Id', mapping.pollKeyField, mapping.pollTitleField,
mapping.pollQuestionField, mapping.pollOptionsField, mapping.pollStartField,
mapping.pollEndField, mapping.pollActiveField]);
const url: string = this.webUrl + "/_api/web/lists(guid'" + this.validateGuid(listId) + "')/items" +
'?$select=Id,Title,Answer,PollId&$filter=PollId eq ' + Number(pollId) + '&$top=5000';
'?$select=' + fields.join(',') + '&$top=5000';
return this.getJson(url).then((payload: any): IPoll[] => { // tslint:disable-line:no-any
const polls: IPoll[] = this.results(payload).map((item: any): IPoll => ({ // tslint:disable-line:no-any
id: Number(item.Id),
key: this.readKey(item, mapping.pollKeyField),
title: this.readText(item, mapping.pollTitleField),
question: this.readText(item, mapping.pollQuestionField) || this.readText(item, mapping.pollTitleField),
options: this.toStringArray(item[mapping.pollOptionsField]),
startDate: this.toDate(item[mapping.pollStartField]),
endDate: this.toDate(item[mapping.pollEndField]),
isActive: this.toBoolean(item[mapping.pollActiveField])
})).filter((poll: IPoll): boolean => !!poll.key && !!poll.question && poll.options.length > 0);
return selectActivePolls(polls, new Date());
});
}
private getAnswers(listId: string, mapping: IPollFieldMapping): Promise<IPollAnswer[]> {
const keySelect: string = mapping.answerPollKeyFieldType === 'Lookup' ? mapping.answerPollKeyField + 'Id' :
mapping.answerPollKeyField;
const fields: string[] = this.uniqueFields(['Id', keySelect, mapping.answerUserKeyField, mapping.answerValueField]);
const url: string = this.webUrl + "/_api/web/lists(guid'" + this.validateGuid(listId) + "')/items" +
'?$select=' + fields.join(',') + '&$top=5000';
return this.getJson(url).then((payload: any): IPollAnswer[] => this.results(payload).map((item: any): IPollAnswer => ({ // tslint:disable-line:no-any
id: Number(item.Id), userKey: String(item.Title || ''), answer: String(item.Answer || '')
})));
id: Number(item.Id),
pollKey: mapping.answerPollKeyFieldType === 'Lookup' ? String(item[keySelect] || '') :
this.readKey(item, mapping.answerPollKeyField),
userKey: this.readText(item, mapping.answerUserKeyField),
answer: this.readText(item, mapping.answerValueField)
})).filter((item: IPollAnswer): boolean => !!item.pollKey && !!item.userKey));
}
private getListEntityType(listId: string): Promise<string> {
@@ -128,16 +135,32 @@ export class PollDataService {
const source: any = payload && payload.d !== undefined ? payload.d : payload; // tslint:disable-line:no-any
return source && Array.isArray(source.results) ? source.results : [];
}
private readKey(item: any, fieldName: string): string { // tslint:disable-line:no-any
if (!fieldName) { return ''; }
const value: any = fieldName.toLocaleUpperCase() === 'ID' ? item.Id : item[fieldName]; // tslint:disable-line:no-any
return String(value && (value.Name || value.LoginName || value.EMail || value.LookupValue || value.Title || value.Id) || value || '').trim();
}
private readText(item: any, fieldName: string): string { // tslint:disable-line:no-any
if (!fieldName) { return ''; }
const value: any = item[fieldName]; // tslint:disable-line:no-any
return String(value && (value.Title || value.LookupValue || value.Description || value.Url || value.Name) || value || '').trim();
}
private toStringArray(value: any): string[] { // tslint:disable-line:no-any
const source: any[] = value && Array.isArray(value.results) ? value.results : // tslint:disable-line:no-any
(Array.isArray(value) ? value : [value]);
return parsePollOptionLines(source.map((entry: any): string => String(entry || ''))); // tslint:disable-line:no-any
}
private toBoolean(value: any): boolean { // tslint:disable-line:no-any
return value === true || value === 1 || String(value || '').toLowerCase() === 'true' || String(value) === '1';
}
private toDate(value: any): Date | undefined { // tslint:disable-line:no-any
if (!value) { return undefined; }
const date: Date = new Date(String(value));
return isNaN(date.getTime()) ? undefined : date;
}
private uniqueFields(fields: string[]): string[] {
return fields.filter((field: string, index: number): boolean => !!field && fields.indexOf(field) === index);
}
private validateGuid(value: string): string {
const guid: string = this.cleanGuid(value);
if (!/^[0-9a-f]{8}-[0-9a-f-]{27}$/i.test(guid)) { throw new Error('Invalid list ID.'); }
+16 -1
View File
@@ -1,6 +1,21 @@
export interface IListOption { id: string; title: string; }
export interface IListFieldOption { internalName: string; title: string; type: string; readOnly: boolean; }
export interface IPollFieldMapping {
pollKeyField: string;
pollTitleField: string;
pollQuestionField: string;
pollOptionsField: string;
pollStartField: string;
pollEndField: string;
pollActiveField: string;
answerPollKeyField: string;
answerPollKeyFieldType: string;
answerUserKeyField: string;
answerValueField: string;
}
export interface IPoll {
id: number;
key: string;
title: string;
question: string;
options: string[];
@@ -8,7 +23,7 @@ export interface IPoll {
endDate?: Date;
isActive: boolean;
}
export interface IPollAnswer { id: number; userKey: string; answer: string; }
export interface IPollAnswer { id: number; pollKey: string; userKey: string; answer: string; }
export interface IPollResult { option: string; votes: number; percentage: number; }
export interface IPollSnapshot {
poll: IPoll;
@@ -1,5 +1,8 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
.webPart { color: "[theme: neutralPrimary, default: #323130]"; font-family: "Segoe UI", "Segoe UI Web (West European)", sans-serif; }
.webPartHeading { font-size: 24px; font-weight: 300; line-height: 32px; margin: 0 0 14px; }
.poll {
background: "[theme: white, default: #ffffff]";
box-sizing: border-box;
@@ -5,6 +5,7 @@ import { IDynamicPollStrings } from './IDynamicPollProps';
import styles from './DynamicPoll.module.scss';
export interface IDynamicPollCollectionProps {
title: string;
displayMode: PollDisplayMode;
snapshots: IPollSnapshot[];
showResultsBeforeVote: boolean;
@@ -29,7 +30,10 @@ export default class DynamicPollCollection
}
public render(): JSX.Element {
return this.props.displayMode === 'accordion' ? this.renderAccordion() : this.renderSlider();
return <section className={styles.webPart} aria-label={this.props.title}>
{this.props.title && <h2 className={styles.webPartHeading}>{this.props.title}</h2>}
{this.props.displayMode === 'accordion' ? this.renderAccordion() : this.renderSlider()}
</section>;
}
private renderAccordion(): JSX.Element {
+14
View File
@@ -2,8 +2,22 @@ define([], function() { return {
"PropertyPaneDescription": "Wählen Sie die Umfrage- und Antwortliste aus.",
"DataGroupName": "Datenquelle",
"BehaviorGroupName": "Verhalten",
"TitleFieldLabel": "WebPart-Titel",
"DefaultTitle": "Umfrage",
"PollListLabel": "Umfrageliste",
"AnswerListLabel": "Antwortliste",
"FieldMappingGroupName": "Spaltenzuordnung",
"NoFieldOption": "Nicht verwendet",
"PollKeyFieldLabel": "Umfrage: stabiler Schlüssel",
"PollTitleFieldLabel": "Umfrage: Titel (optional)",
"PollQuestionFieldLabel": "Umfrage: Frage",
"PollOptionsFieldLabel": "Umfrage: Antwortoptionen",
"PollStartFieldLabel": "Umfrage: Startdatum (optional)",
"PollEndFieldLabel": "Umfrage: Enddatum (optional)",
"PollActiveFieldLabel": "Umfrage: Aktiv",
"AnswerPollKeyFieldLabel": "Antwort: Umfrageschlüssel",
"AnswerUserKeyFieldLabel": "Antwort: Benutzerschlüssel",
"AnswerValueFieldLabel": "Antwort: ausgewählte Option",
"ShowResultsBeforeVoteLabel": "Ergebnisse vor der Abstimmung anzeigen",
"DisplayModeLabel": "Mehrere aktive Umfragen anzeigen als",
"AccordionModeLabel": "Accordion",
+14
View File
@@ -2,8 +2,22 @@ define([], function() { return {
"PropertyPaneDescription": "Select the poll and answer lists.",
"DataGroupName": "Data source",
"BehaviorGroupName": "Behavior",
"TitleFieldLabel": "Web part title",
"DefaultTitle": "Dynamic Poll",
"PollListLabel": "Poll list",
"AnswerListLabel": "Answer list",
"FieldMappingGroupName": "Column mapping",
"NoFieldOption": "Not used",
"PollKeyFieldLabel": "Poll: stable key",
"PollTitleFieldLabel": "Poll: title (optional)",
"PollQuestionFieldLabel": "Poll: question",
"PollOptionsFieldLabel": "Poll: answer options",
"PollStartFieldLabel": "Poll: start date (optional)",
"PollEndFieldLabel": "Poll: end date (optional)",
"PollActiveFieldLabel": "Poll: active",
"AnswerPollKeyFieldLabel": "Answer: poll key",
"AnswerUserKeyFieldLabel": "Answer: user key",
"AnswerValueFieldLabel": "Answer: selected option",
"ShowResultsBeforeVoteLabel": "Show results before voting",
"DisplayModeLabel": "Show multiple active polls as",
"AccordionModeLabel": "Accordion",
+6
View File
@@ -1,6 +1,12 @@
declare interface IDynamicPollWebPartStrings {
PropertyPaneDescription: string; DataGroupName: string; BehaviorGroupName: string;
TitleFieldLabel: string; DefaultTitle: string;
PollListLabel: string; AnswerListLabel: string; ShowResultsBeforeVoteLabel: string;
FieldMappingGroupName: string; NoFieldOption: string;
PollKeyFieldLabel: string; PollTitleFieldLabel: string; PollQuestionFieldLabel: string;
PollOptionsFieldLabel: string; PollStartFieldLabel: string; PollEndFieldLabel: string;
PollActiveFieldLabel: string; AnswerPollKeyFieldLabel: string;
AnswerUserKeyFieldLabel: string; AnswerValueFieldLabel: string;
RefreshSecondsLabel: string; SelectOption: string; ConfigureMessage: string;
LoadingMessage: string; NoPollMessage: string; LoadErrorMessage: string; VoteErrorMessage: string;
AlreadyVotedMessage: string; VoteButton: string; VotingButton: string; ThanksMessage: string;
+4 -4
View File
@@ -34,10 +34,10 @@ assert('all active polls are returned in activation order',
activePolls.length === 2 && activePolls[0].id === 2 && activePolls[1].id === 4);
const answers: IPollAnswer[] = [
{ id: 1, userKey: 'one@contoso.local', answer: 'A' },
{ id: 2, userKey: 'two@contoso.local', answer: 'B' },
{ id: 3, userKey: 'three@contoso.local', answer: 'A' },
{ id: 4, userKey: 'legacy@contoso.local', answer: 'Removed option' }
{ id: 1, pollKey: 'poll-1', userKey: 'one@contoso.local', answer: 'A' },
{ id: 2, pollKey: 'poll-1', userKey: 'two@contoso.local', answer: 'B' },
{ id: 3, pollKey: 'poll-1', userKey: 'three@contoso.local', answer: 'A' },
{ id: 4, pollKey: 'poll-1', userKey: 'legacy@contoso.local', answer: 'Removed option' }
];
const results = calculatePollResults(['A', 'B'], answers);
assert('votes are counted per configured option', results[0].votes === 2 && results[1].votes === 1);
+14 -3
View File
@@ -13,9 +13,10 @@ var manifest = json('src/webparts/dynamicPoll/DynamicPollWebPart.manifest.json')
var webPart = read('src/webparts/dynamicPoll/DynamicPollWebPart.ts');
var service = read('src/webparts/dynamicPoll/PollDataService.ts');
var component = read('src/webparts/dynamicPoll/components/DynamicPoll.tsx');
var collection = read('src/webparts/dynamicPoll/components/DynamicPollCollection.tsx');
var styles = read('src/webparts/dynamicPoll/components/DynamicPoll.module.scss');
assert(packageJson.version === '1.1.0' && solution.version === '1.1.0.0', 'Version mismatch.');
assert(packageJson.version === '1.3.0' && solution.version === '1.3.0.0', 'Version mismatch.');
assert(solution.includeClientSideAssets === true && solution.skipFeatureDeployment === true, 'Central deployment missing.');
assert(packageJson.name === 'dynamic-poll-web-part' && solution.name === 'dynamic-poll-web-part' &&
solution.title === 'dynamic-poll-web-part', 'Dynamic Poll package naming is inconsistent.');
@@ -24,17 +25,27 @@ assert(json('config/package-solution.json').paths.zippedPackage === 'solution/dy
assert(manifest.componentType === 'WebPart', 'Component is not a web part.');
assert(webPart.indexOf("PropertyPaneDropdown('pollListId'") >= 0 && webPart.indexOf("PropertyPaneDropdown('answerListId'") >= 0,
'List selection is missing.');
assert(webPart.indexOf("PropertyPaneDropdown('pollKeyField'") >= 0 &&
webPart.indexOf("PropertyPaneDropdown('answerPollKeyField'") >= 0 &&
webPart.indexOf("PropertyPaneDropdown('answerValueField'") >= 0, 'Column mapping is missing.');
assert(webPart.indexOf("PropertyPaneTextField('title'") >= 0 &&
collection.indexOf('styles.webPartHeading') >= 0,
'Configurable web part title is missing.');
assert(webPart.indexOf("PropertyPaneDropdown('displayMode'") >= 0 &&
webPart.indexOf('DynamicPollCollection') >= 0, 'Multiple-poll display mode is missing.');
assert(service.indexOf('getSnapshots') >= 0 && service.indexOf('getActivePolls') >= 0,
'All active polls must be loaded and remain voteable.');
assert(service.indexOf("'PollId': pollId") >= 0 && service.indexOf("'Answer': answer") >= 0, 'Vote persistence is missing.');
assert(service.indexOf("body[mapping.answerPollKeyField]") >= 0 &&
service.indexOf("body[mapping.answerValueField]") >= 0, 'Mapped vote persistence is missing.');
assert(service.indexOf("new Error('ALREADY_VOTED')") >= 0, 'Duplicate vote protection is missing.');
assert(service.indexOf("new Error('POLL_NOT_ACTIVE')") >= 0, 'Active poll and answer validation is missing.');
assert(service.indexOf("'OptionsText'") >= 0 && service.indexOf('parsePollOptionLines') >= 0,
assert(service.indexOf('mapping.pollOptionsField') >= 0 && service.indexOf('parsePollOptionLines') >= 0,
'Multiline poll options are missing.');
assert(read('deployment/Initialize-DynamicPollLists.ps1').indexOf('Type="Note" DisplayName="Options"') >= 0,
'Multiline options field provisioning is missing.');
assert(read('deployment/Initialize-DynamicPollLists.ps1').indexOf("InternalName 'PollKey'") >= 0 &&
read('deployment/Initialize-DynamicPollLists.ps1').indexOf('Type="Lookup" DisplayName="Poll"') < 0,
'Content Type Hub compatible text key provisioning is missing.');
assert(component.indexOf("type='radio'") >= 0 && component.indexOf("role='progressbar'") >= 0,
'Accessible voting or results UI is missing.');
assert(fs.existsSync(path.join(root, 'deployment/Initialize-DynamicPollLists.ps1')), 'Provisioning script is missing.');