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.');