Add Dynamic Poll Web Part with core functionality and styling
- Initialize Gulp build process with gulpfile.js - Create package.json for project dependencies and scripts - Add TypeScript declarations for SCSS modules - Implement DynamicPollWebPart with property pane configuration - Create PollDataService for SharePoint list interactions - Develop core polling logic in PollCore.ts - Define types for polls and answers in PollTypes.ts - Create React components for DynamicPoll UI - Add localization support for English and German - Implement unit tests for core polling functionality - Set up TypeScript and TSLint configurations
This commit is contained in:
+19
@@ -0,0 +1,19 @@
|
||||
node_modules/
|
||||
lib/
|
||||
dist/
|
||||
temp/
|
||||
release/
|
||||
solution/
|
||||
sharepoint/solution/
|
||||
*.sppkg
|
||||
*.tgz
|
||||
coverage/
|
||||
*.resx.ts
|
||||
*.scss.ts
|
||||
*.log
|
||||
npm-debug.log*
|
||||
.vs/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"@microsoft/generator-sharepoint": {
|
||||
"version": "1.4.1",
|
||||
"libraryName": "dynamic-poll",
|
||||
"libraryId": "900239c9-b6c7-495d-a386-2d08a81a66f3",
|
||||
"environment": "onprem",
|
||||
"packageManager": "npm",
|
||||
"solutionName": "dynamic-poll",
|
||||
"componentType": "webpart"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Dynamic Poll
|
||||
|
||||
Ein eigenstaendiges, mit SharePoint Server Subscription Edition kompatibles SPFx-1.4.1-WebPart fuer einfache Umfragen. Die Funktion orientiert sich am PnP-Beispiel `react-dynamic-poll`, ist aber fuer die vorhandene Legacy-Toolchain und klassische SharePoint-REST-Endpunkte umgesetzt.
|
||||
|
||||
## Funktionen
|
||||
|
||||
- aktive Umfrage anhand von Aktiv-Flag sowie optionalem Start- und Enddatum
|
||||
- genau eine Antwort je angemeldetem Benutzer und Umfrage
|
||||
- unmittelbare Ergebnisanzeige mit Stimmen und Prozentwerten
|
||||
- optional sichtbare Ergebnisse vor der eigenen Abstimmung
|
||||
- konfigurierbares Aktualisierungsintervall
|
||||
- barrierearme Radio-Auswahl, Statusmeldungen und Ergebnisbalken
|
||||
- SharePoint-Theme-Farben statt fest verdrahteter Portal-Farben
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- SharePoint Server Subscription Edition mit SPFx 1.4.1
|
||||
- Node.js 8.17.0 und npm 6.x fuer den Build
|
||||
- SharePoint Management Shell fuer die Listenbereitstellung
|
||||
|
||||
## Listen bereitstellen
|
||||
|
||||
In der SharePoint Management Shell auf einem SharePoint-Server ausfuehren:
|
||||
|
||||
```powershell
|
||||
.\deployment\Initialize-DynamicPollLists.ps1 `
|
||||
-SiteUrl 'http://portal.contoso.local/sites/DEINE-SITE'
|
||||
```
|
||||
|
||||
Das Skript ist idempotent und legt standardmaessig diese Listen an:
|
||||
|
||||
- `Polls`: `Title`, `Question`, `Options`, `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.
|
||||
|
||||
Danach das WebPart auf einer modernen Seite einfuegen und im Property Pane die Listen `Polls` und `Poll Answers` auswaehlen.
|
||||
|
||||
## Paket bauen
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
npm run package
|
||||
```
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,6 @@
|
||||
# ToDo
|
||||
|
||||
- optionales Administrations-WebPart fuer das Anlegen und Beenden von Umfragen
|
||||
- serverseitig belastbare Eindeutigkeitsregel fuer Benutzer und Umfrage
|
||||
- optionales anonymisiertes Abstimmungsmodell
|
||||
- Listen mit mehr als 5.000 Antworten ueber Paging oder voraggregierte Ergebnisse unterstuetzen
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
|
||||
"version": "2.0",
|
||||
"bundles": {
|
||||
"dynamic-poll-web-part": {
|
||||
"components": [{
|
||||
"entrypoint": "./lib/webparts/dynamicPoll/DynamicPollWebPart.js",
|
||||
"manifest": "./src/webparts/dynamicPoll/DynamicPollWebPart.manifest.json"
|
||||
}]
|
||||
}
|
||||
},
|
||||
"externals": {},
|
||||
"localizedResources": {
|
||||
"DynamicPollWebPartStrings": "lib/webparts/dynamicPoll/loc/{locale}.js"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
{"$schema":"https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json","deployCdnPath":"temp/deploy"}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
|
||||
"solution": {
|
||||
"name": "dynamic-poll-client-side-solution",
|
||||
"id": "900239c9-b6c7-495d-a386-2d08a81a66f3",
|
||||
"version": "1.0.0.0",
|
||||
"includeClientSideAssets": true,
|
||||
"skipFeatureDeployment": true
|
||||
},
|
||||
"paths": { "zippedPackage": "solution/dynamic-poll.sppkg" }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/core-build/serve.schema.json",
|
||||
"port": 4321, "https": true, "initialPage": "https://localhost:5432/workbench",
|
||||
"api": { "port": 5432, "entryPath": "node_modules/@microsoft/sp-webpart-workbench/lib/api/" }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
{"$schema":"https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json","cdnBasePath":"<!-- PATH TO CDN -->"}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$SiteUrl,
|
||||
|
||||
[string]$PollListTitle = 'Polls',
|
||||
|
||||
[string]$AnswerListTitle = 'Poll Answers'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Ensure-Field {
|
||||
param(
|
||||
[Microsoft.SharePoint.SPList]$List,
|
||||
[string]$InternalName,
|
||||
[string]$SchemaXml
|
||||
)
|
||||
|
||||
if (-not $List.Fields.ContainsField($InternalName)) {
|
||||
[void]$List.Fields.AddFieldAsXml($SchemaXml, $true, [Microsoft.SharePoint.SPAddFieldOptions]::AddFieldInternalNameHint)
|
||||
$List.Update()
|
||||
}
|
||||
}
|
||||
|
||||
$web = Get-SPWeb -Identity $SiteUrl
|
||||
try {
|
||||
$polls = $web.Lists.TryGetList($PollListTitle)
|
||||
if (-not $polls) {
|
||||
$listId = $web.Lists.Add($PollListTitle, 'Questions and options for the Dynamic Poll web part.', [Microsoft.SharePoint.SPListTemplateType]::GenericList)
|
||||
$polls = $web.Lists[$listId]
|
||||
$polls.OnQuickLaunch = $true
|
||||
$polls.Update()
|
||||
}
|
||||
|
||||
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>'
|
||||
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>'
|
||||
|
||||
$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)
|
||||
$answers = $web.Lists[$listId]
|
||||
$answers.OnQuickLaunch = $false
|
||||
$answers.Update()
|
||||
}
|
||||
|
||||
$pollListId = $polls.ID.ToString('B')
|
||||
Ensure-Field -List $answers -InternalName 'Poll' -SchemaXml (('<Field Type="Lookup" DisplayName="Poll" Name="Poll" StaticName="Poll" List="{0}" ShowField="Title" Required="TRUE" />' -f $pollListId))
|
||||
Ensure-Field -List $answers -InternalName 'Answer' -SchemaXml '<Field Type="Text" DisplayName="Answer" Name="Answer" StaticName="Answer" Required="TRUE" MaxLength="255" />'
|
||||
|
||||
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)
|
||||
}
|
||||
finally {
|
||||
if ($web) { $web.Dispose() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
'use strict';
|
||||
const build = require('@microsoft/sp-build-web');
|
||||
build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`);
|
||||
build.initialize(require('gulp'));
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "dynamic-poll-web-part",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "lib/index.js",
|
||||
"engines": { "node": ">=6.9.0 <9.0.0" },
|
||||
"scripts": {
|
||||
"build": "gulp bundle",
|
||||
"clean": "gulp clean",
|
||||
"test": "tsc --noEmit -p . && tsc -p tests/tsconfig.json && node temp/tests/tests/PollCore.test.js && node tests/validate-project.js",
|
||||
"package": "npm test && gulp clean && gulp bundle --ship && gulp package-solution --ship"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/sp-core-library": "1.4.1",
|
||||
"@microsoft/sp-http": "1.4.1",
|
||||
"@microsoft/sp-office-ui-fabric-core": "1.4.1",
|
||||
"@microsoft/sp-webpart-base": "1.4.1",
|
||||
"@types/es6-promise": "0.0.33",
|
||||
"@types/react": "15.6.6",
|
||||
"@types/react-dom": "15.5.6",
|
||||
"@types/webpack-env": "1.13.1",
|
||||
"react": "15.6.2",
|
||||
"react-dom": "15.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@microsoft/sp-build-web": "1.4.1",
|
||||
"@microsoft/sp-module-interfaces": "1.4.1",
|
||||
"@microsoft/sp-tslint-rules": "1.4.1",
|
||||
"@microsoft/sp-webpart-workbench": "1.4.1",
|
||||
"ajv": "5.2.2",
|
||||
"gulp": "~3.9.1"
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare module '*.module.scss' {
|
||||
const styles: { [className: string]: string };
|
||||
export default styles;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Required TypeScript entry file.
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
|
||||
"id": "a786d2cc-2f81-488e-849a-312c75892a0f",
|
||||
"alias": "DynamicPollWebPart",
|
||||
"componentType": "WebPart",
|
||||
"version": "*",
|
||||
"manifestVersion": 2,
|
||||
"requiresCustomScript": false,
|
||||
"preconfiguredEntries": [{
|
||||
"groupId": "5c03119e-3074-46fd-976b-c60198311f70",
|
||||
"group": { "default": "Other", "de-de": "Weitere" },
|
||||
"title": { "default": "Dynamic Poll", "de-de": "Umfrage" },
|
||||
"description": {
|
||||
"default": "Displays an active poll and its results.",
|
||||
"de-de": "Zeigt eine aktive Umfrage und deren Ergebnisse an."
|
||||
},
|
||||
"officeFabricIconFontName": "PollResults",
|
||||
"properties": {
|
||||
"pollListId": "",
|
||||
"answerListId": "",
|
||||
"showResultsBeforeVote": false,
|
||||
"refreshSeconds": 60
|
||||
}
|
||||
}]
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDom from 'react-dom';
|
||||
import { Version } from '@microsoft/sp-core-library';
|
||||
import {
|
||||
BaseClientSideWebPart, IPropertyPaneConfiguration, IPropertyPaneDropdownOption,
|
||||
PropertyPaneDropdown, PropertyPaneSlider, PropertyPaneToggle
|
||||
} from '@microsoft/sp-webpart-base';
|
||||
import * as strings from 'DynamicPollWebPartStrings';
|
||||
import DynamicPoll from './components/DynamicPoll';
|
||||
import { IDynamicPollProps } from './components/IDynamicPollProps';
|
||||
import styles from './components/DynamicPoll.module.scss';
|
||||
import { PollDataService } from './PollDataService';
|
||||
import { IListOption, IPollSnapshot } from './PollTypes';
|
||||
|
||||
export interface IDynamicPollWebPartProps {
|
||||
pollListId: string;
|
||||
answerListId: string;
|
||||
showResultsBeforeVote: boolean;
|
||||
refreshSeconds: number;
|
||||
}
|
||||
|
||||
export default class DynamicPollWebPart extends BaseClientSideWebPart<IDynamicPollWebPartProps> {
|
||||
private service: PollDataService;
|
||||
private lists: IListOption[] = [];
|
||||
private loadingLists: boolean = false;
|
||||
private requestVersion: number = 0;
|
||||
private refreshHandle: number | undefined;
|
||||
private currentRefreshSeconds: number = 0;
|
||||
private disposed: boolean = false;
|
||||
|
||||
protected onInit(): Promise<void> {
|
||||
this.service = new PollDataService(this.context);
|
||||
return super.onInit().then((): Promise<void> => this.loadLists());
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
this.ensureRefreshTimer();
|
||||
this.loadSnapshot(true);
|
||||
}
|
||||
|
||||
protected onDispose(): void {
|
||||
this.disposed = true;
|
||||
this.requestVersion++;
|
||||
this.clearRefreshTimer();
|
||||
ReactDom.unmountComponentAtNode(this.domElement);
|
||||
}
|
||||
|
||||
protected get dataVersion(): Version { return Version.parse('1.0'); }
|
||||
|
||||
protected onPropertyPaneConfigurationStart(): void {
|
||||
if (!this.lists.length && !this.loadingLists) { this.loadLists(); }
|
||||
}
|
||||
|
||||
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
|
||||
const options: IPropertyPaneDropdownOption[] = [{ key: '', text: strings.SelectOption }];
|
||||
for (let i: number = 0; i < this.lists.length; i++) {
|
||||
options.push({ key: this.lists[i].id, text: this.lists[i].title });
|
||||
}
|
||||
return { pages: [{
|
||||
header: { description: strings.PropertyPaneDescription },
|
||||
groups: [{
|
||||
groupName: strings.DataGroupName,
|
||||
groupFields: [
|
||||
PropertyPaneDropdown('pollListId', { label: strings.PollListLabel, options: options, disabled: this.loadingLists }),
|
||||
PropertyPaneDropdown('answerListId', { label: strings.AnswerListLabel, options: options, disabled: this.loadingLists })
|
||||
]
|
||||
}, {
|
||||
groupName: strings.BehaviorGroupName,
|
||||
groupFields: [
|
||||
PropertyPaneToggle('showResultsBeforeVote', { label: strings.ShowResultsBeforeVoteLabel }),
|
||||
PropertyPaneSlider('refreshSeconds', {
|
||||
label: strings.RefreshSecondsLabel, min: 0, max: 300, step: 15, showValue: true
|
||||
})
|
||||
]
|
||||
}]
|
||||
}] };
|
||||
}
|
||||
|
||||
private loadSnapshot(showLoading: boolean): Promise<void> {
|
||||
const version: number = ++this.requestVersion;
|
||||
if (!this.isConfigured()) {
|
||||
this.renderStatus(strings.ConfigureMessage, false);
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (showLoading) { this.renderStatus(strings.LoadingMessage, false); }
|
||||
return this.service.getSnapshot(this.properties.pollListId, this.properties.answerListId, this.userKey)
|
||||
.then((snapshot: IPollSnapshot): void => {
|
||||
if (this.disposed || version !== this.requestVersion) { return; }
|
||||
if (!snapshot) { this.renderStatus(strings.NoPollMessage, false); return; }
|
||||
const element: React.ReactElement<IDynamicPollProps> = React.createElement(DynamicPoll, {
|
||||
snapshot: snapshot,
|
||||
showResultsBeforeVote: this.properties.showResultsBeforeVote === true,
|
||||
strings: {
|
||||
voteButton: strings.VoteButton, votingButton: strings.VotingButton,
|
||||
thanksMessage: strings.ThanksMessage, resultsHeading: strings.ResultsHeading,
|
||||
votesLabel: strings.VotesLabel, oneVoteLabel: strings.OneVoteLabel,
|
||||
selectAnswerMessage: strings.SelectAnswerMessage, voteErrorMessage: strings.VoteErrorMessage,
|
||||
alreadyVotedMessage: strings.AlreadyVotedMessage
|
||||
},
|
||||
onVote: (answer: string): Promise<void> => this.vote(snapshot.poll.id, answer)
|
||||
});
|
||||
ReactDom.render(element, this.domElement);
|
||||
}).catch((error: Error): void => {
|
||||
if (this.disposed || version !== this.requestVersion) { return; }
|
||||
if (window.console) { console.error('[DynamicPoll] ' + strings.LoadErrorMessage, error); }
|
||||
this.renderStatus(strings.LoadErrorMessage, true);
|
||||
});
|
||||
}
|
||||
|
||||
private vote(pollId: number, answer: string): Promise<void> {
|
||||
return this.service.vote(this.properties.pollListId, this.properties.answerListId, pollId, answer, this.userKey)
|
||||
.then((): Promise<void> => this.loadSnapshot(false));
|
||||
}
|
||||
|
||||
private loadLists(): Promise<void> {
|
||||
this.loadingLists = true;
|
||||
this.refreshPropertyPane();
|
||||
return this.service.getLists().then((lists: IListOption[]): void => { this.lists = lists; })
|
||||
.catch((error: Error): void => {
|
||||
this.lists = [];
|
||||
if (window.console) { console.error('[DynamicPoll] Lists could not be loaded.', error); }
|
||||
}).then((): void => {
|
||||
this.loadingLists = false;
|
||||
this.refreshPropertyPane();
|
||||
});
|
||||
}
|
||||
|
||||
private ensureRefreshTimer(): void {
|
||||
const seconds: number = this.normalizedRefreshSeconds();
|
||||
if (seconds === this.currentRefreshSeconds && (seconds === 0 || this.refreshHandle !== undefined)) { return; }
|
||||
this.clearRefreshTimer();
|
||||
this.currentRefreshSeconds = seconds;
|
||||
if (seconds > 0) {
|
||||
this.refreshHandle = window.setInterval((): void => { this.loadSnapshot(false); }, seconds * 1000);
|
||||
}
|
||||
}
|
||||
private clearRefreshTimer(): void {
|
||||
if (this.refreshHandle !== undefined) { window.clearInterval(this.refreshHandle); this.refreshHandle = undefined; }
|
||||
this.currentRefreshSeconds = 0;
|
||||
}
|
||||
private normalizedRefreshSeconds(): number {
|
||||
const value: number = Number(this.properties.refreshSeconds);
|
||||
return isFinite(value) ? Math.min(Math.max(Math.floor(value), 0), 300) : 60;
|
||||
}
|
||||
private get userKey(): string {
|
||||
const user: any = this.context.pageContext.user as any; // tslint:disable-line:no-any
|
||||
return String(user.email || user.loginName || user.displayName || '').trim();
|
||||
}
|
||||
private isConfigured(): boolean {
|
||||
return !!(this.service && this.properties.pollListId && this.properties.answerListId && this.userKey);
|
||||
}
|
||||
private refreshPropertyPane(): void {
|
||||
if (this.context && this.context.propertyPane) { this.context.propertyPane.refresh(); }
|
||||
}
|
||||
private renderStatus(message: string, error: boolean): void {
|
||||
ReactDom.unmountComponentAtNode(this.domElement);
|
||||
this.domElement.innerHTML = '';
|
||||
const status: HTMLDivElement = document.createElement('div');
|
||||
status.className = error ? styles.errorStatus : styles.status;
|
||||
status.setAttribute('role', error ? 'alert' : 'status');
|
||||
status.textContent = message;
|
||||
this.domElement.appendChild(status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { IPoll, IPollAnswer, IPollResult } from './PollTypes';
|
||||
|
||||
export function selectActivePoll(polls: IPoll[], now: Date): IPoll | undefined {
|
||||
const timestamp: number = now.getTime();
|
||||
const active: IPoll[] = polls.filter((poll: IPoll): boolean =>
|
||||
poll.isActive && poll.options.length > 0 &&
|
||||
(!poll.startDate || poll.startDate.getTime() <= timestamp) &&
|
||||
(!poll.endDate || poll.endDate.getTime() >= timestamp));
|
||||
active.sort((left: IPoll, right: IPoll): number =>
|
||||
dateValue(right.startDate) - dateValue(left.startDate) || right.id - left.id);
|
||||
return active[0];
|
||||
}
|
||||
|
||||
export function calculatePollResults(options: string[], answers: IPollAnswer[]): IPollResult[] {
|
||||
const counts: { [option: string]: number } = {};
|
||||
for (let i: number = 0; i < options.length; i++) { counts[options[i]] = 0; }
|
||||
for (let i: number = 0; i < answers.length; i++) {
|
||||
if (counts[answers[i].answer] !== undefined) { counts[answers[i].answer]++; }
|
||||
}
|
||||
const total: number = answers.filter((answer: IPollAnswer): boolean => counts[answer.answer] !== undefined).length;
|
||||
return options.map((option: string): IPollResult => ({
|
||||
option: option,
|
||||
votes: counts[option],
|
||||
percentage: total > 0 ? Math.round((counts[option] / total) * 100) : 0
|
||||
}));
|
||||
}
|
||||
|
||||
export function findUserAnswer(answers: IPollAnswer[], userKey: string): string {
|
||||
const expected: string = String(userKey || '').trim().toLowerCase();
|
||||
for (let i: number = 0; i < answers.length; i++) {
|
||||
if (String(answers[i].userKey || '').trim().toLowerCase() === expected) { return answers[i].answer; }
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function dateValue(value?: Date): number { return value ? value.getTime() : 0; }
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
|
||||
import { WebPartContext } from '@microsoft/sp-webpart-base';
|
||||
import { calculatePollResults, findUserAnswer, selectActivePoll } from './PollCore';
|
||||
import { IListOption, IPoll, IPollAnswer, IPollSnapshot } from './PollTypes';
|
||||
|
||||
export class PollDataService {
|
||||
public constructor(private context: WebPartContext) { }
|
||||
|
||||
public getLists(): Promise<IListOption[]> {
|
||||
const url: string = this.webUrl + '/_api/web/lists?$select=Id,Title&$filter=Hidden eq false&$orderby=Title';
|
||||
return this.getJson(url).then((payload: any): IListOption[] => this.results(payload).map((list: any): IListOption => ({ // tslint:disable-line:no-any
|
||||
id: this.cleanGuid(list.Id), title: String(list.Title || list.Id)
|
||||
})));
|
||||
}
|
||||
|
||||
public getSnapshot(pollListId: string, answerListId: string, userKey: string): Promise<IPollSnapshot | undefined> {
|
||||
return this.getActivePoll(pollListId).then((poll: IPoll): Promise<IPollSnapshot | undefined> => {
|
||||
if (!poll) { return Promise.resolve(undefined); }
|
||||
return 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)
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public vote(pollListId: string, answerListId: string, pollId: number, answer: string, userKey: string): Promise<void> {
|
||||
return this.getActivePoll(pollListId).then((poll: IPoll): Promise<void> => {
|
||||
if (!poll || poll.id !== pollId || 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<void> {
|
||||
return this.getAnswers(answerListId, pollId).then((answers: IPollAnswer[]): Promise<void> => {
|
||||
if (findUserAnswer(answers, userKey)) { return Promise.reject(new Error('ALREADY_VOTED')); }
|
||||
return this.getListEntityType(answerListId).then((entityType: string): Promise<void> => {
|
||||
const body: any = { // tslint:disable-line:no-any
|
||||
'__metadata': { 'type': entityType },
|
||||
'Title': userKey,
|
||||
'PollId': pollId,
|
||||
'Answer': answer
|
||||
};
|
||||
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)
|
||||
}).then((response: SPHttpClientResponse): Promise<void> => this.ensureOk(response));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private getActivePoll(listId: 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';
|
||||
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());
|
||||
});
|
||||
}
|
||||
|
||||
private getAnswers(listId: string, pollId: number): Promise<IPollAnswer[]> {
|
||||
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';
|
||||
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 || '')
|
||||
})));
|
||||
}
|
||||
|
||||
private getListEntityType(listId: string): Promise<string> {
|
||||
const url: string = this.webUrl + "/_api/web/lists(guid'" + this.validateGuid(listId) + "')?$select=ListItemEntityTypeFullName";
|
||||
return this.getJson(url).then((payload: any): string => { // tslint:disable-line:no-any
|
||||
const source: any = payload.d || payload; // tslint:disable-line:no-any
|
||||
if (!source.ListItemEntityTypeFullName) { throw new Error('List item entity type is missing.'); }
|
||||
return String(source.ListItemEntityTypeFullName);
|
||||
});
|
||||
}
|
||||
|
||||
private getJson(url: string): Promise<any> { // tslint:disable-line:no-any
|
||||
return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.readHeaders })
|
||||
.then((response: SPHttpClientResponse): Promise<any> => { // tslint:disable-line:no-any
|
||||
if (response.ok) { return response.json(); }
|
||||
return this.error(response).then((error: Error): Promise<any> => Promise.reject(error)); // tslint:disable-line:no-any
|
||||
});
|
||||
}
|
||||
private ensureOk(response: SPHttpClientResponse): Promise<void> {
|
||||
return response.ok ? Promise.resolve() : this.error(response).then((error: Error): Promise<void> => Promise.reject(error));
|
||||
}
|
||||
private error(response: SPHttpClientResponse): Promise<Error> {
|
||||
return response.text().then((text: string): Error => new Error('SharePoint request failed (' + response.status + '): ' + text));
|
||||
}
|
||||
private results(payload: any): any[] { // tslint:disable-line:no-any
|
||||
const source: any = payload && payload.d !== undefined ? payload.d : payload; // tslint:disable-line:no-any
|
||||
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
|
||||
}
|
||||
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 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.'); }
|
||||
return guid;
|
||||
}
|
||||
private cleanGuid(value: string): string { return String(value || '').replace(/[{}]/g, '').toLowerCase(); }
|
||||
private get webUrl(): string { return this.context.pageContext.web.absoluteUrl.replace(/\/$/, ''); }
|
||||
private get readHeaders(): any { return { 'Accept': 'application/json;odata=verbose', 'OData-Version': '3.0' }; } // tslint:disable-line:no-any
|
||||
private get writeHeaders(): any { return { // tslint:disable-line:no-any
|
||||
'Accept': 'application/json;odata=verbose', 'Content-Type': 'application/json;odata=verbose', 'OData-Version': '3.0'
|
||||
}; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface IListOption { id: string; title: string; }
|
||||
export interface IPoll {
|
||||
id: number;
|
||||
title: string;
|
||||
question: string;
|
||||
options: string[];
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
isActive: boolean;
|
||||
}
|
||||
export interface IPollAnswer { id: number; userKey: string; answer: string; }
|
||||
export interface IPollResult { option: string; votes: number; percentage: number; }
|
||||
export interface IPollSnapshot {
|
||||
poll: IPoll;
|
||||
results: IPollResult[];
|
||||
totalVotes: number;
|
||||
userAnswer: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
|
||||
|
||||
.poll {
|
||||
background: "[theme: white, default: #ffffff]";
|
||||
border: 1px solid "[theme: neutralLight, default: #edebe9]";
|
||||
box-sizing: border-box;
|
||||
color: "[theme: neutralPrimary, default: #323130]";
|
||||
font-family: "Segoe UI", "Segoe UI Web (West European)", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif;
|
||||
font-size: 14px;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
.question { font-size: 22px; font-weight: 600; line-height: 1.3; margin: 0 0 16px; }
|
||||
.options { border: 0; margin: 0; padding: 0; }
|
||||
.option {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid "[theme: neutralLight, default: #edebe9]";
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
min-height: 40px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.option:hover { background: "[theme: neutralLighter, default: #f3f2f1]"; }
|
||||
.option input { height: 16px; margin: 0; width: 16px; }
|
||||
.voteButton {
|
||||
background: "[theme: themePrimary, default: #0078d4]";
|
||||
border: 1px solid "[theme: themePrimary, default: #0078d4]";
|
||||
color: "[theme: white, default: #ffffff]";
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
margin-top: 16px;
|
||||
min-height: 32px;
|
||||
padding: 6px 20px;
|
||||
}
|
||||
.voteButton:hover { background: "[theme: themeDark, default: #005a9e]"; }
|
||||
.voteButton:focus { outline: 2px solid "[theme: neutralPrimary, default: #323130]"; outline-offset: 2px; }
|
||||
.voteButton:disabled { cursor: default; opacity: .6; }
|
||||
.thanks { background: "[theme: themeLighterAlt, default: #eff6fc]"; margin-bottom: 16px; padding: 10px 12px; }
|
||||
.error { color: "[theme: errorText, default: #a80000]"; margin-top: 10px; }
|
||||
.results { margin-top: 20px; }
|
||||
.resultsHeading { font-size: 18px; font-weight: 600; margin: 0 0 12px; }
|
||||
.result { margin-bottom: 12px; }
|
||||
.resultHeader { display: flex; gap: 12px; justify-content: space-between; margin-bottom: 4px; }
|
||||
.track { background: "[theme: neutralLighter, default: #f3f2f1]"; height: 10px; overflow: hidden; width: 100%; }
|
||||
.bar { background: "[theme: themePrimary, default: #0078d4]"; height: 100%; min-width: 0; transition: width 180ms ease; }
|
||||
.bar1 { background: #498205; }
|
||||
.bar2 { background: #8764b8; }
|
||||
.bar3 { background: #ca5010; }
|
||||
.bar4 { background: #038387; }
|
||||
.bar0 { background: "[theme: themePrimary, default: #0078d4]"; }
|
||||
.status,
|
||||
.errorStatus {
|
||||
border: 1px solid "[theme: neutralLight, default: #edebe9]";
|
||||
color: "[theme: neutralPrimary, default: #323130]";
|
||||
font-family: "Segoe UI", "Segoe UI Web (West European)", sans-serif;
|
||||
padding: 16px;
|
||||
}
|
||||
.errorStatus { border-color: "[theme: errorText, default: #a80000]"; color: "[theme: errorText, default: #a80000]"; }
|
||||
.visuallyHidden {
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from 'react';
|
||||
import { IPollResult } from '../PollTypes';
|
||||
import { IDynamicPollProps } from './IDynamicPollProps';
|
||||
import styles from './DynamicPoll.module.scss';
|
||||
|
||||
export interface IDynamicPollState {
|
||||
selected: string;
|
||||
submitting: boolean;
|
||||
error: string;
|
||||
voted: boolean;
|
||||
}
|
||||
|
||||
export default class DynamicPoll extends React.Component<IDynamicPollProps, IDynamicPollState> {
|
||||
private groupName: string = 'poll-' + Math.random().toString(36).substring(2, 10);
|
||||
|
||||
public constructor(props: IDynamicPollProps) {
|
||||
super(props);
|
||||
this.state = { selected: props.snapshot.userAnswer || '', submitting: false, error: '', voted: false };
|
||||
}
|
||||
|
||||
public componentWillReceiveProps(nextProps: IDynamicPollProps): void {
|
||||
if (nextProps.snapshot.poll.id !== this.props.snapshot.poll.id) {
|
||||
this.setState({ selected: nextProps.snapshot.userAnswer || '', submitting: false, error: '', voted: false });
|
||||
}
|
||||
}
|
||||
|
||||
public render(): React.ReactElement<any> { // tslint:disable-line:no-any
|
||||
const hasVoted: boolean = !!this.props.snapshot.userAnswer || this.state.voted;
|
||||
const showResults: boolean = hasVoted || this.props.showResultsBeforeVote;
|
||||
return <section className={styles.poll} aria-labelledby={this.groupName + '-question'}>
|
||||
<h2 id={this.groupName + '-question'} className={styles.question}>{this.props.snapshot.poll.question}</h2>
|
||||
{!hasVoted ? this.renderForm() : <div className={styles.thanks} role='status'>{this.props.strings.thanksMessage}</div>}
|
||||
{showResults ? this.renderResults() : undefined}
|
||||
</section>;
|
||||
}
|
||||
|
||||
private renderForm(): React.ReactElement<any> { // tslint:disable-line:no-any
|
||||
return <div>
|
||||
<fieldset className={styles.options} disabled={this.state.submitting}>
|
||||
<legend className={styles.visuallyHidden}>{this.props.snapshot.poll.question}</legend>
|
||||
{this.props.snapshot.poll.options.map((option: string, index: number) => {
|
||||
const id: string = this.groupName + '-option-' + index;
|
||||
return <label className={styles.option} htmlFor={id} key={option}>
|
||||
<input id={id} type='radio' name={this.groupName} value={option}
|
||||
checked={this.state.selected === option}
|
||||
onChange={() => this.setState({ selected: option, error: '' })} />
|
||||
<span>{option}</span>
|
||||
</label>;
|
||||
})}
|
||||
</fieldset>
|
||||
{this.state.error ? <div className={styles.error} role='alert'>{this.state.error}</div> : undefined}
|
||||
<button type='button' className={styles.voteButton} disabled={this.state.submitting} onClick={this.submitVote}>
|
||||
{this.state.submitting ? this.props.strings.votingButton : this.props.strings.voteButton}
|
||||
</button>
|
||||
</div>;
|
||||
}
|
||||
|
||||
private renderResults(): React.ReactElement<any> { // tslint:disable-line:no-any
|
||||
return <div className={styles.results} aria-live='polite'>
|
||||
<h3 className={styles.resultsHeading}>{this.props.strings.resultsHeading}</h3>
|
||||
{this.props.snapshot.results.map((result: IPollResult, index: number) =>
|
||||
<div className={styles.result} key={result.option}>
|
||||
<div className={styles.resultHeader}>
|
||||
<span>{result.option}</span>
|
||||
<span>{result.percentage}% · {result.votes} {result.votes === 1
|
||||
? this.props.strings.oneVoteLabel : this.props.strings.votesLabel}</span>
|
||||
</div>
|
||||
<div className={styles.track} role='progressbar' aria-label={result.option}
|
||||
aria-valuemin={0} aria-valuemax={100} aria-valuenow={result.percentage}>
|
||||
<div className={styles.bar + ' ' + styles['bar' + (index % 5)]} style={{ width: result.percentage + '%' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
private submitVote = (): void => {
|
||||
if (!this.state.selected) {
|
||||
this.setState({ error: this.props.strings.selectAnswerMessage });
|
||||
return;
|
||||
}
|
||||
this.setState({ submitting: true, error: '' });
|
||||
this.props.onVote(this.state.selected).then((): void => {
|
||||
this.setState({ submitting: false, voted: true });
|
||||
}).catch((error: Error): void => {
|
||||
const message: string = error && error.message === 'ALREADY_VOTED'
|
||||
? this.props.strings.alreadyVotedMessage : this.props.strings.voteErrorMessage;
|
||||
this.setState({ submitting: false, error: message });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { IPollSnapshot } from '../PollTypes';
|
||||
|
||||
export interface IDynamicPollStrings {
|
||||
voteButton: string; votingButton: string; thanksMessage: string; resultsHeading: string;
|
||||
votesLabel: string; oneVoteLabel: string; selectAnswerMessage: string;
|
||||
voteErrorMessage: string; alreadyVotedMessage: string;
|
||||
}
|
||||
export interface IDynamicPollProps {
|
||||
snapshot: IPollSnapshot;
|
||||
showResultsBeforeVote: boolean;
|
||||
strings: IDynamicPollStrings;
|
||||
onVote: (answer: string) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
define([], function() { return {
|
||||
"PropertyPaneDescription": "Wählen Sie die Umfrage- und Antwortliste aus.",
|
||||
"DataGroupName": "Datenquelle",
|
||||
"BehaviorGroupName": "Verhalten",
|
||||
"PollListLabel": "Umfrageliste",
|
||||
"AnswerListLabel": "Antwortliste",
|
||||
"ShowResultsBeforeVoteLabel": "Ergebnisse vor der Abstimmung anzeigen",
|
||||
"RefreshSecondsLabel": "Automatische Aktualisierung (Sekunden)",
|
||||
"SelectOption": "Auswählen...",
|
||||
"ConfigureMessage": "Wählen Sie in den WebPart-Einstellungen die Umfrage- und Antwortliste aus.",
|
||||
"LoadingMessage": "Umfrage wird geladen...",
|
||||
"NoPollMessage": "Derzeit ist keine aktive Umfrage vorhanden.",
|
||||
"LoadErrorMessage": "Die Umfrage konnte nicht geladen werden. Prüfen Sie die Listen und deren Spalten.",
|
||||
"VoteErrorMessage": "Ihre Stimme konnte nicht gespeichert werden.",
|
||||
"AlreadyVotedMessage": "Sie haben an dieser Umfrage bereits teilgenommen.",
|
||||
"VoteButton": "Abstimmen",
|
||||
"VotingButton": "Wird gespeichert...",
|
||||
"ThanksMessage": "Vielen Dank für Ihre Teilnahme!",
|
||||
"ResultsHeading": "Ergebnis",
|
||||
"VotesLabel": "Stimmen",
|
||||
"OneVoteLabel": "Stimme",
|
||||
"SelectAnswerMessage": "Wählen Sie vor der Abstimmung eine Antwort aus."
|
||||
}; });
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
define([], function() { return {
|
||||
"PropertyPaneDescription": "Select the poll and answer lists.",
|
||||
"DataGroupName": "Data source",
|
||||
"BehaviorGroupName": "Behavior",
|
||||
"PollListLabel": "Poll list",
|
||||
"AnswerListLabel": "Answer list",
|
||||
"ShowResultsBeforeVoteLabel": "Show results before voting",
|
||||
"RefreshSecondsLabel": "Automatic refresh (seconds)",
|
||||
"SelectOption": "Select...",
|
||||
"ConfigureMessage": "Select the poll and answer lists in the web part settings.",
|
||||
"LoadingMessage": "Loading poll...",
|
||||
"NoPollMessage": "There is currently no active poll.",
|
||||
"LoadErrorMessage": "The poll could not be loaded. Check the lists and their columns.",
|
||||
"VoteErrorMessage": "Your vote could not be saved.",
|
||||
"AlreadyVotedMessage": "You have already voted in this poll.",
|
||||
"VoteButton": "Vote",
|
||||
"VotingButton": "Saving...",
|
||||
"ThanksMessage": "Thank you for voting!",
|
||||
"ResultsHeading": "Results",
|
||||
"VotesLabel": "votes",
|
||||
"OneVoteLabel": "vote",
|
||||
"SelectAnswerMessage": "Select an answer before voting."
|
||||
}; });
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
declare interface IDynamicPollWebPartStrings {
|
||||
PropertyPaneDescription: string; DataGroupName: string; BehaviorGroupName: string;
|
||||
PollListLabel: string; AnswerListLabel: string; ShowResultsBeforeVoteLabel: string;
|
||||
RefreshSecondsLabel: string; SelectOption: string; ConfigureMessage: string;
|
||||
LoadingMessage: string; NoPollMessage: string; LoadErrorMessage: string; VoteErrorMessage: string;
|
||||
AlreadyVotedMessage: string; VoteButton: string; VotingButton: string; ThanksMessage: string;
|
||||
ResultsHeading: string; VotesLabel: string; OneVoteLabel: string; SelectAnswerMessage: string;
|
||||
}
|
||||
declare module 'DynamicPollWebPartStrings' {
|
||||
const strings: IDynamicPollWebPartStrings;
|
||||
export = strings;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { calculatePollResults, findUserAnswer, selectActivePoll } from '../src/webparts/dynamicPoll/PollCore';
|
||||
import { IPoll, IPollAnswer } from '../src/webparts/dynamicPoll/PollTypes';
|
||||
|
||||
let passed: number = 0;
|
||||
function assert(name: string, condition: boolean): void {
|
||||
if (!condition) { throw new Error('FAILED: ' + name); }
|
||||
passed++; console.log('OK:', name);
|
||||
}
|
||||
function poll(value: any): IPoll { return value as IPoll; } // tslint:disable-line:no-any
|
||||
|
||||
const now: Date = new Date('2026-08-23T12:00:00Z');
|
||||
const selected: IPoll = selectActivePoll([
|
||||
poll({ id: 1, question: 'old', options: ['A'], isActive: true,
|
||||
startDate: new Date('2026-08-20T00:00:00Z'), endDate: new Date('2026-08-22T00:00:00Z') }),
|
||||
poll({ id: 2, question: 'current', options: ['A', 'B'], isActive: true,
|
||||
startDate: new Date('2026-08-23T00:00:00Z'), endDate: new Date('2026-08-24T00:00:00Z') }),
|
||||
poll({ id: 3, question: 'future', options: ['A'], isActive: true,
|
||||
startDate: new Date('2026-08-24T00:00:00Z') })
|
||||
], now);
|
||||
assert('current poll is selected by activation window', !!selected && selected.id === 2);
|
||||
|
||||
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' }
|
||||
];
|
||||
const results = calculatePollResults(['A', 'B'], answers);
|
||||
assert('votes are counted per configured option', results[0].votes === 2 && results[1].votes === 1);
|
||||
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') === '');
|
||||
console.log('DynamicPoll core tests passed:', passed);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"outDir": "../temp/tests",
|
||||
"skipLibCheck": true,
|
||||
"typeRoots": ["../node_modules/@types", "../node_modules/@microsoft"],
|
||||
"types": ["es6-promise"]
|
||||
},
|
||||
"files": [
|
||||
"../src/webparts/dynamicPoll/PollTypes.ts",
|
||||
"../src/webparts/dynamicPoll/PollCore.ts",
|
||||
"PollCore.test.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
'use strict';
|
||||
var fs = require('fs');
|
||||
var path = require('path');
|
||||
var root = path.resolve(__dirname, '..');
|
||||
var passed = 0;
|
||||
function read(file) { return fs.readFileSync(path.join(root, file), 'utf8'); }
|
||||
function json(file) { return JSON.parse(read(file)); }
|
||||
function assert(condition, message) { if (!condition) { throw new Error(message); } passed++; }
|
||||
|
||||
var packageJson = json('package.json');
|
||||
var solution = json('config/package-solution.json').solution;
|
||||
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 styles = read('src/webparts/dynamicPoll/components/DynamicPoll.module.scss');
|
||||
|
||||
assert(packageJson.version === '1.0.0' && solution.version === '1.0.0.0', 'Version mismatch.');
|
||||
assert(solution.includeClientSideAssets === true && solution.skipFeatureDeployment === true, 'Central deployment missing.');
|
||||
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(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(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.');
|
||||
['neutralPrimary', 'neutralLight', 'themePrimary', 'themeDark', 'white'].forEach(function (slot) {
|
||||
assert(styles.indexOf('[theme: ' + slot + ', default:') >= 0, 'Theme slot missing: ' + slot);
|
||||
});
|
||||
console.log('DynamicPoll project validation passed:', passed);
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5", "forceConsistentCasingInFileNames": true, "module": "esnext",
|
||||
"moduleResolution": "node", "jsx": "react", "declaration": true, "sourceMap": true,
|
||||
"experimentalDecorators": true, "skipLibCheck": true,
|
||||
"typeRoots": ["./node_modules/@types", "./node_modules/@microsoft"],
|
||||
"types": ["es6-promise", "webpack-env"],
|
||||
"lib": ["es5", "dom", "es2015.collection"]
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@microsoft/sp-tslint-rules/base-tslint.json",
|
||||
"rules": {
|
||||
"class-name": false, "export-name": false, "forin": false, "label-position": false,
|
||||
"member-access": true, "no-arg": false, "no-console": false, "no-construct": false,
|
||||
"no-duplicate-variable": true, "no-eval": false, "no-function-expression": true,
|
||||
"no-internal-module": true, "no-shadowed-variable": true, "no-switch-case-fall-through": true,
|
||||
"no-unnecessary-semicolons": true, "no-unused-expression": true, "no-use-before-declare": true,
|
||||
"no-with-statement": true, "semicolon": true, "trailing-comma": false, "typedef": false,
|
||||
"typedef-whitespace": false, "use-named-parameter": true, "variable-name": false, "whitespace": false
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user