Merge pull request #1509 from bogeorge/react-questions-and-answers

This commit is contained in:
Hugo Bernier 2020-09-24 01:35:29 -04:00 committed by GitHub
commit 04584a4816
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
73 changed files with 30242 additions and 0 deletions

View File

@ -0,0 +1,25 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# change these settings to your own preference
indent_style = space
indent_size = 2
# we recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[{package,bower}.json]
indent_style = space
indent_size = 2

View File

@ -0,0 +1,32 @@
# Logs
logs
*.log
npm-debug.log*
# Dependency directories
node_modules
# Build generated files
dist
lib
solution
temp
*.sppkg
# Coverage directory used by tools like istanbul
coverage
# OSX
.DS_Store
# Visual Studio files
.ntvs_analysis.dat
.vs
bin
obj
# Resx Generated Code
*.resx.ts
# Styles Generated Code
*.scss.ts

View File

@ -0,0 +1,12 @@
{
"@microsoft/generator-sharepoint": {
"isCreatingSolution": true,
"environment": "spo",
"version": "1.7.0",
"libraryName": "questions",
"libraryId": "6feb4c2f-341b-499c-998c-9b2ebd95435c",
"packageManager": "npm",
"isDomainIsolated": false,
"componentType": "webpart"
}
}

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 ThreeWill
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,136 @@
# React Questions and Answers
## Summary
This is an application that supports Questions & Answers through a web part that can be used directly on a Modern SharePoint Site without the need for Yammer or a backing Microsoft Team site. It relies on a backing SharePoint list that is hidden and a provisioned Site Page that hosts a pre-configured version of the questions web part.
![Questions and Answers](./assets/QuestionsAndAnswers.gif)
## Used SharePoint Framework Version
![1.11.0](https://img.shields.io/badge/version-1.11.0-green.svg)
## Applies to
* [SharePoint Framework](https://docs.microsoft.com/sharepoint/dev/spfx/sharepoint-framework-overview)
* [Office 365 tenant](https://docs.microsoft.com/sharepoint/dev/spfx/set-up-your-development-environment)
## Prerequisites
* Office 365 subscription with SharePoint Online
* SharePoint Framework [development environment](https://docs.microsoft.com/sharepoint/dev/spfx/set-up-your-development-environment) set up
## Solution
Solution|Author(s)
--------|---------
react-questions-and-answers | Bo George ([@bo_george](https://twitter.com/bo_george))
## Version history
Version|Date|Comments
-------|----|--------
1.0|September 13, 2020|Initial release
## Disclaimer
**THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
---
## Minimal Path to Awesome
* Clone this repository
* in the command line run:
* `npm install`
* `gulp serve`
### Slightly Longer Path to More Awesome
*To really get the full experience go to the workbench on a SharePoint Site [Your site url]/_layouts/15/workbench.aspx and that's where the magic will happen but this requires that you deploy and activate features to provision the required SharePoint assets*
1. Run one of the following custom commands to clean, build, bundle and package the solution.
* If you want to be able to debug using your local code using gulp serve
`gulp package`
2. Navigate to the output `questions-application.sppkg` (found in the `/sharepoint/solution` folder)
3. Upload it to an application catalog (either a tenant or site collection one)
4. In your site collection go to **Site Contents** and click **New** > **App**
5. Find and add the **Questions Application** App
* wait for it to finish installing and activating features on the **Site Contents** page
6. Go to a site page like home, edit the page and find and add the **Questions** web part
* If you deployed a shippable (SharePoint Online) version you don't need to do anything else
* If you deployed a debug (http://localhost:4321) version you'll need to ensure gulp serve is running
## Features
Below is intended to provide “notable” details on different features. Notable meaning, they may be different than a typical expectation or require clarification. For all features see “Features based Questions Role” to understand if they are available for a specific role.
### Questions
Feature|Details
-------|-------
Search for a Question|After 3 characters are entered in the search box the list of questions will filter based on matching somewhere in the question title
Show List of Questions by All, Answered or Open|Allows a user when viewing the list of Questions to only show those that are answered or not answered (open) in addition to seeing all.
View a Question and Replies|
Ask a Question|Title and details are required to Post
Edit a Question|Title and details are required to Post. Available on Gear icon.
Delete a question|Deletes the question (and replies when done by moderator). Available on Gear icon.
Follow/Unfollow a Question|Filled envelop if followed & empty envelop if not followed for the current user. Used to drive email notification on questions.
Like/Unlike a Question|Filled thumbs if liked & empty thumbs if not. Count should be for all likes on the Question but not including replies.
### Replies
Feature|Details
-------|-------
Reply to a Question or Reply|
Edit a Reply|
Delete a Reply|Deletes the reply (and replies when done by moderator). Available on Gear icon.
Like/Unlike a Reply|Filled thumbs if liked & empty thumbs if not.
Mark/Unmark a Reply as Helpful|Filled star if liked & empty star if not. Count should be for all helpful items on the Reply but not including replies
Mark/Unmark a Reply as Correct Answer|When marked as correct answer a banner will show with “CORRECT ANSWER”. Copy of reply is promoted just below question so that long thread doesnt have to be viewed for the answer. Unmarking will remove the banner and the copy of reply promoted to the to of the thread. Available on Gear icon.
## User Roles Overview
The Questions web part relies on the standard SharePoint Groups and the permissions they are granted.
Questions Role|SharePoint Group|Details
--------------|----------------|-------
Moderator|Site owners|Users in this group are treated as moderators of Questions with additional capabilities and notifications than others outlined below.
Contributor|Site members|Users in this group are treated as “typical” users and considered contributors to questions by default.
Viewer|Site visitors|By default, users in this group can only view questions and replies.
Contributor|Site visitors|Within the web part there is a toggle that allows a Site owner to grant visitors the same capabilities as a site member on questions. When set to Yes site visitors are considered contributors to questions.
## Features based Questions Role
The table below shows what features and capabilities a user can perform for each role. Where a feature is dynamic (such as questions entered by others or me) separate line items call out the different behaviors.
Feature|Moderator|Contributor|Viewer
-------|---------|-----------|------
**Questions**|||
Search for a Question|Yes|Yes|Yes
View a list of Questions|Yes|Yes|Yes
Show List of Questions by All, Answered or Open|Yes|Yes|Yes
View a Question and Replies|Yes|Yes|Yes
Ask a Question|Yes|Yes|No
Edit a Question - Entered by me|Yes|Yes|No
Edit a Question - Entered by others|No|No|No
Delete a question - Entered by me with no replies|Yes|Yes|No
Delete a question - Entered by me with replies|Yes|No|No
Delete a question - Entered by others with no replies|Yes|No|No
Delete a question - Entered by others with replies|Yes|No|No
Follow/Unfollow a Question|Yes|Yes|No
Like/Unlike a Question|Yes|Yes|No
**Replies**|||
Reply to a Question or Reply|Yes|Yes|No
Edit a Reply - Entered by me|Yes|Yes|No
Edit a Reply - Entered by others|No|No|No
Delete a Reply - Entered by me with no replies|Yes|Yes|No
Delete a Reply - Entered by me with replies|Yes|No|No
Delete a Reply - Entered by others with no replies|Yes|No|No
Delete a Reply - Entered by others with replies|Yes|No|No
Like/Unlike a Reply|Yes|Yes|No
Mark/Unmark a Reply as Helpful|Yes|Yes|No
Mark/Unmark a Reply as Correct Answer - Question entered by me|Yes|Yes|No
Mark/Unmark a Reply as Correct Answer - Question entered by others|Yes|No|No
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-questions-and-answers" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 MiB

View File

@ -0,0 +1,19 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"questions-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/questions/QuestionsWebPart.js",
"manifest": "./src/webparts/questions/QuestionsWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"QuestionsWebPartStrings": "lib/webparts/questions/loc/{locale}.js",
"ControlStrings": "node_modules/@pnp/spfx-controls-react/lib/loc/{locale}.js"
}
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json",
"deployCdnPath": "temp/deploy"
}

View File

@ -0,0 +1,7 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
"workingDir": "./temp/deploy/",
"account": "<!-- STORAGE ACCOUNT NAME -->",
"container": "questions",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1,38 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "Questions and Answers",
"id": "6feb4c2f-341b-499c-998c-9b2ebd95435c",
"version": "1.0.0.0",
"skipFeatureDeployment": false,
"iconPath": "images/Feedback_Icon.png",
"includeClientSideAssets": true,
"features": [
{
"id": "2088baa5-aab6-43ec-907b-147a786c2b36",
"title": "Questions Assets",
"description": "Provides assets to support Questions webpart. This includes schema elements such as content types, site columns and list instance as well as the Questions application page.",
"version": "1.0.2.0",
"assets": {
"elementManifests": [
"elements.xml"
],
"elementFiles": [
"schema.xml",
"QuestionsSitePage.aspx"
]
}
}
],
"developer": {
"name": "Bo George",
"websiteUrl": "https://threewill.com/team/bo-george/",
"privacyUrl": "",
"termsOfUseUrl": "",
"mpnId": ""
}
},
"paths": {
"zippedPackage": "solution/react-questions-and-answers.sppkg"
}
}

View File

@ -0,0 +1,10 @@
{
"$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/"
}
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
"cdnBasePath": "<!-- PATH TO CDN -->"
}

View File

@ -0,0 +1,12 @@
'use strict';
const gulp = require('gulp');
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(gulp);
var runSequence = require('run-sequence');
gulp.task('package', function (cb) {
runSequence('clean', 'build', 'bundle', 'package-solution', cb);
});

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,55 @@
{
"name": "react-questions-and-answers",
"version": "1.0.0",
"private": true,
"engines": {
"node": ">=0.10.0"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test",
"package-dev": "gulp package",
"package-prod": "gulp package --ship"
},
"dependencies": {
"@microsoft/sp-core-library": "1.11.0",
"@microsoft/sp-lodash-subset": "1.11.0",
"@microsoft/sp-office-ui-fabric-core": "1.11.0",
"@microsoft/sp-webpart-base": "1.11.0",
"@pnp/common": "2.0.10",
"@pnp/graph": "2.0.10",
"@pnp/logging": "2.0.10",
"@pnp/odata": "2.0.10",
"@pnp/polyfill-ie11": "2.0.2",
"@pnp/sp": "2.0.10",
"@pnp/spfx-controls-react": "1.20.0",
"@types/es6-promise": "3.3.0",
"@types/react-dom": "16.9.8",
"@types/webpack-env": "1.15.2",
"classnames": "^2.2.6",
"immutable": "^4.0.0-rc.12",
"primeicons": "4.0.0",
"primereact": "4.2.1",
"quill": "1.3.7",
"react": "16.8.5",
"react-dom": "16.8.5",
"react-redux": "7.2.1",
"redux": "4.0.5",
"redux-logger": "3.0.6",
"redux-thunk": "2.3.0"
},
"devDependencies": {
"@microsoft/rush-stack-compiler-3.3": "0.9.14",
"@microsoft/sp-build-web": "1.11.0",
"@microsoft/sp-module-interfaces": "1.11.0",
"@microsoft/sp-tslint-rules": "1.11.0",
"@microsoft/sp-webpart-workbench": "1.11.0",
"@types/chai": "4.2.12",
"@types/mocha": "8.0.3",
"@types/react": "16.9.49",
"ajv": "6.12.4",
"gulp": "^3.9.1",
"run-sequence": "^2.2.1"
}
}

View File

@ -0,0 +1,19 @@
<%@ Page language="C#" Inherits="Microsoft.SharePoint.WebControls.ClientSidePage, Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %><%@ Register Tagprefix="SharePoint" Namespace="Microsoft.SharePoint.WebControls" Assembly="Microsoft.SharePoint, Version=16.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" %>
<html xmlns:mso="urn:schemas-microsoft-com:office:office" xmlns:msdt="uuid:C2F41010-65B3-11d1-A29F-00AA00C14882"><head>
<meta name="WebPartPageExpansion" content="full" />
<!--[if gte mso 9]>
<SharePoint:CTFieldRefs runat=server Prefix="mso:" FieldList="FileLeafRef,ClientSideApplicationId,PageLayoutType,CanvasContent1,BannerImageUrl,BannerImageOffset,PromotedState,FirstPublishedDate,LayoutWebpartsContent,_TopicHeader,_SPSitePageFlags"><xml>
<mso:CustomDocumentProperties>
<mso:PageLayoutType msdt:dt="string">SingleWebPartAppPage</mso:PageLayoutType>
<mso:CanvasContent1 msdt:dt="string">&lt;div&gt;&lt;div data-sp-canvascontrol=&quot;&quot; data-sp-canvasdataversion=&quot;1.0&quot; data-sp-controldata=&quot;&amp;#123;&amp;quot;controlType&amp;quot;&amp;#58;3,&amp;quot;displayMode&amp;quot;&amp;#58;2,&amp;quot;id&amp;quot;&amp;#58;&amp;quot;17dafb10-3bee-4cec-bf2a-28ff74f048bf&amp;quot;,&amp;quot;position&amp;quot;&amp;#58;&amp;#123;&amp;quot;zoneIndex&amp;quot;&amp;#58;1,&amp;quot;sectionIndex&amp;quot;&amp;#58;1,&amp;quot;controlIndex&amp;quot;&amp;#58;1,&amp;quot;layoutIndex&amp;quot;&amp;#58;1&amp;#125;,&amp;quot;webPartId&amp;quot;&amp;#58;&amp;quot;761fbf9d-6ef9-4099-8488-02d5e2826f36&amp;quot;,&amp;quot;emphasis&amp;quot;&amp;#58;&amp;#123;&amp;#125;,&amp;quot;reservedHeight&amp;quot;&amp;#58;449,&amp;quot;reservedWidth&amp;quot;&amp;#58;1188&amp;#125;&quot;&gt;&lt;div data-sp-webpart=&quot;&quot; data-sp-webpartdataversion=&quot;1.0&quot; data-sp-webpartdata=&quot;&amp;#123;&amp;quot;id&amp;quot;&amp;#58;&amp;quot;761fbf9d-6ef9-4099-8488-02d5e2826f36&amp;quot;,&amp;quot;instanceId&amp;quot;&amp;#58;&amp;quot;17dafb10-3bee-4cec-bf2a-28ff74f048bf&amp;quot;,&amp;quot;title&amp;quot;&amp;#58;&amp;quot;Questions&amp;quot;,&amp;quot;description&amp;quot;&amp;#58;&amp;quot;Ask questions or find answers to answered questions&amp;quot;,&amp;quot;serverProcessedContent&amp;quot;&amp;#58;&amp;#123;&amp;quot;htmlStrings&amp;quot;&amp;#58;&amp;#123;&amp;#125;,&amp;quot;searchablePlainTexts&amp;quot;&amp;#58;&amp;#123;&amp;#125;,&amp;quot;imageSources&amp;quot;&amp;#58;&amp;#123;&amp;#125;,&amp;quot;links&amp;quot;&amp;#58;&amp;#123;&amp;#125;&amp;#125;,&amp;quot;dataVersion&amp;quot;&amp;#58;&amp;quot;1.0&amp;quot;,&amp;quot;properties&amp;quot;&amp;#58;&amp;#123;&amp;quot;title&amp;quot;&amp;#58;&amp;quot;Questions&amp;quot;,&amp;quot;pageSize&amp;quot;&amp;#58;20,&amp;quot;sortOption&amp;quot;&amp;#58;&amp;quot;Title&amp;quot;,&amp;quot;loadInitialPage&amp;quot;&amp;#58;true,&amp;quot;hideViewAllButton&amp;quot;&amp;#58;true,&amp;quot;showQuestionAnsweredDropDown&amp;quot;&amp;#58;true,&amp;quot;useApplicationPage&amp;quot;&amp;#58;false,&amp;quot;applicationPage&amp;quot;&amp;#58;&amp;quot;Questions.aspx&amp;quot;&amp;#125;&amp;#125;&quot;&gt;&lt;div data-sp-componentid=&quot;&quot;&gt;761fbf9d-6ef9-4099-8488-02d5e2826f36&lt;/div&gt;&lt;div data-sp-htmlproperties=&quot;&quot;&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;&lt;div data-sp-canvascontrol=&quot;&quot; data-sp-canvasdataversion=&quot;1.0&quot; data-sp-controldata=&quot;&amp;#123;&amp;quot;controlType&amp;quot;&amp;#58;0,&amp;quot;pageSettingsSlice&amp;quot;&amp;#58;&amp;#123;&amp;quot;isDefaultDescription&amp;quot;&amp;#58;true,&amp;quot;isDefaultThumbnail&amp;quot;&amp;#58;true&amp;#125;&amp;#125;&quot;&gt;&lt;/div&gt;&lt;/div&gt;</mso:CanvasContent1>
<mso:ClientSideApplicationId msdt:dt="string">b6917cb1-93a0-4b97-a84d-7cf49975d4ec</mso:ClientSideApplicationId>
<mso:PromotedState msdt:dt="string">0</mso:PromotedState>
<mso:_TopicHeader msdt:dt="string"></mso:_TopicHeader>
<mso:BannerImageUrl msdt:dt="string">/_layouts/15/images/sitepagethumbnail.png, /_layouts/15/images/sitepagethumbnail.png</mso:BannerImageUrl>
<mso:_AuthorByline msdt:dt="string"></mso:_AuthorByline>
<mso:LayoutWebpartsContent msdt:dt="string">&lt;div&gt;&lt;div data-sp-canvascontrol=&quot;&quot; data-sp-canvasdataversion=&quot;1.4&quot; data-sp-controldata=&quot;&amp;#123;&amp;quot;id&amp;quot;&amp;#58;&amp;quot;cbe7b0a9-3504-44dd-a3a3-0e5cacd07788&amp;quot;,&amp;quot;instanceId&amp;quot;&amp;#58;&amp;quot;cbe7b0a9-3504-44dd-a3a3-0e5cacd07788&amp;quot;,&amp;quot;title&amp;quot;&amp;#58;&amp;quot;Title area&amp;quot;,&amp;quot;description&amp;quot;&amp;#58;&amp;quot;Title Region Description&amp;quot;,&amp;quot;serverProcessedContent&amp;quot;&amp;#58;&amp;#123;&amp;quot;htmlStrings&amp;quot;&amp;#58;&amp;#123;&amp;#125;,&amp;quot;searchablePlainTexts&amp;quot;&amp;#58;&amp;#123;&amp;#125;,&amp;quot;imageSources&amp;quot;&amp;#58;&amp;#123;&amp;#125;,&amp;quot;links&amp;quot;&amp;#58;&amp;#123;&amp;#125;&amp;#125;,&amp;quot;dataVersion&amp;quot;&amp;#58;&amp;quot;1.4&amp;quot;,&amp;quot;properties&amp;quot;&amp;#58;&amp;#123;&amp;quot;title&amp;quot;&amp;#58;&amp;quot;Question2&amp;quot;,&amp;quot;imageSourceType&amp;quot;&amp;#58;4,&amp;quot;layoutType&amp;quot;&amp;#58;&amp;quot;FullWidthImage&amp;quot;,&amp;quot;textAlignment&amp;quot;&amp;#58;&amp;quot;Left&amp;quot;,&amp;quot;showTopicHeader&amp;quot;&amp;#58;false,&amp;quot;showPublishDate&amp;quot;&amp;#58;false,&amp;quot;topicHeader&amp;quot;&amp;#58;&amp;quot;&amp;quot;,&amp;quot;authors&amp;quot;&amp;#58;[&amp;#123;&amp;quot;id&amp;quot;&amp;#58;&amp;quot;i&amp;#58;0#.f|membership|bo@boinga.onmicrosoft.com&amp;quot;,&amp;quot;upn&amp;quot;&amp;#58;&amp;quot;bo@boinga.onmicrosoft.com&amp;quot;,&amp;quot;name&amp;quot;&amp;#58;&amp;quot;Bo George&amp;quot;,&amp;quot;role&amp;quot;&amp;#58;&amp;quot;&amp;quot;&amp;#125;],&amp;quot;authorByline&amp;quot;&amp;#58;[6]&amp;#125;&amp;#125;&quot;&gt;&lt;/div&gt;&lt;/div&gt;</mso:LayoutWebpartsContent>
<mso:display_urn_x003a_schemas-microsoft-com_x003a_office_x003a_office_x0023__AuthorByline msdt:dt="string">Bo George</mso:display_urn_x003a_schemas-microsoft-com_x003a_office_x003a_office_x0023__AuthorByline>
</mso:CustomDocumentProperties>
</xml></SharePoint:CTFieldRefs><![endif]-->
<title>Questions</title></head>

View File

@ -0,0 +1,188 @@
<?xml version="1.0" encoding="utf-8"?>
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
<!-- Post fields - shared by Question and Reply Content Types -->
<Field ID="{8f1abe02-cc82-477c-be27-6fefaac2cb6f}"
Name="TW_Details"
DisplayName="Details"
Type="Note"
Required="FALSE"
Group="_TW"
NumLines="6"
RichText="TRUE"
RichTextMode="FullHtml"
AppendOnly="FALSE">
</Field>
<Field ID="{b9e9da58-096d-4d3d-bf8c-4034f97461a9}"
Name="TW_DetailsText"
DisplayName="Details Text"
Type="Note"
Required="FALSE"
Group="_TW"
NumLines="6"
RichText="FALSE"
RichTextMode="FullHtml"
AppendOnly="FALSE">
</Field>
<Field ID="{54c33e2a-a458-4ef9-8d7a-f1167887ec5c}"
Name="TW_LikeCount"
DisplayName="Count Likes"
Type="Number"
Required="FALSE"
Group="_TW"
Percentage="FALSE">
<Default>0</Default>
</Field>
<Field ID="{9fb0a0fc-75e9-4224-a9b1-9bb0acc3bdcd}"
Name="TW_LikeIds"
DisplayName="Ids Like"
Type="Note"
Required="FALSE"
Group="_TW"
NumLines="6"
RichText="FALSE"
RichTextMode="Compatible"
AppendOnly="FALSE">
</Field>
<!-- Question Fields -->
<Field ID="{7d4ee5f7-d1cc-4d04-983d-2052031800f5}"
Name="TW_IsAnswered"
DisplayName="Is Answered"
Type="Boolean"
Required="FALSE"
Group="_TW">
<Default>0</Default>
</Field>
<Field ID="{521325f8-6e0a-4b9f-9a53-a34fe70f2488}"
Name="TW_FollowEmails"
DisplayName="Follow Emails"
Type="Note"
Required="FALSE"
Group="_TW"
NumLines="6"
RichText="FALSE"
RichTextMode="Compatible"
AppendOnly="FALSE">
</Field>
<!-- Reply Fields -->
<Field ID="{a41b6b54-7fa0-426b-8bf6-d8b7d07f1baa}"
Name="TW_IsAnswer"
DisplayName="Is Answer"
Type="Boolean"
Required="FALSE"
Group="_TW">
<Default>0</Default>
</Field>
<Field ID="{9b2e96d7-4084-4881-8f6a-91f07eb78ca6}"
Name="TW_HelpfulCount"
DisplayName="Count Helpful"
Type="Number"
Required="FALSE"
Group="_TW"
Percentage="FALSE">
<Default>0</Default>
</Field>
<Field ID="{d78817a1-eaba-48f6-a55a-5ecfe645de46}"
Name="TW_HelpfulIds"
DisplayName="Ids Helpful"
Type="Note"
Required="FALSE"
Group="_TW"
NumLines="6"
RichText="FALSE"
RichTextMode="Compatible"
AppendOnly="FALSE">
</Field>
<Field ID="{672f66ce-f9a0-447e-ba2c-96b1814f1898}"
Type="Lookup"
Name="TW_QuestionLookup"
DisplayName="Related Question"
Required="FALSE"
Group="_TW"
List="Lists/Questions"
ShowField="Title" />
<Field ID="{06fb6a45-b62c-4c31-a82c-4fc95e510029}"
Type="Lookup"
Name="TW_ReplyLookup"
DisplayName="Related Reply"
Required="FALSE"
Group="_TW"
List="Lists/Questions"
ShowField="Title" />
<ContentType ID="0x01008B64E4A73BE90C46A62FC880772E3184"
Name="Question"
Description="Content Type for asking a question"
Group="_TW">
<FieldRefs>
<!--Title Site Column -->
<FieldRef ID="{fa564e0f-0c70-4ab9-b863-0177e6ddd247}" />
<!-- TW_Details Site Column -->
<FieldRef ID="{8f1abe02-cc82-477c-be27-6fefaac2cb6f}" />
<!-- TW_IsAnswered Site Column -->
<FieldRef ID="{7d4ee5f7-d1cc-4d04-983d-2052031800f5}" />
<!-- TW_LikeCount Site Column -->
<FieldRef ID="{54c33e2a-a458-4ef9-8d7a-f1167887ec5c}" />
<!-- TW_LikeIds Site Column -->
<FieldRef ID="{9fb0a0fc-75e9-4224-a9b1-9bb0acc3bdcd}" />
<!-- TW_FollowEmails Site Column -->
<FieldRef ID="{521325f8-6e0a-4b9f-9a53-a34fe70f2488}" />
</FieldRefs>
</ContentType>
<ContentType ID="0x0100E33B533F27584848845AF62A7C292F00"
Name="Reply"
Description="Content Type for replying to a question"
Group="_TW">
<FieldRefs>
<!--Title Site Column -->
<FieldRef ID="{fa564e0f-0c70-4ab9-b863-0177e6ddd247}" />
<!-- TW_Details Site Column -->
<FieldRef ID="{8f1abe02-cc82-477c-be27-6fefaac2cb6f}" />
<!-- TW_IsAnswer Site Column -->
<FieldRef ID="{a41b6b54-7fa0-426b-8bf6-d8b7d07f1baa}" />
<!-- TW_HelpfulCount Site Column -->
<FieldRef ID="{9b2e96d7-4084-4881-8f6a-91f07eb78ca6}" />
<!-- TW_HelpfulIds Site Column -->
<FieldRef ID="{d78817a1-eaba-48f6-a55a-5ecfe645de46}" />
<!-- TW_LikeCount Site Column -->
<FieldRef ID="{54c33e2a-a458-4ef9-8d7a-f1167887ec5c}" />
<!-- TW_LikeIds Site Column -->
<FieldRef ID="{9fb0a0fc-75e9-4224-a9b1-9bb0acc3bdcd}" />
<!-- TW_QuestionLookup Site Column -->
<FieldRef ID="{672f66ce-f9a0-447e-ba2c-96b1814f1898}" />
<!-- TW_ReplyLookup Site Column -->
<FieldRef ID="{06fb6a45-b62c-4c31-a82c-4fc95e510029}" />
</FieldRefs>
</ContentType>
<!-- FeatureId must match the id of the Custom List Feature -->
<ListInstance
CustomSchema="schema.xml"
FeatureId="00bfea71-de22-43b2-a848-c05709900100"
Title="Questions"
Hidden="TRUE"
VersioningEnabled="TRUE"
Description="Questions List"
TemplateType="100"
Url="Lists/Questions">
</ListInstance>
<Module Name="QuestionsPage" Url="SitePages">
<File Path="QuestionsSitePage.aspx"
Url="Questions.aspx"
IgnoreIfAlreadyExists="TRUE"
ReplaceContent="TRUE"
Type="GhostableInLibrary">
</File>
</Module>
</Elements>

View File

@ -0,0 +1,90 @@
<List xmlns:ows="Microsoft SharePoint" Title="Questions"
EnableContentTypes="TRUE"
FolderCreation="FALSE"
Direction="$Resources:Direction;"
Url="Lists/Questions"
BaseType="0"
NoCrawl="TRUE"
xmlns="http://schemas.microsoft.com/sharepoint/">
<MetaData>
<!-- ContentTypeRef was resulting in empty Item list content type (Title only) but still with Question/Reply as parent -->
<ContentTypes>
<ContentType ID="0x01008B64E4A73BE90C46A62FC880772E3184" Name="Question">
<FieldRefs>
<!--Title Site Column -->
<FieldRef ID="{fa564e0f-0c70-4ab9-b863-0177e6ddd247}" />
<!-- TW_Details Site Column -->
<FieldRef ID="{8f1abe02-cc82-477c-be27-6fefaac2cb6f}" />
<!-- TW_DetailsText Site Column -->
<FieldRef ID="{b9e9da58-096d-4d3d-bf8c-4034f97461a9}"/>
<!-- TW_IsAnswered Site Column -->
<FieldRef ID="{7d4ee5f7-d1cc-4d04-983d-2052031800f5}" />
<!-- TW_LikeCount Site Column -->
<FieldRef ID="{54c33e2a-a458-4ef9-8d7a-f1167887ec5c}" />
<!-- TW_LikeIds Site Column -->
<FieldRef ID="{9fb0a0fc-75e9-4224-a9b1-9bb0acc3bdcd}" />
<!-- TW_FollowEmails Site Column -->
<FieldRef ID="{521325f8-6e0a-4b9f-9a53-a34fe70f2488}" />
</FieldRefs>
</ContentType>
<ContentType ID="0x0100E33B533F27584848845AF62A7C292F00" Name="Reply">
<FieldRefs>
<!--Title Site Column -->
<FieldRef ID="{fa564e0f-0c70-4ab9-b863-0177e6ddd247}" />
<!-- TW_Details Site Column -->
<FieldRef ID="{8f1abe02-cc82-477c-be27-6fefaac2cb6f}" />
<!-- TW_DetailsText Site Column -->
<FieldRef ID="{b9e9da58-096d-4d3d-bf8c-4034f97461a9}"/>
<!-- TW_IsAnswer Site Column -->
<FieldRef ID="{a41b6b54-7fa0-426b-8bf6-d8b7d07f1baa}" />
<!-- TW_HelpfulCount Site Column -->
<FieldRef ID="{9b2e96d7-4084-4881-8f6a-91f07eb78ca6}" />
<!-- TW_HelpfulIds Site Column -->
<FieldRef ID="{d78817a1-eaba-48f6-a55a-5ecfe645de46}" />
<!-- TW_LikeCount Site Column -->
<FieldRef ID="{54c33e2a-a458-4ef9-8d7a-f1167887ec5c}" />
<!-- TW_LikeIds Site Column -->
<FieldRef ID="{9fb0a0fc-75e9-4224-a9b1-9bb0acc3bdcd}" />
<!-- TW_QuestionLookup Site Column -->
<FieldRef ID="{672f66ce-f9a0-447e-ba2c-96b1814f1898}" />
<!-- TW_ReplyLookup Site Column -->
<FieldRef ID="{06fb6a45-b62c-4c31-a82c-4fc95e510029}" />
</FieldRefs>
</ContentType>
</ContentTypes>
<Fields></Fields>
<Views>
<View BaseViewID="1" Type="HTML" WebPartZoneID="Main" DisplayName="All Items" DefaultView="TRUE" MobileView="TRUE" MobileDefaultView="TRUE" SetupPath="pages\viewpage.aspx" ImageUrl="/_layouts/15/images/generic.png" Url="AllItems.aspx">
<XslLink Default="TRUE">main.xsl</XslLink>
<JSLink>clienttemplates.js</JSLink>
<RowLimit Paged="TRUE">30</RowLimit>
<Toolbar Type="Standard" />
<ViewFields>
<FieldRef Name="ContentType" />
<FieldRef Name="ID" />
<FieldRef Name="LinkTitle" />
<FieldRef Name="TW_IsAnswered" />
<FieldRef Name="TW_IsAnswer" />
<FieldRef Name="TW_LikeCount" />
<FieldRef Name="TW_HelpfulCount" />
<FieldRef Name="TW_QuestionLookup" />
<FieldRef Name="TW_ReplyLookup" />
<FieldRef Name="Created" />
<FieldRef Name="Author" />
<FieldRef Name="Modified" />
<FieldRef Name="Editor" />
</ViewFields>
<Query>
<OrderBy>
<FieldRef Name="ID" />
</OrderBy>
</Query>
</View>
</Views>
<Forms>
<Form Type="DisplayForm" Url="DispForm.aspx" SetupPath="pages\form.aspx" WebPartZoneID="Main" />
<Form Type="EditForm" Url="EditForm.aspx" SetupPath="pages\form.aspx" WebPartZoneID="Main" />
<Form Type="NewForm" Url="NewForm.aspx" SetupPath="pages\form.aspx" WebPartZoneID="Main" />
</Forms>
</MetaData>
</List>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1 @@
// A file is required to be in the root of the /src directory by the TypeScript compiler

View File

@ -0,0 +1,13 @@
import { IPersonaProps } from 'office-ui-fabric-react/lib/Persona';
export interface IBaseItem {
id?: number;
title?: string;
author?: IPersonaProps | null;
editor?: IPersonaProps | null;
createdDate?: Date | null;
modifiedDate?: Date | null;
etag?: string | null;
uiErrors?: Map<string, string>;
}

View File

@ -0,0 +1,7 @@
import { IBaseItem } from './IBaseItem';
export interface IBaseLookupItem extends IBaseItem {
name: string;
sortOrder?: number;
showInUI?: boolean;
}

View File

@ -0,0 +1,13 @@
export interface ICurrentUser {
id: number;
loginName: string;
email: string;
displayName: string;
isSiteAdmin: boolean;
canViewItems: boolean;
canAddItems: boolean;
canEditItems: boolean;
canDeleteItems: boolean;
canModerateItems: boolean;
canManagePermissions: boolean;
}

View File

@ -0,0 +1,6 @@
import { PagedItemCollection } from '@pnp/sp/items';
export interface IPagedItems<T> {
items: T[];
pagedItemCollection?: PagedItemCollection<any>;
}

View File

@ -0,0 +1,15 @@
import { IBaseItem } from './IBaseItem';
export interface IPostItem extends IBaseItem {
details: string;
detailsText: string;
likeCount: number;
likeIds: string[];
// determined based on current user and item intersection
likedByCurrentUser: boolean;
canEdit: boolean;
canDelete: boolean;
canReact: boolean;
canReply: boolean;
}

View File

@ -0,0 +1,17 @@
import { IReplyItem } from './IReplyItem';
import { IPostItem } from './IPostItem';
export interface IQuestionItem extends IPostItem {
isAnswered: boolean;
followEmails: string[];
// built by things that lookup to this
totalReplyCount: number;
replies: IReplyItem[];
// determined based on current user and item intersection
followedByCurrentUser: boolean;
// set when isAnswered is true
answerReply?: IReplyItem;
}

View File

@ -0,0 +1,13 @@
export interface IQuestionsFilter {
// paging information
pageSize: number;
// sorting information
orderByColumnName: string;
orderByAscending: boolean;
// filtering information
searchText?: string;
selectedShowQuestionsOption: string;
}

View File

@ -0,0 +1,20 @@
import { IPostItem } from './IPostItem';
import { IBaseLookupItem } from './IBaseLookupItem';
export interface IReplyItem extends IPostItem {
isAnswer: boolean;
helpfulCount: number;
helpfulIds: string[];
parentQuestionId?: number | null;
parentQuestion?: IBaseLookupItem | null;
parentReplyId?: number | null;
parentReply?: IBaseLookupItem | null;
// build by things that lookup to this
replies: IReplyItem[];
// determined based on current user and item intersection
helpfulByCurrentUser: boolean;
canMarkAsAnswer: boolean;
}

View File

@ -0,0 +1,8 @@
export * from './IBaseItem';
export * from './IBaseLookupItem';
export * from './ICurrentUser';
export * from './IPostItem';
export * from './IQuestionItem';
export * from './IReplyItem';
export * from './IPagedItems';
export * from './IQuestionsFilter';

View File

@ -0,0 +1,151 @@
import { HttpRequestError } from '@pnp/odata';
import { sp } from "@pnp/sp";
import { PermissionKind } from '@pnp/sp/security';
import { IPersonaProps } from 'office-ui-fabric-react/lib/Persona';
import { IBaseItem, IBaseLookupItem } from '../models';
import { LogHelper } from 'utilities';
export class BaseService {
constructor() {
}
public handleHttpError(methodName: string, error: HttpRequestError): void {
this.logError(methodName, error);
}
public logError(methodName: string, error: Error) {
LogHelper.exception(this.constructor.name, methodName, error);
}
public logPnpError(methodName: string, error: HttpRequestError | any): string | undefined {
let msg: string | undefined;
if (error instanceof HttpRequestError) {
if (error.message) {
msg = error.message;
LogHelper.error(this.constructor.name, methodName, msg);
}
else {
LogHelper.exception(this.constructor.name, methodName, error);
}
}
else if (error.data != null && error.data.responseBody && error.data.responseBody.error && error.data.responseBody.error.message) {
// for email exceptions they weren't coming in as "instanceof HttpRequestError"
msg = error.data.responseBody.error.message.value;
LogHelper.error(this.constructor.name, methodName, msg!);
}
else if (error instanceof Error) {
if (error.message.indexOf('[412] Precondition Failed') !== -1) {
msg = 'Save Conflict. Your changes conflict with those made concurrently by another user. If you want your changes to be applied, resubmit your changes.';
LogHelper.error(this.constructor.name, methodName, msg);
}
else if (error.message !== 'Unexpected token < in JSON at position 0') {
// 'Unexpected token < in JSON at position 0' will be thrown if XML file is read; this was issue in MDF project
msg = error.message;
LogHelper.error(this.constructor.name, methodName, msg);
}
return msg;
}
}
public mapBaseItemProperties(sourceItem: any): IBaseItem {
if (sourceItem !== undefined && sourceItem !== null) {
return {
id: sourceItem.ID,
title: sourceItem.Title,
createdDate: sourceItem.Created !== null ? new Date(sourceItem.Created) : null,
modifiedDate: sourceItem.Modified !== null ? new Date(sourceItem.Modified) : null,
author: sourceItem.Author !== null ? this.mapPersonaProps(sourceItem.Author) : null,
editor: sourceItem.Editor !== null ? this.mapPersonaProps(sourceItem.Editor) : null,
etag: sourceItem.__metadata ? sourceItem.__metadata.etag : new Date().toISOString(),
};
}
return { id: undefined };
}
public mapPersonaProps(item: any): IPersonaProps | null {
// Note it's okay if the lookup passed in does not have all these properties but these below are all the 'possible ones' we might use
if (item && item.Name) {
let persona: IPersonaProps = {};
persona.id = item.Name;
persona.text = item.Title;
persona.primaryText = item.Title;
persona.secondaryText = item.JobTitle;
return persona;
}
else {
return null;
}
}
public getLookupId(lookupValue: any): number | null {
if (lookupValue !== undefined && lookupValue !== null && lookupValue.ID) {
return lookupValue.ID;
}
else {
return null;
}
}
public getMultiLookupIds(lookupValue: any): number[] {
if (lookupValue !== undefined && lookupValue !== null) {
return lookupValue.results.map(i => i.ID);
}
else {
return [];
}
}
public getLookup(lookupValue: any): IBaseLookupItem | null {
if (lookupValue !== undefined && lookupValue !== null && lookupValue.ID) {
return { id: lookupValue.ID, name: lookupValue.Title };
}
else {
return null;
}
}
public getMultiLookup(lookupValue: any): IBaseLookupItem[] {
if (lookupValue !== undefined && lookupValue !== null) {
// tslint:disable-next-line:arrow-return-shorthand
return lookupValue.results.map(i => { return { id: i.ID, name: i.Title }; });
}
else {
return [];
}
}
public getMultiChoice(choice: any): string[] {
if (choice !== undefined && choice !== null && choice.results !== null) {
// tslint:disable-next-line:arrow-return-shorthand
return choice.results;
}
else {
return [];
}
}
protected getEmailProperty(object: any): string {
// SharePoint User Information list uses "EMail" as property name
// SharePoint User Profile uses "Email" as property name
// To support mock data, we need ability to "translate" the property name
return object.Email ? object.Email : (object.EMail ? object.EMail : '');
}
protected async checkUserPermission(listTitle: string, permission: PermissionKind) {
let userHasPermission: boolean = false;
let perms = await sp.web.lists.getByTitle(listTitle)
.effectiveBasePermissions
.get()
.catch(e => { this.logPnpError('checkUserPermission', e); return false; });
if (perms && perms.EffectiveBasePermissions) {
userHasPermission = sp.web.hasPermissions(perms.EffectiveBasePermissions, permission);
}
LogHelper.verbose(this.constructor.name, 'checkUserPermission', `list=${listTitle},permission=${PermissionKind[permission]},userHasPermission=${userHasPermission}`);
return userHasPermission;
}
}

View File

@ -0,0 +1,29 @@
import { IFetchOptions, FetchClient } from '@pnp/common';
import { MockResponse } from './mocks/mockresponse';
import { MockListFactory } from './mocks/mockListFactory';
import { LogHelper } from 'utilities';
export class CustomFetchClient extends FetchClient {
private mockListFactory: MockListFactory = new MockListFactory();
private isUsingSharePoint: boolean;
constructor(isUsingSharePoint: boolean) {
super();
this.isUsingSharePoint = isUsingSharePoint;
}
public fetch(url: string, options: IFetchOptions): Promise<Response> {
LogHelper.verbose(this.constructor.name, 'fetch', url);
if (this.isUsingSharePoint === false) {
url = url.replace('/#/', '/'); // deal with HashLocationStrategy when on local host
return new MockResponse(this.mockListFactory).fetch(url, options);
}
else {
return super.fetch(url, options);
}
}
}

View File

@ -0,0 +1,134 @@
// The code below was initiated using code from the link below and then customized
// https://github.com/jadrake75/odata-filter-parser/blob/master/src/odata-parser.js
// For testing the regex https://www.regexpal.com/
export class FilterParser {
public static Operators = {
EQUALS: 'eq',
AND: 'and',
OR: 'or',
GREATER_THAN: 'gt',
GREATER_THAN_EQUAL: 'ge',
LESS_THAN: 'lt',
LESS_THAN_EQUAL: 'le',
LIKE: 'like',
IS_NULL: 'is null',
NOT_EQUAL: 'ne',
SUBSTRINGOF: 'substringof'
};
/*
Examples documented when we know that we are using them. Any Regex items defined below without an example may not be explicitly tested
lookupop - Lookup/ID eq 1
op - ID eq 1
*/
private REGEX = {
parenthesis: /^([(](.*)[)])$/,
andor: /^(.*?) (or|and)+ (.*)$/,
lookupop: /(\w*\/\w*) (eq|gt|lt|ge|le|ne) (datetimeoffset'(.*)'|'(.*)'|[0-9]*)/,
op: /(\w*) (eq|gt|lt|ge|le|ne) (datetimeoffset'(.*)'|'(.*)'|[0-9]*)/,
startsWith: /^startswith[(](.*),'(.*)'[)]/,
endsWith: /^endswith[(](.*),'(.*)'[)]/,
contains: /^contains[(](.*),'(.*)'[)]/,
substringof: /^substringof[(](.*),(.*)[)]/
};
public parse(filterString: string): any {
// LoggingService.getLogger(this.constructor.name).info(`parse - filter=[${filterString}]`);
if (!filterString || filterString === '') {
return null;
}
let filter = filterString.trim();
let obj = {};
if (filter.length > 0) {
obj = this.parseFragment(filter);
}
return obj;
}
private parseFragment(filter): any {
// LoggingService.getLogger(this.constructor.name).info(`parseFragment - filter=[${filter}]`);
let found: boolean = false;
let obj: Predicate = new Predicate();
// tslint:disable-next-line:forin
for (let key in this.REGEX) {
let regex = this.REGEX[key];
if (found) {
break;
}
let match = filter.match(regex);
if (match) {
switch (regex) {
case this.REGEX.parenthesis:
if (match.length > 2) {
if (match[2].indexOf(')') < match[2].indexOf('(')) {
continue;
}
obj = this.parseFragment(match[2]);
}
break;
case this.REGEX.andor:
obj = {
property: this.parseFragment(match[1]),
operator: match[2],
value: this.parseFragment(match[3])
};
break;
case this.REGEX.lookupop:
case this.REGEX.op:
let property = match[1].split('/');
obj = {
property: property,
operator: match[2],
value: (match[3].indexOf('\'') === -1) ? +match[3] : match[3]
};
if (typeof obj.value === 'string') {
let quoted = obj.value.match(/^'(.*)'$/);
let m = obj.value.match(/^datetimeoffset'(.*)'$/);
if (quoted && quoted.length > 1) {
obj.value = quoted[1];
} else if (m && m.length > 1) {
obj.value = new Date(m[1]).toISOString();
}
}
break;
case this.REGEX.startsWith:
case this.REGEX.endsWith:
case this.REGEX.contains:
obj = this.buildLike(match, key);
break;
case this.REGEX.substringof:
obj = this.buildSubstringof(match, key);
break;
}
found = true;
}
}
return obj;
}
private buildSubstringof(match, key): Predicate {
return {
property: match[2].trim().split('/'),
operator: FilterParser.Operators.SUBSTRINGOF,
value: match[1].trim().split(`'`).join('')
};
}
private buildLike(match, key): Predicate {
let right = (key === 'startsWith') ? match[2] + '*' : (key === 'endsWith') ? '*' + match[2] : '*' + match[2] + '*';
return {
property: match[1],
operator: FilterParser.Operators.LIKE,
value: right
};
}
}
export class Predicate {
public property: string[] = [];
public operator: string;
public value: string;
}

View File

@ -0,0 +1,69 @@
import { ListTitles, StandardFields, ReplyFields } from 'utilities';
import { BaseList } from 'services/mocks/mockListFactory';
export class QuestionsList implements BaseList {
public listTitle = ListTitles.QUESTIONS;
public lookups = [
{ itemProperty: ReplyFields.QUESTIONLOOKUP, itemKey: 'QuestionLookupId', lookupListTitle: ListTitles.QUESTIONS },
{ itemProperty: ReplyFields.REPLYLOOKUP, itemKey: 'ReplyLookupId', lookupListTitle: ListTitles.QUESTIONS },
{ itemProperty: StandardFields.AUTHOR, itemKey: 'AuthorId', lookupListTitle: ListTitles.USERS_INFORMATION },
{ itemProperty: StandardFields.EDITOR, itemKey: 'EditorId', lookupListTitle: ListTitles.USERS_INFORMATION }
];
public items: any[] = [
{
ID: 1,
Title: 'How old is Alex Trebek?',
TW_Details: '<p>I have always wondered.</p>',
TW_IsAnswered: true,
ContentType: 'Question',
Created: new Date(),
Modified: new Date(),
AuthorId: 1,
EditorId: 1
},
{
ID: 2,
Title: 'Who is the best baseball player ever?',
TW_Details: '<p>I think it is Ozzie Smith.</p>',
TW_IsAnswered: false,
ContentType: 'Question',
Created: new Date(),
Modified: new Date(),
AuthorId: 1,
EditorId: 1
},
{
ID: 3,
Title: 'I think he is',
TW_Details: '<p>He is 75</p>',
TW_IsAnswer: true,
TW_QuestionLookupId: 1,
ContentType: 'Reply',
Created: new Date(),
Modified: new Date(),
AuthorId: 1,
EditorId: 1
},
{
ID: 4,
Title: 'I am pretty sure he is',
TW_Details: '<p>I am pretty sure he is 79</p>',
TW_IsAnswer: true,
TW_QuestionLookupId: 1,
TW_ReplyLookupId: 3,
ContentType: 'Reply',
Created: new Date(),
Modified: new Date(),
AuthorId: 1,
EditorId: 1
}
];
}

View File

@ -0,0 +1,61 @@
import { ListTitles } from 'utilities';
import { BaseList } from 'services/mocks/mockListFactory';
/*The first user in this will be used as the 'current user'
Notes about the data
Only the first item has a Title property as it only seems that Pnps wrapper on /_api/web/currentuser sends this back
The call to /_api/sp.utilities.utility.searchprincipalsusingcontextweb returns a PrincipalInfo object (PnP object) which has DisplayName rather than Title
*/
export class UsersInformationList implements BaseList {
public listTitle = ListTitles.USERS_INFORMATION;
public items: any[] = [
{
ID: 1,
DisplayName: 'Demo Designer',
Title: 'Demo Designer',
LoginName: 'i:0#.w|domain\\designer',
Key: 'i:0#.w|domain\\designer',
Name: 'i:0#.w|domain\\designer',
JobTitle: 'Demo Designer Title',
Email: 'designer@demo.com',
Mobile: '404-555-1211',
IsSiteAdmin: false,
Groups: {
results: [
]
}
},
{
ID: 2,
DisplayName: 'Demo User',
Title: 'Demo User',
LoginName: 'i:0#.w|domain\\user',
Key: 'i:0#.w|domain\\user',
Name: 'i:0#.w|domain\\user',
JobTitle: 'Demo User Title',
Email: 'user@demo.com',
Mobile: '404-555-1212'
},
{
ID: 3,
DisplayName: 'Demo External User',
Title: 'Demo External User',
LoginName: 'i:0#.w|domain\\externaluser',
Key: 'i:0#.w|domain\\externaluser',
Name: 'i:0#.w|domain\\externaluser',
JobTitle: 'Demo External User Title',
Email: 'externaluser@demo.com',
Mobile: '404-555-1213'
},
{
ID: 4,
DisplayName: 'Demo No Email',
Title: 'Demo No Email',
LoginName: 'i:0#.w|domain\\demo01',
Key: 'i:0#.w|domain\\demo01',
Name: 'i:0#.w|domain\\demo01',
JobTitle: 'Demo No Email Title',
Mobile: '404-555-1215'
}
];
}

View File

@ -0,0 +1,120 @@
import { QuestionsList } from './lists/questionsList';
import { UsersInformationList } from './lists/usersInformationList';
import { LogHelper } from 'utilities';
export class MockListFactory {
private listMap: BaseList[] = [
new QuestionsList(),
new UsersInformationList()
];
public getListItems(listTitle: string): any[] {
LogHelper.verbose(this.constructor.name, 'getListItems', listTitle);
let items = this.getItemsForMockList(listTitle);
let list = this.listMap.filter(l => l.listTitle === listTitle)[0];
if (list) {
items = this.getStoredItems(list.listTitle, list.items);
}
else {
LogHelper.error(this.constructor.name, 'getListItems', `List factory not found: [${listTitle}]`);
}
if (list && list.lookups !== undefined) {
for (let lookup of list.lookups) {
let lookupListItems = this.getItemsForMockList(lookup.lookupListTitle);
for (let item of items) {
if (lookup.isMulti !== true) {
item[lookup.itemProperty] = this.getLookup(item, lookup.itemKey, lookupListItems);
}
else {
item[lookup.itemProperty] = this.getMultiLookup(item, lookup.itemKey, lookupListItems);
}
}
}
}
return items;
}
public saveListItems(listTitle: string, items: any[]): void {
LogHelper.verbose(this.constructor.name, 'saveListItems', listTitle);
let storageKey = listTitle.split(' ').join('');
this.storeItems(storageKey, items);
}
private getItemsForMockList(listTitle: string): any[] {
let items: any[] = [];
let list = this.listMap.filter(l => l.listTitle === listTitle)[0];
if (list != null) {
items = this.getStoredItems(list.listTitle, list.items);
}
return items;
}
private getStoredItems(listTitle: string, defaultItems: any[]): any[] {
let storageKey = listTitle.split(' ').join('');
let items: any[] = [];
let storedData: string | null;
storedData = localStorage.getItem(storageKey);
if (storedData !== null) {
items = JSON.parse(storedData);
}
else {
items = defaultItems;
this.storeItems(storageKey, items);
}
return items;
}
private storeItems(storageKey: string, items: any[]): void {
let storedData = JSON.stringify(items);
localStorage.setItem(storageKey, storedData);
}
private getLookup(item: any, lookupIdProperty: string, lookupListItems: any[]): any {
if (item[lookupIdProperty] !== undefined) {
return lookupListItems.filter(i => i.ID === item[lookupIdProperty])[0];
}
else {
return null;
}
}
private getMultiLookup(item: any, lookupIdProperty: string, lookupListItems: any[]): any {
if (item[lookupIdProperty] !== undefined && item[lookupIdProperty].results !== undefined && item[lookupIdProperty].results instanceof Array) {
let results: any[] = [];
for (let id of item[lookupIdProperty].results) {
let lookupItem = lookupListItems.filter(i => i.ID === id)[0];
if (lookupItem) {
results.push(lookupItem);
}
}
return { results: results };
}
else {
return { results: [] };
}
}
}
export interface BaseList {
listTitle: string;
items: any[];
lookups?: Lookup[];
}
export class Lookup {
public itemKey: string;
public itemProperty: string;
public lookupListTitle: string;
public isMulti?: boolean;
}

View File

@ -0,0 +1,962 @@
import { IItemUpdateResult, IContextInfo } from '@pnp/sp/presets/all';
import { IFetchOptions } from '@pnp/common';
import { FilterParser } from './filterParser';
import { MockListFactory } from './mockListFactory';
import { parse } from 'url';
// import * as FileSaver from 'file-saver';
import { LogHelper, ListTitles } from 'utilities';
export class MockResponse {
private listTitle: string;
private currentUser;
constructor(private mockListFactory: MockListFactory) { }
public async fetch(url: string, options: IFetchOptions): Promise<Response> {
let response;
this.listTitle = this.getListTitleFromUrl(url);
this.currentUser = this.mockListFactory.getListItems(ListTitles.USERS_INFORMATION)[0];
if (options.method!.toUpperCase() === 'GET' && url.toLowerCase().indexOf('_api/web/currentuser') !== -1) {
response = this.getCurrentUser(url);
}
else if (options!.method!.toUpperCase() === 'GET' && url.toLowerCase().indexOf('/_api/web/siteusers(@v)?') !== -1) {
response = this.getSiteUser(url);
}
else if (options!.method!.toUpperCase() === 'GET' && url.toLowerCase().indexOf('/_api/web/siteusers/getbyid') !== -1) {
response = this.getSiteUserById(url);
}
else if (options!.method!.toUpperCase() === 'GET' && url.toLowerCase().indexOf('/_api/web/siteusers/getbyemail') !== -1) {
response = this.getSiteUserByEmail(url);
}
else if (options!.method!.toUpperCase() === 'GET' && this.endsWith(url, '/_api/web')) {
response = this.getWeb(url);
}
else if (options!.method!.toUpperCase() === 'GET' && this.endsWith(url, '/attachmentfiles')) {
response = this.getAttachments(url);
}
else if (options!.method!.toUpperCase() === 'GET' && url.toLowerCase().indexOf('/_api/web/getfilebyserverrelativeurl') !== -1) {
response = await this.getFileByServerRelativeUrl(url);
}
else if (options!.method!.toUpperCase() === 'GET' && url.toLowerCase().indexOf('/items') === -1) {
response = this.getListProperties(url);
}
else if (options!.method!.toUpperCase() === 'GET') {
response = this.getListItems(url);
}
else if (options!.method!.toUpperCase() === 'POST' && this.endsWith(url, '_api/contextinfo')) {
response = this.getContextInfo();
}
else if (options!.method!.toUpperCase() === 'POST' && this.endsWith(url, '_api/$batch')) {
response = await this.processBatch(url, options);
}
else if (options!.method!.toLocaleUpperCase() === 'POST' && this.endsWith(url, '/_api/sp.utilities.utility.searchprincipalsusingcontextweb')) {
response = this.searchPrincipals(url, options);
}
else if (options!.method!.toLocaleUpperCase() === 'POST' && this.endsWith(url, '/_api/sp.ui.applicationpages.clientpeoplepickerwebserviceinterface.clientpeoplepickersearchuser')) {
response = this.clientPeoplePickerSearchUser(url, options);
}
else if (options!.method!.toLocaleUpperCase() === 'POST' && this.endsWith(url, '/_api/sp.utilities.utility.sendemail')) {
response = this.sendEmail(url, options);
}
else if (options!.method!.toUpperCase() === 'POST' && this.endsWith(url, '_api/web/ensureuser')) {
response = this.ensureUser(url, options);
}
else if (options!.method!.toUpperCase() === 'POST' && url.toLowerCase().indexOf('/attachmentfiles') !== -1) {
// add, updates and deletes
response = this.saveAttachmentChanges(url, options);
}
else if (options!.method!.toUpperCase() === 'POST' && url.toLowerCase().indexOf('_api/web/sitegroups/') !== -1) {
response = new Response('', { status: 200 });
}
else if (options!.method!.toUpperCase() === 'POST' && this.endsWith(url, '/getitems')) {
response = this.getListItemsCamlQuery(url, options);
}
else if (options!.method!.toUpperCase() === 'POST' && url.toLowerCase().indexOf('/files/add') !== -1) {
// add, updates and deletes
response = this.saveFile(url, options);
}
else {
// add, updates and deletes
response = this.saveListItemChanges(url, options);
}
return new Promise<Response>((resolve) => {
setTimeout(() => resolve(response), 200);
});
}
private endsWith(url: string, search: string): boolean {
return url.substring(url.length - search.length, url.length) === search;
}
private getListItems(urlString: string): Response {
LogHelper.verbose(this.constructor.name, 'getListItems', urlString);
let url = parse(urlString, true, true);
let body: string | undefined;
let totalItemsCount: number = 0;
// try to get the data from local storage and then from mock data
let items = this.mockListFactory.getListItems(this.listTitle);
// apply select, filter, top, etc...
items = this.applyFilter(items, url.query.$filter);
items = this.applySelect(items, url.query.$select);
items = this.applyOrderBy(items, url.query.$orderby);
items = this.applySkip(items, url.query.$skip);
totalItemsCount = items.length;
items = this.applyTop(items, url.query.$top);
if (url.pathname.endsWith('/items')) {
body = JSON.stringify(items);
// revisit to figure out how the source is telling us to page
if (items.length < totalItemsCount) {
let skipParts = urlString.split('&$skip=');
let nextUrl = '';
if (skipParts.length === 1) {
nextUrl = `${urlString}"&$skip=${items.length}`;
}
else if (skipParts.length === 2) {
nextUrl = `${urlString}"&$skip=${+skipParts[1] + items.length}`;
}
let result = {
'd': {
'results': items,
'__next': nextUrl
}
};
body = JSON.stringify(result);
}
}
else if (url.pathname.endsWith(')')) {
let index = url.pathname.lastIndexOf('(');
let id = url.pathname.slice(index + 1, url.pathname.length - 1);
let item = items.filter(i => i.ID === +id)[0];
body = JSON.stringify(item);
}
else {
// not sure what might hit here yet
}
return new Response(body, { status: 200 });
}
private getListProperties(urlString: string): Response {
LogHelper.verbose(this.constructor.name, 'getListProperties', urlString);
let body = {
'RootFolder': {
'ServerRelativeUrl': `/${this.listTitle}`
},
'ParentWeb': {
'Url': `${window.location.origin}/`
},
'ParentWebUrl': '/',
'Title': this.listTitle
};
return new Response(JSON.stringify(body), { status: 200 });
}
private getListItemsCamlQuery(urlString: string, options: IFetchOptions): Response {
LogHelper.verbose(this.constructor.name, 'getListItemsCamlQuery', urlString);
let url = parse(urlString, true, true);
let body: string | undefined;
// try to get the data from local storage and then from mock data
let items = this.mockListFactory.getListItems(this.listTitle);
// tslint:disable-next-line:max-line-length
// {"query":{"__metadata":{"type":"SP.CamlQuery"},"ViewXml":"<View><ViewFields><FieldRef Name='Group1'/><FieldRef Name='ProductGroup'/>...</ViewFields><Query><Where><Eq><FieldRef Name='AppliesTo'/><Value Type='Choice'>Cost</Value></Eq></Where></Query><RowLimit>1000</RowLimit></View>"}}
let camlQuery = JSON.parse(options.body);
let viewXml: string = camlQuery.query.ViewXml;
let viewFieldsStart = viewXml.indexOf('<ViewFields>') + 12;
let viewFieldsEnd = viewXml.indexOf('</ViewFields>');
let queryStart = viewXml.indexOf('<Query>') + 7;
let queryEnd = viewXml.indexOf('</Query>');
let rowLimitStart = viewXml.indexOf('<RowLimit>') + 10;
let rowLimitEnd = viewXml.indexOf('</RowLimit>');
let viewFields = viewXml.substring(viewFieldsStart, viewFieldsEnd);
let query = viewXml.substring(queryStart, queryEnd); // <Where><Eq><FieldRef Name='AppliesTo'/><Value Type='Choice'>Cost</Value></Eq></Where>
let rowLimit = viewXml.substring(rowLimitStart, rowLimitEnd);
let select = viewFields.split(`<FieldRef Name='`).join('').split(`'/>`).join(',');
// WARNING - currently this assumes only one clause with an Eq
let whereStart = query.indexOf('<Where>') + 7;
let whereEnd = query.indexOf('</Where>');
let where = query.substring(whereStart, whereEnd); // <Eq><FieldRef Name='AppliesTo'/><Value Type='Choice'>Cost</Value></Eq>
let compare = where.startsWith('<Eq>') ? 'eq' : null; // add other checks for future compares
where = where.split('<Eq>').join('').split('</Eq>').join(''); // <FieldRef Name='AppliesTo'/><Value Type='Choice'>Cost</Value>
let filter = where.split(`<FieldRef Name='`).join('').split(`'/>`).join(` ${compare} `)
.split(`<Value Type='Choice'>`).join(`'`).split('</Value>').join(`'`);
items = this.applyFilter(items, filter);
items = this.applySelect(items, select);
items = this.applyTop(items, rowLimit);
if (url.pathname.endsWith('/getitems')) {
body = JSON.stringify(items);
}
else if (url.pathname.endsWith(')')) {
let index = url.pathname.lastIndexOf('(');
let id = url.pathname.slice(index + 1, url.pathname.length - 1);
let item = items.filter(i => i.ID === +id)[0];
body = JSON.stringify(item);
}
else {
// not sure what might hit here yet
}
return new Response(body, { status: 200 });
}
private getAttachments(urlString: string): Response {
LogHelper.verbose(this.constructor.name, 'getAttachments', urlString);
let url = parse(urlString, true, true);
let body: string;
// try to get the data from local storage and then from mock data
let items = this.mockListFactory.getListItems(this.listTitle);
// _api/web/lists/getByTitle([list name])/items([id])/AttachmentFiles
let index = url.pathname.lastIndexOf('(');
let id = url.pathname.slice(index + 1, url.pathname.length - 17);
let item = items.filter(i => i.ID === +id)[0];
if (item.AttachmentFiles !== undefined) {
body = JSON.stringify(item.AttachmentFiles);
}
else {
body = JSON.stringify([]);
}
return new Response(body, { status: 200 });
}
private getFileByServerRelativeUrl(urlString: string): Promise<Response> {
LogHelper.verbose(this.constructor.name, 'getFileByServerRelativeUrl', urlString);
return new Promise((resolve) => {
let response;
let startIndex = urlString.lastIndexOf(`(`) + 2;
let endIndex = urlString.lastIndexOf(`)`) - 1;
let filePath = urlString.substring(startIndex, endIndex);
/* TODO Revisit
if (filePath.indexOf(ApplicationValues.Path) !== -1) {
filePath = filePath.split(ApplicationValues.Path)[1];
}
*/
if (this.endsWith(urlString, '$value')) {
let xmlhttp = new XMLHttpRequest();
xmlhttp.responseType = 'arraybuffer';
// tslint:disable-next-line:no-function-expression
xmlhttp.onreadystatechange = function () {
if (xmlhttp.status === 200 && xmlhttp.readyState === 4) {
response = new Response(xmlhttp.response, { status: 200 });
resolve(response);
}
};
xmlhttp.open('GET', filePath, true);
xmlhttp.send();
}
else {
// TO DO if we need file properties
}
});
}
private getWeb(urlString: string): Response {
// only have a method for this stuff in case we need to do more mock stuff in the future
let url = parse(urlString, true, true);
let body = {
'Url': `${url.protocol}//${url.host}/`,
'ServerRelativeUrl': ''
};
return new Response(JSON.stringify(body), { status: 200 });
}
private getCurrentUser(urlString: string): Response {
LogHelper.verbose(this.constructor.name, 'getCurrentUser', urlString);
// only have a method for this stuff in case we need to do more mock stuff in the future
return new Response(JSON.stringify(this.currentUser), { status: 200 });
}
private getSiteUser(urlString: string): Response {
LogHelper.verbose(this.constructor.name, 'getSiteUser', urlString);
let url = parse(urlString, true, true);
let search = decodeURIComponent(url.search);
let loginName = search.substring(5, search.length - 1);
let users = this.mockListFactory.getListItems(ListTitles.USERS_INFORMATION);
let user = users.filter(i => {
return (i.LoginName && i.LoginName.toLowerCase().indexOf(loginName.toLowerCase()) !== -1);
})[0];
return new Response(JSON.stringify(user), { status: 200 });
}
// _api/web/siteusers/getById(1)
private getSiteUserById(urlString: string): Response {
LogHelper.verbose(this.constructor.name, 'getSiteUserById', urlString);
let url = parse(urlString, true, true);
let index = url.pathname.lastIndexOf('(');
let id = url.pathname.slice(index + 1, url.pathname.length - 1);
let users = this.mockListFactory.getListItems(ListTitles.USERS_INFORMATION);
let user = users.filter(i => {
return (i.ID === +id);
})[0];
return new Response(JSON.stringify(user), { status: 200 });
}
// _api/web/siteusers/getByEmail('administrator@demo.com')
private getSiteUserByEmail(urlString: string): Response {
LogHelper.verbose(this.constructor.name, 'getSiteUserByEmail', urlString);
let url = parse(urlString, true, true);
let pathName = decodeURIComponent(url.pathname); // get rid of encoded characters
let index = pathName.lastIndexOf(`('`);
let email = pathName.slice(index + 2, pathName.length - 2);
let user: any;
if (email.length > 0) {
let users = this.mockListFactory.getListItems(ListTitles.USERS_INFORMATION);
// To better work with SharePoint and mock data...
// User Profile uses "Email"
// User Information uses "EMail"
user = users.filter(i => {
return (
i.Email ? i.Email.toLocaleLowerCase() === email.toLocaleLowerCase() : (i.EMail ? i.EMail.toLocaleLowerCase() === email.toLocaleLowerCase() : false)
);
})[0];
}
return new Response(JSON.stringify(user), { status: 200 });
}
private saveListItemChanges(urlString: string, options: IFetchOptions): Response {
LogHelper.verbose(this.constructor.name, 'saveListItemChanges', urlString);
let url = parse(urlString, true, true);
let body: string | undefined;
let items = this.mockListFactory.getListItems(this.listTitle);
if (url.pathname.endsWith('/items')) {
// add a new item
let item: any = {};
let storageKey = this.listTitle + '_ItemCount';
let maxId: number = 0;
if (localStorage.getItem(storageKey) !== null) {
maxId = +localStorage.getItem(storageKey)!;
if (maxId === NaN || maxId === 0) {
if (items.length > 0) {
maxId = Math.max.apply(Math, items.map(i => i.ID));
}
else {
maxId = 0;
}
}
maxId = maxId + 1;
item['ID'] = maxId;
}
let requestBody = JSON.parse(options.body);
Object.keys(requestBody).map(
(e) => item[e] = requestBody[e]
);
// Common to all SharePoint List Items
let now = new Date();
item.Created = now;
item.Modified = now;
item.AuthorId = this.currentUser.ID;
item.EditorId = this.currentUser.ID;
items.push(item);
item.Id = item.ID;
body = JSON.stringify(item);
localStorage.setItem(storageKey, JSON.stringify(maxId));
}
else if (url.pathname.endsWith(')')) {
// update
let index = url.pathname.lastIndexOf('(');
let id = url.pathname.slice(index + 1, url.pathname.length - 1);
let item = items.filter(i => i.ID === +id)[0];
if (options.body !== undefined) {
// update an item
let requestBody = JSON.parse(options.body);
Object.keys(requestBody).map(
(e) => item[e] = requestBody[e]
);
// Common to all SharePoint List Items
let now = new Date();
item.Modified = now;
item.EditorId = this.currentUser.ID;
let result: IItemUpdateResult = {
item: item,
data: { 'odata.etag': '' }
};
body = JSON.stringify(result);
}
else {
// delete an item
items = items.filter(i => i.ID !== +id);
}
}
else {
// not sure what might hit here yet
}
this.mockListFactory.saveListItems(this.listTitle, items);
return new Response(body, { status: 200 });
}
private saveAttachmentChanges(urlString: string, options: IFetchOptions): Response {
LogHelper.verbose(this.constructor.name, 'saveAttachmentChanges', urlString);
let url = parse(urlString, true, true);
let body: string | undefined;
let items = this.mockListFactory.getListItems(this.listTitle);
// '/reqdocs/BR/4/attachments/_api/web/lists/getByTitle(%27Requirement%20Documents%27)/items(4)/AttachmentFiles/add(FileName=%27AA%20Template.docx%27)'
let decodedPath = decodeURI(url.pathname);
let index = decodedPath.lastIndexOf('(');
let fileName = decodedPath.slice(index + 2, decodedPath.length - 2);
let startIndex = decodedPath.lastIndexOf('/items(');
let endIndex = decodedPath.lastIndexOf(')/AttachmentFiles');
let id = decodedPath.slice(startIndex + 7, endIndex);
let item = items.filter(i => i.ID === +id)[0];
if (item.AttachmentFiles === undefined) {
item.AttachmentFiles = [];
}
if (options.body !== undefined) {
// add an attachment
/*
item.AttachmentFiles.push({
FileName: options.body.name,
ServerRelativeUrl: options.body.name,
file: options.body
});
*/
let fileReader = new FileReader();
fileReader.onload = (evt: any) => {
this.fileLoaded(evt, items, item, options);
};
fileReader.readAsDataURL(options.body);
}
else {
// delete an attachment
item.AttachmentFiles = item.AttachmentFiles.filter(a => a.FileName.toLowerCase() !== fileName.toLowerCase());
}
this.mockListFactory.saveListItems(this.listTitle, items);
return new Response(body, { status: 200 });
}
private saveFile(urlString: string, options: IFetchOptions): Response {
LogHelper.verbose(this.constructor.name, 'saveFile', urlString);
let url = parse(urlString, true, true);
let body: string;
// /files/add(overwrite=true,url='Authorized%20Retail%20Pricing%20(effective%2004.27.18).xlsx')
let decodedPath = decodeURI(url.pathname);
let index = decodedPath.lastIndexOf('url=');
let fileName = decodedPath.slice(index + 5, decodedPath.length - 2);
// FileSaver.saveAs(options.body, fileName);
let result = {
file: options.body,
ServerRelativeUrl: fileName
};
body = JSON.stringify(result);
return new Response(body, { status: 200 });
}
private fileLoaded(evt: any, items: any, item: any, options: any) {
let data = evt.target.result;
item.AttachmentFiles.push({
FileName: options.body.name,
ServerRelativeUrl: data
});
this.mockListFactory.saveListItems(this.listTitle, items);
}
private searchPrincipals(urlString: string, options: IFetchOptions): Response {
LogHelper.verbose(this.constructor.name, 'searchPrincipals', urlString);
let body: string;
let searchOptions = JSON.parse(options.body);
let users = this.mockListFactory.getListItems(ListTitles.USERS_INFORMATION);
let items = users.filter(i => {
return ((i.DisplayName && i.DisplayName.toLowerCase().indexOf(searchOptions.input.toLowerCase()) !== -1) ||
(i.LoginName && i.LoginName.toLowerCase().indexOf(searchOptions.input.toLowerCase()) !== -1) ||
(i.Email && i.Email.toLowerCase().indexOf(searchOptions.input.toLowerCase()) !== -1)
);
});
let result = {
'SearchPrincipalsUsingContextWeb': {
'results': items
}
};
body = JSON.stringify(result);
return new Response(body, { status: 200 });
}
private clientPeoplePickerSearchUser(urlString: string, options: IFetchOptions): Response {
LogHelper.verbose(this.constructor.name, 'clientpeoplepickersearchuser', urlString);
let body: string;
let postBody = JSON.parse(options.body);
let query = postBody.queryParams.QueryString.toLowerCase();
let users = this.mockListFactory.getListItems(ListTitles.USERS_INFORMATION);
let items = users.filter(i => {
return ((i.DisplayName && i.DisplayName.toLowerCase().indexOf(query) !== -1) ||
(i.LoginName && i.LoginName.toLowerCase().indexOf(query) !== -1) ||
(i.Email && i.Email.toLowerCase().indexOf(query) !== -1)
);
});
let results: any[] = [];
for (let item of items) {
results.push({
Key: item.Key,
Description: item.Title,
DisplayText: item.DisplayName,
EntityType: 'User',
IsResolved: true,
MultipleMatches: [],
ProviderDisplayName: 'User Information List',
ProviderName: 'UserInformationList',
EntityData: {
AccountName: item.Email,
Department: '',
Title: item.JobTitle,
Email: item.Email,
MobilePhone: item.Mobile
}
});
}
let result = {
d: {
ClientPeoplePickerSearchUser: JSON.stringify(results)
}
};
body = JSON.stringify(result);
return new Response(body, { status: 200 });
}
private sendEmail(urlString: string, options: IFetchOptions): Response {
LogHelper.verbose(this.constructor.name, 'sendEmail', urlString);
let body: string;
/*
let emailOptions = JSON.parse(options.body);
let to = '';
if (emailOptions.properties.To !== undefined && emailOptions.properties.To !== null && emailOptions.properties.To.results.length > 0) {
for (let address of emailOptions.properties.To.results) {
if (address !== null && address.length > 0) {
to += `${address};`;
}
}
}
let cc = '';
if (emailOptions.properties.CC !== undefined && emailOptions.properties.CC !== null && emailOptions.properties.CC.results.length > 0) {
for (let address of emailOptions.properties.CC.results) {
if (address !== null && address.length > 0) {
cc += `${address};`;
}
}
}
let email = `To: ${to}\nCc: ${cc}\nSubject: ${emailOptions.properties.Subject}\nX-Unsent: 1\nContent-Type: text/html\n\n<html><body>${emailOptions.properties.Body}</body></html>`;
let data = new Blob([email], { type: 'text/plain' });
FileSaver.saveAs(data, emailOptions.properties.Subject + '.eml');
*/
body = JSON.stringify('');
return new Response(body, { status: 200 });
}
private ensureUser(urlString: string, options: IFetchOptions): Response {
LogHelper.verbose(this.constructor.name, 'ensureUser', urlString);
let url = parse(urlString, true, true);
let body: string;
let ensureOptions = JSON.parse(options.body);
let users = this.mockListFactory.getListItems(ListTitles.USERS_INFORMATION);
let user = users.filter(i => {
return (i.LoginName && i.LoginName.toLowerCase().indexOf(ensureOptions.logonName.toLowerCase()) !== -1);
})[0];
user['__metadata'] = { id: `${url.protocol}${url.host}/_api/Web/GetUserById(${user.ID})` };
user.Id = user.ID; // because... SharePoint
let result = {
'd': user
};
body = JSON.stringify(result);
return new Response(body, { status: 200 });
}
private getContextInfo(): Response {
let contexInfo: IContextInfo = {
FormDigestTimeoutSeconds: 100,
FormDigestValue: 100
};
let body = JSON.stringify({ d: { GetContextWebInformation: contexInfo } });
return new Response(body, { status: 200 });
}
private async processBatch(urlString: string, options: IFetchOptions): Promise<Response> {
let linesInBody = options.body.split('\n');
let getRequests: string[] = [];
for (let line of linesInBody) {
if (line.startsWith('GET')) {
let httpIndex = line.indexOf('http://');
let protocolIndex = line.indexOf('HTTP/1.1');
let requestUrl = line.substring(httpIndex, protocolIndex);
requestUrl = requestUrl.split('/#/').join('/');
getRequests.push(requestUrl);
}
}
// Creating response lines to look like what should be processed here
// https://github.com/pnp/pnpjs/blob/dev/packages/sp/src/batch.ts
let responseLines: string[] = [];
for (let requestUrl of getRequests) {
let getResponse = await this.fetch(requestUrl, { method: 'GET' });
responseLines.push('--batchresponse_1234');
responseLines.push('Content-Type: application/http');
responseLines.push('Content-Transfer-Encoding: binary');
responseLines.push('');
responseLines.push('HTTP/1.1 200 OK');
responseLines.push('CONTENT-TYPE: application/json;odata=verbose;charset=utf-8');
responseLines.push('');
let text = await getResponse.text();
// TODO - Revisit this as it assumes we are only batching a set of results
responseLines.push(`{"d":{"results":${text}}}`);
}
responseLines.push('--batchresponse_1234--');
responseLines.push('');
let r = responseLines.join('\n');
return new Response(r, { status: 200 });
}
private applyOrderBy(items: any[], orderby: string): any[] {
// Logger.write(`applyOrderBy`);
let sortKey: string;
let sortOrder: string;
if (orderby != null && orderby !== undefined && orderby.length > 0) {
let keys = orderby.split(' ');
sortKey = keys[0];
sortOrder = keys[1].toLocaleLowerCase();
// https://medium.com/@pagalvin/sort-arrays-using-typescript-592fa6e77f1
return items.sort((leftSide, rightSide): number => {
if (leftSide[sortKey] < rightSide[sortKey]) { return (sortOrder === 'asc' ? -1 : 1); }
if (leftSide[sortKey] > rightSide[sortKey]) { return (sortOrder === 'asc' ? 1 : -1); }
return 0;
});
}
else {
return items;
}
}
private applySelect(items: any[], select: string): any[] {
// Logger.write(`applySelect`);
let newItems: any[] = [];
if (select != null && select.length > 0) {
let keys = select.split(',');
for (let item of items) {
let newItem = {};
for (let key of keys) {
if (key.indexOf('/') === -1) {
newItem[key] = item[key];
}
else {
let partKeys = key.split('/');
this.expandedSelect(item, newItem, partKeys);
}
}
newItems.push(newItem);
}
return newItems;
}
else {
return items;
}
}
private applySkip(items: any[], skip: string): any[] {
// Logger.write(`applySkip`);
if (skip != null && +skip !== NaN) {
return items.slice(+skip);
}
else {
return items;
}
}
private applyTop(items: any[], top: string): any[] {
// Logger.write(`applyTop`);
if (top != null && +top !== NaN) {
return items.slice(0, +top);
}
else {
return items;
}
}
/*
This is intended for lookups but is a little 'hokey' and the moment since it really grabs the whole lookup object
rather than just the requested properties of the lookup. To be revisited.
*/
private expandedSelect(parentItem: any, parentNewItem: any, partKeys: string[]): any {
// Logger.write(`expandedSelect [${partKeys}]`);
try {
if (partKeys.length === 0) { return; }
let partKey = partKeys.shift();
if (parentNewItem && partKey) {
parentNewItem[partKey] = parentItem[partKey];
this.expandedSelect(parentItem[partKey], parentNewItem[partKey], partKeys);
}
}
catch (e) {
LogHelper.exception(this.constructor.name, 'expandedSelect', e);
}
}
private applyFilter(items: any[], filter: string): any[] {
// Logger.write(`applyFilter`);
let newItems: any[] = [];
if (filter != null && filter.length > 0) {
let parseResult = new FilterParser().parse(filter);
for (let item of items) {
let match: boolean = this.getMatchResult(item, parseResult);
if (match) {
newItems.push(item);
}
}
return newItems;
}
else {
return items;
}
}
private getMatchResult(item: any, parseResult: any): boolean {
switch (parseResult.operator.toLowerCase()) {
case FilterParser.Operators.EQUALS:
return this.getMatchResult_EQUALS(item, parseResult);
case FilterParser.Operators.SUBSTRINGOF:
return this.getMatchResult_SUBSTRINGOF(item, parseResult);
case FilterParser.Operators.AND:
return this.getMatchResult_AND(item, parseResult);
case FilterParser.Operators.OR:
return this.getMatchResult_OR(item, parseResult);
case FilterParser.Operators.NOT_EQUAL:
return this.getMatchResult_NOTEQUALS(item, parseResult);
default:
return false;
}
}
private getMatchResult_EQUALS(item: any, parseResult: any): boolean {
let propertyValue = item;
for (let property of parseResult.property) {
propertyValue = propertyValue[property];
// if our property is undefined or null no reason to keep looping into it
if (propertyValue === undefined || propertyValue === null) {
break;
}
// hack that for multi
if (propertyValue['results'] !== undefined) {
LogHelper.verbose(this.constructor.name, 'ensureUser', `getMatchResult_EQUALS ${property} - hack based on assumption this is a multiLookup`);
// if (property.toLowerCase().indexOf('multilookup') !== -1) {
// take the results collection and map to a single array of just the property we are matching on
propertyValue = propertyValue['results'].map(r => r[parseResult.property[1]]);
break;
}
}
let filterValue: any;
if (typeof (propertyValue) === 'number') {
filterValue = +parseResult.value;
}
else if (typeof (propertyValue) === 'boolean') {
filterValue = Boolean(parseResult.value);
}
else {
filterValue = parseResult.value;
}
if (propertyValue === filterValue) {
return true;
}
else if (Array.isArray(propertyValue)) {
if (propertyValue.indexOf(filterValue) !== -1) {
return true;
}
}
return false;
}
private getMatchResult_NOTEQUALS(item: any, parseResult: any): boolean {
let propertyValue = item;
for (let property of parseResult.property) {
propertyValue = propertyValue[property];
}
let filterValue: any;
if (typeof (propertyValue) === 'number') {
filterValue = +parseResult.value;
}
else if (typeof (propertyValue) === 'boolean') {
filterValue = Boolean(parseResult.value);
}
else {
filterValue = parseResult.value;
}
if (propertyValue !== filterValue) {
return true;
}
else if (Array.isArray(propertyValue)) {
if (propertyValue.indexOf(filterValue) === -1) {
return true;
}
}
return false;
}
private getMatchResult_SUBSTRINGOF(item: any, parseResult: any): boolean {
let propertyValue = item;
for (let property of parseResult.property) {
propertyValue = propertyValue[property];
}
let filterValue: any;
if (typeof (propertyValue) === 'number') {
filterValue = +parseResult.value;
}
else {
filterValue = parseResult.value;
}
if (propertyValue.toLowerCase().indexOf(filterValue.toLowerCase()) !== -1) {
return true;
}
return false;
}
// This assumes just one 'AND'
private getMatchResult_AND(item: any, parseResult: any): boolean {
let parseResult1 = this.getMatchResult(item, parseResult.property);
let parseResult2 = this.getMatchResult(item, parseResult.value);
if (parseResult1 === true && parseResult2 === true) {
return true;
}
else {
return false;
}
}
// This assumes just one 'OR'
private getMatchResult_OR(item: any, parseResult: any): boolean {
let parseResult1 = this.getMatchResult(item, parseResult.property);
let parseResult2 = this.getMatchResult(item, parseResult.value);
if (parseResult1 === true || parseResult2 === true) {
return true;
}
else {
return false;
}
}
private getListTitleFromUrl(urlString: string): string {
let listTitle = '';
let index = urlString.indexOf(`getByTitle('`);
if (index !== -1) {
listTitle = urlString.substring(index + 12);
index = listTitle.indexOf(`')`);
if (index !== -1) {
listTitle = listTitle.substring(0, index);
}
}
return listTitle;
}
}

View File

@ -0,0 +1,27 @@
import { sp, IEmailProperties } from '@pnp/sp/presets/all';
import { BaseService } from './base.service';
import { LogHelper } from 'utilities';
export class NotificationService extends BaseService {
public async sendEmail(emailProps: IEmailProperties) {
LogHelper.verbose(this.constructor.name, 'sendQuestionNotification', '');
if (emailProps.Subject.length > 255) {
emailProps.Subject = emailProps.Subject.substring(0, 252).concat('...');
}
if (emailProps.To && emailProps.To.length > 0) {
await sp.utility.sendEmail(emailProps)
.catch(e => {
super.handleHttpError('sendEmail', e);
throw e;
});
LogHelper.verbose(this.constructor.name, 'sendEmail', 'Email sent');
}
else {
LogHelper.verbose(this.constructor.name, 'sendEmail', 'Email not sent - No to recipient');
}
}
}

View File

@ -0,0 +1,55 @@
import { sp, PermissionKind, RoleType } from '@pnp/sp/presets/all';
import { BaseService } from './base.service';
import { LogHelper, ListTitles } from 'utilities';
export class PermissionService extends BaseService {
private listTitle = ListTitles.QUESTIONS;
public async canVisitorsAskQuestions(): Promise<boolean> {
LogHelper.verbose(this.constructor.name, 'canVisitorsAskQuestions', '');
let canAsk: boolean = false;
debugger;
let visitorGroup = await sp.web.associatedVisitorGroup();
let perms = await sp.web.lists.getByTitle(this.listTitle).getUserEffectivePermissions(visitorGroup.LoginName);
if(sp.web.hasPermissions(perms, PermissionKind.AddListItems)) {
canAsk = true;
}
return canAsk;
}
public async toggleVisitorCanAskQuestions(): Promise<void> {
LogHelper.verbose(this.constructor.name, 'toggleVisitorCanAskQuestions', '');
let canAsk = await this.canVisitorsAskQuestions();
if (canAsk === true) {
// reset list to inherit parent permissions
sp.web.lists.getByTitle(this.listTitle)
.resetRoleInheritance();
}
else {
let contributorPerms = await sp.web.roleDefinitions
.getByType(RoleType.Contributor)
.get();
let visitorGroupId = (await sp.web.associatedVisitorGroup()).Id;
if (contributorPerms && visitorGroupId) {
// break the list inheritance from the parent
await sp.web.lists.getByTitle(this.listTitle)
.breakRoleInheritance(true, true);
// give the visitor group contribute permissions
await sp.web.lists.getByTitle(this.listTitle)
.roleAssignments
.add(visitorGroupId, contributorPerms.Id);
}
}
}
}

View File

@ -0,0 +1,699 @@
import '@pnp/polyfill-ie11';
import { sp, PagedItemCollection, SPBatch } from '@pnp/sp/presets/all';
import { BaseService } from './base.service';
import { LogHelper, ContentTypes, ListTitles, StandardFields, PostFields, ReplyFields, QuestionFields, ShowQuestionsOption } from 'utilities';
import { IQuestionItem, IPostItem, IReplyItem, ICurrentUser, IQuestionsFilter, IPagedItems } from 'models';
export class QuestionService extends BaseService {
// private currentUser: ICurrentUser;
private listTitle = ListTitles.QUESTIONS;
private questionSelectColumns: string[] = [
StandardFields.ID,
StandardFields.TITLE,
PostFields.DETAILS,
PostFields.DETAILSTEXT,
QuestionFields.ISANSWERED,
QuestionFields.FOLLOW_EMAILS,
PostFields.LIKE_COUNT,
PostFields.LIKE_IDS,
// Standard Created/Modified Columns
StandardFields.CREATED,
StandardFields.MODIFIED,
StandardFields.AUTHOR_ID,
StandardFields.AUTHOR_NAME,
StandardFields.AUTHOR_TITLE,
StandardFields.EDITOR_ID,
StandardFields.EDITOR_NAME,
StandardFields.EDITOR_TITLE
];
private replySelectColumns: string[] = [
StandardFields.ID,
StandardFields.TITLE,
PostFields.DETAILS,
PostFields.DETAILSTEXT,
ReplyFields.ISANSWER,
PostFields.LIKE_COUNT,
PostFields.LIKE_IDS,
ReplyFields.HELPFULCOUNT,
ReplyFields.HELPFULIDS,
// question this item is related to
ReplyFields.QUESTIONLOOKUP_ID,
ReplyFields.QUESTIONLOOKUP_TITLE,
// parent of this item
ReplyFields.REPLYLOOKUP_ID,
ReplyFields.REPLYLOOKUP_TITLE,
// Standard Created/Modified Columns
StandardFields.CREATED,
StandardFields.MODIFIED,
StandardFields.AUTHOR_ID,
StandardFields.AUTHOR_NAME,
StandardFields.AUTHOR_TITLE,
StandardFields.EDITOR_ID,
StandardFields.EDITOR_NAME,
StandardFields.EDITOR_TITLE
];
private questionExpandColumns: string[] = [
StandardFields.CONTENTTYPE,
StandardFields.AUTHOR,
StandardFields.EDITOR
];
private replyExpandColumns: string[] = [
ReplyFields.QUESTIONLOOKUP,
ReplyFields.REPLYLOOKUP,
StandardFields.CONTENTTYPE,
StandardFields.AUTHOR,
StandardFields.EDITOR
];
public async getPagedQuestions(currentUser: ICurrentUser, filter: IQuestionsFilter, previousPagedItems: IPagedItems<IQuestionItem>): Promise<IPagedItems<IQuestionItem>> {
LogHelper.verbose(this.constructor.name, 'getPagedQuestions', `[filter=${JSON.stringify(filter)}]`);
let pagedItems: IPagedItems<IQuestionItem> = {
items: [],
pagedItemCollection: undefined
};
if (previousPagedItems !== null && previousPagedItems.pagedItemCollection && previousPagedItems.pagedItemCollection.hasNext) {
pagedItems.pagedItemCollection = await previousPagedItems.pagedItemCollection.getNext();
}
else {
let filterText = this.getQuestionFilterText(filter);
let top = filter.pageSize ? filter.pageSize : 20;
pagedItems.pagedItemCollection = <PagedItemCollection<any>>await sp.web.lists.getByTitle(this.listTitle).items
.select(this.questionSelectColumns.join(','))
.expand(this.questionExpandColumns.join(','))
.filter(filterText)
.top(top)
.orderBy(filter.orderByColumnName, filter.orderByAscending)
.getPaged()
.catch(e => {
super.handleHttpError('getPagedQuestions', e);
throw e;
});
}
if (pagedItems.pagedItemCollection) {
for (let questionItem of pagedItems.pagedItemCollection.results) {
let question = this.mapQuestion(questionItem, currentUser);
let currentUserCreatedQuestion: boolean = false;
if (currentUser.loginName.toLowerCase() === question.author!.id!.toLowerCase()) {
currentUserCreatedQuestion = true;
}
this.updateUserPermissions(currentUser, question, currentUserCreatedQuestion);
pagedItems.items.push(question);
}
}
return pagedItems;
}
public async getQuestionById(currentUser: ICurrentUser, id: number, skipReplies: boolean = false): Promise<IQuestionItem | null> {
LogHelper.verbose(this.constructor.name, 'getQuestionById', `[id:${id}]`);
let questionItem = await sp.web.lists.getByTitle(this.listTitle).items
.getById(id)
.select(this.questionSelectColumns.join(','))
.expand(this.questionExpandColumns.join(','))
.get()
.catch(e => {
super.handleHttpError('getQuestionById', e);
throw e;
});
if (questionItem !== null) {
let question: IQuestionItem = this.mapQuestion(questionItem, currentUser);
if (skipReplies === false) {
let flatReplies = await this.getFlatRepliesByQuestionId(currentUser, id);
question.replies = this.buildReplyTree(flatReplies, [], null, true);
question.totalReplyCount = flatReplies.length;
if (question.isAnswered === true) {
question.answerReply = flatReplies.find(r => r.isAnswer === true);
}
}
let currentUserCreatedQuestion: boolean = false;
if (currentUser.loginName.toLowerCase() === question.author!.id!.toLowerCase()) {
currentUserCreatedQuestion = true;
}
this.updateUserPermissions(currentUser, question, currentUserCreatedQuestion);
return question;
}
else {
return null;
}
}
public async isDuplicateQuestion(question: IQuestionItem): Promise<boolean> {
LogHelper.verbose(this.constructor.name, 'isDuplicate', `[title:${question.title}]`);
let isDuplicate: boolean = false;
let cleanedTitle = question.title!.replace(/'/g, "''"); // the ' isn't encoded
let encodedTitle = encodeURIComponent(cleanedTitle); // encode all other characters
let filterText = `${StandardFields.TITLE} eq '${encodedTitle}'`;
let items = await sp.web.lists.getByTitle(this.listTitle).items
.select(StandardFields.ID)
.filter(filterText)
.get()
.catch(e => {
super.handleHttpError('isDuplicate', e);
throw e;
});
if (items !== null) {
for (let item of items) {
if (question.id != null && question.id !== 0) {
if (question.id !== item[StandardFields.ID]) {
// this is an update but matches an existing item
isDuplicate = true;
break;
}
}
else {
// this must be new but matches an existing item
isDuplicate = true;
break;
}
}
}
return isDuplicate;
}
public async deleteQuestion(question: IQuestionItem): Promise<void> {
this.deletePostById(question.id!).then(() => {
this.deleteReplies(question);
});
}
public async deleteReply(reply: IReplyItem): Promise<void> {
this.deletePostById(reply.id!).then(() => {
this.deleteReplies(reply);
});
}
private deleteReplies(item: IReplyItem | IQuestionItem, batch?: SPBatch) {
if (!batch) { batch = sp.createBatch(); }
for (let reply of item.replies) {
sp.web.lists.getByTitle(this.listTitle).items
.getById(reply.id!)
.inBatch(batch)
.delete();
this.deleteReplies(reply);
}
batch.execute()
.catch(e => {
super.handleHttpError('deleteReplies', e);
throw e;
});
}
private async deletePostById(id: number): Promise<void> {
LogHelper.verbose(this.constructor.name, 'deletePostById', `id:[${id}]`);
if (id != null && id !== 0) {
await sp.web.lists.getByTitle(this.listTitle).items
.getById(id)
.delete()
.catch(e => {
super.handleHttpError('deletePostById', e);
throw e;
});
}
}
public async saveQuestion(question: IQuestionItem): Promise<number | null> {
let itemId = 0;
let item = {};
item[StandardFields.TITLE] = question.title;
item[PostFields.DETAILS] = question.details;
item[PostFields.DETAILSTEXT] = question.detailsText;
let contentType = await sp.web.lists.getByTitle(this.listTitle).contentTypes
.filter(`Name eq '${ContentTypes.QUESTION}'`)
.get()
.catch(e => {
super.handleHttpError('saveQuestion', e);
throw e;
});
item[StandardFields.CONTENTTYPEID] = contentType[0].StringId;
if (question.id != null && question.id !== 0) {
LogHelper.verbose(this.constructor.name, 'saveQuestion', `update [id:${question.id}]`);
let result: any = await sp.web.lists.getByTitle(this.listTitle).items
.getById(question.id)
.update(item)
.catch(e => {
super.handleHttpError('saveQuestion', e);
throw e;
});
if (!result) {
return null;
}
itemId = question.id;
}
else {
LogHelper.verbose(this.constructor.name, 'saveQuestion', `add`);
item[QuestionFields.FOLLOW_EMAILS] = question.followEmails.join(';');
let result: any = await sp.web.lists.getByTitle(this.listTitle).items
.add(item)
.catch(e => {
super.handleHttpError('saveQuestion', e);
throw e;
});
if (!result) {
return null;
}
itemId = result.data.Id;
}
return itemId;
}
public async getReplyById(currentUser: ICurrentUser, id: number, skipReplies: boolean = false): Promise<IReplyItem | null> {
LogHelper.verbose(this.constructor.name, 'getReplyById', `[id:${id}]`);
let replyItem = await sp.web.lists.getByTitle(this.listTitle).items
.getById(id)
.select(this.replySelectColumns.join(','))
.expand(this.replyExpandColumns.join(','))
.get()
.catch(e => {
super.handleHttpError('getReplyById', e);
throw e;
});
if (replyItem !== null) {
let reply: IReplyItem = this.mapReply(replyItem, currentUser);
if (skipReplies === false) {
let flatReplies = await this.getFlatRepliesByQuestionId(currentUser, reply.parentQuestionId!);
reply.replies = this.buildReplyTree(flatReplies, [], reply, true);
}
let currentUserCreatedQuestion: boolean = false;
if (reply.parentQuestionId) {
let question = await this.getQuestionById(currentUser, reply.parentQuestionId!, true);
if (question && currentUser.loginName.toLowerCase() === question.author!.id!.toLowerCase()) {
currentUserCreatedQuestion = true;
}
}
this.updateUserPermissions(currentUser, reply, currentUserCreatedQuestion);
return reply;
}
else {
return null;
}
}
public async saveReply(reply: IReplyItem): Promise<number | null> {
let itemId = 0;
let item = {};
item[StandardFields.TITLE] = reply.title;
item[PostFields.DETAILS] = reply.details;
item[PostFields.DETAILSTEXT] = reply.detailsText;
item[ReplyFields.ISANSWER] = reply.isAnswer ? reply.isAnswer : false;
item[ReplyFields.QUESTIONLOOKUPID] = reply.parentQuestionId;
item[ReplyFields.REPLYLOOKUPID] = reply.parentReplyId;
let contentType = await sp.web.lists.getByTitle(this.listTitle).contentTypes
.filter(`Name eq '${ContentTypes.REPLY}'`)
.get()
.catch(e => {
super.handleHttpError('saveReply', e);
throw e;
});
item[StandardFields.CONTENTTYPEID] = contentType[0].StringId;
if (reply.id != null && reply.id !== 0) {
LogHelper.verbose(this.constructor.name, 'saveReply', `update [id:${reply.id}]`);
let result: any = await sp.web.lists.getByTitle(this.listTitle).items
.getById(reply.id)
.update(item)
.catch(e => {
super.handleHttpError('saveReply', e);
throw e;
});
if (!result) {
return null;
}
itemId = reply.id;
}
else {
LogHelper.verbose(this.constructor.name, 'saveReply', `add`);
let result: any = await sp.web.lists.getByTitle(this.listTitle).items
.add(item)
.catch(e => {
super.handleHttpError('saveReply', e);
throw e;
});
if (!result) {
return null;
}
itemId = result.data.Id;
}
return itemId;
}
public async markAnswer(reply: IReplyItem): Promise<void> {
if (reply !== null && reply.id != null && reply.id !== 0) {
LogHelper.verbose(this.constructor.name, 'markAnswer', `update [id:${reply.id}]`);
let replyItem = {};
replyItem[ReplyFields.ISANSWER] = reply.isAnswer;
await sp.web.lists.getByTitle(this.listTitle).items
.getById(reply.id)
.update(replyItem, '*')
.catch(e => {
super.handleHttpError('markAnswer', e);
throw e;
});
let questionItem = {};
questionItem[QuestionFields.ISANSWERED] = reply.isAnswer;
await sp.web.lists.getByTitle(this.listTitle).items
.getById(reply.parentQuestionId!)
.update(questionItem, '*')
.catch(e => {
super.handleHttpError('markAnswer', e);
throw e;
});
}
}
public async updateLiked(post: IPostItem): Promise<void> {
let item = {};
if (post.id != null && post.id !== 0) {
item[PostFields.LIKE_IDS] = post.likeIds.join(';');
item[PostFields.LIKE_COUNT] = post.likeIds.length;
LogHelper.verbose(this.constructor.name, 'updateLiked', `update [id:${post.id}]`);
await sp.web.lists.getByTitle(this.listTitle).items
.getById(post.id)
.update(item)
.catch(e => {
super.handleHttpError('updateLiked', e);
throw e;
});
}
}
public async updateFollowed(question: IQuestionItem): Promise<void> {
let item = {};
if (question.id != null && question.id !== 0) {
item[QuestionFields.FOLLOW_EMAILS] = question.followEmails.join(';');
LogHelper.verbose(this.constructor.name, 'updateFollowed', `update [id:${question.id}]`);
await sp.web.lists.getByTitle(this.listTitle).items
.getById(question.id)
.update(item)
.catch(e => {
super.handleHttpError('updateFollowed', e);
throw e;
});
}
}
public async updateHelpful(updateItem: IReplyItem): Promise<void> {
let item = {};
if (updateItem.id != null && updateItem.id !== 0) {
item[ReplyFields.HELPFULIDS] = updateItem.helpfulIds.join(';');
item[ReplyFields.HELPFULCOUNT] = updateItem.helpfulIds.length;
LogHelper.verbose(this.constructor.name, 'updateHelpful', `update [id:${updateItem.id}]`);
await sp.web.lists.getByTitle(this.listTitle).items
.getById(updateItem.id)
.update(item)
.catch(e => {
super.handleHttpError('updateHelpful', e);
throw e;
});
}
}
private async getFlatRepliesByQuestionId(currentUser: ICurrentUser, id: number): Promise<IReplyItem[]> {
LogHelper.verbose(this.constructor.name, 'getRepliesByQuestionId', `[id:${id}]`);
let replies: IReplyItem[] = [];
let filterText = `${StandardFields.CONTENTTYPE} eq '${ContentTypes.REPLY}'`;
filterText += `and ${ReplyFields.QUESTIONLOOKUP_ID} eq ${id}`;
let replyItems = await sp.web.lists.getByTitle(this.listTitle).items
.select(this.replySelectColumns.join(','))
.expand(this.replyExpandColumns.join(','))
.filter(filterText)
.top(5000)
.orderBy(StandardFields.ID, true)
.get()
.catch(e => {
super.handleHttpError('getRepliesByQuestionId', e);
throw e;
});
for (let replyItem of replyItems) {
let reply = this.mapReply(replyItem, currentUser);
replies.push(reply);
}
return replies;
}
private buildReplyTree(flatReplies: IReplyItem[], replyTree: IReplyItem[], parentReply: IReplyItem | null, isRoot: boolean) {
let matchingReplies = flatReplies.filter(f => f.parentReplyId === (parentReply !== null ? parentReply.id : null));
for (let reply of matchingReplies) {
this.buildReplyTree(flatReplies, replyTree, reply, false);
if (isRoot === true) {
replyTree.push(reply);
}
else {
parentReply!.replies.push(reply);
}
}
return replyTree;
}
private getQuestionFilterText(filter: IQuestionsFilter): string {
let filterText = '';
filterText = `${StandardFields.CONTENTTYPE} eq '${ContentTypes.QUESTION}'`;
// from the search box
if (filter.searchText && filter.searchText.length > 0) {
let encodedSearchText = encodeURIComponent(filter.searchText.replace(`'`, `''`));
filterText += ` and substringof('${encodedSearchText}',${StandardFields.TITLE})`;
// only questions and not replies
}
switch (filter.selectedShowQuestionsOption) {
case ShowQuestionsOption.Answered:
filterText += `and ${QuestionFields.ISANSWERED} eq 1`;
break;
case ShowQuestionsOption.Open:
filterText += `and ${QuestionFields.ISANSWERED} eq 0`;
break;
}
return filterText;
}
private mapQuestion(item: any, currentUser: ICurrentUser): IQuestionItem {
// Map Base Properties (id, created/modified info)
let base = super.mapBaseItemProperties(item);
let question: IQuestionItem = {
...base,
details: item[PostFields.DETAILS],
detailsText: item[PostFields.DETAILSTEXT],
isAnswered: item[QuestionFields.ISANSWERED],
totalReplyCount: 0,
likeCount: 0,
likeIds: [],
likedByCurrentUser: false,
followEmails: [],
followedByCurrentUser: false,
canDelete: false,
canEdit: false,
canReact: false,
canReply: false,
replies: []
};
this.mapLikeInfo(item[PostFields.LIKE_IDS], question, currentUser);
this.mapFollowInfo(item[QuestionFields.FOLLOW_EMAILS], question, currentUser);
return question;
}
private mapReply(item: any, currentUser: ICurrentUser): IReplyItem {
// Map Base Properties (id, created/modified info)
let base = super.mapBaseItemProperties(item);
let reply: IReplyItem = {
...base,
details: item[PostFields.DETAILS],
detailsText: item[PostFields.DETAILSTEXT],
isAnswer: item[ReplyFields.ISANSWER],
parentQuestionId: super.getLookupId(item[ReplyFields.QUESTIONLOOKUP]),
parentQuestion: super.getLookup(item[ReplyFields.QUESTIONLOOKUP]),
parentReplyId: super.getLookupId(item[ReplyFields.REPLYLOOKUP]),
parentReply: super.getLookup(item[ReplyFields.REPLYLOOKUP]),
likeCount: 0,
likeIds: [],
likedByCurrentUser: false,
helpfulCount: 0,
helpfulIds: [],
helpfulByCurrentUser: false,
canDelete: false,
canEdit: false,
canMarkAsAnswer: false,
canReact: false,
canReply: false,
replies: []
};
this.mapLikeInfo(item[PostFields.LIKE_IDS], reply, currentUser);
this.mapHelpfulInfo(item[ReplyFields.HELPFULIDS], reply, currentUser);
return reply;
}
private mapFollowInfo(ids: string, updateItem: IQuestionItem, currentUser: ICurrentUser) {
let currentUserMatch: boolean = false;
if (ids) {
updateItem.followEmails = ids.split(';');
currentUserMatch = updateItem.followEmails.indexOf(currentUser.email) != -1;
}
else {
updateItem.followEmails = [];
}
updateItem.followedByCurrentUser = currentUserMatch;
}
private mapLikeInfo(ids: string, updateItem: IQuestionItem | IReplyItem, currentUser: ICurrentUser) {
let currentUserMatch: boolean = false;
if (ids) {
updateItem.likeIds = ids.split(';');
updateItem.likeCount = updateItem.likeIds.length;
currentUserMatch = updateItem.likeIds.indexOf(`${currentUser.id}`) != -1;
}
else {
updateItem.likeIds = [];
updateItem.likeCount = 0;
}
updateItem.likedByCurrentUser = currentUserMatch;
}
private mapHelpfulInfo(ids: string, updateItem: IReplyItem, currentUser: ICurrentUser) {
let currentUserMatch: boolean = false;
if (ids) {
updateItem.helpfulIds = ids.split(';');
updateItem.helpfulCount = updateItem.helpfulIds.length;
currentUserMatch = updateItem.helpfulIds.indexOf(`${currentUser.id}`) != -1;
}
else {
updateItem.helpfulIds = [];
updateItem.helpfulCount = 0;
}
updateItem.helpfulByCurrentUser = currentUserMatch;
}
// business logic needs to move
private updateUserPermissions(currentUser: ICurrentUser, post: IQuestionItem | IReplyItem, currentUserCreatedQuestion: boolean) {
post.canEdit = false;
post.canDelete = false;
post.canReact = false;
post.canReply = false;
// current user can add
if (currentUser.canAddItems) {
post.canReply = true;
}
// current user can edit
if (currentUser.canEditItems) {
post.canReact = true;
// AND they created the question or reply
if (currentUser.loginName.toLowerCase() === post.author!.id!.toLowerCase()) {
post.canEdit = true;
}
}
// current user can delete
if (currentUser.canDeleteItems) {
// AND they created the question or reply
if (currentUser.loginName.toLowerCase() === post.author!.id!.toLowerCase()) {
// AND there are no replies
if (post.replies.length === 0) {
post.canDelete = true;
}
}
// OR they are a moderator
if (currentUser.canModerateItems) {
post.canDelete = true;
}
}
// find out if this is a question and the current user created the question
if (this.isReply(post)) {
post.canMarkAsAnswer = false;
// current user can mark as answer based on list permissions
if (currentUser.canModerateItems) {
post.canMarkAsAnswer = true;
}
// they mark as answer because created the question
if (currentUserCreatedQuestion === true && currentUser.canEditItems) {
post.canMarkAsAnswer = true;
}
}
if (post.replies.length > 0) {
for (let reply of post.replies) {
this.updateUserPermissions(currentUser, reply, currentUserCreatedQuestion);
}
}
}
private isReply(arg: any): arg is IReplyItem {
return arg.canMarkAsAnswer !== undefined;
}
}

View File

@ -0,0 +1,137 @@
import '@pnp/polyfill-ie11';
import { sp, PermissionKind, ISiteGroupInfo } from '@pnp/sp/presets/all';
import { BaseService } from './base.service';
import { LogHelper, ListTitles, NotificationGroup } from 'utilities';
import { ICurrentUser } from 'models';
export class UserService extends BaseService {
private currentUser: ICurrentUser;
private listTitle = ListTitles.QUESTIONS;
public async getCurrentUser(): Promise<ICurrentUser> {
if (this.currentUser == null) {
LogHelper.verbose(this.constructor.name, 'getCurrentUser', 'currentUser is null fetching');
let result = await sp.web.currentUser
.select('Id', 'Email', 'LoginName', 'Title', 'IsSiteAdmin', 'Groups/Id,Groups/Title,Groups/LoginName')
.expand('Groups')
.get()
.catch(e => {
super.handleHttpError('getCurrentUser', e);
throw e;
});
if (result) {
this.currentUser = {
id: result.Id,
loginName: result.LoginName.toLocaleLowerCase(),
email: result.Email,
displayName: result.Title,
isSiteAdmin: result.IsSiteAdmin,
canAddItems: false,
canDeleteItems: false,
canEditItems: false,
canModerateItems: false,
canViewItems: false,
canManagePermissions: false
};
await sp.web.ensureUser(this.currentUser.loginName);
await this.updatePermissionInfo(this.currentUser);
}
}
else {
LogHelper.verbose(this.constructor.name, 'getCurrentUser', 'currentUser already retrieved');
}
return this.currentUser;
}
public async getNotificationGroup() {
let notificationGroup = await sp.web.siteGroups.getByName(NotificationGroup.NAME)
.get()
.catch(e => {
super.handleHttpError('getNotificationGroup', e);
});
return notificationGroup;
}
// https://www.ktskumar.com/2016/09/pnp-js-core-create-sharepoint-group/
public async createNotificationGroup(): Promise<ISiteGroupInfo> {
let notificationGroup = await this.getNotificationGroup();
if (!notificationGroup) {
await sp.web.siteGroups.add({
Title: NotificationGroup.NAME,
Description: NotificationGroup.DESCRIPTION,
OnlyAllowMembersViewMembership: false,
AllowMembersEditMembership: true
})
.catch(e => {
super.handleHttpError('createNotificationGroup', e);
throw e;
});
notificationGroup = await this.getNotificationGroup() as ISiteGroupInfo;
}
return notificationGroup;
}
public async getNotificationGroupUserEmails(): Promise<string[]> {
let emails: string[] = [];
let users = await sp.web.siteGroups.getByName(NotificationGroup.NAME).users
.get()
.catch(e => {
super.handleHttpError('getNotificationGroup', e);
throw e;
});
if(users) {
for(let user of users) {
if(user && user.Email) {
if(emails.indexOf(user.Email) === -1) {
emails.push(user.Email);
}
}
}
}
return emails;
}
private async updatePermissionInfo(currentUser: ICurrentUser) {
let listPerms = await sp.web.lists.getByTitle(this.listTitle)
.getCurrentUserEffectivePermissions()
.catch(e => {
super.handleHttpError('updatePermissionInfo', e);
});
if (listPerms) {
if (sp.web.hasPermissions(listPerms, PermissionKind.ViewListItems)) {
currentUser.canViewItems = true;
}
if (sp.web.hasPermissions(listPerms, PermissionKind.AddListItems)) {
currentUser.canAddItems = true;
}
if (sp.web.hasPermissions(listPerms, PermissionKind.EditListItems)) {
currentUser.canEditItems = true;
}
if (sp.web.hasPermissions(listPerms, PermissionKind.DeleteListItems)) {
currentUser.canDeleteItems = true;
}
if (sp.web.hasPermissions(listPerms, PermissionKind.ApproveItems)) {
currentUser.canModerateItems = true;
}
if (sp.web.hasPermissions(listPerms, PermissionKind.ManagePermissions)) {
currentUser.canManagePermissions = true;
}
}
}
}

View File

@ -0,0 +1,65 @@
export class Parameters {
public static readonly QUESTIONID = 'QuestionId';
}
export class NotificationGroup {
public static readonly NAME = 'Question Notifications';
public static readonly DESCRIPTION = 'Users who receive question notifications';
}
export class ListTitles {
public static readonly QUESTIONS = 'Questions';
public static readonly USERS_INFORMATION = 'Users Information';
}
export class ContentTypes {
public static readonly QUESTION = 'Question';
public static readonly REPLY = 'Reply';
}
export class StandardFields {
public static readonly ID = 'ID';
public static readonly TITLE = 'Title';
// Standard Created/Modified Columns
public static readonly CREATED = 'Created';
public static readonly MODIFIED = 'Modified';
public static readonly AUTHOR = 'Author';
public static readonly AUTHOR_ID = 'Author/ID';
public static readonly AUTHOR_NAME = 'Author/Name';
public static readonly AUTHOR_TITLE = 'Author/Title';
public static readonly EDITOR = 'Editor';
public static readonly EDITOR_ID = 'Editor/ID';
public static readonly EDITOR_NAME = 'Editor/Name';
public static readonly EDITOR_TITLE = 'Editor/Title';
public static readonly CONTENTTYPE = 'ContentType';
public static readonly CONTENTTYPEID = 'ContentTypeId';
public static readonly CONTENTTYPE_NAME = 'ContentType/Name';
}
// base fields shared by Question and Reply Content Types
export class PostFields {
public static readonly DETAILS = 'TW_Details';
public static readonly DETAILSTEXT = 'TW_DetailsText';
public static readonly LIKE_COUNT = 'TW_LikeCount';
public static readonly LIKE_IDS = 'TW_LikeIds';
}
export class QuestionFields {
public static readonly ISANSWERED = 'TW_IsAnswered';
public static readonly FOLLOW_EMAILS = 'TW_FollowEmails';
}
export class ReplyFields {
public static readonly ISANSWER = 'TW_IsAnswer';
public static readonly HELPFULCOUNT = 'TW_HelpfulCount';
public static readonly HELPFULIDS = 'TW_HelpfulIds';
// question this item is related to
public static readonly QUESTIONLOOKUP = 'TW_QuestionLookup';
public static readonly QUESTIONLOOKUPID = 'TW_QuestionLookupId';
public static readonly QUESTIONLOOKUP_ID = 'TW_QuestionLookup/ID';
public static readonly QUESTIONLOOKUP_TITLE = 'TW_QuestionLookup/Title';
// reply this item is related to
public static readonly REPLYLOOKUP = 'TW_ReplyLookup';
public static readonly REPLYLOOKUPID = 'TW_ReplyLookupId';
public static readonly REPLYLOOKUP_ID = 'TW_ReplyLookup/ID';
public static readonly REPLYLOOKUP_TITLE = 'TW_ReplyLookup/Title';
}

View File

@ -0,0 +1,32 @@
export enum FormMode {
View = 'View',
New = 'New',
Edit = 'Edit'
}
export enum Action {
Cancel = 'cancel',
Add = 'add',
Update = 'update',
Delete = 'delete'
}
export enum SortOption {
Title = 'Title',
MostRecent = 'MostRecent',
MostLiked = 'MostLiked'
}
export enum ShowQuestionsOption {
All = 'all',
Open = 'open',
Answered = 'answered'
}
export enum WebPartRenderMode {
Standard = 'standard',
Application = 'application',
OpenQuestions = 'openQuestions',
AnsweredQuestions = 'answeredQuestions'
}

View File

@ -0,0 +1,25 @@
import { IBaseItem } from "models";
export class ErrorHelper {
public static setUIError(object: IBaseItem, propertyName: string, errorMessage: string): void {
if (object && object.uiErrors) {
object.uiErrors.set(propertyName, errorMessage);
}
}
public static removeUIError(object: IBaseItem, propertyName: string): void {
if (object && object.uiErrors && object.uiErrors.has(propertyName)) {
object.uiErrors.delete(propertyName);
}
}
public static getUIError(object: IBaseItem, propertyName: string): string | undefined {
if (object && object.uiErrors) {
return object.uiErrors.get(propertyName);
}
return undefined;
}
}

View File

@ -0,0 +1,36 @@
import { Logger, LogLevel } from "@pnp/logging";
export class LogHelper {
public static verbose(className: string, methodName: string, message: string) {
message = this.formatMessage(className, methodName, message);
Logger.write(message, LogLevel.Verbose);
}
public static info(className: string, methodName: string, message: string) {
message = this.formatMessage(className, methodName, message);
Logger.write(message, LogLevel.Info);
}
public static warning(className: string, methodName: string, message: string) {
message = this.formatMessage(className, methodName, message);
Logger.write(message, LogLevel.Warning);
}
public static error(className: string, methodName: string, message: string) {
message = this.formatMessage(className, methodName, message);
Logger.write(message, LogLevel.Error);
}
public static exception(className: string, methodName: string, error: Error) {
error.message = this.formatMessage(className, methodName, error.message);
Logger.error(error);
}
private static formatMessage(className: string, methodName: string, message: string): string {
let d = new Date();
let dateStr = d.getDate() + '-' + (d.getMonth() + 1) + '-' + d.getFullYear() + ' ' +
d.getHours() + ':' + d.getMinutes() + ':' + d.getSeconds() + '.' + d.getMilliseconds();
return `${dateStr} ${className} > ${methodName} > ${message}`;
}
}

View File

@ -0,0 +1,4 @@
export * from './Constants';
export * from './Enums';
export * from './ErrorHelper';
export * from './LogHelper';

View File

@ -0,0 +1,44 @@
import { BaseClientSideWebPart } from '@microsoft/sp-webpart-base';
import { sp } from "@pnp/sp";
import { Logger, ConsoleListener, LogLevel } from "@pnp/logging";
import { CustomFetchClient } from 'services/customfetchclient';
export default class BaseWebPart<TProperties> extends BaseClientSideWebPart<TProperties> {
protected async onInit(): Promise<void> {
let url = this.context.pageContext.web.absoluteUrl;
let isUsingSharePoint = true;
if (url === 'https://wwww.contoso.com/sites/workbench') {
isUsingSharePoint = false;
}
return super.onInit().then(_ => {
// configure pnp
/*
pnpSetup({
spfxContext: this.context
});
*/
sp.setup({
spfxContext: this.context,
sp: {
fetchClientFactory: () => {
return new CustomFetchClient(isUsingSharePoint);
},
}
});
// subscribe a listener
Logger.subscribe(new ConsoleListener());
// set the active log level -- eventually make this a web part property
Logger.activeLogLevel = LogLevel.Verbose;
});
}
public render(): void {
}
}

View File

@ -0,0 +1,14 @@
export interface IQuestionsWebPartProps {
title: string;
pageSize: number;
sortOption: string;
loadInitialPage: boolean;
showQuestionAnsweredDropDown: boolean;
hideViewAllButton: boolean;
applicationPage: string;
useApplicationPage: boolean;
canVisitorsAskQuestions?: boolean;
notificationGroup?: string;
webPartRenderMode: string;
}

View File

@ -0,0 +1,85 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "761fbf9d-6ef9-4099-8488-02d5e2826f36",
"alias": "QuestionsWebPart",
"componentType": "WebPart",
"supportsThemeVariants": true,
"version": "*",
"manifestVersion": 2,
"requiresCustomScript": false,
"supportedHosts": ["SharePointWebPart", "SharePointFullPage", "TeamsTab"],
"preconfiguredEntries": [
{
"groupId": "75e22ed5-fa14-4829-850a-c890608aca2d",
"group": {
"default": "Communication and collaboration"
},
"title": {
"default": "Questions"
},
"description": {
"default": "Ask questions or find answers to answered questions"
},
"officeFabricIconFontName": "Feedback",
"properties": {
"title": "Questions",
"pageSize": 5,
"sortOption": "Title",
"loadInitialPage": false,
"hideViewAllButton": false,
"showQuestionAnsweredDropDown": false,
"useApplicationPage": true,
"applicationPage": "Questions.aspx",
"webPartRenderMode": "standard"
}
},
{
"groupId": "75e22ed5-fa14-4829-850a-c890608aca2d",
"group": {
"default": "Communication and collaboration"
},
"title": {
"default": "Open Questions"
},
"description": {
"default": "Show a list of open questions"
},
"officeFabricIconFontName": "FeedbackRequestSolid",
"properties": {
"title": "Open Questions",
"pageSize": 10,
"sortOption": "Title",
"loadInitialPage": true,
"hideViewAllButton": true,
"showQuestionAnsweredDropDown": false,
"useApplicationPage": true,
"applicationPage": "Questions.aspx",
"webPartRenderMode": "openQuestions"
}
},
{
"groupId": "75e22ed5-fa14-4829-850a-c890608aca2d",
"group": {
"default": "Communication and collaboration"
},
"title": {
"default": "Answered Questions"
},
"description": {
"default": "Show a list of answered questions"
},
"officeFabricIconFontName": "FeedbackResponseSolid",
"properties": {
"title": "Answered Questions",
"pageSize": 10,
"sortOption": "Title",
"loadInitialPage": true,
"hideViewAllButton": true,
"showQuestionAnsweredDropDown": false,
"useApplicationPage": true,
"applicationPage": "Questions.aspx",
"webPartRenderMode": "answeredQuestions"
}
}
]
}

View File

@ -0,0 +1,294 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
// redux related
import { Store } from 'redux';
import { Provider } from 'react-redux';
import configureStore from './redux/store/configureStore';
import { updateThemeVariant, updateWebPartProperty, updateWebPartContext, updateWebPartDisplayMode, getPagedQuestions, getCurrentUser, updateShowQuestionsOption } from './redux/actions/actions';
import { IApplicationState } from './redux/reducers/appReducer';
// other
import { Parameters, SortOption, WebPartRenderMode, ShowQuestionsOption } from 'utilities';
import { IPropertyPaneConfiguration, PropertyPaneSlider, PropertyPaneDropdown, PropertyPaneToggle, PropertyPaneLabel, PropertyPaneButton, PropertyPaneButtonType, IPropertyPaneField } from '@microsoft/sp-property-pane';
import * as strings from 'QuestionsWebPartStrings';
import BaseWebPart from 'webparts/BaseWebPart';
import DefaultContainerComponent, { IDefaultContainerProps } from './components/DefaultContainer/DefaultContainer';
import { IQuestionsWebPartProps } from './IQuestionsWebPartProps';
import { PermissionService } from '../../services/permission.service';
import { ThemeProvider, IReadonlyTheme, ThemeChangedEventArgs } from '@microsoft/sp-component-base';
import { UserService } from 'services/user.service';
export default class QuestionsWebPart extends BaseWebPart<IQuestionsWebPartProps> {
private store: Store<IApplicationState, any>;
// private AppContext: React.Context<any>;
private permissionService: PermissionService;
private userService: UserService;
private canVisitorsAskQuestions: boolean;
protected themeProvider: ThemeProvider;
protected themeVariant: IReadonlyTheme | undefined;
protected onInit(): Promise<void> {
this.store = configureStore();
// this.AppContext = React.createContext({ store: this.store });
// Consume the new ThemeProvider service
this.themeProvider = this.context.serviceScope.consume(ThemeProvider.serviceKey);
// If it exists, get the theme variant
this.themeVariant = this.themeProvider.tryGetTheme();
// Register a handler to be notified if the theme variant changes
this.themeProvider.themeChangedEvent.add(this, this.handleThemeChangedEvent);
return super.onInit().then(_ => {
this.permissionService = new PermissionService();
this.userService = new UserService();
if (!this.properties.webPartRenderMode) {
this.properties.webPartRenderMode = WebPartRenderMode.Standard;
}
// initialize the store with web part property values
this.store.dispatch(updateWebPartProperty('webPartRenderMode', this.properties.webPartRenderMode));
this.store.dispatch(updateWebPartProperty('pageSize', this.properties.pageSize));
this.store.dispatch(updateWebPartProperty('hideViewAllButton', this.properties.hideViewAllButton));
this.store.dispatch(updateWebPartProperty('showQuestionAnsweredDropDown', this.properties.showQuestionAnsweredDropDown));
this.store.dispatch(updateWebPartProperty('loadInitialPage', this.properties.loadInitialPage));
this.store.dispatch(updateWebPartProperty('sortOption', this.properties.sortOption));
this.store.dispatch(updateWebPartProperty('applicationPage', this.properties.applicationPage));
this.store.dispatch(updateWebPartProperty('title', this.properties.title));
this.store.dispatch(updateWebPartProperty('useApplicationPage', this.properties.useApplicationPage));
// initialize the store with web part context
this.store.dispatch(updateWebPartContext(this.context));
// initialize the store for the current user
this.store.dispatch(getCurrentUser());
// initialize the store with display mode
this.store.dispatch(updateWebPartDisplayMode(this.displayMode));
this.store.dispatch(updateThemeVariant(this.themeVariant));
switch (this.properties.webPartRenderMode) {
case WebPartRenderMode.OpenQuestions:
this.store.dispatch(updateShowQuestionsOption(ShowQuestionsOption.Open));
break;
case WebPartRenderMode.AnsweredQuestions:
this.store.dispatch(updateShowQuestionsOption(ShowQuestionsOption.Answered));
break;
}
});
}
public render(): void {
let queryParms = new URLSearchParams(window.location.search);
let questionId: any;
if(queryParms.has(Parameters.QUESTIONID)) {
questionId = Number(queryParms.get(Parameters.QUESTIONID));
}
else {
questionId = null;
}
const containerComponent: React.ReactElement<IDefaultContainerProps> = React.createElement(
DefaultContainerComponent,
{
selectedQuestionId: questionId ,
updateTitle: (value: string) => {
this.properties.title = value;
}
}
);
const provider: React.ReactElement<any> = React.createElement(
Provider, {
store: this.store
},
containerComponent
);
/*
<Provider store={store}>
<DefaultContainerComponent {...} />
</Provider>
*/
ReactDom.render(provider, this.domElement);
}
private handleThemeChangedEvent(args: ThemeChangedEventArgs): void {
this.themeVariant = args.theme;
this.store.dispatch(updateThemeVariant(this.themeVariant));
this.render();
}
protected onPropertyPaneFieldChanged(propertyPath: string, oldValue: any, newValue: any) {
this.store.dispatch(updateWebPartProperty(propertyPath, newValue));
if (propertyPath === 'pageSize' || propertyPath === 'sortOption' || propertyPath === 'loadInitialPage') {
this.store.dispatch(getPagedQuestions(false));
}
if (propertyPath === 'canVisitorsAskQuestions') {
this.permissionService.toggleVisitorCanAskQuestions();
}
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
private async manageNotificationGroup(): Promise<void> {
let notificationGroup = await this.userService.getNotificationGroup();
if (!notificationGroup) {
notificationGroup = await this.userService.createNotificationGroup();
}
window.open(`${this.context.pageContext.web.absoluteUrl}/_layouts/15/people.aspx?MembershipGroupId=${notificationGroup.Id}`, '_blank');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
const { currentUser } = this.store.getState();
if (currentUser && currentUser.canManagePermissions && this.canVisitorsAskQuestions === undefined) {
this.permissionService.canVisitorsAskQuestions().then(c => {
this.canVisitorsAskQuestions = c;
this.context.propertyPane.refresh();
});
}
let showManageNotificationGroup: boolean = false;
if (currentUser && currentUser.canManagePermissions) {
showManageNotificationGroup = true;
}
let canVisitorsAskQuestionsDetails = currentUser && currentUser.canManagePermissions ?
strings.PropertyPane_Label_CanVisitorsAskQuestionsDetails : strings.PropertyPane_Label_CanVisitorsAskQuestionsDisabled;
let layoutGroupFields: IPropertyPaneField<any>[] = [];
let permissionGroupFields: IPropertyPaneField<any>[] = [];
let aboutGroupFields: IPropertyPaneField<any>[] = [];
// Show in Standard, Application, OpenQuestions, AnsweredQuestions
layoutGroupFields.push(PropertyPaneSlider('pageSize', {
label: strings.PropertyPane_Label_PageSize,
min: 5,
max: 50,
step: 5,
value: this.properties.pageSize,
showValue: true
}));
// Show in Standard, Application, OpenQuestions, AnsweredQuestions
layoutGroupFields.push(PropertyPaneDropdown('sortOption', {
label: strings.PropertyPane_Label_SortOption,
options: [
{
key: SortOption.Title,
text: strings.PropertyPane_SortOption_Title,
},
{
key: SortOption.MostRecent,
text: strings.PropertyPane_SortOption_MostRecent
},
{
key: SortOption.MostLiked,
text: strings.PropertyPane_SortOption_MostLiked
}
],
selectedKey: this.properties.sortOption
}));
// Show in Standard, Application
if (this.properties.webPartRenderMode !== WebPartRenderMode.OpenQuestions &&
this.properties.webPartRenderMode !== WebPartRenderMode.AnsweredQuestions) {
layoutGroupFields.push(PropertyPaneToggle('loadInitialPage', {
label: strings.PropertyPane_Label_LoadInitialPage,
onText: strings.PropertyPaneText_Yes,
offText: strings.PropertyPaneText_No,
checked: this.properties.loadInitialPage
}));
layoutGroupFields.push(PropertyPaneToggle('hideViewAllButton', {
label: strings.PropertyPane_Label_HideViewAllButton,
onText: strings.PropertyPaneText_Yes,
offText: strings.PropertyPaneText_No,
checked: this.properties.hideViewAllButton
}));
}
// Show in Standard, Application, OpenQuestions, AnsweredQuestions
layoutGroupFields.push(PropertyPaneToggle('useApplicationPage', {
label: strings.PropertyPane_Label_UseApplicationPage,
onText: strings.PropertyPaneText_Yes,
offText: strings.PropertyPaneText_No,
checked: this.properties.useApplicationPage
}));
// Show in Standard, Application, OpenQuestions, AnsweredQuestions
layoutGroupFields.push(PropertyPaneLabel('useApplicationPage', {
text: strings.PropertyPane_Label_UseApplicationPageDetails
}));
//Permission Group Fields
// Show in Standard, Application, OpenQuestions, AnsweredQuestions
permissionGroupFields.push(PropertyPaneToggle('canVisitorsAskQuestions', {
label: strings.PropertyPane_Lable_CanVisitorsAskQuestions,
checked: this.canVisitorsAskQuestions,
onText: strings.PropertyPaneText_Yes,
offText: strings.PropertyPaneText_No,
disabled: !currentUser || !currentUser.canManagePermissions
}));
// Show in Standard, Application, OpenQuestions, AnsweredQuestions
permissionGroupFields.push(PropertyPaneLabel('canVisitorsAskQuestions', {
text: canVisitorsAskQuestionsDetails
}));
if (showManageNotificationGroup === true) {
permissionGroupFields.push(PropertyPaneButton('notificationGroup', {
text: strings.PropertyPage_ButtonText_ManageNotificationGroup,
buttonType: PropertyPaneButtonType.Primary,
onClick: (value) => this.manageNotificationGroup()
}));
permissionGroupFields.push(PropertyPaneLabel('notificationGroup', {
text: strings.PropertyPage_Label_ManageNotificationGroupDetails
}));
}
// Show in Standard, Application, OpenQuestions, AnsweredQuestions
aboutGroupFields.push(PropertyPaneLabel('versionNumber', {
text: strings.PropertyPane_Label_VersionInfo + this.manifest.version
}));
let config: IPropertyPaneConfiguration = {
pages: [
{
header: {
description: strings.PropertyPane_Description
},
groups: [
{
groupName: strings.PropertyPane_GroupName_LayoutSettings,
groupFields: layoutGroupFields
},
{
groupName: strings.PropertyPane_GroupName_Permissions,
groupFields: permissionGroupFields
},
{
groupName: strings.PropertyPane_GroupName_About,
groupFields: aboutGroupFields
},
]
}
]
};
return config;
}
}

View File

@ -0,0 +1,22 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
@import '../Shared.module.scss';
.defaultcontainer {
margin: 0px auto;
max-width: 1284px;
/* these are removed by global override below when on a canvas*/
padding-left: 32px;
padding-right: 32px;
padding-bottom: 20px;
}
/* Override to remove padding when this is inside a canvas */
:global {
div.SPCanvas {
:local(.defaultcontainer) {
padding-left: 5px;
padding-right: 5px;
padding-bottom: 0px;
}
}
}

View File

@ -0,0 +1,163 @@
import * as React from 'react';
// redux related
import { connect } from 'react-redux';
import { IApplicationState } from 'webparts/questions/redux/reducers/appReducer';
// other
import styles from './DefaultContainer.module.scss';
import { DisplayMode } from '@microsoft/sp-core-library';
import { WebPartTitle } from "@pnp/spfx-controls-react/lib/WebPartTitle";
import SearchComponent from '../Search/Search';
import QuestionListComponent from '../QuestionList/QuestionList';
import QuestionComponent from '../Question/Question';
import ErrorComponent from '../Error/Error';
import { IQuestionItem } from 'models';
import { getPagedQuestions, inializeNewQuestion, getSelectedQuestion } from 'webparts/questions/redux/actions/actions';
import { IReadonlyTheme } from '@microsoft/sp-component-base';
import * as Quill from 'quill';
import { getIconClassName } from '@uifabric/styling';
import { autobind } from '@uifabric/utilities/lib';
import { WebPartRenderMode } from 'utilities';
interface IConnectedDispatch {
getPagedQuestions: (goingToNextPage: boolean) => void;
inializeNewQuestion: (initialTitle: string) => void;
getSelectedQuestion: (questionId: number) => void;
}
interface IConnectedState {
title: string;
displayMode: DisplayMode;
selectedQuestion: IQuestionItem | null;
loadInitialPage: boolean;
applicationErrorMessage?: string;
themeVariant: IReadonlyTheme | undefined;
webPartRenderMode: string;
}
// map the application state to properties on this component so they can be used
function mapStateToProps(state: IApplicationState, ownProps: any): IConnectedState {
return {
title: state.title,
displayMode: state.displayMode,
selectedQuestion: state.selectedQuestion,
loadInitialPage: state.loadInitialPage,
applicationErrorMessage: state.applicationErrorMessage,
themeVariant: state.themeVariant,
webPartRenderMode: state.webPartRenderMode
};
}
// map actions to properties so they can be invoked
const mapDispatchToProps = {
getPagedQuestions,
inializeNewQuestion,
getSelectedQuestion
};
export interface IDefaultContainerProps {
selectedQuestionId: number;
updateTitle: (value: string) => void;
}
export interface IDefaultContainerState {
showQuestions: boolean;
}
class DefaultContainerComponent extends React.Component<IDefaultContainerProps & IConnectedState & IConnectedDispatch, IDefaultContainerState> {
constructor(props: IDefaultContainerProps & IConnectedState & IConnectedDispatch) {
super(props);
this.state = { showQuestions: true };
this.updateQuillIcons();
}
public componentDidMount() {
if (this.props.selectedQuestionId === 0) {
this.props.inializeNewQuestion('');
this.setState({ showQuestions: false });
}
else if (this.props.selectedQuestionId > 0) {
this.props.getSelectedQuestion(this.props.selectedQuestionId);
this.setState({ showQuestions: false });
}
else if (this.props.loadInitialPage === true) {
this.props.getPagedQuestions(false);
}
}
public componentDidUpdate(prevProps: IDefaultContainerProps & IConnectedState & IConnectedDispatch, prevState: IDefaultContainerState): void {
// if our question has changed
if (this.props.selectedQuestion !== prevProps.selectedQuestion) {
if (this.props.selectedQuestion !== null) {
this.setState({ showQuestions: false });
}
else {
this.setState({ showQuestions: true });
}
}
}
public render(): React.ReactElement<IDefaultContainerProps> {
const { applicationErrorMessage, webPartRenderMode } = this.props;
const { showQuestions } = this.state;
const color: string = (!!this.props.themeVariant && this.props.themeVariant.semanticColors.bodyText) || "inherit";
return (
<div style={{ color: color }} className={styles.defaultcontainer}>
<WebPartTitle displayMode={this.props.displayMode}
themeVariant={this.props.themeVariant}
title={this.props.title}
updateProperty={this.props.updateTitle} />
{!applicationErrorMessage ? (
<div>
{webPartRenderMode !== WebPartRenderMode.OpenQuestions &&
webPartRenderMode !== WebPartRenderMode.AnsweredQuestions &&
<SearchComponent show={showQuestions} />
}
<QuestionListComponent show={showQuestions} />
<QuestionComponent show={!showQuestions} />
</div>
) : (
<ErrorComponent erroMessage={applicationErrorMessage} />
)
}
</div>
);
}
@autobind
private updateQuillIcons() {
let icons = Quill.default.import('ui/icons');
icons['bold'] = `<i class="${getIconClassName('Bold')}" />`;
icons['italic'] = `<i class="${getIconClassName('Italic')}" />`;
icons['underline'] = `<i class="${getIconClassName('Underline')}" />`;
icons['strike'] = `<i class="${getIconClassName('Strikethrough')}" />`;
icons['color'] = `<i class="${getIconClassName('FontColor')}" />`;
icons['background'] = `<i class="${getIconClassName('BackgroundColor')}" />`;
icons['header']['1'] = `<i class="${getIconClassName('Header1')}" />`;
icons['header']['2'] = `<i class="${getIconClassName('Header2')}" />`;
icons['header']['3'] = `<i class="${getIconClassName('Header3')}" />`;
icons['blockquote'] = `<i class="${getIconClassName('RightDoubleQuote')}" />`;
icons['list']['ordered'] = `<i class="${getIconClassName('NumberedList')}" />`;
icons['list']['bullet'] = `<i class="${getIconClassName('BulletedList2')}" />`;
icons['indent']['-1'] = `<i class="${getIconClassName('DecreaseIndentLegacy')}" />`;
icons['indent']['+1'] = `<i class="${getIconClassName('IncreaseIndentLegacy')}" />`;
icons['align'][''] = `<i class="${getIconClassName('AlignLeft')}" />`;
icons['align']['center'] = `<i class="${getIconClassName('AlignCenter')}" />`;
icons['align']['justify'] = `<i class="${getIconClassName('AlignJustify')}" />`;
icons['align']['right'] = `<i class="${getIconClassName('AlignRight')}" />`;
icons['link'] = `<i class="${getIconClassName('Link')}" />`;
icons['clean'] = `<i class="${getIconClassName('ClearFormatting')}" />`;
}
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(DefaultContainerComponent);

View File

@ -0,0 +1,25 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
@import '../Shared.module.scss';
.error {
.container {
margin: 0px auto;
padding: 20px;
background-color: $ms-color-neutralLight;
display: flex;
}
.errorTextContainer {
flex-grow: 1;
line-height: 40px;
height: 40px;
vertical-align: middle;
}
.errorIconContainer {
@include ms-font-xxl;
color: $ms-color-red;
padding-right: 10px;
line-height: 40px;
height: 40px;
vertical-align: middle;
}
}

View File

@ -0,0 +1,25 @@
import * as React from 'react';
import styles from './Error.module.scss';
import { Icon } from 'office-ui-fabric-react/lib/Icon';
export interface IErrorProps {
erroMessage: string;
}
export default class ErrorComponent extends React.Component<IErrorProps, {}> {
public render(): React.ReactElement<IErrorProps> {
return (
<div className={styles.error}>
<div className={styles.container}>
<div className={styles.errorIconContainer}>
<Icon iconName={'ErrorBadge'} />
</div>
<div className={styles.errorTextContainer}>
{this.props.erroMessage}
</div>
</div>
</div>
);
}
}

View File

@ -0,0 +1,78 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
@import '../Shared.module.scss';
.question {
.container {
margin: 0px auto;
}
.containerHeader {
text-align: right;
color: $ms-color-themePrimary;
}
.questionContainer {
@include tw-item-container
}
.questionItem {
@include tw-item
}
.questionTitle {
@include ms-font-xxl;
@include ms-fontWeight-regular;
padding-top: 10px;
}
.questionTitle textarea {
background-color: transparent;
border: none;
box-sizing: border-box;
color: inherit;
display: block;
font-family: inherit;
font-size: inherit;
font-weight: inherit;
height: 40px;
line-height: inherit;
margin: 0;
outline: 0;
overflow: hidden;
resize: none;
text-align: inherit;
white-space: pre;
width: 100%;
}
.questionInfo {
@include ms-font-s;
font-style: italic;
padding-bottom: 10px;
}
.questionBodyContainer {
padding-top: 10px;
padding-bottom: 10px;
@include ms-font-m-plus;
}
.questionIsAnsweredContainer {
@include tw-answer-info;
}
.adminActionsContainer {
@include tw-admin-actions;
}
.messagebarContainer {
@include tw-message-bar-container;
}
.userActions {
@include tw-user-actions;
}
.errorMessage {
@include tw-error-message;
}
}

View File

@ -0,0 +1,594 @@
import * as React from 'react';
import { LogHelper, ErrorHelper, FormMode } from 'utilities';
import styles from './Question.module.scss';
import { autobind } from '@uifabric/utilities/lib';
import * as strings from 'QuestionsWebPartStrings';
import { QuillConfig } from '../QuillConfig';
// redux related
import { connect } from 'react-redux';
import { IApplicationState } from 'webparts/questions/redux/reducers/appReducer';
import { getSelectedQuestion, likeQuestion, followQuestion, isDuplicateQuestion, deleteQuestion, saveQuestion } from 'webparts/questions/redux/actions/actions';
// models
import { IQuestionItem } from 'models';
// controls
import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog';
import { MessageBar, MessageBarType } from 'office-ui-fabric-react/lib/MessageBar';
import { Persona, PersonaSize } from 'office-ui-fabric-react/lib/Persona';
import { ActionButton, PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button';
import { Editor } from 'primereact/editor';
import { Icon } from 'office-ui-fabric-react/lib/Icon';
import { DirectionalHint } from 'office-ui-fabric-react/lib/Callout';
import ReplyListComponent from '../ReplyList/ReplyList';
import ReplyComponent from '../Reply/Reply';
import { IContextualMenuItem } from 'office-ui-fabric-react/lib/ContextualMenu';
interface IConnectedDispatch {
getSelectedQuestion: (questionId?: number) => void;
likeQuestion: (question: IQuestionItem) => Promise<void>;
followQuestion: (question: IQuestionItem) => Promise<void>;
deleteQuestion: (question: IQuestionItem) => Promise<void>;
isDuplicateQuestion: (question: IQuestionItem) => Promise<boolean>;
saveQuestion: (question: IQuestionItem) => Promise<number>;
}
interface IConnectedState {
selectedQuestion: IQuestionItem | null;
}
// map the application state to properties on this component so they can be used
function mapStateToProps(state: IApplicationState, ownProps: IQuestionProps): IConnectedState {
return {
selectedQuestion: state.selectedQuestion,
};
}
// map actions to properties so they can be invoked
const mapDispatchToProps = {
getSelectedQuestion,
likeQuestion,
followQuestion,
deleteQuestion,
saveQuestion,
isDuplicateQuestion,
};
export interface IQuestionProps {
show: boolean;
}
interface IQuestionState {
formMode: FormMode;
question?: IQuestionItem;
isChanged: boolean;
showUndoConfirm: boolean;
showDeleteConfirm: boolean;
showNotification: boolean;
closeOnUndoConfirm: boolean;
notificationMessage?: string;
notificationType?: MessageBarType;
showNewReply: boolean;
}
class QuestionComponent extends React.Component<IQuestionProps & IConnectedState & IConnectedDispatch, IQuestionState> {
constructor(props: IQuestionProps & IConnectedState & IConnectedDispatch) {
super(props);
LogHelper.verbose(this.constructor.name, 'ctor', 'start');
this.state = {
formMode: FormMode.View,
isChanged: false,
showUndoConfirm: false,
closeOnUndoConfirm: false,
showDeleteConfirm: false,
showNotification: false,
showNewReply: false
};
}
public componentDidUpdate(prevProps: IQuestionProps & IConnectedState & IConnectedDispatch, prevState: IQuestionState): void {
// if our question has changed
if (this.props.selectedQuestion && this.props.selectedQuestion !== prevProps.selectedQuestion) {
// reset our question in state managed by this component
this.setState({ question: this.props.selectedQuestion });
// and previously it was null or undefined
if (!prevProps.selectedQuestion) {
// determine and set the appropriate initial form mode
if (this.props.selectedQuestion.id && this.props.selectedQuestion.id > 0) {
this.setState({ formMode: FormMode.View });
}
else {
this.setState({ formMode: FormMode.New });
}
}
}
}
public render(): React.ReactElement<IQuestionProps> {
const { question, showNewReply: showNewReply } = this.state;
let id: string = question ? `question-${question.id}` : `question-new`;
if (question) {
return (
<div id={id} className={styles.question}
style={{ display: this.props.show === true ? 'block' : 'none' }}>
<div className={styles.containerHeader}>
<ActionButton id="closePanel" text={strings.ButtonText_CloseQuestion}
onClick={(ev: any) => this.handleCloseClick()} />
</div>
<div className={styles.container}>
{this.renderQuestion()}
{question.isAnswered === true && question.answerReply !== undefined &&
<ReplyComponent
show={true}
readOnlyMode={true}
formMode={FormMode.View}
answerMode={true}
reply={question.answerReply}
parentQuestion={question} />
}
<ReplyComponent
show={showNewReply}
formMode={FormMode.New}
parentQuestion={question}
onActionCompleted={() => this.setState({ showNewReply: false })} />
<ReplyListComponent
replies={question.replies}
parentQuestion={question} />
{this.getUndoConfirmDialog()}
{this.getUndoDeleteConfirmDialog()}
</div>
</div>
);
}
else {
return (<div id={id}></div>);
}
}
private renderQuestion(): JSX.Element | undefined {
const { question, formMode, showNotification, notificationType, notificationMessage } = this.state;
if (question) {
return (
<div className={styles.questionContainer}>
<div className={styles.questionItem}>
{this.renderQuestionAdminActions()}
{this.renderIsAnswered(question)}
{this.renderQuestionTitle(question)}
{formMode === FormMode.View && question.author && question.createdDate &&
<div>
<span>
<Persona size={PersonaSize.size24}
text={question.author.primaryText}
showSecondaryText={true}
onRenderSecondaryText={props => this.onRenderSecondaryText(question)} />
</span>
</div>
}
{this.renderQuestionDetails(question)}
{this.renderQuestionUserActions()}
{formMode !== FormMode.View &&
<div className={styles.userActions}>
<PrimaryButton id='Post' text={strings.ButtonText_Post}
iconProps={{ iconName: 'Send' }} onClick={() => this.handleSaveClick()} />
<DefaultButton id='Undo' text={strings.ButtonText_Cancel}
iconProps={{ iconName: 'Undo' }} onClick={() => this.handleCancelClick()} />
</div>
}
{
showNotification &&
<div className={styles.messagebarContainer}>
<MessageBar messageBarType={notificationType}>{notificationMessage}</MessageBar>
</div>
}
</div>
</div>
);
}
}
private renderQuestionAdminActions(): JSX.Element | undefined {
const { question } = this.state;
const { formMode } = this.state;
let items: IContextualMenuItem[] = [];
if (question) {
if (question.canEdit) {
items.push({
key: 'editQuestion', name: (strings.MenuText_Edit), iconProps: { iconName: 'Edit' },
onClick: (ev: any) => this.handleEditClick()
});
}
if (question.canDelete) {
items.push({
key: 'deleteQuestion', name: (strings.MenuText_Delete), iconProps: { iconName: 'Delete' },
onClick: (ev: any) => this.setState({ showDeleteConfirm: true })
});
}
}
if (formMode === FormMode.View && items.length > 0) {
return (
<div className={styles.adminActionsContainer}>
<ActionButton id="questionSettings"
iconProps={{ iconName: 'Settings' }}
menuProps={{
directionalHint: DirectionalHint.bottomRightEdge,
items: items
}}
/>
</div>
);
}
}
private renderQuestionUserActions(): JSX.Element | undefined {
const { question } = this.state;
const { formMode } = this.state;
if (question && formMode === FormMode.View) {
return (
<div className={styles.userActions}>
{question.canReact === true &&
<ActionButton id="likeQuestion"
text={` ${question.followedByCurrentUser ? strings.ButtonText_Following : strings.ButtonText_NotFollowing}`}
title={strings.ButtonTitle_Following}
iconProps={{ iconName: question.followedByCurrentUser === true ? 'MailSolid' : 'Mail' }}
onClick={() => this.props.followQuestion(question)} />
}
{question.canReact === true &&
<ActionButton id="likeQuestion"
text={` ${strings.ButtonText_Like} (${question.likeCount ? question.likeCount : 0})`}
iconProps={{ iconName: question.likedByCurrentUser === true ? 'LikeSolid' : 'Like' }}
onClick={() => this.props.likeQuestion(question)} />
}
{question.canReply === true &&
<ActionButton id="addReply"
text={` ${strings.ButtonText_Reply} (${question.totalReplyCount ? question.totalReplyCount : 0})`}
iconProps={{ iconName: 'CommentAdd' }}
onClick={() => this.setState({ showNewReply: true })} />
}
</div>
);
}
}
private renderIsAnswered(question: IQuestionItem): JSX.Element | undefined {
if (question.isAnswered === true) {
return (
<div>
<span className={styles.questionIsAnsweredContainer}>
<Icon iconName={'CheckMark'} />
{strings.Message_IsAnswered}
</span>
</div>
);
}
}
private renderQuestionTitle(question: IQuestionItem): JSX.Element {
const { formMode } = this.state;
if (formMode === FormMode.View) {
return (
<div className={styles.questionTitle}>
{question.title}
</div>
);
}
else {
return (
<div className={styles.questionTitle}>
<textarea placeholder={strings.Placeholder_QuestionTitle}
value={question.title}
maxLength={255}
autoFocus={true}
onKeyPress={this.handleOnKeyPress}
onChange={(e) => this.handleTextChanged('title', e.target.value)}></textarea>
<div className={styles.errorMessage}>{ErrorHelper.getUIError(question, 'title')}</div>
</div>
);
}
}
private renderQuestionDetails(question: IQuestionItem): JSX.Element {
const { formMode } = this.state;
if (formMode === FormMode.View) {
return (
<div className={styles.questionBodyContainer}>
<div dangerouslySetInnerHTML={{ __html: question.details }}></div>
</div>
);
}
else {
return (
<div className={styles.questionBodyContainer}>
<Editor id="details"
value={question.details}
headerTemplate={QuillConfig.header}
placeholder={strings.Placeholder_QuestionDetails}
onTextChange={(e) => this.handleEditorChanged('details', e.htmlValue, 'detailsText', e.textValue)} />
<div className={styles.errorMessage}>{ErrorHelper.getUIError(question, 'details')}</div>
</div>
);
}
}
@autobind
private onRenderSecondaryText(question: IQuestionItem): JSX.Element {
return (
<div>
<Icon iconName={'Comment'} />
{strings.Message_AskedOn} {question.createdDate!.toLocaleString()}
</div>
);
}
@autobind
private handleOnKeyPress(e) {
if (e.key === 'Enter') {
e.preventDefault();
}
}
@autobind
private handleTextChanged(propertyName: string, newValue: string) {
const { question } = this.state;
if (question) {
question[propertyName] = newValue;
ErrorHelper.removeUIError(question, propertyName);
this.setState({ question });
this.handleOnChanged(true);
}
}
@autobind
private handleEditorChanged(htmlPropertyName: string, htmlValue: string | null, textPropertyName: string, textValue: string) {
const { question } = this.state;
if (question) {
question[htmlPropertyName] = htmlValue;
question[textPropertyName] = textValue;
ErrorHelper.removeUIError(question, htmlPropertyName);
this.setState({ question });
this.handleOnChanged(true);
}
}
@autobind
private handleEditClick(): void {
const { question } = this.state;
if (question) {
// this is a refetch but we want to get the latest version when going to edit in case they've been on view awhile
this.props.getSelectedQuestion(question.id);
this.setState({ formMode: FormMode.Edit });
}
}
@autobind
private handleConfirmDeleteClick(): void {
const { question } = this.state;
if (question) {
this.props.deleteQuestion(question).then(() => {
this.setState({ showDeleteConfirm: false });
});
}
}
@autobind
private handleCloseClick(): void {
let { isChanged } = this.state;
if (isChanged === true) {
// show a confirmation
this.setState({ showUndoConfirm: true, closeOnUndoConfirm: true });
}
else {
this.props.getSelectedQuestion();
this.setState({
formMode: FormMode.View,
showUndoConfirm: false,
showNotification: false
});
this.handleOnChanged(false);
}
}
@autobind
private handleCancelClick(): void {
let { isChanged, formMode } = this.state;
if (isChanged === true) {
// show a confirmation
this.setState({ showUndoConfirm: true });
}
else {
if (formMode === FormMode.New) {
this.props.getSelectedQuestion();
}
this.setState({
formMode: FormMode.View,
showUndoConfirm: false,
showNotification: false
});
this.handleOnChanged(false);
}
}
@autobind
private handleUndoChangesClick(): void {
let { formMode, question, closeOnUndoConfirm } = this.state;
if (formMode === FormMode.New || closeOnUndoConfirm === true) {
this.props.getSelectedQuestion();
}
else {
/* revisit this to improve responsiveness
selectedQuestion = this.props.selectedQuestion;
this.setState({ question });
*/
if (question) {
this.props.getSelectedQuestion(question.id);
}
}
this.setState({
formMode: FormMode.View,
showUndoConfirm: false,
showNotification: false,
closeOnUndoConfirm: false
});
this.handleOnChanged(false);
}
@autobind
private async handleSaveClick(): Promise<void> {
const { question } = this.state;
let isQuestionValid = await this.isQuestionValid();
if (isQuestionValid) {
this.setState({
showNotification: true,
notificationType: MessageBarType.info,
notificationMessage: strings.Message_SavingQuestion
});
if (question) {
this.props.saveQuestion(question).then(id => {
this.setState({
formMode: FormMode.View,
showUndoConfirm: false,
notificationType: MessageBarType.success,
notificationMessage: strings.Message_SavedQuestion,
});
this.handleOnChanged(false);
// after 5 seconds hide the notification
setTimeout(() => { this.setState({ showNotification: false, notificationMessage: undefined }); }, 5000);
});
}
}
}
@autobind
private async isQuestionValid(): Promise<boolean> {
const { question } = this.state;
let isValid: boolean = true;
if (question) {
question.uiErrors = new Map<string, string>();
if (question.title) {
let isDuplicate = await this.props.isDuplicateQuestion(question);
if (isDuplicate === true) {
ErrorHelper.setUIError(question, 'title', strings.ErrorMessage_DuplicateQuestion);
isValid = false;
}
}
else {
isValid = false;
ErrorHelper.setUIError(question, 'title', strings.ErrorMessage_QuestionRequired);
}
if (!question.details) {
isValid = false;
ErrorHelper.setUIError(question, 'details', strings.ErrorMessage_DetailsRequired);
}
}
this.setState({
showNotification: !isValid,
notificationType: MessageBarType.error,
notificationMessage: isValid === false ? strings.MessageBar_QuestionSaveErrors : undefined
});
return isValid;
}
@autobind
private handleOnChanged(changed: boolean): void {
this.setState({ isChanged: changed });
}
// future common
@autobind
private getUndoConfirmDialog(): JSX.Element | undefined {
const { showUndoConfirm } = this.state;
if (showUndoConfirm === true) {
return (
<Dialog
hidden={!showUndoConfirm}
dialogContentProps={{
type: DialogType.normal,
title: (strings.Dialog_UnsavedChangesTitle),
subText: (strings.Dialog_UnsavedChangedSubText)
}}
modalProps={{ isBlocking: true }}>
<DialogFooter>
<PrimaryButton text={strings.ButtonText_Continue}
onClick={this.handleUndoChangesClick} />
<DefaultButton text={strings.ButtonText_ResumeEdit}
onClick={() => this.setState({ showUndoConfirm: false })} />/>
</DialogFooter>
</Dialog>
);
}
}
@autobind
private getUndoDeleteConfirmDialog(): JSX.Element | undefined {
const { showDeleteConfirm } = this.state;
if (showDeleteConfirm === true) {
return (
<Dialog
hidden={!showDeleteConfirm}
dialogContentProps={{
type: DialogType.normal,
title: (strings.Dialog_DeleteConfirmTitle),
subText: (strings.Dialog_DeleteConfirmSubText)
}}
modalProps={{ isBlocking: true }}>
<DialogFooter>
<PrimaryButton text={strings.ButtonText_Yes}
onClick={this.handleConfirmDeleteClick} />
<DefaultButton text={strings.ButtonText_No}
onClick={() => this.setState({ showDeleteConfirm: false })} />
</DialogFooter>
</Dialog>
);
}
}
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(QuestionComponent);

View File

@ -0,0 +1,5 @@
export interface IQuestionListState {
selectedQuestionId: number;
showQuestion: boolean;
questionChanged: boolean;
}

View File

@ -0,0 +1,87 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
.questionList {
.container {
margin: 0px auto;
}
.questionOuterContainer {
margin: 0px auto;
}
.questionInnerContainer {
display: flex;
padding: 10px;
margin-bottom: 5px;
margin-top: 5px;
border: 1px solid;
border-color: $ms-color-neutralLight;
transition: .2s;
}
.questionInnerContainer:hover {
border-color: $ms-color-neutralPrimary;
transform: scale(1.02);
cursor: pointer;
}
.questionBody {
flex-grow: 1;
overflow: hidden;
padding-left: 20px;
}
.questionDetails {
display: block;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.questionAuthorDetails {
@include ms-font-s;
padding-top: 10px;
@include ms-fontWeight-regular;
}
.questionAuthor {
@include ms-fontWeight-semibold;
}
.questionTitle {
@include ms-font-l;
@include ms-fontWeight-regular;
white-space: nowrap;
overflow: hidden;
padding-bottom: 5px;
text-overflow: ellipsis;
}
.questionIconContainer {
flex-shrink: 0;
padding: 5px 5px 5px 0px;
}
.questionUnanswered {
font-size: 30px;
height: 30px;
width: 30px;
color:$ms-color-white;
background-color: $ms-color-themeLight;
padding: 20px;
}
.questionAnswered {
font-size: 30px;
height: 30px;
width: 30px;
color:$ms-color-white;
background-color: $ms-color-themePrimary;
padding: 20px;
}
.pagingContainer {
margin: auto;
text-align: center;
}
}

View File

@ -0,0 +1,131 @@
import * as React from 'react';
import { autobind } from '@uifabric/utilities/lib';
import * as strings from 'QuestionsWebPartStrings';
import styles from './QuestionList.module.scss';
// redux related
import { connect } from 'react-redux';
import { IApplicationState } from 'webparts/questions/redux/reducers/appReducer';
import { getPagedQuestions, getPrevPagedQuestions, launchQuestion } from 'webparts/questions/redux/actions/actions';
// models
import { IQuestionItem, IPagedItems } from 'models';
// controls
import { Icon } from 'office-ui-fabric-react/lib/Icon';
import { List } from 'office-ui-fabric-react/lib/List';
import { ActionButton } from 'office-ui-fabric-react/lib/Button';
interface IConnectedDispatch {
getPagedQuestions: (goingToNextPage: boolean) => void;
getPrevPagedQuestions: () => void;
launchQuestion: (questionId?: number) => void;
}
interface IConnectedState {
currentPagedQuestions: IPagedItems<IQuestionItem> | null;
previousPagedQuestions: IPagedItems<IQuestionItem>[];
}
// map actions to properties so they can be invoked
function mapStateToProps(state: IApplicationState, ownProps: any): IConnectedState {
return {
currentPagedQuestions: state.currentPagedQuestions,
previousPagedQuestions: state.previousPagedQuestions,
};
}
//Map the actions to the properties of the Component. Making them available in this.props inside the component.
const mapDispatchToProps = {
getPagedQuestions,
getPrevPagedQuestions,
launchQuestion
};
export interface IQuestionListProps {
show: boolean;
}
class QuestionListComponent extends React.Component<IQuestionListProps & IConnectedState & IConnectedDispatch, {}> {
public componentDidUpdate(prevProps: IQuestionListProps & IConnectedState & IConnectedDispatch, prevState: any): void {
/*
this tells us they are returning to the list of questions from a question and we don't have a list qustions
typically this is when user launched via a querystring directly to a new/existing question
*/
if(this.props.show !== prevProps.show &&
this.props.show === true &&
this.props.currentPagedQuestions === null) {
this.props.getPagedQuestions(false);
}
}
public render(): React.ReactElement<IQuestionListProps> {
const { currentPagedQuestions, previousPagedQuestions } = this.props;
const showPrev = previousPagedQuestions && previousPagedQuestions.length > 0;
let showNext = currentPagedQuestions &&
currentPagedQuestions.pagedItemCollection &&
currentPagedQuestions.pagedItemCollection.hasNext === true;
if (currentPagedQuestions && currentPagedQuestions.items && currentPagedQuestions.items.length > 0) {
return (
<div id="questionsList" className={styles.questionList}
style={{ display: this.props.show === true ? 'block' : 'none' }}>
<div className={styles.container}>
<List items={currentPagedQuestions.items} onRenderCell={this.onRenderQuestion}></List>
</div>
{(showPrev === true || showNext) &&
<div className={styles.pagingContainer}>
<ActionButton id="prevButton"
text={strings.ButtonText_Prev}
disabled={!showPrev}
iconProps={{ iconName: 'ChevronLeft' }}
onClick={this.props.getPrevPagedQuestions} />
<ActionButton id="nextButton"
text={strings.ButtonText_Next}
styles={{ flexContainer: { flexDirection: 'row-reverse' } }}
disabled={!showNext}
iconProps={{ iconName: 'ChevronRight' }}
onClick={() => this.props.getPagedQuestions(true)} />
</div>
}
</div>
);
}
else {
return (<div id="questionsList"></div>);
}
}
@autobind
private onRenderQuestion(question: IQuestionItem, index: number | undefined): JSX.Element {
return (
<div id={`question-${question.id}`} className={styles.questionOuterContainer}>
<div className={styles.questionInnerContainer}
onClick={ev => this.props.launchQuestion(question.id)} data-is-focusable={true}>
<div className={styles.questionIconContainer}>
{question.isAnswered === true &&
<Icon className={styles.questionAnswered} iconName="FeedbackResponseSolid" />
}
{question.isAnswered !== true &&
<Icon className={styles.questionUnanswered} iconName="FeedbackRequestSolid" />
}
</div >
<div className={styles.questionBody}>
<div className={styles.questionTitle}>{question.title}</div>
<div className={styles.questionDetails}>
{question.detailsText}
</div>
<div className={styles.questionAuthorDetails}>
<span className={styles.questionAuthor}>{question.author!.primaryText}</span> {question.createdDate!.toLocaleDateString()}
</div>
</div>
</div>
</div>
);
}
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(QuestionListComponent);

View File

@ -0,0 +1,42 @@
import * as React from 'react';
export class QuillConfig {
// https://github.com/quilljs/quill/blob/develop/docs/_includes/standalone/full.html
public static header = (
<div>
<span className="ql-formats">
<button className="ql-bold"></button>
<button className="ql-italic"></button>
<button className="ql-underline"></button>
<button className="ql-strike"></button>
</span>
<span className="ql-formats">
<select className="ql-color"></select>
<select className="ql-background"></select>
</span>
<span className="ql-formats">
<button className="ql-header" value="1"></button>
<button className="ql-header" value="2"></button>
<button className="ql-header" value="3"></button>
<button className="ql-blockquote"></button>
</span>
<span className="ql-formats">
<button className="ql-list" value="ordered"></button>
<button className="ql-list" value="bullet"></button>
<button className="ql-indent" value="-1"></button>
<button className="ql-indent" value="+1"></button>
</span>
<span className="ql-formats">
<select className="ql-align"></select>
</span>
<span className="ql-formats">
<button className="ql-link"></button>
</span>
<span className="ql-formats">
<button className="ql-clean"></button>
</span>
</div>
);
}

View File

@ -0,0 +1,60 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
@import '../Shared.module.scss';
.reply {
.container {
@include tw-reply-leftmargin;
}
.containerAnswerMode {
padding-top: 5px;
padding-bottom: 20px;
}
.replyContainer {
@include tw-item-container;
@include ms-font-m-plus;
}
.replyItem {
@include tw-item;
}
.replyAnswer {
@include tw-item;
border-left-width: 5px;
}
.replyDetails {
@include tw-reply-details-container;
}
.replyIsAnswerContainer {
@include tw-answer-info;
}
.adminActionsContainer {
@include tw-admin-actions;
}
.messagebarContainer {
@include tw-message-bar-container;
}
.userActions {
@include tw-user-actions;
}
.errorMessage {
@include tw-error-message;
}
// override styles in answer mode
.containerAnswerMode {
.replyContainer {
margin-top: 0px;
}
.replyAnswer {
border-left-width: 1px;
}
}
}

View File

@ -0,0 +1,603 @@
import * as React from 'react';
import { LogHelper, ErrorHelper, FormMode, Action } from 'utilities';
import styles from './Reply.module.scss';
import { autobind } from '@uifabric/utilities/lib';
import * as strings from 'QuestionsWebPartStrings';
import { QuillConfig } from '../QuillConfig';
// redux related
import { connect } from 'react-redux';
import { IApplicationState } from 'webparts/questions/redux/reducers/appReducer';
import { getReply, likeReply, helpfulReply, deleteReply, saveReply, markAnswer } from 'webparts/questions/redux/actions/actions';
// models
import { IReplyItem, IQuestionItem } from 'models';
// controls
import { Icon } from 'office-ui-fabric-react/lib/Icon';
import { Persona, PersonaSize } from 'office-ui-fabric-react/lib/Persona';
import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog';
import { MessageBar, MessageBarType } from 'office-ui-fabric-react/lib/MessageBar';
import { ActionButton, PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button';
import { Editor } from 'primereact/editor';
import { DirectionalHint } from 'office-ui-fabric-react/lib/Callout';
import ReplyListComponent from '../ReplyList/ReplyList';
import { IContextualMenuItem } from 'office-ui-fabric-react/lib/ContextualMenu';
interface IConnectedDispatch {
getReply: (replyId: number) => Promise<IReplyItem>;
likeReply: (reply: IReplyItem) => Promise<void>;
helpfulReply: (reply: IReplyItem) => Promise<void>;
deleteReply: (reply: IReplyItem) => Promise<void>;
saveReply: (reply: IReplyItem) => Promise<number>;
markAnswer: (reply: IReplyItem) => Promise<void>;
}
interface IConnectedState {
}
// map the application state to properties on this component so they can be used
function mapStateToProps(state: IApplicationState, ownProps: IReplyProps): IConnectedState {
return {
};
}
// map actions to properties so they can be invoked
const mapDispatchToProps = {
getReply,
likeReply,
helpfulReply,
deleteReply,
saveReply,
markAnswer
};
export interface IReplyProps {
show: boolean;
formMode: FormMode;
parentQuestion: IQuestionItem;
parentReply?: IReplyItem;
reply?: IReplyItem;
readOnlyMode?: boolean;
answerMode?: boolean;
onActionCompleted?: () => void;
}
interface IReplyState {
formMode: FormMode;
reply: IReplyItem;
isChanged: boolean;
showUndoConfirm: boolean;
showDeleteConfirm: boolean;
showNotification: boolean;
notificationMessage?: string;
notificationType?: MessageBarType;
showNewReply: boolean;
}
class ReplyComponent extends React.Component<IReplyProps & IConnectedState & IConnectedDispatch, IReplyState> {
constructor(props: IReplyProps & IConnectedState & IConnectedDispatch) {
super(props);
LogHelper.verbose(this.constructor.name, 'ctor', `start`);
this.state = {
formMode: props.formMode,
reply: props.reply !== undefined ? props.reply : this.initializeReply(),
isChanged: false,
showUndoConfirm: false,
showDeleteConfirm: false,
showNotification: false,
showNewReply: false
};
}
public componentDidUpdate(prevProps: IReplyProps & IConnectedState & IConnectedDispatch, prevState: IReplyState): void {
// if our reply has changed
if (this.props.reply && this.props.reply !== prevProps.reply) {
// reset our reply in state managed by this component
this.setState({ reply: this.props.reply });
}
if (this.props.show !== prevProps.show) {
if (this.props.show === true
&& this.state.formMode === FormMode.New) {
this.setState({
reply: this.initializeReply(),
isChanged: false,
showUndoConfirm: false,
showDeleteConfirm: false,
showNotification: false,
});
}
}
}
public render(): React.ReactElement<IReplyProps> {
const { answerMode, show, reply } = this.props;
let id: string = reply ? `reply-${reply.id}` : `reply-new`;
if (show === true) {
return (
<div id={id} className={styles.reply}>
<div className={answerMode === true ? styles.containerAnswerMode : styles.container}>
{this.renderReply()}
</div>
{this.getUndoConfirmDialog()}
{this.getUndoDeleteConfirmDialog()}
</div>
);
}
else {
return (
<div id={id}></div>
);
}
}
private renderReply(): JSX.Element {
const { parentQuestion, readOnlyMode, answerMode } = this.props;
const { reply, formMode, showNewReply, showNotification, notificationType, notificationMessage } = this.state;
return (
<div className={styles.replyContainer}>
<div className={reply.isAnswer === true ? styles.replyAnswer : styles.replyItem}>
{this.renderReplyAdminActions()}
{this.renderIsAnswer(reply)}
{formMode === FormMode.View && reply.author && reply.createdDate &&
<div>
<span>
<Persona size={PersonaSize.size24}
text={reply.author.primaryText}
showSecondaryText={true}
onRenderSecondaryText={props =>
<div>
<Icon iconName={'Comment'} />
{strings.Message_RepliedOn} {reply.createdDate!.toLocaleString()}
</div>
} />
</span>
</div>
}
{this.renderReplyDetails()}
{this.renderQuestionUserActions()}
{formMode !== FormMode.View && readOnlyMode !== true &&
<div className={styles.userActions}>
<PrimaryButton id='Post' text={strings.ButtonText_Reply}
iconProps={{ iconName: 'Send' }} onClick={() => this.handleSaveClick()} />
<DefaultButton id='Undo' text={strings.ButtonText_Cancel}
iconProps={{ iconName: 'Undo' }} onClick={() => this.handleCancelClick()} />
</div>
}
{
showNotification &&
<div className={styles.messagebarContainer}>
<MessageBar messageBarType={notificationType}>{notificationMessage}</MessageBar>
</div>
}
</div>
{answerMode !== true &&
<ReplyComponent
show={showNewReply}
formMode={FormMode.New}
parentQuestion={parentQuestion}
parentReply={reply}
onActionCompleted={() => this.setState({ showNewReply: false })}
getReply={this.props.getReply}
likeReply={this.props.likeReply}
helpfulReply={this.props.helpfulReply}
deleteReply={this.props.deleteReply}
saveReply={this.props.saveReply}
markAnswer={this.props.markAnswer}
/>
}
{answerMode !== true &&
<ReplyListComponent
replies={reply.replies}
parentQuestion={parentQuestion} />
}
</div>
);
}
private renderReplyAdminActions(): JSX.Element | undefined {
const { formMode, reply } = this.state;
const { readOnlyMode, parentQuestion } = this.props;
let items: IContextualMenuItem[] = [];
if (reply.canEdit) {
items.push({
key: 'editReply', name: (strings.MenuText_Edit), iconProps: { iconName: 'Edit' },
onClick: (ev: any) => this.handleEditClick()
});
}
if (reply.canDelete) {
items.push({
key: 'deleteReply', name: (strings.MenuText_Delete), iconProps: { iconName: 'Delete' },
onClick: (ev: any) => this.setState({ showDeleteConfirm: true })
});
}
if (reply.canMarkAsAnswer) {
if (reply.isAnswer) {
items.push({
key: 'markAnswer', name: (strings.MenuText_UnmarkAnswer), iconProps: { iconName: 'SingleBookMark' },
onClick: (ev: any) => this.handleMarkAnswerClick()
});
}
else {
if (parentQuestion.isAnswered === false) {
items.push({
key: 'markAnswer', name: (strings.MenuText_MarkAnswer), iconProps: { iconName: 'SingleBookMarkSolid' },
onClick: (ev: any) => this.handleMarkAnswerClick()
});
}
}
}
if (formMode === FormMode.View && readOnlyMode !== true && items.length > 0) {
return (
<div className={styles.adminActionsContainer}>
<ActionButton id="replySettings"
iconProps={{ iconName: 'Settings' }}
menuProps={{
directionalHint: DirectionalHint.bottomRightEdge,
items: items
}}
/>
</div>
);
}
}
private renderQuestionUserActions(): JSX.Element | undefined {
const { readOnlyMode } = this.props;
const { reply, formMode } = this.state;
if (formMode === FormMode.View && readOnlyMode !== true) {
return (
<div className={styles.userActions}>
{reply.canReact === true &&
<ActionButton id="helpfulReply"
text={` ${strings.ButtonText_Helpful} (${reply.helpfulCount ? reply.helpfulCount : 0})`}
iconProps={{ iconName: reply.helpfulByCurrentUser === true ? 'FavoriteStarFill' : 'FavoriteStar' }}
onClick={() => this.handleHelpfulClick()} />
}
{reply.canReact === true &&
<ActionButton id="likeReply"
text={` ${strings.ButtonText_Like} (${reply.likeCount ? reply.likeCount : 0})`}
iconProps={{ iconName: reply.likedByCurrentUser === true ? 'LikeSolid' : 'Like' }}
onClick={() => this.handleLikeClick() } />
}
{reply.canReply === true &&
<ActionButton id="addReply"
text={strings.ButtonText_Reply}
iconProps={{ iconName: 'CommentAdd' }}
onClick={() => this.setState({ showNewReply: true })} />
}
</div>
);
}
}
private renderIsAnswer(reply: IReplyItem): JSX.Element | undefined {
if (reply.isAnswer === true) {
return (
<div>
<span className={styles.replyIsAnswerContainer}>
<Icon iconName={'SingleBookMarkSolid'} />
{strings.Message_IsAnswer}
</span>
</div>
);
}
}
private renderReplyDetails(): JSX.Element {
const { formMode, reply } = this.state;
if (formMode === FormMode.View) {
return (
<div className={styles.replyDetails}>
<div dangerouslySetInnerHTML={{ __html: reply.details }}></div>
</div>
);
}
else {
return (
<div className={styles.replyDetails}>
<Editor id="details"
value={reply.details}
headerTemplate={QuillConfig.header}
placeholder={strings.Placeholder_QuestionDetails}
onTextChange={(e) => this.handleEditorChanged('details', e.htmlValue, 'detailsText', e.textValue)} />
<div className={styles.errorMessage}>{ErrorHelper.getUIError(reply, 'details')}</div>
</div>
);
}
}
@autobind
private handleEditClick(): void {
this.refreshReplyInState();
this.setState({ formMode: FormMode.Edit });
}
@autobind
private handleSaveClick(): void {
const { parentQuestion, parentReply } = this.props;
const { reply, formMode } = this.state;
let action = Action.Update;
let isReplyValid = this.isReplyValid();
if (isReplyValid) {
this.setState({
showNotification: true,
notificationType: MessageBarType.info,
notificationMessage: strings.Message_SavingReply
});
if (formMode === FormMode.New) {
action = Action.Add;
reply.parentQuestionId = parentQuestion.id;
reply.title = `${strings.Prefix_Reply}${parentQuestion.title}`;
if (parentReply) {
reply.parentReplyId = parentReply.id;
reply.title = `${strings.Prefix_Reply}${parentReply.title}`;
}
}
this.props.saveReply(reply).then(id => {
this.setState({
formMode: FormMode.View,
isChanged: false,
notificationType: MessageBarType.success,
notificationMessage: strings.Message_SavedReply,
});
// delay hiding the notification
setTimeout(() => {
this.setState({ showNotification: false, notificationMessage: undefined });
this.handleActionCompleted(action);
}, 2000);
})
.catch(e => {
this.setState({
showNotification: true,
notificationType: MessageBarType.error,
notificationMessage: e.message
});
});
}
}
@autobind
private handleLikeClick(): void {
const { reply } = this.state;
this.props.likeReply(reply).then(() => {
this.refreshReplyInState();
})
.catch(e => {
this.setState({
showNotification: true,
notificationType: MessageBarType.error,
notificationMessage: e.message
});
});
}
@autobind
private handleHelpfulClick(): void {
const { reply } = this.state;
this.props.helpfulReply(reply).then(() => {
this.refreshReplyInState();
})
.catch(e => {
this.setState({
showNotification: true,
notificationType: MessageBarType.error,
notificationMessage: e.message
});
});
}
@autobind
private handleMarkAnswerClick(): void {
const { reply } = this.state;
this.props.markAnswer(reply).then(() => {
this.refreshReplyInState();
})
.catch(e => {
this.setState({
showNotification: true,
notificationType: MessageBarType.error,
notificationMessage: e.message
});
});
}
@autobind
private isReplyValid(): boolean {
let isValid: boolean = true;
const { reply } = this.state;
reply.uiErrors = new Map<string, string>();
if (!reply.details) {
isValid = false;
ErrorHelper.setUIError(reply, 'details', strings.ErrorMessage_DetailsRequired);
}
this.setState({
reply: reply,
showNotification: !isValid,
notificationType: MessageBarType.error,
notificationMessage: isValid === false ? strings.MessageBar_QuestionSaveErrors : undefined
});
return isValid;
}
@autobind
private handleCancelClick(): void {
let { isChanged } = this.state;
if (isChanged === true) {
// show a confirmation
this.setState({ showUndoConfirm: true });
}
else {
this.handleActionCompleted(Action.Cancel);
}
}
@autobind
private handleConfirmDeleteClick(): void {
const { reply } = this.state;
this.props.deleteReply(reply).then(() => {
this.setState({ showDeleteConfirm: false });
})
.catch(e => {
this.setState({
showDeleteConfirm: false,
showNotification: true,
notificationType: MessageBarType.error,
notificationMessage: e.message
});
});
}
private refreshReplyInState() {
const { reply } = this.state;
if (reply && reply.id) {
this.props.getReply(reply.id).then(r => {
this.setState({ reply: r });
});
}
}
@autobind
private handleActionCompleted(action: Action): void {
let { formMode } = this.state;
this.setState({ isChanged: false });
switch (action) {
case Action.Cancel:
this.setState({ showUndoConfirm: false, showNotification: false });
if (formMode == FormMode.Edit) {
this.refreshReplyInState();
this.setState({ formMode: FormMode.View });
}
break;
case Action.Update:
break;
case Action.Add:
this.setState({ formMode: FormMode.New, reply: this.initializeReply() });
break;
case Action.Delete:
break;
}
if (this.props.onActionCompleted) {
this.props.onActionCompleted();
}
}
// future common
@autobind
private getUndoConfirmDialog(): JSX.Element | undefined {
const { showUndoConfirm } = this.state;
if (showUndoConfirm === true) {
return (
<Dialog
hidden={!showUndoConfirm}
dialogContentProps={{
type: DialogType.normal,
title: (strings.Dialog_UnsavedChangesTitle),
subText: (strings.Dialog_UnsavedChangedSubText)
}}
modalProps={{ isBlocking: true }}>
<DialogFooter>
<PrimaryButton text={strings.ButtonText_Continue}
onClick={() => this.handleActionCompleted(Action.Cancel)} />
<DefaultButton text={strings.ButtonText_ResumeEdit}
onClick={() => this.setState({ showUndoConfirm: false })} />
</DialogFooter>
</Dialog>
);
}
}
@autobind
private getUndoDeleteConfirmDialog(): JSX.Element | undefined {
const { showDeleteConfirm } = this.state;
if (showDeleteConfirm === true) {
return (
<Dialog
hidden={!showDeleteConfirm}
dialogContentProps={{
type: DialogType.normal,
title: (strings.Dialog_DeleteConfirmTitle),
subText: (strings.Dialog_DeleteConfirmSubText)
}}
modalProps={{ isBlocking: true }}>
<DialogFooter>
<PrimaryButton text={strings.ButtonText_Yes}
onClick={this.handleConfirmDeleteClick} />
<DefaultButton text={strings.ButtonText_No}
onClick={() => this.setState({ showDeleteConfirm: false })} />
</DialogFooter>
</Dialog>
);
}
}
@autobind
private handleEditorChanged(htmlPropertyName: string, htmlValue: string | null, textPropertyName: string, textValue: string) {
const { reply } = this.state;
reply[htmlPropertyName] = htmlValue;
reply[textPropertyName] = textValue;
ErrorHelper.removeUIError(reply, htmlPropertyName);
this.setState({ reply: reply, isChanged: true });
}
private initializeReply(): IReplyItem {
return {
id: 0,
details: '',
detailsText: '',
likeCount: 0,
likeIds: [],
likedByCurrentUser: false,
helpfulCount: 0,
helpfulIds: [],
helpfulByCurrentUser: false,
isAnswer: false,
canDelete: false,
canEdit: false,
canMarkAsAnswer: false,
canReact: false,
canReply: false,
replies: []
};
}
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(ReplyComponent);

View File

@ -0,0 +1,11 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
.replyList {
.container {
margin: 0px auto;
padding: 0px;
}
}

View File

@ -0,0 +1,49 @@
import * as React from 'react';
import { FormMode } from 'utilities';
import { autobind } from '@uifabric/utilities/lib';
import styles from './ReplyList.module.scss';
// models
import { IReplyItem, IQuestionItem } from 'models';
// controls
import { List } from 'office-ui-fabric-react/lib/List';
import ReplyComponent from '../Reply/Reply';
export interface IReplyListProps {
parentQuestion: IQuestionItem;
replies: IReplyItem[];
}
export default class ReplyListComponent extends React.Component<IReplyListProps, {}> {
public render(): React.ReactElement<IReplyListProps> {
const { replies, parentQuestion } = this.props;
if (replies && replies.length > 0) {
return (
<div id={`replies-${parentQuestion.id}`} className={styles.replyList}>
<div className={styles.container}>
<List items={replies} onRenderCell={this.onRenderCell}></List>
</div>
</div>
);
}
else {
return (
<div id={`replies-${parentQuestion.id}`}></div>
);
}
}
@autobind
private onRenderCell(reply: IReplyItem, index: number | undefined): JSX.Element | undefined {
const { parentQuestion } = this.props;
if (reply) {
return (
<ReplyComponent
show={true}
reply={reply}
formMode={FormMode.View}
parentQuestion={parentQuestion} />
);
}
}
}

View File

@ -0,0 +1,49 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
@import '../Shared.module.scss';
.search {
.container {
margin: 0px auto;
}
.searchContainer {
margin: 0px auto;
}
.userActions {
text-align: left;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
}
.askAQuestionButton {
border: 1px solid;
border-color: $ms-color-white;
flex-grow: 0;
}
.viewAll {
float: right;
flex-grow: 0;
margin-left: auto;
}
.showQuestionsDropDown {
min-width: 180px;
flex-grow: 0;
margin-left: auto;
}
.searchBoxContainer {
flex-grow: 1;
margin-bottom: 10px;
background-color: $ms-color-white;
}
.pagingContainer {
margin: auto;
text-align: center;
}
}

View File

@ -0,0 +1,126 @@
import * as React from 'react';
import styles from './Search.module.scss';
import { createRef } from '@uifabric/utilities/lib';
import * as strings from 'QuestionsWebPartStrings';
// redux related
import { connect } from 'react-redux';
import { IApplicationState } from 'webparts/questions/redux/reducers/appReducer';
import { searchQuestions, launchNewQuestion, navigateToViewAll, changeShowQuestionsOption } from 'webparts/questions/redux/actions/actions';
// models
import { ICurrentUser } from 'models';
// controls
import { SearchBox, ISearchBox } from 'office-ui-fabric-react/lib/SearchBox';
import { PrimaryButton, ActionButton } from 'office-ui-fabric-react/lib/Button';
import { Dropdown } from 'office-ui-fabric-react/lib/Dropdown';
import { IReadonlyTheme } from '@microsoft/sp-component-base';
import { ShowQuestionsOption } from 'utilities';
interface IConnectedDispatch {
searchQuestions: (searchText: string) => void;
changeShowQuestionsOption: (option: string) => void;
launchNewQuestion: (initialTitle: string) => void;
navigateToViewAll: () => void;
}
interface IConnectedState {
currentUser?: ICurrentUser;
searchText: string;
hideViewAllButton: boolean;
showQuestionAnsweredDropDown: boolean;
themeVariant: IReadonlyTheme | undefined;
selectedShowQuestionsOption: string;
}
// map the application state to properties on this component so they can be used
function mapStateToProps(state: IApplicationState, ownProps: ISearchProps): IConnectedState {
return {
currentUser: state.currentUser,
searchText: state.searchText,
hideViewAllButton: state.hideViewAllButton,
showQuestionAnsweredDropDown: state.showQuestionAnsweredDropDown,
themeVariant: state.themeVariant,
selectedShowQuestionsOption: state.selectedShowQuestionsOption
};
}
// map actions to properties so they can be invoked
const mapDispatchToProps = {
searchQuestions,
launchNewQuestion,
navigateToViewAll,
changeShowQuestionsOption
};
export interface ISearchProps {
show: boolean;
}
class SearchComponent extends React.Component<ISearchProps & IConnectedState & IConnectedDispatch, {}> {
private _searchBox = createRef<ISearchBox>();
public render(): React.ReactElement<ISearchProps> {
let viewAllStyle: any = undefined;
if (this.props.themeVariant) {
viewAllStyle = { color: this.props.themeVariant.semanticColors.link };
}
return (
<div className={styles.search} style={{ display: this.props.show === true ? 'block' : 'none' }} >
<div className={styles.container}>
<div className={styles.searchContainer}>
<SearchBox
className={styles.searchBoxContainer}
placeholder={strings.Placeholder_Search}
onClear={ev => this.props.searchQuestions('')}
value={this.props.searchText}
componentRef={this._searchBox}
onChange={filterText => this.props.searchQuestions(filterText)}
onSearch={filterText => this.props.searchQuestions(filterText)}
/>
<div className={styles.userActions}>
{this.props.currentUser && this.props.currentUser.canAddItems === true &&
<PrimaryButton className={styles.askAQuestionButton} text={strings.ButtonText_AskQuestion}
onClick={() => this.props.launchNewQuestion(this.props.searchText)} />
}
{this.props.hideViewAllButton !== true &&
<ActionButton className={styles.viewAll} style={viewAllStyle}
text={strings.ButtonText_ViewAll} onClick={this.props.navigateToViewAll} />
}
{this.props.showQuestionAnsweredDropDown === true &&
<Dropdown className={styles.showQuestionsDropDown}
onChanged={(option) => this.props.changeShowQuestionsOption(option.key.toString())}
selectedKey={this.props.selectedShowQuestionsOption}
options={[
{
key: ShowQuestionsOption.All,
text: strings.DropDownItem_AllQuestions,
},
{
key: ShowQuestionsOption.Open,
text: strings.DropDownItem_OpenQuestions
},
{
key: ShowQuestionsOption.Answered,
text: strings.DropDownItem_AnsweredQuestions
}
]}
/>
}
</div>
</div>
</div>
</div>
);
}
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(SearchComponent);

View File

@ -0,0 +1,104 @@
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
:global {
.ms-Persona-secondaryText i {
padding-right: 5px;
color: $ms-color-themePrimary;
}
}
@mixin tw-error-message {
@include ms-font-xs;
@include ms-fontWeight-semibold;
font-style: italic;
color: $ms-color-red;
}
@mixin tw-reply-leftmargin {
margin-left: 20px;
}
@mixin tw-item-container {
position: relative;
margin-top: 10px;
}
@mixin tw-item {
padding: 10px;
border: 1px solid;
border-color: $ms-color-themePrimary;
background-color: $ms-color-white;
}
@mixin tw-reply-details-container {
padding-top: 10px;
padding-bottom: 10px;
}
@mixin tw-admin-actions {
position: absolute;
top: 1px;
right: 1px;
z-index: 99;
}
@mixin tw-answer-info {
background-color: $ms-color-themePrimary;
color: $ms-color-white;
text-transform: uppercase;
display: inline-block;
padding: 2px 10px;
i {
padding-right: 5px;
}
}
@mixin tw-user-actions {
text-align: right;
button {
margin-left: 10px;
}
}
@mixin tw-message-bar-container {
padding-top: 10px;
/*
:global {
.ms-MessageBar--success,
.ms-MessageBar {
background-color: $ms-color-themePrimary;
color: $ms-color-white;
.ms-MessageBar-icon {
color:$ms-color-white;
}
}
.ms-MessageBar--error {
background-color: $ms-color-red;
}
}
*/
}
:global {
.ql-toolbar, .ql-toolbar .ql-snow,
.ql-snow.ql-toolbar button {
background-color: $ms-color-neutralPrimary;
color: $ms-color-white;
}
.ql-snow .ql-picker {
color: $ms-color-white;
}
.ql-snow .ql-picker-options {
background-color: $ms-color-neutralPrimary;
}
.ql-snow .ql-editor, .ql-editor{
font-family: "Segoe UI Web (West European)", Segoe UI, -apple-system,
BlinkMacSystemFont, Roboto, Helvetica Neue, sans-serif;
@include ms-font-m-plus;
}
}

View File

@ -0,0 +1,99 @@
define([], function () {
return {
"PropertyPage_ButtonText_ManageNotificationGroup": "Manage Notification Group",
"PropertyPane_Description": "Support questions and answers on your page.",
"PropertyPane_GroupName_About": "About Webpart",
"PropertyPane_GroupName_LayoutSettings": "Layout Settings",
"PropertyPane_GroupName_Permissions": "Permissions",
"PropertyPane_Label_ApplicationPage": "Application Page",
"PropertyPane_Lable_CanVisitorsAskQuestions": "Allow visitors to ask questions",
"PropertyPane_Label_CanVisitorsAskQuestionsDetails" : "Default is 'No' which means asking questions inherits site permissions. Setting to 'Yes' will grant visitors permissions to ask questions. Setting back 'No' will return to inheriting site permissions.",
"PropertyPane_Label_CanVisitorsAskQuestionsDisabled" : "You do not have permission to change this setting. Have someone who is a site owner change the setting if you want to allow or disallow vistors the ability to ask questions.",
"PropertyPane_Label_HideShowQuestionsDropDown": "Hide dropdown for showing all, open, answered questions",
"PropertyPane_Label_HideViewAllButton": "Hide 'See all' link",
"PropertyPage_Label_ManageNotificationGroupDetails": "Use Manage Notification Group to add and remove users that receive question notifications.",
"PropertyPane_Label_UseApplicationPage": "Open questions in a new tab",
"PropertyPane_Label_UseApplicationPageDetails": "Default is 'Yes' which works best on pages with lots of content. Only set this to 'No' if you want the user to remain on this page for all question activities.",
"PropertyPane_Label_LoadInitialPage": "Show an initial page of questions",
"PropertyPane_Label_PageSize": "Show this many questions per page",
"PropertyPane_Label_SortOption": "Show questions ordered by",
"PropertyPane_Label_VersionInfo": "Version: ",
"PropertyPane_SortOption_Title": "Question title",
"PropertyPane_SortOption_MostRecent": "Newest",
"PropertyPane_SortOption_MostLiked": "Most liked",
"PropertyPaneText_No": "No",
"PropertyPaneText_Yes": "Yes",
"DropDownItem_AllQuestions": "All Questions",
"DropDownItem_OpenQuestions": "Open Questions",
"DropDownItem_AnsweredQuestions": "Answered Questions",
"ButtonText_AskQuestion": "Ask a question",
"ButtonText_Cancel": "Cancel",
"ButtonText_CloseQuestion": "Close Question",
"ButtonText_Continue": "Continue",
"ButtonText_Following" : "Following",
"ButtonText_Like": "Like",
"ButtonText_Helpful": "Helpful",
"ButtonText_No": "No",
"ButtonText_NotFollowing" : "Not Following",
"ButtonText_Next": "Next",
"ButtonText_Post": "Post",
"ButtonText_Prev": "Previous",
"ButtonText_Reply": "Reply",
"ButtonText_ResumeEdit": "Resume edit",
"ButtonText_ViewAll": "See all",
"ButtonText_Yes": "Yes",
"ButtonTitle_Following": "Indicate if you would like to receive email notifications about this question",
"Dialog_UnsavedChangesTitle": "Unsaved Changes",
"Dialog_UnsavedChangedSubText": "You have unsaved changes. Are you sure you want to continue and lose your changes?",
"Dialog_DeleteConfirmTitle": "Delete Confirmation",
"Dialog_DeleteConfirmSubText": "Are you sure you want to delete?",
"EmailMessage_Body_HasMarkedAnswerTo": "has marked a reply as an anwer to the question",
"EmailMessage_Body_HasUnmarkedAnswerTo": "has unmarked a reply as an anwer to the question",
"EmailMessage_Body_HasNewRepliedTo" : "has replied to the question",
"EmailMessage_Body_HasNewQuestion" : "has added the question",
"EmailMessage_Body_HasUpdatedRepliedTo" : "has updated a reply to the question",
"EmailMessage_Body_QuestionDetails": "Question details:",
"EmailMessage_Body_ReplyDetails": "Reply details:",
"EmailMessage_Body_ViewQuestion" : "View Question",
"EmailMessage_Subject_NewReply" : "There is a new reply to:",
"EmailMessage_Subject_NewQuestion": "There is a new question: ",
"EmailMessage_Subject_ReplyMarkedAnswer" : "A reply has been marked as an answer to:",
"EmailMessage_Subject_ReplyUnMarkedAnswer" : "A reply has been unmarked as an answer to:",
"EmailMessage_Subject_UpdatedReply" : "There is an updated reply to:",
"ErrorMessage_DetailsRequired": "Details are Required",
"ErrorMessage_DuplicateQuestion": "Another question already exists with the same title",
"ErrorMessage_QuestionRequired": "Question is Required",
"ErrorMessage_HTTP_AccessDenied": "You do not have permission to perform this action",
"ErrorMessage_HTTP_Generic": "Unable to perform this action.",
"ErrorMessage_HTTP_NotFound": "Unable to find the requested resource.",
"ErrorMessage_HTTP_QuestionNotFound": "Sorry, we could not find that question, it may have been deleted.",
"MenuText_Edit": "Edit",
"MenuText_Delete": "Delete",
"MenuText_MarkAnswer": "Mark answer",
"MenuText_UnmarkAnswer": "Unmark answer",
"Message_AskedOn": "asked on",
"Message_IsAnswer": "Correct Answer",
"Message_IsAnswered": "Answered",
"Message_RepliedOn": "replied on",
"Message_SavedQuestion": "Question posted",
"Message_SavingQuestion": "Posting question",
"Message_SavedReply": "Reply posted",
"Message_SavingReply": "Posting reply",
"MessageBar_QuestionSaveErrors": "Sorry there were problems posting. Please fix any errors and try again.",
"Placeholder_QuestionDetails": "Add additional details for your question here",
"Placeholder_QuestionTitle": "Enter your question here",
"Placeholder_Search": "Search questions",
"Prefix_Reply": "RE: ",
}
});

View File

@ -0,0 +1,102 @@
declare interface IQuestionsWebPartStrings {
PropertyPage_ButtonText_ManageNotificationGroup: string;
PropertyPane_Description: string;
PropertyPane_GroupName_About: string;
PropertyPane_GroupName_LayoutSettings: string;
PropertyPane_GroupName_Permissions: string;
PropertyPane_Label_ApplicationPage: string;
PropertyPane_Lable_CanVisitorsAskQuestions: string;
PropertyPane_Label_CanVisitorsAskQuestionsDetails: string;
PropertyPane_Label_CanVisitorsAskQuestionsDisabled: string;
PropertyPane_Label_HideShowQuestionsDropDown: string;
PropertyPane_Label_HideViewAllButton: string;
PropertyPage_Label_ManageNotificationGroupDetails: string;
PropertyPane_Label_UseApplicationPage: string;
PropertyPane_Label_UseApplicationPageDetails: string;
PropertyPane_Label_LoadInitialPage: string;
PropertyPane_Label_PageSize: string;
PropertyPane_Label_SortOption: string;
PropertyPane_Label_VersionInfo: string;
PropertyPane_SortOption_Title: string;
PropertyPane_SortOption_MostRecent: string;
PropertyPane_SortOption_MostLiked: string;
PropertyPaneText_No: string;
PropertyPaneText_Yes: string;
DropDownItem_AllQuestions: string;
DropDownItem_OpenQuestions: string;
DropDownItem_AnsweredQuestions: string;
ButtonText_AskQuestion: string;
ButtonText_Cancel: string;
ButtonText_CloseQuestion: string;
ButtonText_Continue: string;
ButtonText_Following: string;
ButtonText_Helpful: string;
ButtonText_Like: string;
ButtonText_Next: string;
ButtonText_No: string;
ButtonText_NotFollowing: string;
ButtonText_Prev: string;
ButtonText_Post: string;
ButtonText_Reply: string;
ButtonText_ResumeEdit: string;
ButtonText_ViewAll: string;
ButtonText_Yes: string;
ButtonTitle_Following: string;
Dialog_UnsavedChangesTitle: string;
Dialog_UnsavedChangedSubText: string;
Dialog_DeleteConfirmTitle: string;
Dialog_DeleteConfirmSubText: string;
EmailMessage_Body_HasMarkedAnswerTo: string;
EmailMessage_Body_HasUnmarkedAnswerTo: string;
EmailMessage_Body_HasNewRepliedTo: string;
EmailMessage_Body_HasNewQuestion: string;
EmailMessage_Body_HasUpdatedRepliedTo: string;
EmailMessage_Body_QuestionDetails: string;
EmailMessage_Body_ReplyDetails: string;
EmailMessage_Body_ViewQuestion: string;
EmailMessage_Subject_NewReply: string;
EmailMessage_Subject_NewQuestion: string;
EmailMessage_Subject_ReplyMarkedAnswer: string;
EmailMessage_Subject_ReplyUnMarkedAnswer: string
EmailMessage_Subject_UpdatedReply: string;
ErrorMessage_DetailsRequired: string;
ErrorMessage_DuplicateQuestion: string;
ErrorMessage_QuestionRequired: string;
ErrorMessage_HTTP_AccessDenied: string;
ErrorMessage_HTTP_Generic: string;
ErrorMessage_HTTP_NotFound: string;
ErrorMessage_HTTP_QuestionNotFound: string;
MenuText_Edit: string;
MenuText_Delete: string;
MenuText_MarkAnswer: string;
MenuText_UnmarkAnswer: string;
Message_AskedOn: string;
Message_IsAnswer: string;
Message_IsAnswered: string;
Message_RepliedOn: string;
Message_SavedQuestion: string;
Message_SavingQuestion: string;
Message_SavedReply: string;
Message_SavingReply: string;
MessageBar_QuestionSaveErrors: string;
Placeholder_QuestionDetails: string;
Placeholder_QuestionTitle: string;
Placeholder_Search: string;
Prefix_Reply: string;
}
declare module 'QuestionsWebPartStrings' {
const strings: IQuestionsWebPartStrings;
export = strings;
}

View File

@ -0,0 +1,733 @@
import { IQuestionsFilter, IQuestionItem, IPagedItems, ICurrentUser, IReplyItem, IPostItem } from "models";
import { IWebPartContext } from "@microsoft/sp-webpart-base";
import { IReadonlyTheme } from '@microsoft/sp-component-base';
import { DisplayMode } from "@microsoft/sp-core-library";
import { StandardFields, SortOption, PostFields, FormMode, Parameters } from "utilities";
import { IApplicationState } from "../reducers/appReducer";
import { HttpRequestError } from "@pnp/odata";
import { IEmailProperties } from '@pnp/sp/sputilities';
import * as strings from 'QuestionsWebPartStrings';
// types of actions that can be performed
export enum ActionTypes {
UPDATE_THEMEVARIANT = 'UPDATE_THEMEVARIANT',
UPDATE_WEBPARTPROPERTY = 'UPDATE_WEBPARTPROPERTY',
UPDATE_WEBPARTCONTEXT = 'UPDATE_WEBPARTCONTEXT',
UPDATE_WEBPARTDISPLAYMODE = 'UPDATE_WEBPARTDISPLAYMODE',
UPDATE_SEARCHTEXT = 'UPDATE_SEARCHTEXT',
UPDATE_SHOWQUESTIONSOPTION = 'UPDATE_SHOWQUESTIONSOPTION',
GET_CURRENTUSER_START = 'GET_CURRENTUSER_START',
GET_CURRENTUSER_SUCCESSFUL = 'GET_CURRENTUSER_SUCCESSFUL',
UPDATE_CURRENTUSER = 'UPDATE_CURRENTUSER',
UPDATE_PAGED_QUESTIONS = 'UPDATE_PAGED_QUESTIONS',
LIKE_QUESTION_START = 'LIKE_QUESTION_START',
LIKE_QUESTION_SUCCESSFUL = 'LIKE_QUESTION_SUCCESSFUL',
FOLLOW_QUESTION_START = 'FOLLOW_QUESTION_START',
FOLLOW_QUESTION_SUCCESSFUL = 'FOLLOW_QUESTION_SUCCESSFUL',
LIKE_REPLY_START = 'LIKE_REPLY_START',
LIKE_REPLY_SUCCESSFUL = 'LIKE_REPLY_SUCCESSFUL',
HELPFUL_REPLY_START = 'HELPFUL_REPLY_START',
HELPFUL_REPLY_SUCCESSFUL = 'HELPFUL_REPLY_SUCCESSFUL',
GET_QUESTIONS_START = 'GET_QUESTIONS_START',
GET_QUESTIONS_FINISHED = 'GET_QUESTIONS_FINISHED',
GET_QUESTION_START = 'GET_QUESTION_START',
GET_QUESTION_FINISHED = 'GET_QUESTION_FINISHED',
UPDATE_SELECTED_QUESTION = 'UPDATE_SELECTED_QUESTION',
DELETE_QUESTION_START = 'DELETE_QUESTION_START',
DELETE_QUESTION_SUCCESSFUL = 'DELETE_QUESTION_SUCCESSFUL',
SAVE_QUESTION_START = 'SAVE_QUESTION_START',
SAVE_QUESTION_SUCCESSFUL = 'SAVE_QUESTION_SUCCESSFUL',
GET_REPLY_START = 'GET_REPLY_START',
GET_REPLY_FINISHED = 'GET_REPLY_FINISHED',
DELETE_REPLY_START = 'DELETE_REPLY_START',
DELETE_REPLY_SUCCESSFUL = 'DELETE_REPLY_SUCCESSFUL',
SAVE_REPLY_START = 'SAVE_REPLY_START',
SAVE_REPLY_SUCCESSFUL = 'SAVE_REPLY_SUCCESSFUL',
REPLY_ANSWER_START = 'REPLY_ANSWER_START',
REPLY_ANSWER_SUCCESSFUL = 'REPLY_ANSWER_SUCCESSFUL',
UPDATE_APPLICATION_ERROR_MESSAGE = 'UPDATE_APPLICATION_ERROR_MESSAGE'
}
// contracts for those actions
export type Action =
{ type: ActionTypes.UPDATE_THEMEVARIANT, themeVariant: IReadonlyTheme | undefined } |
{ type: ActionTypes.UPDATE_WEBPARTPROPERTY, propertyName: string, propertyValue: any } |
{ type: ActionTypes.UPDATE_WEBPARTCONTEXT, webPartContext: IWebPartContext } |
{ type: ActionTypes.UPDATE_WEBPARTDISPLAYMODE, displayMode: DisplayMode } |
{ type: ActionTypes.UPDATE_SEARCHTEXT, searchText: string } |
{ type: ActionTypes.UPDATE_SHOWQUESTIONSOPTION, option: string } |
{ type: ActionTypes.UPDATE_CURRENTUSER, currentUser: ICurrentUser } |
{ type: ActionTypes.UPDATE_APPLICATION_ERROR_MESSAGE, errorMessage: string } |
{ type: ActionTypes.GET_QUESTIONS_START } |
{
type: ActionTypes.UPDATE_PAGED_QUESTIONS,
currentPagedQuestions: IPagedItems<IQuestionItem> | null,
previousPagedQuestions: IPagedItems<IQuestionItem>[]
} |
{
type: ActionTypes.UPDATE_SELECTED_QUESTION,
selectedQuestion: IQuestionItem | null,
formMode: FormMode
}
;
// functions for those actions
const simpleAction = (actionType: ActionTypes) => ({
type: actionType
});
export const updateThemeVariant = (themeVariant: IReadonlyTheme | undefined): Action => ({
type: ActionTypes.UPDATE_THEMEVARIANT,
themeVariant
});
export const updateWebPartProperty = (propertyName: string, propertyValue: any): Action => ({
type: ActionTypes.UPDATE_WEBPARTPROPERTY,
propertyName,
propertyValue
});
export const updateWebPartDisplayMode = (displayMode: DisplayMode): Action => ({
type: ActionTypes.UPDATE_WEBPARTDISPLAYMODE,
displayMode
});
export const updateWebPartContext = (webPartContext: IWebPartContext): Action => ({
type: ActionTypes.UPDATE_WEBPARTCONTEXT,
webPartContext
});
const updateCurrentUser = (currentUser: ICurrentUser): Action => ({
type: ActionTypes.UPDATE_CURRENTUSER,
currentUser
});
const updateSearchText = (searchText: string): Action => ({
type: ActionTypes.UPDATE_SEARCHTEXT,
searchText
});
export const updateShowQuestionsOption = (option: string): Action => ({
type: ActionTypes.UPDATE_SHOWQUESTIONSOPTION,
option: option
});
const updateApplicationErrorMessage = (errorMessage: string): Action => ({
type: ActionTypes.UPDATE_APPLICATION_ERROR_MESSAGE,
errorMessage
});
const updatePagedQuestions = (currentPagedQuestions: IPagedItems<IQuestionItem> | null,
previousPagedQuestions: IPagedItems<IQuestionItem>[]): Action => ({
type: ActionTypes.UPDATE_PAGED_QUESTIONS,
currentPagedQuestions,
previousPagedQuestions
});
export function searchQuestions(searchText: string) {
return (dispatch) => {
dispatch(updateSearchText(searchText));
dispatch(updatePagedQuestions(null, []));
dispatch(getPagedQuestions(false));
};
}
export function changeShowQuestionsOption(option: string) {
return (dispatch) => {
dispatch(updateShowQuestionsOption(option));
dispatch(updatePagedQuestions(null, []));
dispatch(getPagedQuestions(false));
};
}
const updateSelectedQuestion = (selectedQuestion: IQuestionItem | null, formMode: FormMode): Action => ({
type: ActionTypes.UPDATE_SELECTED_QUESTION,
selectedQuestion,
formMode
});
export function getPagedQuestions(goingToNextPage: boolean) {
return async (dispatch, getState, { questionService }) => {
let currentUser = await dispatch(getCurrentUser());
const { pageSize, searchText, sortOption, selectedShowQuestionsOption, loadInitialPage, currentPagedQuestions, previousPagedQuestions } = getState();
if (loadInitialPage === true || (searchText && searchText.length > 2)) {
let orderByColumn: string = StandardFields.TITLE;
let orderByAscending: boolean = true;
switch (sortOption) {
case SortOption.MostRecent:
orderByColumn = StandardFields.CREATED;
orderByAscending = false;
break;
case SortOption.MostLiked:
orderByColumn = PostFields.LIKE_COUNT;
orderByAscending = false;
break;
}
let filter: IQuestionsFilter = {
pageSize: pageSize,
searchText: searchText,
orderByColumnName: orderByColumn,
orderByAscending: orderByAscending,
selectedShowQuestionsOption: selectedShowQuestionsOption
};
if (currentPagedQuestions !== null && goingToNextPage === true) {
previousPagedQuestions.push(currentPagedQuestions);
}
dispatch(simpleAction(ActionTypes.GET_QUESTIONS_START));
let questions = await questionService.getPagedQuestions(currentUser, filter, currentPagedQuestions)
.catch(e => {
let message = handleHttpError(e);
dispatch(updateApplicationErrorMessage(message));
});
dispatch(updatePagedQuestions(questions, previousPagedQuestions));
dispatch(simpleAction(ActionTypes.GET_QUESTIONS_FINISHED));
}
else {
// reset state of paged items
dispatch(updatePagedQuestions(null, []));
}
};
}
export function navigateToViewAll() {
return (dispatch, getState) => {
const { webPartContext, applicationPage, useApplicationPage }: IApplicationState = getState();
if (useApplicationPage === true
&& webPartContext
&& applicationPage
&& applicationPage.endsWith('.aspx')) {
window.open(`${webPartContext.pageContext.web.absoluteUrl}/SitePages/${applicationPage}`, '_blank');
}
else {
dispatch(updateWebPartProperty('loadInitialPage', true));
dispatch(updateSearchText(''));
dispatch(getPagedQuestions(false));
}
};
}
export function getPrevPagedQuestions() {
return (dispatch, getState) => {
const { previousPagedQuestions } = getState();
let questions = previousPagedQuestions.pop();
dispatch(updatePagedQuestions(questions, previousPagedQuestions));
};
}
export function launchNewQuestion(initialTitle: string) {
return (dispatch, getState) => {
const { webPartContext, applicationPage, useApplicationPage }: IApplicationState = getState();
if (useApplicationPage === true
&& webPartContext
&& applicationPage
&& applicationPage.endsWith('.aspx')) {
window.open(`${webPartContext.pageContext.web.absoluteUrl}/SitePages/${applicationPage}?${Parameters.QUESTIONID}=0`, '_blank');
}
else {
dispatch(inializeNewQuestion(initialTitle));
}
};
}
export function launchQuestion(questionId?: number) {
return (dispatch, getState) => {
const { webPartContext, applicationPage, useApplicationPage }: IApplicationState = getState();
if (useApplicationPage === true
&& webPartContext
&& applicationPage
&& applicationPage.endsWith('.aspx')) {
window.open(`${webPartContext.pageContext.web.absoluteUrl}/SitePages/${applicationPage}?${Parameters.QUESTIONID}=${questionId}`, '_blank');
}
else {
dispatch(getSelectedQuestion(questionId));
}
};
}
export function inializeNewQuestion(initialTitle: string) {
return (dispatch, getState) => {
let newQuestion: IQuestionItem = {
id: 0,
title: initialTitle,
details: '',
detailsText: '',
likeCount: 0,
likeIds: [],
likedByCurrentUser: false,
followEmails: [],
followedByCurrentUser: false,
isAnswered: false,
totalReplyCount: 0,
canDelete: false,
canEdit: false,
canReact: false,
canReply: false,
replies: []
};
dispatch(updateSelectedQuestion(newQuestion, FormMode.New));
};
}
export function getSelectedQuestion(questionId?: number) {
return async (dispatch, getState, { questionService }) => {
let currentUser = await dispatch(getCurrentUser());
if (questionId && questionId > 0) {
dispatch(simpleAction(ActionTypes.GET_QUESTION_START));
let question = await questionService.getQuestionById(currentUser, questionId)
.catch(e => {
let message = handleHttpError(e);
if (e.status === 404) {
message = strings.ErrorMessage_HTTP_QuestionNotFound;
}
dispatch(updateApplicationErrorMessage(message));
});
dispatch(updateSelectedQuestion(question, FormMode.View));
dispatch(simpleAction(ActionTypes.GET_QUESTION_FINISHED));
}
else {
dispatch(updateSelectedQuestion(null, FormMode.View));
}
};
}
// Delete question actions
export function deleteQuestion(question: IQuestionItem) {
return async (dispatch, getState, { questionService }) => {
if (question && question.id && question.id > 0) {
dispatch(simpleAction(ActionTypes.DELETE_QUESTION_START));
await questionService.deleteQuestion(question)
.catch(e => {
let message = handleHttpError(e);
throw new Error(message);
});
dispatch(simpleAction(ActionTypes.DELETE_QUESTION_SUCCESSFUL));
dispatch(updateSelectedQuestion(null, FormMode.View));
}
};
}
export function likeQuestion(question: IQuestionItem) {
return async (dispatch, getState, { questionService }) => {
let currentUser = await dispatch(getCurrentUser());
if (question && question.id && question.id > 0) {
updateLiked(currentUser, question);
dispatch(simpleAction(ActionTypes.LIKE_QUESTION_START));
await questionService.updateLiked(question)
.catch(e => {
let message = handleHttpError(e);
throw new Error(message);
});
dispatch(getSelectedQuestion(question.id));
dispatch(simpleAction(ActionTypes.LIKE_QUESTION_SUCCESSFUL));
}
};
}
export function followQuestion(question: IQuestionItem) {
return async (dispatch, getState, { questionService }) => {
let currentUser = await dispatch(getCurrentUser());
if (question && question.id && question.id > 0) {
updateFollowed(currentUser, question);
dispatch(simpleAction(ActionTypes.FOLLOW_QUESTION_START));
await questionService.updateFollowed(question)
.catch(e => {
let message = handleHttpError(e);
throw new Error(message);
});
dispatch(getSelectedQuestion(question.id));
dispatch(simpleAction(ActionTypes.FOLLOW_QUESTION_SUCCESSFUL));
}
};
}
// Save question action
export function isDuplicateQuestion(question: IQuestionItem) {
return async (dispatch, getState, { questionService }) => {
if (question) {
let isDuplicate = await questionService.isDuplicateQuestion(question)
.catch(e => {
let message = handleHttpError(e);
throw new Error(message);
});
return isDuplicate;
}
};
}
export function saveQuestion(question: IQuestionItem) {
return async (dispatch, getState, { questionService }) => {
if (question) {
let currentUser = await dispatch(getCurrentUser());
dispatch(simpleAction(ActionTypes.SAVE_QUESTION_START));
// make sure user asking question is following by default for new questions
let isNewQuestion: boolean = false;
if (!question.id || question.id <= 0) {
question.followEmails.push(currentUser.email);
isNewQuestion = true;
}
let id = await questionService.saveQuestion(question)
.catch(e => {
let message = handleHttpError(e);
throw new Error(message);
});
dispatch(simpleAction(ActionTypes.SAVE_QUESTION_SUCCESSFUL));
dispatch(getSelectedQuestion(id));
// TODO send notification to all follows and moderators on update
// TODO send notification to moderators for new
if (isNewQuestion === true) {
dispatch(createAndSendNewQuestionEmail(question, id));
}
return id;
}
};
}
// REPLY actions
export function getReply(replyId: number) {
return async (dispatch, getState, { questionService }) => {
let currentUser = await dispatch(getCurrentUser());
if (replyId && replyId > 0) {
dispatch(simpleAction(ActionTypes.GET_REPLY_START));
let reply = await questionService.getReplyById(currentUser, replyId)
.catch(e => {
let message = handleHttpError(e);
throw new Error(message);
});
dispatch(simpleAction(ActionTypes.GET_REPLY_FINISHED));
return reply;
}
else {
dispatch(updateSelectedQuestion(null, FormMode.View));
}
};
}
export function deleteReply(reply: IReplyItem) {
return async (dispatch, getState, { questionService }) => {
if (reply && reply.id && reply.id > 0) {
dispatch(simpleAction(ActionTypes.DELETE_REPLY_START));
// Bug 16315 - if we are deleting a reply that has been marked as the answer then let's unmark it
// Bug 16328 - if we are deleting a reply and a child reply is the answer then let's unmark it
await dispatch(tryUnMarkAnswerOnDelete(reply));
await questionService.deleteReply(reply)
.catch(e => {
let message = handleHttpError(e);
throw new Error(message);
});
dispatch(simpleAction(ActionTypes.DELETE_REPLY_SUCCESSFUL));
if (reply.parentQuestionId) {
dispatch(getSelectedQuestion(reply.parentQuestionId));
}
}
};
}
export function saveReply(reply: IReplyItem) {
return async (dispatch, getState, { questionService, notificationService }) => {
if (reply) {
dispatch(simpleAction(ActionTypes.SAVE_REPLY_START));
let id = await questionService.saveReply(reply)
.catch(e => {
let message = handleHttpError(e);
throw new Error(message);
});
dispatch(simpleAction(ActionTypes.SAVE_REPLY_SUCCESSFUL));
if (reply.parentQuestionId) {
dispatch(getSelectedQuestion(reply.parentQuestionId));
}
dispatch(createAndSendReplyEmail(reply));
return id;
}
};
}
export function helpfulReply(reply: IReplyItem) {
return async (dispatch, getState, { questionService }) => {
let currentUser = await dispatch(getCurrentUser());
if (reply && reply.id && reply.id > 0) {
updateHelpful(currentUser, reply);
dispatch(simpleAction(ActionTypes.HELPFUL_REPLY_START));
await questionService.updateHelpful(reply)
.catch(e => {
let message = handleHttpError(e);
throw new Error(message);
});
dispatch(simpleAction(ActionTypes.HELPFUL_REPLY_SUCCESSFUL));
}
};
}
export function likeReply(reply: IReplyItem) {
return async (dispatch, getState, { questionService }) => {
let currentUser = await dispatch(getCurrentUser());
if (reply && reply.id && reply.id > 0) {
updateLiked(currentUser, reply);
dispatch(simpleAction(ActionTypes.LIKE_REPLY_START));
await questionService.updateLiked(reply)
.catch(e => {
let message = handleHttpError(e);
throw new Error(message);
});
dispatch(simpleAction(ActionTypes.LIKE_REPLY_SUCCESSFUL));
}
};
}
export function markAnswer(reply: IReplyItem) {
return async (dispatch, getState, { questionService }) => {
if (reply && reply.id && reply.id > 0) {
reply.isAnswer = !reply.isAnswer;
dispatch(simpleAction(ActionTypes.REPLY_ANSWER_START));
await questionService.markAnswer(reply)
.catch(e => {
let message = handleHttpError(e);
throw new Error(message);
});
dispatch(simpleAction(ActionTypes.REPLY_ANSWER_SUCCESSFUL));
if (reply.parentQuestionId) {
dispatch(getSelectedQuestion(reply.parentQuestionId));
}
if (reply.isAnswer === true) {
dispatch(createAndSendMarkedAnswerEmail(reply));
}
}
};
}
export function tryUnMarkAnswerOnDelete(reply: IReplyItem) {
return async (dispatch, getState, { questionService }) => {
if (reply.isAnswer) {
await dispatch(markAnswer(reply));
}
else {
if (reply.replies) {
for (let childReply of reply.replies) {
await dispatch(tryUnMarkAnswerOnDelete(childReply));
}
}
}
};
}
function updateFollowed(currentUser: ICurrentUser, question: IQuestionItem) {
question.followedByCurrentUser = !question.followedByCurrentUser;
const index = question.followEmails.indexOf(currentUser.email);
if (question.followedByCurrentUser === true) {
if (index === -1) {
question.followEmails.push(currentUser.email);
}
}
else {
if (index > -1) {
question.followEmails.splice(index, 1);
}
}
}
function updateLiked(currentUser: ICurrentUser, post: IPostItem) {
post.likedByCurrentUser = !post.likedByCurrentUser;
let currentUserId = `${currentUser.id}`;
const index = post.likeIds.indexOf(currentUserId);
if (post.likedByCurrentUser === true) {
if (index === -1) {
post.likeIds.push(currentUserId);
}
}
else {
if (index > -1) {
post.likeIds.splice(index, 1);
}
}
}
function updateHelpful(currentUser: ICurrentUser, reply: IReplyItem) {
reply.helpfulByCurrentUser = !reply.helpfulByCurrentUser;
let currentUserId = `${currentUser.id}`;
const index = reply.helpfulIds.indexOf(currentUserId);
if (reply.helpfulByCurrentUser === true) {
if (index === -1) {
reply.helpfulIds.push(currentUserId);
}
}
else {
if (index > -1) {
reply.helpfulIds.splice(index, 1);
}
}
}
function createAndSendNewQuestionEmail(question: IQuestionItem, questionId: number) {
return async (dispatch, getState, { userService, notificationService }) => {
if (question) {
let currentUser = await dispatch(getCurrentUser());
let notifyEmails = await userService.getNotificationGroupUserEmails();
if (notifyEmails && notifyEmails.length > 0) {
let subjectPrefix = strings.EmailMessage_Subject_NewQuestion;
let actionMessage = strings.EmailMessage_Body_HasNewQuestion;
const { webPartContext, applicationPage } = getState();
let email: IEmailProperties = {
Subject: `${subjectPrefix} ${question.title}`,
To: notifyEmails,
Body: `
<p>
<a href='mailto:${currentUser.email}' >${currentUser.displayName}</a>
${actionMessage}
<a href='${webPartContext.pageContext.web.absoluteUrl}/SitePages/${applicationPage}?${Parameters.QUESTIONID}=${questionId}'>
${question.title}
</a>
</p>
<p><b>${strings.EmailMessage_Body_QuestionDetails}</b></p>
<p><hr>${question.details}</p>
`
};
notificationService.sendEmail(email);
}
}
};
}
function createAndSendReplyEmail(reply: IReplyItem) {
return async (dispatch, getState, { notificationService }) => {
if (reply) {
let currentUser = await dispatch(getCurrentUser());
let subjectPrefix = strings.EmailMessage_Subject_NewReply;
let actionMessage = strings.EmailMessage_Body_HasNewRepliedTo;
if (reply.id && reply.id > 0) {
subjectPrefix = strings.EmailMessage_Subject_UpdatedReply;
actionMessage = strings.EmailMessage_Body_HasUpdatedRepliedTo;
}
const { selectedQuestion, webPartContext, applicationPage } = getState();
let email: IEmailProperties = {
Subject: `${subjectPrefix} ${selectedQuestion.title}`,
To: selectedQuestion.followEmails,
Body: `
<p>
<a href='mailto:${currentUser.email}' >${currentUser.displayName}</a>
${actionMessage}
<a href='${webPartContext.pageContext.web.absoluteUrl}/SitePages/${applicationPage}?${Parameters.QUESTIONID}=${selectedQuestion.id}'>
${selectedQuestion.title}
</a>
</p>
<p><b>${strings.EmailMessage_Body_ReplyDetails}</b></p>
<p><hr>${reply.details}</p>
`
};
notificationService.sendEmail(email);
}
};
}
function createAndSendMarkedAnswerEmail(reply: IReplyItem) {
return async (dispatch, getState, { notificationService }) => {
if (reply) {
let currentUser = await dispatch(getCurrentUser());
let subjectPrefix = strings.EmailMessage_Subject_ReplyMarkedAnswer;
let actionMessage = strings.EmailMessage_Body_HasMarkedAnswerTo;
if (reply.id && reply.id > 0) {
if (reply.isAnswer === false) {
subjectPrefix = strings.EmailMessage_Subject_ReplyUnMarkedAnswer;
actionMessage = strings.EmailMessage_Body_HasUnmarkedAnswerTo;
}
}
const { selectedQuestion, webPartContext, applicationPage } = getState();
let email: IEmailProperties = {
Subject: `${subjectPrefix} ${selectedQuestion.title}`,
To: selectedQuestion.followEmails,
Body: `
<p>
<a href='mailto:${currentUser.email}' >${currentUser.displayName}</a>
${actionMessage}
<a href='${webPartContext.pageContext.web.absoluteUrl}/SitePages/${applicationPage}?${Parameters.QUESTIONID}=${selectedQuestion.id}'>
${selectedQuestion.title}
</a>
</p>
<p><b>${strings.EmailMessage_Body_ReplyDetails}</b></p>
<p><hr>${reply.details}</p>
`
};
notificationService.sendEmail(email);
}
};
}
export function getCurrentUser() {
return async (dispatch, getState, { userService }) => {
let { currentUser } = getState();
if (!currentUser) {
dispatch(simpleAction(ActionTypes.GET_CURRENTUSER_START));
currentUser = await userService.getCurrentUser();
dispatch(simpleAction(ActionTypes.GET_CURRENTUSER_SUCCESSFUL));
dispatch(updateCurrentUser(currentUser));
}
return currentUser;
};
}
export function handleHttpError(error: HttpRequestError): string {
switch (error.status) {
case 403:
return strings.ErrorMessage_HTTP_AccessDenied;
case 404:
return strings.ErrorMessage_HTTP_NotFound;
default:
return strings.ErrorMessage_HTTP_Generic;
}
}

View File

@ -0,0 +1,103 @@
import { Action, ActionTypes } from '../actions/actions';
import { Reducer } from 'redux';
import { SortOption, FormMode, ShowQuestionsOption, WebPartRenderMode } from 'utilities';
import { IQuestionItem, ICurrentUser, IPagedItems } from 'models';
import { IQuestionsWebPartProps } from '../../IQuestionsWebPartProps';
import { IWebPartContext } from '@microsoft/sp-webpart-base';
import { DisplayMode } from '@microsoft/sp-core-library';
import { IReadonlyTheme } from '@microsoft/sp-component-base';
export interface IApplicationState extends IQuestionsWebPartProps {
currentUser?: ICurrentUser;
webPartContext?: IWebPartContext;
displayMode: DisplayMode;
searchText: string;
selectedShowQuestionsOption: string;
currentPagedQuestions: IPagedItems<IQuestionItem> | null;
previousPagedQuestions: IPagedItems<IQuestionItem>[];
selectedQuestion: IQuestionItem | null;
selectedQuestionFormMode: FormMode;
showQuestion: boolean;
applicationErrorMessage?: string;
themeVariant: IReadonlyTheme | undefined;
}
export const initialState: IApplicationState = {
currentUser: undefined,
webPartContext: undefined,
displayMode: DisplayMode.Read,
title: '',
pageSize: 5,
sortOption: SortOption.Title,
selectedShowQuestionsOption: ShowQuestionsOption.All,
loadInitialPage: false,
showQuestionAnsweredDropDown: false,
hideViewAllButton: false,
applicationPage: '',
useApplicationPage: true,
searchText: '',
currentPagedQuestions: null,
previousPagedQuestions: [],
selectedQuestion: null,
selectedQuestionFormMode: FormMode.View,
showQuestion: false,
themeVariant: undefined,
webPartRenderMode: WebPartRenderMode.Standard
};
//Reducer determines how the state should change after every action.
export const appReducer: Reducer<IApplicationState> = (state: IApplicationState = initialState, action: Action): IApplicationState => {
switch (action.type) {
case ActionTypes.UPDATE_THEMEVARIANT:
state = { ...state, themeVariant: action.themeVariant };
break;
case ActionTypes.UPDATE_WEBPARTPROPERTY:
state = { ...state, [action.propertyName]: action.propertyValue };
break;
case ActionTypes.UPDATE_WEBPARTDISPLAYMODE:
state = { ...state, displayMode: action.displayMode };
break;
case ActionTypes.UPDATE_WEBPARTCONTEXT:
state = { ...state, webPartContext: action.webPartContext };
break;
case ActionTypes.UPDATE_CURRENTUSER:
state = { ...state, currentUser: action.currentUser };
break;
case ActionTypes.UPDATE_PAGED_QUESTIONS:
state = {
...state,
currentPagedQuestions: action.currentPagedQuestions,
previousPagedQuestions: action.previousPagedQuestions
};
break;
case ActionTypes.UPDATE_SEARCHTEXT:
state = { ...state, searchText: action.searchText };
break;
case ActionTypes.UPDATE_SHOWQUESTIONSOPTION:
state = { ...state, selectedShowQuestionsOption: action.option };
break;
case ActionTypes.UPDATE_SELECTED_QUESTION:
state = {
...state,
selectedQuestion: action.selectedQuestion,
selectedQuestionFormMode: action.formMode
};
break;
case ActionTypes.UPDATE_APPLICATION_ERROR_MESSAGE:
state = { ...state, applicationErrorMessage: action.errorMessage };
break;
default:
// loading?
break;
}
return state;
};

View File

@ -0,0 +1,22 @@
import { createStore, applyMiddleware, Store } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { createLogger } from 'redux-logger';
import { appReducer, IApplicationState } from '../reducers/appReducer';
import { UserService } from 'services/user.service';
import { QuestionService } from 'services/questions.service';
import { NotificationService } from 'services/notification.service';
const loggerMiddleware = createLogger();
export default function configureStore() {
let userService = new UserService();
let questionService = new QuestionService();
let notificationService = new NotificationService();
const appStateStore: Store<IApplicationState, any> = createStore(appReducer,
applyMiddleware(
thunkMiddleware.withExtraArgument({ userService, questionService, notificationService }),
loggerMiddleware));
return appStateStore;
}

View File

@ -0,0 +1,38 @@
{
"extends": "./node_modules/@microsoft/rush-stack-compiler-3.3/includes/tsconfig-web.json",
"compilerOptions": {
"target": "es5",
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"jsx": "react",
"declaration": true,
"sourceMap": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"baseUrl": "src",
"outDir": "lib",
"typeRoots": [
"./node_modules/@types",
"./node_modules/@microsoft"
],
"types": [
"es6-promise",
"webpack-env"
],
"lib": [
// commented out but may need to revisit
// "es5",
"es2017",
"dom",
"es2015.collection"
]
},
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules",
"lib"
]
}

View File

@ -0,0 +1,29 @@
{
"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-with-statement": true,
"semicolon": true,
"trailing-comma": false,
"typedef": false,
"typedef-whitespace": false,
"use-named-parameter": true,
"variable-name": false,
"whitespace": false
}
}