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:
|
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`
|
- `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.
|
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
|
## 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.
|
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
|
## 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 `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 '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 '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 '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>'
|
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 {
|
finally {
|
||||||
if ($web) { $web.Dispose() }
|
if ($web) { $web.Dispose() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
import { IPoll, IPollAnswer, IPollResult } from './PollTypes';
|
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 {
|
export function selectActivePoll(polls: IPoll[], now: Date): IPoll | undefined {
|
||||||
const timestamp: number = now.getTime();
|
const timestamp: number = now.getTime();
|
||||||
const active: IPoll[] = polls.filter((poll: IPoll): boolean =>
|
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; }
|
function dateValue(value?: Date): number { return value ? value.getTime() : 0; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
|
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
|
||||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
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';
|
import { IListOption, IPoll, IPollAnswer, IPollSnapshot } from './PollTypes';
|
||||||
|
|
||||||
export class PollDataService {
|
export class PollDataService {
|
||||||
|
private optionsFieldNames: { [listId: string]: string } = {};
|
||||||
|
|
||||||
public constructor(private context: WebPartContext) { }
|
public constructor(private context: WebPartContext) { }
|
||||||
|
|
||||||
public getLists(): Promise<IListOption[]> {
|
public getLists(): Promise<IListOption[]> {
|
||||||
@@ -56,19 +58,37 @@ export class PollDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getActivePoll(listId: string): Promise<IPoll | undefined> {
|
private getActivePoll(listId: string): Promise<IPoll | undefined> {
|
||||||
const url: string = this.webUrl + "/_api/web/lists(guid'" + this.validateGuid(listId) + "')/items" +
|
return this.getOptionsFieldName(listId).then((optionsField: string): Promise<IPoll | undefined> => {
|
||||||
'?$select=Id,Title,Question,Options,StartDate,EndDate,IsActive&$filter=IsActive eq 1&$orderby=StartDate desc&$top=100';
|
const url: string = this.webUrl + "/_api/web/lists(guid'" + this.validateGuid(listId) + "')/items" +
|
||||||
return this.getJson(url).then((payload: any): IPoll | undefined => { // tslint:disable-line:no-any
|
'?$select=Id,Title,Question,' + optionsField +
|
||||||
const polls: IPoll[] = this.results(payload).map((item: any): IPoll => ({ // tslint:disable-line:no-any
|
',StartDate,EndDate,IsActive&$filter=IsActive eq 1&$orderby=StartDate desc&$top=100';
|
||||||
id: Number(item.Id),
|
return this.getJson(url).then((payload: any): IPoll | undefined => { // tslint:disable-line:no-any
|
||||||
title: String(item.Title || ''),
|
const polls: IPoll[] = this.results(payload).map((item: any): IPoll => ({ // tslint:disable-line:no-any
|
||||||
question: String(item.Question || item.Title || ''),
|
id: Number(item.Id),
|
||||||
options: this.toStringArray(item.Options),
|
title: String(item.Title || ''),
|
||||||
startDate: this.toDate(item.StartDate),
|
question: String(item.Question || item.Title || ''),
|
||||||
endDate: this.toDate(item.EndDate),
|
options: this.toStringArray(item[optionsField]),
|
||||||
isActive: item.IsActive === true || item.IsActive === 1
|
startDate: this.toDate(item.StartDate),
|
||||||
}));
|
endDate: this.toDate(item.EndDate),
|
||||||
return selectActivePoll(polls, new Date());
|
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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,9 +127,9 @@ export class PollDataService {
|
|||||||
return source && Array.isArray(source.results) ? source.results : [];
|
return source && Array.isArray(source.results) ? source.results : [];
|
||||||
}
|
}
|
||||||
private toStringArray(value: any): string[] { // tslint:disable-line:no-any
|
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 : // tslint:disable-line:no-any
|
||||||
const source: any[] = value && Array.isArray(value.results) ? value.results : (Array.isArray(value) ? value : []); // tslint:disable-line:no-any
|
(Array.isArray(value) ? value : [value]);
|
||||||
return source.map((entry: any): string => String(entry || '').trim()).filter((entry: string): boolean => !!entry); // tslint:disable-line:no-any
|
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
|
private toDate(value: any): Date | undefined { // tslint:disable-line:no-any
|
||||||
if (!value) { return undefined; }
|
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';
|
import { IPoll, IPollAnswer } from '../src/webparts/dynamicPoll/PollTypes';
|
||||||
|
|
||||||
let passed: number = 0;
|
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('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('user lookup is case insensitive', findUserAnswer(answers, 'ONE@CONTOSO.LOCAL') === 'A');
|
||||||
assert('unknown user has no answer', findUserAnswer(answers, 'unknown@contoso.local') === '');
|
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);
|
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("'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('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("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,
|
assert(component.indexOf("type='radio'") >= 0 && component.indexOf("role='progressbar'") >= 0,
|
||||||
'Accessible voting or results UI is missing.');
|
'Accessible voting or results UI is missing.');
|
||||||
assert(fs.existsSync(path.join(root, 'deployment/Initialize-DynamicPollLists.ps1')), 'Provisioning script is missing.');
|
assert(fs.existsSync(path.join(root, 'deployment/Initialize-DynamicPollLists.ps1')), 'Provisioning script is missing.');
|
||||||
|
|||||||
Reference in New Issue
Block a user