Merge pull request #1509 from bogeorge/react-questions-and-answers
This commit is contained in:
commit
04584a4816
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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.
|
|
@ -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 doesn’t 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 |
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json",
|
||||
"deployCdnPath": "temp/deploy"
|
||||
}
|
|
@ -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 -->"
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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/"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
|
||||
"cdnBasePath": "<!-- PATH TO CDN -->"
|
||||
}
|
|
@ -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
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"><div><div data-sp-canvascontrol="" data-sp-canvasdataversion="1.0" data-sp-controldata="&#123;&quot;controlType&quot;&#58;3,&quot;displayMode&quot;&#58;2,&quot;id&quot;&#58;&quot;17dafb10-3bee-4cec-bf2a-28ff74f048bf&quot;,&quot;position&quot;&#58;&#123;&quot;zoneIndex&quot;&#58;1,&quot;sectionIndex&quot;&#58;1,&quot;controlIndex&quot;&#58;1,&quot;layoutIndex&quot;&#58;1&#125;,&quot;webPartId&quot;&#58;&quot;761fbf9d-6ef9-4099-8488-02d5e2826f36&quot;,&quot;emphasis&quot;&#58;&#123;&#125;,&quot;reservedHeight&quot;&#58;449,&quot;reservedWidth&quot;&#58;1188&#125;"><div data-sp-webpart="" data-sp-webpartdataversion="1.0" data-sp-webpartdata="&#123;&quot;id&quot;&#58;&quot;761fbf9d-6ef9-4099-8488-02d5e2826f36&quot;,&quot;instanceId&quot;&#58;&quot;17dafb10-3bee-4cec-bf2a-28ff74f048bf&quot;,&quot;title&quot;&#58;&quot;Questions&quot;,&quot;description&quot;&#58;&quot;Ask questions or find answers to answered questions&quot;,&quot;serverProcessedContent&quot;&#58;&#123;&quot;htmlStrings&quot;&#58;&#123;&#125;,&quot;searchablePlainTexts&quot;&#58;&#123;&#125;,&quot;imageSources&quot;&#58;&#123;&#125;,&quot;links&quot;&#58;&#123;&#125;&#125;,&quot;dataVersion&quot;&#58;&quot;1.0&quot;,&quot;properties&quot;&#58;&#123;&quot;title&quot;&#58;&quot;Questions&quot;,&quot;pageSize&quot;&#58;20,&quot;sortOption&quot;&#58;&quot;Title&quot;,&quot;loadInitialPage&quot;&#58;true,&quot;hideViewAllButton&quot;&#58;true,&quot;showQuestionAnsweredDropDown&quot;&#58;true,&quot;useApplicationPage&quot;&#58;false,&quot;applicationPage&quot;&#58;&quot;Questions.aspx&quot;&#125;&#125;"><div data-sp-componentid="">761fbf9d-6ef9-4099-8488-02d5e2826f36</div><div data-sp-htmlproperties=""></div></div></div><div data-sp-canvascontrol="" data-sp-canvasdataversion="1.0" data-sp-controldata="&#123;&quot;controlType&quot;&#58;0,&quot;pageSettingsSlice&quot;&#58;&#123;&quot;isDefaultDescription&quot;&#58;true,&quot;isDefaultThumbnail&quot;&#58;true&#125;&#125;"></div></div></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"><div><div data-sp-canvascontrol="" data-sp-canvasdataversion="1.4" data-sp-controldata="&#123;&quot;id&quot;&#58;&quot;cbe7b0a9-3504-44dd-a3a3-0e5cacd07788&quot;,&quot;instanceId&quot;&#58;&quot;cbe7b0a9-3504-44dd-a3a3-0e5cacd07788&quot;,&quot;title&quot;&#58;&quot;Title area&quot;,&quot;description&quot;&#58;&quot;Title Region Description&quot;,&quot;serverProcessedContent&quot;&#58;&#123;&quot;htmlStrings&quot;&#58;&#123;&#125;,&quot;searchablePlainTexts&quot;&#58;&#123;&#125;,&quot;imageSources&quot;&#58;&#123;&#125;,&quot;links&quot;&#58;&#123;&#125;&#125;,&quot;dataVersion&quot;&#58;&quot;1.4&quot;,&quot;properties&quot;&#58;&#123;&quot;title&quot;&#58;&quot;Question2&quot;,&quot;imageSourceType&quot;&#58;4,&quot;layoutType&quot;&#58;&quot;FullWidthImage&quot;,&quot;textAlignment&quot;&#58;&quot;Left&quot;,&quot;showTopicHeader&quot;&#58;false,&quot;showPublishDate&quot;&#58;false,&quot;topicHeader&quot;&#58;&quot;&quot;,&quot;authors&quot;&#58;[&#123;&quot;id&quot;&#58;&quot;i&#58;0#.f|membership|bo@boinga.onmicrosoft.com&quot;,&quot;upn&quot;&#58;&quot;bo@boinga.onmicrosoft.com&quot;,&quot;name&quot;&#58;&quot;Bo George&quot;,&quot;role&quot;&#58;&quot;&quot;&#125;],&quot;authorByline&quot;&#58;[6]&#125;&#125;"></div></div></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>
|
|
@ -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>
|
|
@ -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 |
|
@ -0,0 +1 @@
|
|||
// A file is required to be in the root of the /src directory by the TypeScript compiler
|
|
@ -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>;
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
import { IBaseItem } from './IBaseItem';
|
||||
|
||||
export interface IBaseLookupItem extends IBaseItem {
|
||||
name: string;
|
||||
sortOrder?: number;
|
||||
showInUI?: boolean;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
import { PagedItemCollection } from '@pnp/sp/items';
|
||||
|
||||
export interface IPagedItems<T> {
|
||||
items: T[];
|
||||
pagedItemCollection?: PagedItemCollection<any>;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
export interface IQuestionsFilter {
|
||||
// paging information
|
||||
pageSize: number;
|
||||
|
||||
// sorting information
|
||||
orderByColumnName: string;
|
||||
orderByAscending: boolean;
|
||||
|
||||
// filtering information
|
||||
searchText?: string;
|
||||
|
||||
selectedShowQuestionsOption: string;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
}
|
||||
];
|
||||
}
|
|
@ -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'
|
||||
}
|
||||
];
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
}
|
|
@ -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'
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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}`;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
export * from './Constants';
|
||||
export * from './Enums';
|
||||
export * from './ErrorHelper';
|
||||
export * from './LogHelper';
|
|
@ -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 {
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -0,0 +1,5 @@
|
|||
export interface IQuestionListState {
|
||||
selectedQuestionId: number;
|
||||
showQuestion: boolean;
|
||||
questionChanged: boolean;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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>
|
||||
);
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -0,0 +1,11 @@
|
|||
@import '~@microsoft/sp-office-ui-fabric-core/dist/sass/SPFabricCore.scss';
|
||||
|
||||
.replyList {
|
||||
|
||||
.container {
|
||||
margin: 0px auto;
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -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} />
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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: ",
|
||||
}
|
||||
});
|
102
samples/react-questions-and-answers/src/webparts/questions/loc/mystrings.d.ts
vendored
Normal file
102
samples/react-questions-and-answers/src/webparts/questions/loc/mystrings.d.ts
vendored
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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"
|
||||
]
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue