diff --git a/README.md b/README.md
index 4beda02..8e437e1 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/config/package-solution.json b/config/package-solution.json
index 3d7f4cb..992ba36 100644
--- a/config/package-solution.json
+++ b/config/package-solution.json
@@ -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
},
diff --git a/deployment/Initialize-DynamicPollLists.ps1 b/deployment/Initialize-DynamicPollLists.ps1
index 58aabdf..55cc569 100644
--- a/deployment/Initialize-DynamicPollLists.ps1
+++ b/deployment/Initialize-DynamicPollLists.ps1
@@ -34,6 +34,7 @@ try {
}
Ensure-Field -List $polls -InternalName 'Question' -SchemaXml ''
+ Ensure-Field -List $polls -InternalName 'PollKey' -SchemaXml ''
$legacyOptionsField = $null
try { $legacyOptionsField = $polls.Fields.GetFieldByInternalName('Options') } catch { }
@@ -71,6 +72,13 @@ try {
Ensure-Field -List $polls -InternalName 'EndDate' -SchemaXml ''
Ensure-Field -List $polls -InternalName 'IsActive' -SchemaXml '0'
+ 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 (('' -f $pollListId))
+ Ensure-Field -List $answers -InternalName 'PollKey' -SchemaXml ''
+ Ensure-Field -List $answers -InternalName 'UserKey' -SchemaXml ''
Ensure-Field -List $answers -InternalName 'Answer' -SchemaXml ''
+ # 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)
diff --git a/package.json b/package.json
index 4272a17..1efe62a 100644
--- a/package.json
+++ b/package.json
@@ -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" },
diff --git a/src/webparts/dynamicPoll/DynamicPollWebPart.manifest.json b/src/webparts/dynamicPoll/DynamicPollWebPart.manifest.json
index c2ca047..4a0dcf0 100644
--- a/src/webparts/dynamicPoll/DynamicPollWebPart.manifest.json
+++ b/src/webparts/dynamicPoll/DynamicPollWebPart.manifest.json
@@ -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
diff --git a/src/webparts/dynamicPoll/DynamicPollWebPart.ts b/src/webparts/dynamicPoll/DynamicPollWebPart.ts
index 719be3f..907cc0c 100644
--- a/src/webparts/dynamicPoll/DynamicPollWebPart.ts
+++ b/src/webparts/dynamicPoll/DynamicPollWebPart.ts
@@ -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 {
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 {
this.service = new PollDataService(this.context);
- return super.onInit().then((): Promise => this.loadLists());
+ return super.onInit().then((): Promise => this.loadLists())
+ .then((): Promise => {
+ const requests: Array> = [];
+ 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 {
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 = 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 {
- 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 => this.loadSnapshot(false));
}
@@ -136,6 +206,80 @@ export default class DynamicPollWebPart extends BaseClientSideWebPart {
+ 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
+ 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(); }
diff --git a/src/webparts/dynamicPoll/PollDataService.ts b/src/webparts/dynamicPoll/PollDataService.ts
index 14e1401..cfe593c 100644
--- a/src/webparts/dynamicPoll/PollDataService.ts
+++ b/src/webparts/dynamicPoll/PollDataService.ts
@@ -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 {
@@ -15,42 +13,58 @@ export class PollDataService {
})));
}
- public getSnapshots(pollListId: string, answerListId: string, userKey: string): Promise {
- return this.getActivePolls(pollListId).then((polls: IPoll[]): Promise => {
- const requests: Array> = polls.map((poll: IPoll): Promise =>
- 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 {
+ 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 {
- return this.getActivePolls(pollListId).then((polls: IPoll[]): Promise => {
- 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 {
- return this.getAnswers(answerListId, pollId).then((answers: IPollAnswer[]): Promise => {
- if (findUserAnswer(answers, userKey)) { return Promise.reject(new Error('ALREADY_VOTED')); }
- return this.getListEntityType(answerListId).then((entityType: string): Promise => {
- 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 {
+ 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 {
+ return this.getActivePolls(pollListId, mapping).then((polls: IPoll[]): Promise => {
+ 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 {
+ return this.getAnswers(answerListId, mapping).then((answers: IPollAnswer[]): Promise => {
+ 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 => {
+ 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 {
- return this.getOptionsFieldName(listId).then((optionsField: string): Promise => {
- 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 {
- 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 {
+ private getActivePolls(listId: string, mapping: IPollFieldMapping): Promise {
+ 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 {
+ 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 {
@@ -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.'); }
diff --git a/src/webparts/dynamicPoll/PollTypes.ts b/src/webparts/dynamicPoll/PollTypes.ts
index 96718d9..5445caa 100644
--- a/src/webparts/dynamicPoll/PollTypes.ts
+++ b/src/webparts/dynamicPoll/PollTypes.ts
@@ -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;
diff --git a/src/webparts/dynamicPoll/components/DynamicPoll.module.scss b/src/webparts/dynamicPoll/components/DynamicPoll.module.scss
index d375434..654cad8 100644
--- a/src/webparts/dynamicPoll/components/DynamicPoll.module.scss
+++ b/src/webparts/dynamicPoll/components/DynamicPoll.module.scss
@@ -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;
diff --git a/src/webparts/dynamicPoll/components/DynamicPollCollection.tsx b/src/webparts/dynamicPoll/components/DynamicPollCollection.tsx
index cad846a..1d59c88 100644
--- a/src/webparts/dynamicPoll/components/DynamicPollCollection.tsx
+++ b/src/webparts/dynamicPoll/components/DynamicPollCollection.tsx
@@ -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
+ {this.props.title && {this.props.title}
}
+ {this.props.displayMode === 'accordion' ? this.renderAccordion() : this.renderSlider()}
+ ;
}
private renderAccordion(): JSX.Element {
diff --git a/src/webparts/dynamicPoll/loc/de-de.js b/src/webparts/dynamicPoll/loc/de-de.js
index 0d9d107..57ebda4 100644
--- a/src/webparts/dynamicPoll/loc/de-de.js
+++ b/src/webparts/dynamicPoll/loc/de-de.js
@@ -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",
diff --git a/src/webparts/dynamicPoll/loc/en-us.js b/src/webparts/dynamicPoll/loc/en-us.js
index d6c2f52..3be59f2 100644
--- a/src/webparts/dynamicPoll/loc/en-us.js
+++ b/src/webparts/dynamicPoll/loc/en-us.js
@@ -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",
diff --git a/src/webparts/dynamicPoll/loc/myStrings.d.ts b/src/webparts/dynamicPoll/loc/myStrings.d.ts
index ac9dafd..3d2e0b7 100644
--- a/src/webparts/dynamicPoll/loc/myStrings.d.ts
+++ b/src/webparts/dynamicPoll/loc/myStrings.d.ts
@@ -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;
diff --git a/tests/PollCore.test.ts b/tests/PollCore.test.ts
index b54c42a..cc44645 100644
--- a/tests/PollCore.test.ts
+++ b/tests/PollCore.test.ts
@@ -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);
diff --git a/tests/validate-project.js b/tests/validate-project.js
index 6248d53..c672b59 100644
--- a/tests/validate-project.js
+++ b/tests/validate-project.js
@@ -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.');