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:
Torsten Brendgen
2026-08-23 22:51:10 +02:00
commit ca41674188
30 changed files with 955 additions and 0 deletions
+19
View File
@@ -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
View File
@@ -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"
}
}
+54
View File
@@ -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.
+6
View File
@@ -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
+17
View File
@@ -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"
}
}
+2
View File
@@ -0,0 +1,2 @@
{"$schema":"https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json","deployCdnPath":"temp/deploy"}
+12
View File
@@ -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" }
}
+6
View File
@@ -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/" }
}
+2
View File
@@ -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() }
}
+5
View File
@@ -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'));
+33
View File
@@ -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"
}
}
+5
View File
@@ -0,0 +1,5 @@
declare module '*.module.scss' {
const styles: { [className: string]: string };
export default styles;
}
+2
View File
@@ -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);
}
}
+37
View File
@@ -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; }
+130
View File
@@ -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'
}; }
}
+19
View File
@@ -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>;
}
+24
View File
@@ -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."
}; });
+24
View File
@@ -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
View File
@@ -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;
}
+34
View File
@@ -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);
+17
View File
@@ -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"
]
}
+32
View File
@@ -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);
+11
View File
@@ -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
View File
@@ -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
}
}