From ca41674188f57b4bee6b10dd6233d3f43088d18b Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Sun, 23 Aug 2026 22:51:10 +0200 Subject: [PATCH] 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 --- .gitignore | 19 ++ .yo-rc.json | 12 ++ README.md | 54 ++++++ ToDo.md | 6 + config/config.json | 17 ++ config/copy-assets.json | 2 + config/package-solution.json | 12 ++ config/serve.json | 6 + config/write-manifests.json | 2 + deployment/Initialize-DynamicPollLists.ps1 | 61 +++++++ gulpfile.js | 5 + package.json | 33 ++++ src/declarations.d.ts | 5 + src/index.ts | 2 + .../DynamicPollWebPart.manifest.json | 25 +++ .../dynamicPoll/DynamicPollWebPart.ts | 164 ++++++++++++++++++ src/webparts/dynamicPoll/PollCore.ts | 37 ++++ src/webparts/dynamicPoll/PollDataService.ts | 130 ++++++++++++++ src/webparts/dynamicPoll/PollTypes.ts | 19 ++ .../components/DynamicPoll.module.scss | 70 ++++++++ .../dynamicPoll/components/DynamicPoll.tsx | 92 ++++++++++ .../components/IDynamicPollProps.ts | 14 ++ src/webparts/dynamicPoll/loc/de-de.js | 24 +++ src/webparts/dynamicPoll/loc/en-us.js | 24 +++ src/webparts/dynamicPoll/loc/myStrings.d.ts | 13 ++ tests/PollCore.test.ts | 34 ++++ tests/tsconfig.json | 17 ++ tests/validate-project.js | 32 ++++ tsconfig.json | 11 ++ tslint.json | 13 ++ 30 files changed, 955 insertions(+) create mode 100644 .gitignore create mode 100644 .yo-rc.json create mode 100644 README.md create mode 100644 ToDo.md create mode 100644 config/config.json create mode 100644 config/copy-assets.json create mode 100644 config/package-solution.json create mode 100644 config/serve.json create mode 100644 config/write-manifests.json create mode 100644 deployment/Initialize-DynamicPollLists.ps1 create mode 100644 gulpfile.js create mode 100644 package.json create mode 100644 src/declarations.d.ts create mode 100644 src/index.ts create mode 100644 src/webparts/dynamicPoll/DynamicPollWebPart.manifest.json create mode 100644 src/webparts/dynamicPoll/DynamicPollWebPart.ts create mode 100644 src/webparts/dynamicPoll/PollCore.ts create mode 100644 src/webparts/dynamicPoll/PollDataService.ts create mode 100644 src/webparts/dynamicPoll/PollTypes.ts create mode 100644 src/webparts/dynamicPoll/components/DynamicPoll.module.scss create mode 100644 src/webparts/dynamicPoll/components/DynamicPoll.tsx create mode 100644 src/webparts/dynamicPoll/components/IDynamicPollProps.ts create mode 100644 src/webparts/dynamicPoll/loc/de-de.js create mode 100644 src/webparts/dynamicPoll/loc/en-us.js create mode 100644 src/webparts/dynamicPoll/loc/myStrings.d.ts create mode 100644 tests/PollCore.test.ts create mode 100644 tests/tsconfig.json create mode 100644 tests/validate-project.js create mode 100644 tsconfig.json create mode 100644 tslint.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c678b59 --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/.yo-rc.json b/.yo-rc.json new file mode 100644 index 0000000..6eb9fc7 --- /dev/null +++ b/.yo-rc.json @@ -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" + } +} + diff --git a/README.md b/README.md new file mode 100644 index 0000000..9fc8cff --- /dev/null +++ b/README.md @@ -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. diff --git a/ToDo.md b/ToDo.md new file mode 100644 index 0000000..8d0579c --- /dev/null +++ b/ToDo.md @@ -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 diff --git a/config/config.json b/config/config.json new file mode 100644 index 0000000..c270832 --- /dev/null +++ b/config/config.json @@ -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" + } +} + diff --git a/config/copy-assets.json b/config/copy-assets.json new file mode 100644 index 0000000..be190ad --- /dev/null +++ b/config/copy-assets.json @@ -0,0 +1,2 @@ +{"$schema":"https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json","deployCdnPath":"temp/deploy"} + diff --git a/config/package-solution.json b/config/package-solution.json new file mode 100644 index 0000000..c4988c1 --- /dev/null +++ b/config/package-solution.json @@ -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" } +} + diff --git a/config/serve.json b/config/serve.json new file mode 100644 index 0000000..9cfe356 --- /dev/null +++ b/config/serve.json @@ -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/" } +} + diff --git a/config/write-manifests.json b/config/write-manifests.json new file mode 100644 index 0000000..f5d7bd3 --- /dev/null +++ b/config/write-manifests.json @@ -0,0 +1,2 @@ +{"$schema":"https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json","cdnBasePath":""} + diff --git a/deployment/Initialize-DynamicPollLists.ps1 b/deployment/Initialize-DynamicPollLists.ps1 new file mode 100644 index 0000000..33a727d --- /dev/null +++ b/deployment/Initialize-DynamicPollLists.ps1 @@ -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 '' + Ensure-Field -List $polls -InternalName 'Options' -SchemaXml 'Option 1Option 2' + Ensure-Field -List $polls -InternalName 'StartDate' -SchemaXml '' + Ensure-Field -List $polls -InternalName 'EndDate' -SchemaXml '' + Ensure-Field -List $polls -InternalName 'IsActive' -SchemaXml '0' + + $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 (('' -f $pollListId)) + Ensure-Field -List $answers -InternalName 'Answer' -SchemaXml '' + + 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() } +} + diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..39ffe65 --- /dev/null +++ b/gulpfile.js @@ -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')); + diff --git a/package.json b/package.json new file mode 100644 index 0000000..7c84828 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/declarations.d.ts b/src/declarations.d.ts new file mode 100644 index 0000000..c058c04 --- /dev/null +++ b/src/declarations.d.ts @@ -0,0 +1,5 @@ +declare module '*.module.scss' { + const styles: { [className: string]: string }; + export default styles; +} + diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..147e3fe --- /dev/null +++ b/src/index.ts @@ -0,0 +1,2 @@ +// Required TypeScript entry file. + diff --git a/src/webparts/dynamicPoll/DynamicPollWebPart.manifest.json b/src/webparts/dynamicPoll/DynamicPollWebPart.manifest.json new file mode 100644 index 0000000..516c137 --- /dev/null +++ b/src/webparts/dynamicPoll/DynamicPollWebPart.manifest.json @@ -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 + } + }] +} diff --git a/src/webparts/dynamicPoll/DynamicPollWebPart.ts b/src/webparts/dynamicPoll/DynamicPollWebPart.ts new file mode 100644 index 0000000..f8a06ed --- /dev/null +++ b/src/webparts/dynamicPoll/DynamicPollWebPart.ts @@ -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 { + 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 { + this.service = new PollDataService(this.context); + return super.onInit().then((): Promise => 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 { + 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 = 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 => 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 { + return this.service.vote(this.properties.pollListId, this.properties.answerListId, pollId, answer, this.userKey) + .then((): Promise => this.loadSnapshot(false)); + } + + private loadLists(): Promise { + 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); + } +} diff --git a/src/webparts/dynamicPoll/PollCore.ts b/src/webparts/dynamicPoll/PollCore.ts new file mode 100644 index 0000000..167cef7 --- /dev/null +++ b/src/webparts/dynamicPoll/PollCore.ts @@ -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; } + diff --git a/src/webparts/dynamicPoll/PollDataService.ts b/src/webparts/dynamicPoll/PollDataService.ts new file mode 100644 index 0000000..67cd136 --- /dev/null +++ b/src/webparts/dynamicPoll/PollDataService.ts @@ -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 { + 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 { + return this.getActivePoll(pollListId).then((poll: IPoll): Promise => { + 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 { + return this.getActivePoll(pollListId).then((poll: IPoll): Promise => { + 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 { + 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 + }; + 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 => this.ensureOk(response)); + }); + }); + } + + private getActivePoll(listId: string): Promise { + 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 { + 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 { + 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 { // tslint:disable-line:no-any + return this.context.spHttpClient.get(url, SPHttpClient.configurations.v1, { headers: this.readHeaders }) + .then((response: SPHttpClientResponse): Promise => { // tslint:disable-line:no-any + if (response.ok) { return response.json(); } + return this.error(response).then((error: Error): Promise => Promise.reject(error)); // tslint:disable-line:no-any + }); + } + private ensureOk(response: SPHttpClientResponse): Promise { + return response.ok ? Promise.resolve() : this.error(response).then((error: Error): Promise => Promise.reject(error)); + } + private error(response: SPHttpClientResponse): Promise { + 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' + }; } +} diff --git a/src/webparts/dynamicPoll/PollTypes.ts b/src/webparts/dynamicPoll/PollTypes.ts new file mode 100644 index 0000000..6f3a43f --- /dev/null +++ b/src/webparts/dynamicPoll/PollTypes.ts @@ -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; +} + diff --git a/src/webparts/dynamicPoll/components/DynamicPoll.module.scss b/src/webparts/dynamicPoll/components/DynamicPoll.module.scss new file mode 100644 index 0000000..ee92b2b --- /dev/null +++ b/src/webparts/dynamicPoll/components/DynamicPoll.module.scss @@ -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; +} + diff --git a/src/webparts/dynamicPoll/components/DynamicPoll.tsx b/src/webparts/dynamicPoll/components/DynamicPoll.tsx new file mode 100644 index 0000000..a008d6f --- /dev/null +++ b/src/webparts/dynamicPoll/components/DynamicPoll.tsx @@ -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 { + 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 { // tslint:disable-line:no-any + const hasVoted: boolean = !!this.props.snapshot.userAnswer || this.state.voted; + const showResults: boolean = hasVoted || this.props.showResultsBeforeVote; + return
+

{this.props.snapshot.poll.question}

+ {!hasVoted ? this.renderForm() :
{this.props.strings.thanksMessage}
} + {showResults ? this.renderResults() : undefined} +
; + } + + private renderForm(): React.ReactElement { // tslint:disable-line:no-any + return
+
+ {this.props.snapshot.poll.question} + {this.props.snapshot.poll.options.map((option: string, index: number) => { + const id: string = this.groupName + '-option-' + index; + return ; + })} +
+ {this.state.error ?
{this.state.error}
: undefined} + +
; + } + + private renderResults(): React.ReactElement { // tslint:disable-line:no-any + return
+

{this.props.strings.resultsHeading}

+ {this.props.snapshot.results.map((result: IPollResult, index: number) => +
+
+ {result.option} + {result.percentage}% · {result.votes} {result.votes === 1 + ? this.props.strings.oneVoteLabel : this.props.strings.votesLabel} +
+
+
+
+
+ )} +
; + } + + 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 }); + }); + } +} + diff --git a/src/webparts/dynamicPoll/components/IDynamicPollProps.ts b/src/webparts/dynamicPoll/components/IDynamicPollProps.ts new file mode 100644 index 0000000..47b5af4 --- /dev/null +++ b/src/webparts/dynamicPoll/components/IDynamicPollProps.ts @@ -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; +} + diff --git a/src/webparts/dynamicPoll/loc/de-de.js b/src/webparts/dynamicPoll/loc/de-de.js new file mode 100644 index 0000000..575d32c --- /dev/null +++ b/src/webparts/dynamicPoll/loc/de-de.js @@ -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." +}; }); + diff --git a/src/webparts/dynamicPoll/loc/en-us.js b/src/webparts/dynamicPoll/loc/en-us.js new file mode 100644 index 0000000..14d14df --- /dev/null +++ b/src/webparts/dynamicPoll/loc/en-us.js @@ -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." +}; }); + diff --git a/src/webparts/dynamicPoll/loc/myStrings.d.ts b/src/webparts/dynamicPoll/loc/myStrings.d.ts new file mode 100644 index 0000000..1f30e8e --- /dev/null +++ b/src/webparts/dynamicPoll/loc/myStrings.d.ts @@ -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; +} + diff --git a/tests/PollCore.test.ts b/tests/PollCore.test.ts new file mode 100644 index 0000000..fe5cf6d --- /dev/null +++ b/tests/PollCore.test.ts @@ -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); + diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 0000000..022e93e --- /dev/null +++ b/tests/tsconfig.json @@ -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" + ] +} + diff --git a/tests/validate-project.js b/tests/validate-project.js new file mode 100644 index 0000000..7725d8a --- /dev/null +++ b/tests/validate-project.js @@ -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); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f7c1995 --- /dev/null +++ b/tsconfig.json @@ -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"] + } +} + diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..f934bbd --- /dev/null +++ b/tslint.json @@ -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 + } +} +