diff --git a/README.md b/README.md
index 9fc8cff..ef22ea7 100644
--- a/README.md
+++ b/README.md
@@ -29,14 +29,22 @@ In der SharePoint Management Shell auf einem SharePoint-Server ausfuehren:
Das Skript ist idempotent und legt standardmaessig diese Listen an:
-- `Polls`: `Title`, `Question`, `Options`, `StartDate`, `EndDate`, `IsActive`
+- `Polls`: `Title`, `Question`, `Options` (mehrzeiliger Nur-Text), `StartDate`, `EndDate`, `IsActive`
- `Poll Answers`: `Title` (Benutzerschluessel), `Poll` (Lookup), `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.
## Umfrage anlegen
-In `Polls` ein Element mit Frage und mindestens einer Option anlegen. `IsActive` aktivieren; Start- und Enddatum koennen leer bleiben. Sind mehrere Umfragen gleichzeitig aktiv, wird die mit dem neuesten Startdatum verwendet.
+In `Polls` ein Element mit Frage anlegen und in `Options` jede Antwortmoeglichkeit in eine eigene Zeile schreiben, zum Beispiel:
+
+```text
+Ja
+Nein
+Anderer Termin
+```
+
+Leerzeilen und doppelte Eintraege werden ignoriert. `IsActive` aktivieren; Start- und Enddatum koennen leer bleiben. Sind mehrere Umfragen gleichzeitig aktiv, wird die mit dem neuesten Startdatum verwendet.
Danach das WebPart auf einer modernen Seite einfuegen und im Property Pane die Listen `Polls` und `Poll Answers` auswaehlen.
@@ -52,3 +60,5 @@ Das fertige Paket liegt unter `sharepoint/solution/dynamic-poll.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.
+
+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.
diff --git a/deployment/Initialize-DynamicPollLists.ps1 b/deployment/Initialize-DynamicPollLists.ps1
index 33a727d..58aabdf 100644
--- a/deployment/Initialize-DynamicPollLists.ps1
+++ b/deployment/Initialize-DynamicPollLists.ps1
@@ -34,7 +34,39 @@ try {
}
Ensure-Field -List $polls -InternalName 'Question' -SchemaXml ''
- Ensure-Field -List $polls -InternalName 'Options' -SchemaXml 'Option 1Option 2'
+
+ $legacyOptionsField = $null
+ try { $legacyOptionsField = $polls.Fields.GetFieldByInternalName('Options') } catch { }
+ if ($legacyOptionsField) {
+ $legacyOptionsField.Title = 'Options (legacy)'
+ $legacyOptionsField.Required = $false
+ $legacyOptionsField.ShowInNewForm = $false
+ $legacyOptionsField.ShowInEditForm = $false
+ $legacyOptionsField.ShowInDisplayForm = $false
+ $legacyOptionsField.Update()
+ }
+
+ Ensure-Field -List $polls -InternalName 'OptionsText' -SchemaXml ''
+
+ if ($legacyOptionsField) {
+ foreach ($item in $polls.Items) {
+ if (-not [string]::IsNullOrWhiteSpace([string]$item['OptionsText']) -or $null -eq $item[$legacyOptionsField.Id]) { continue }
+ $legacyValue = $legacyOptionsField.GetFieldValue([string]$item[$legacyOptionsField.Id])
+ $optionLines = New-Object System.Collections.Generic.List[string]
+ if ($legacyValue -is [Microsoft.SharePoint.SPFieldMultiChoiceValue]) {
+ for ($choiceIndex = 0; $choiceIndex -lt $legacyValue.Count; $choiceIndex++) {
+ if (-not [string]::IsNullOrWhiteSpace($legacyValue[$choiceIndex])) { $optionLines.Add($legacyValue[$choiceIndex]) }
+ }
+ }
+ elseif (-not [string]::IsNullOrWhiteSpace([string]$legacyValue)) {
+ $optionLines.Add([string]$legacyValue)
+ }
+ if ($optionLines.Count -gt 0) {
+ $item['OptionsText'] = ($optionLines -join [Environment]::NewLine)
+ $item.SystemUpdate($false)
+ }
+ }
+ }
Ensure-Field -List $polls -InternalName 'StartDate' -SchemaXml ''
Ensure-Field -List $polls -InternalName 'EndDate' -SchemaXml ''
Ensure-Field -List $polls -InternalName 'IsActive' -SchemaXml '0'
@@ -58,4 +90,3 @@ try {
finally {
if ($web) { $web.Dispose() }
}
-
diff --git a/src/webparts/dynamicPoll/PollCore.ts b/src/webparts/dynamicPoll/PollCore.ts
index 167cef7..de005e4 100644
--- a/src/webparts/dynamicPoll/PollCore.ts
+++ b/src/webparts/dynamicPoll/PollCore.ts
@@ -1,5 +1,19 @@
import { IPoll, IPollAnswer, IPollResult } from './PollTypes';
+export function parsePollOptionLines(values: string[]): string[] {
+ const options: string[] = [];
+ const seen: { [key: string]: boolean } = {};
+ for (let valueIndex: number = 0; valueIndex < values.length; valueIndex++) {
+ const lines: string[] = String(values[valueIndex] || '').split(/\r\n|\n|\r/);
+ for (let lineIndex: number = 0; lineIndex < lines.length; lineIndex++) {
+ const option: string = lines[lineIndex].trim();
+ const key: string = option.toLocaleLowerCase();
+ if (option && !seen[key]) { seen[key] = true; options.push(option); }
+ }
+ }
+ return options;
+}
+
export function selectActivePoll(polls: IPoll[], now: Date): IPoll | undefined {
const timestamp: number = now.getTime();
const active: IPoll[] = polls.filter((poll: IPoll): boolean =>
@@ -34,4 +48,3 @@ export function findUserAnswer(answers: IPollAnswer[], userKey: string): string
}
function dateValue(value?: Date): number { return value ? value.getTime() : 0; }
-
diff --git a/src/webparts/dynamicPoll/PollDataService.ts b/src/webparts/dynamicPoll/PollDataService.ts
index 67cd136..4d69ee3 100644
--- a/src/webparts/dynamicPoll/PollDataService.ts
+++ b/src/webparts/dynamicPoll/PollDataService.ts
@@ -1,9 +1,11 @@
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
import { WebPartContext } from '@microsoft/sp-webpart-base';
-import { calculatePollResults, findUserAnswer, selectActivePoll } from './PollCore';
+import { calculatePollResults, findUserAnswer, parsePollOptionLines, selectActivePoll } from './PollCore';
import { IListOption, IPoll, IPollAnswer, IPollSnapshot } from './PollTypes';
export class PollDataService {
+ private optionsFieldNames: { [listId: string]: string } = {};
+
public constructor(private context: WebPartContext) { }
public getLists(): Promise {
@@ -56,19 +58,37 @@ export class PollDataService {
}
private getActivePoll(listId: string): Promise {
- const url: string = this.webUrl + "/_api/web/lists(guid'" + this.validateGuid(listId) + "')/items" +
- '?$select=Id,Title,Question,Options,StartDate,EndDate,IsActive&$filter=IsActive eq 1&$orderby=StartDate desc&$top=100';
- return this.getJson(url).then((payload: any): IPoll | undefined => { // 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.Options),
- startDate: this.toDate(item.StartDate),
- endDate: this.toDate(item.EndDate),
- isActive: item.IsActive === true || item.IsActive === 1
- }));
- return selectActivePoll(polls, new Date());
+ 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 | undefined => { // 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 selectActivePoll(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;
});
}
@@ -107,9 +127,9 @@ export class PollDataService {
return source && Array.isArray(source.results) ? source.results : [];
}
private toStringArray(value: any): string[] { // tslint:disable-line:no-any
- if (typeof value === 'string' && value.trim()) { return [value.trim()]; }
- const source: any[] = value && Array.isArray(value.results) ? value.results : (Array.isArray(value) ? value : []); // tslint:disable-line:no-any
- return source.map((entry: any): string => String(entry || '').trim()).filter((entry: string): boolean => !!entry); // 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 toDate(value: any): Date | undefined { // tslint:disable-line:no-any
if (!value) { return undefined; }
diff --git a/tests/PollCore.test.ts b/tests/PollCore.test.ts
index fe5cf6d..930c350 100644
--- a/tests/PollCore.test.ts
+++ b/tests/PollCore.test.ts
@@ -1,4 +1,4 @@
-import { calculatePollResults, findUserAnswer, selectActivePoll } from '../src/webparts/dynamicPoll/PollCore';
+import { calculatePollResults, findUserAnswer, parsePollOptionLines, selectActivePoll } from '../src/webparts/dynamicPoll/PollCore';
import { IPoll, IPollAnswer } from '../src/webparts/dynamicPoll/PollTypes';
let passed: number = 0;
@@ -30,5 +30,7 @@ assert('votes are counted per configured option', results[0].votes === 2 && resu
assert('invalid legacy answer is excluded from percentage', results[0].percentage === 67 && results[1].percentage === 33);
assert('user lookup is case insensitive', findUserAnswer(answers, 'ONE@CONTOSO.LOCAL') === 'A');
assert('unknown user has no answer', findUserAnswer(answers, 'unknown@contoso.local') === '');
+const parsedOptions: string[] = parsePollOptionLines(['Ja\r\nNein\n\n Anderer Termin ', 'ja']);
+assert('multiline options are trimmed, split and deduplicated',
+ parsedOptions.length === 3 && parsedOptions[0] === 'Ja' && parsedOptions[2] === 'Anderer Termin');
console.log('DynamicPoll core tests passed:', passed);
-
diff --git a/tests/validate-project.js b/tests/validate-project.js
index 7725d8a..2a2017d 100644
--- a/tests/validate-project.js
+++ b/tests/validate-project.js
@@ -23,6 +23,10 @@ assert(webPart.indexOf("PropertyPaneDropdown('pollListId'") >= 0 && webPart.inde
assert(service.indexOf("'PollId': pollId") >= 0 && service.indexOf("'Answer': answer") >= 0, '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,
+ 'Multiline poll options are missing.');
+assert(read('deployment/Initialize-DynamicPollLists.ps1').indexOf('Type="Note" DisplayName="Options"') >= 0,
+ 'Multiline options field 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.');