Implement multiline options support and legacy field handling in Dynamic Poll
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -34,7 +34,39 @@ try {
|
||||
}
|
||||
|
||||
Ensure-Field -List $polls -InternalName 'Question' -SchemaXml '<Field Type="Text" DisplayName="Question" Name="Question" StaticName="Question" Required="TRUE" />'
|
||||
Ensure-Field -List $polls -InternalName 'Options' -SchemaXml '<Field Type="MultiChoice" DisplayName="Options" Name="Options" StaticName="Options" Required="TRUE" FillInChoice="TRUE"><CHOICES><CHOICE>Option 1</CHOICE><CHOICE>Option 2</CHOICE></CHOICES></Field>'
|
||||
|
||||
$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 '<Field Type="Note" DisplayName="Options" Name="OptionsText" StaticName="OptionsText" Required="FALSE" NumLines="8" RichText="FALSE" AppendOnly="FALSE" />'
|
||||
|
||||
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 '<Field Type="DateTime" DisplayName="Start date" Name="StartDate" StaticName="StartDate" Format="DateTime" />'
|
||||
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>'
|
||||
@@ -58,4 +90,3 @@ try {
|
||||
finally {
|
||||
if ($web) { $web.Dispose() }
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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<IListOption[]> {
|
||||
@@ -56,20 +58,38 @@ export class PollDataService {
|
||||
}
|
||||
|
||||
private getActivePoll(listId: string): Promise<IPoll | undefined> {
|
||||
return this.getOptionsFieldName(listId).then((optionsField: string): Promise<IPoll | undefined> => {
|
||||
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';
|
||||
'?$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.Options),
|
||||
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<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[]> {
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.');
|
||||
|
||||
Reference in New Issue
Block a user