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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user