This commit is contained in:
PathToSharePoint 2022-03-06 21:31:33 -08:00
parent dbb43915e4
commit b9e3105f46
21 changed files with 903 additions and 74 deletions

View File

@ -19,7 +19,11 @@ The Cherry=Picked Content Web Part is a modern replacement for the classic Conte
## Prerequisites
> N/A
Start by editing the ApprovedLibraries.ts file to list your approved libraries. Then upload your code snippets to those locations. If you are looking for ideas, check out the samples folder.
The code can be rendered in two ways:
- isolated: the code is wrapped in an iframe to prevent conflicts with other Web Parts. Note: this is not a security feature.
- non isolated: the code is directly inserted in the page.
## Solution
@ -31,6 +35,7 @@ React-Cherry-Picked-Content | [Christophe Humbert](https://github.com/PathToShar
Version|Date|Comments
-------|----|--------
0.2.0|March 6, 2022|Refactoring
0.1.0|February 21, 2022|Initial draft
## Disclaimer
@ -43,6 +48,7 @@ Version|Date|Comments
- Clone this repository
- Under components, edit ApprovedLibraries.ts to list your approved libraries that contain HTML snippets
- Upload the code snippets
- Ensure that you are at the solution folder
- in the command-line run:
- **npm install**
@ -52,9 +58,11 @@ Version|Date|Comments
This Web Part illustrates the following concepts:
- Cascading dropdown in the Property Pane
- Cascading dropdown and conditional display in the Property Pane
- Use of SPHttpClient and the SharePoint REST API to query SharePoint content
- React function component with useState and useEffect hooks
- React Portal in combination with iframe
- Various examples of client-side code in the samples: Microsoft Graph Toolkit (people, email, agenda), charts, widgets (map, stock, countdown, clock, video).
## References
@ -63,3 +71,5 @@ This Web Part illustrates the following concepts:
- [Use Microsoft Graph in your solution](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/web-parts/get-started/using-microsoft-graph-apis)
- [Publish SharePoint Framework applications to the Marketplace](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/publish-to-marketplace-overview)
- [Microsoft 365 Patterns and Practices](https://aka.ms/m365pnp) - Guidance, tooling, samples and open-source controls for your Microsoft 365 development
<img src="https://pnptelemetry.azurewebsites.net/sp-dev-fx-webparts/samples/react-cherry-picked-content" />

View File

@ -1,6 +1,6 @@
{
"name": "react-cherry-picked-content",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"main": "lib/index.js",
"scripts": {

263
samples/AnalogClock.html Normal file
View File

@ -0,0 +1,263 @@
<div style="background-color: CadetBlue; padding: 20px;">
<h3>Analog Clock</h3>
<p>Isolated mode: recommended.</p>
<p>Source: Tushar Nankani on <a href="https://github.com/tusharnankani/AnalogClock" target="_blank">Github</a>.</p>
</div>
<div class="clock">
<div class="hour">
<div class="hr" id="hr">
</div>
</div>
<div class="min">
<div class="mn" id="mn">
</div>
</div>
<div class="sec">
<div class="sc" id="sc">
</div>
</div>
</div>
<div class="toggleClass" onclick="toggleClass()"></div>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #090909;
background: #07141b;
}
body.light {
background: #d1dae3;
}
.clock {
width: 375px;
height: 375px;
display: flex;
justify-content: center;
align-items: center;
background: url(../clock.png);
background-size: cover;
border: 4px;
/* box-shadow: 15px 15px 15px rgba(255, 255, 255, 0.5); */
box-shadow: 0em -1.2em 1.2em rgba(255, 255, 255, 0.06),
inset 0em -1.2em 1.2em rgba(255, 255, 255, 0.06),
0em 1.2em 1.2em rgba(0, 0, 0, 0.3),
inset 0em 1.2em 1.2em rgba(0, 0, 0, 0.3);
border-radius: 50%;
}
body.light .clock {
box-shadow: 0em -1.2em 1.2em rgba(255, 255, 255, 0.3),
inset 1em 1em -1em rgba(255, 255, 255, 0.3),
0em -1.2em -1.2em rgba(0, 0, 0, 0.5),
inset 1em -1em 1em rgba(0, 0, 0, 0.1);
}
.clock :hover {
/* yet to be completed; when hovered, diplay complete information about time, `new Date().toLocaleString();` */
cursor: pointer;
}
/* The small circle int the center */
.clock:before {
content: '';
position: absolute;
width: 15px;
height: 15px;
background: rgb(255, 255, 255);
border-radius: 50%;
/* The z-index property specifies the stack order of an element.
/* An element with greater stack order is always in front of an element with a lower stack order. */
/* Note: z-index only works on positioned elements (position: absolute, position: relative, position: fixed, or position: sticky). */
z-index: 10000;
/* kept as a high value, since wanted at top */
}
body.light .clock:before {
background: #1a74be;
}
.clock .hour,
.clock .min,
.clock .sec {
position: absolute;
}
/* length of respective arms; */
.clock .hour, .hr {
width: 160px;
height: 160px;
}
.clock .min, .mn {
width: 190px;
height: 190px;
}
.clock .sec, .sc {
width: 230px;
height: 230px;
}
.hr, .mn, .sc {
display: flex;
justify-content: center;
/* align-items: center; */
position: absolute;
border-radius: 50%;
}
.hr:before {
content: '';
position: absolute;
width: 7.5px;
height: 80px;
background: #f81460;
z-index: 10;
/* z-index least */
border-radius: 2.8px;
}
.mn:before {
content: '';
position: absolute;
width: 3.5px;
height: 100px;
background: #ffffff;
z-index: 11;
/* z-index more than hour hand */
border-radius: 3px;
}
body.light .mn:before {
background: #091921;
}
.sc:before {
content: '';
position: absolute;
width: 2px;
height: 150px;
background: #0075fa80;
z-index: 12;
/* z-index more than hour minute hand */
border-radius: 3px;
}
.toggleClass {
position: absolute;
top: 35px;
right: 150px;
width: 20px;
margin: 2px;
height: 20px;
font-size: 18px;
border-radius: 50%;
background: #d1dae3;
color: #d1dae3;
font-family: 'Quicksand', sans-serif;
cursor: pointer;
display: flex;
align-items: center;
}
.toggleClass:before {
position: absolute;
content: 'Light Mode';
white-space: nowrap;
left: 25px;
}
body.light .toggleClass {
background: #091921;
color: #091921;
content: 'Dark Mode';
}
body.light .toggleClass:before {
content: 'Dark Mode';
white-space: nowrap;
}
</style>
<script>// For toggle button;
function toggleClass()
{
const body = document.querySelector('body');
body.classList.toggle('light');
body.style.transition = `0.3s linear`;
}
// for time;
const deg = 6;
// 360 / (12 * 5);
const hr = document.querySelector('#hr');
const mn = document.querySelector('#mn');
const sc = document.querySelector('#sc');
setInterval(() => {
let day = new Date();
let hh = day.getHours() * 30;
let mm = day.getMinutes() * deg;
let ss = day.getSeconds() * deg;
let msec = day.getMilliseconds();
// VERY IMPORTANT STEP:
hr.style.transform = `rotateZ(${(hh) + (mm / 12)}deg)`;
mn.style.transform = `rotateZ(${mm}deg)`;
sc.style.transform = `rotateZ(${ss}deg)`;
// gives the smooth transitioning effect, but there's a bug here!
// sc.style.transition = `1s`;
})
</script>

14
samples/BannerSample.html Normal file
View File

@ -0,0 +1,14 @@
<div style="background-color: CadetBlue; padding: 20px;">
<h3>Page Banner</h3>
<p>Isolated mode: not recommended. Read more on the <a href="https://blog.pathtosharepoint.com/2020/10/26/a-temporary-message-on-top-of-your-sharepoint-page/" target="_blank">Path to SharePoint blog</a>.</p>
<style type="text/css">
body::before {
display:block;
width:100%;
background-color: DarkOrange;
font-size: 20px;
padding: 10px;
content: "Headed for the office? Remember to bring your badge!";
}
</style>
</div>

View File

@ -0,0 +1,187 @@
<div style="background-color: CadetBlue; padding: 20px;">
<h3>Flip Styled Countdown</h3>
<p>Isolated mode: recommended. Source: FlipDown on <a href="https://github.com/PButcher/flipdown" target="_blank">Github</a>.</p>
<div class="example">
<p>⏰ FlipDown.js, a lightweight flip styled countdown clock</p>
<div id="flipdown" class="flipdown"></div>
</div>
<link href="https://pbutcher.uk/flipdown/css/flipdown/flipdown.css" rel="stylesheet">
<style>
html {
height: 100%;
}
body {
margin: 0px;
height: 100%;
display: flex;
align-items: center;
align-content: space-around;
}
body,
.example h1,
.example p,
.example .button {
transition: all .2s ease-in-out;
}
body.light-theme {
background-color: #151515;
}
body.light-theme .example h1 {
color: #FFFFFF;
}
body.light-theme .example p {
color: #FFFFFF;
}
body.light-theme .buttons .button {
color: #FFFFFF;
border-color: #FFFFFF;
}
body.light-theme .buttons .button:hover {
color: #151515;
background-color: #FFFFFF;
}
.example {
font-family: 'Roboto', sans-serif;
width: 550px;
height: 378px;
margin: auto;
padding: 20px;
box-sizing: border-box;
}
.example .flipdown {
margin: auto;
}
.example h1 {
text-align: center;
font-weight: 100;
font-size: 3em;
margin-top: 0;
margin-bottom: 10px;
}
.example p {
text-align: center;
font-weight: 100;
margin-top: 0;
margin-bottom: 35px;
}
.example .buttons {
width: 100%;
height: 50px;
margin: 50px auto 0px auto;
display: flex;
align-items: center;
justify-content: space-around;
}
.example .buttons p {
height: 50px;
line-height: 50px;
font-weight: 400;
padding: 0px 25px 0px 0px;
color: #333;
margin: 0px;
}
.example .button {
display: inline-block;
height: 50px;
box-sizing: border-box;
line-height: 46px;
text-decoration: none;
color: #333;
padding: 0px 20px;
border: solid 2px #333;
border-radius: 4px;
text-transform: uppercase;
font-weight: 700;
transition: all .2s ease-in-out;
}
.example .button:hover {
background-color: #333;
color: #FFF;
}
.example .button i {
margin-right: 5px;
}
@media(max-width: 550px) {
.example {
width: 100%;
height: 362px;
}
.example h1 {
font-size: 2.5em;
}
.example p {
margin-bottom: 25px;
}
.example .buttons {
width: 100%;
margin-top: 25px;
text-align: center;
display: block;
}
.example .buttons p,
.example .buttons a {
float: none;
margin: 0 auto;
}
.example .buttons p {
padding-right: 0px;
}
.example .buttons a {
display: inline-block;
}
}
</style>
<script>
const runFlipDown = () => {
// Unix timestamp (in seconds) to count down to
var twoDaysFromNow = (new Date().getTime() / 1000) + (86400 * 2) + 1;
// Set up FlipDown
var flipdown = new FlipDown(twoDaysFromNow)
// Start the countdown
.start()
// Do something when the countdown ends
.ifEnded(() => {
console.log('The countdown has ended!');
});
// Toggle theme
var interval = setInterval(() => {
let body = document.body;
body.classList.toggle('light-theme');
body.querySelector('#flipdown').classList.toggle('flipdown__theme-dark');
body.querySelector('#flipdown').classList.toggle('flipdown__theme-light');
}, 5000);
var ver = document.getElementById('ver');
ver.innerHTML = flipdown.version;
};
</script>
<script src="https://pbutcher.uk/flipdown/js/flipdown/flipdown.js" onload="runFlipDown()"></script>
</div>

7
samples/HTMLSample.html Normal file
View File

@ -0,0 +1,7 @@
<div style="background-color: CadetBlue; padding: 20px;">
<h3>HTML Sample</h3>
<p>Isolated mode: not recommended. Source: <a href="https://blog.pathtosharepoint.com/2021/07/29/introducing-the-property-pane-portal/" target="_blank">Path to SharePoint blog</a>.</p>
<p><i>This got me thinking. Could we in some way avoid the redundancy? And as a bonus, directly hook a regular Reactjs component in here? That&#8217;s what I pictured in the diagram below: a generic, reusable frame that could serve as host to any Reactjs control.</i></p>
<figure><img style="width:100%;" src="https://pathtosharepoint.files.wordpress.com/2021/07/image-3.png" /></figure>
<p><i>It took me a couple days to come up with a workable model (the &#8220;Property Pane Portal&#8221;), weeks to test it, and then months to start writing about it 😊. In the next episodes, I&#8217;ll share some samples of the PPP in action, and I&#8217;ll provide more details on the architecture and the code that supports it. The key ingredient is <a href="https://reactjs.org/docs/portals.html">Reactjs Portals</a>, which allow to beam an element to another part of the DOM.</i></p>
</div>

View File

@ -0,0 +1,79 @@
<div style="background-color: CadetBlue; padding: 20px;">
<h3>Chart.js Bubble Chart</h3>
<p>Isolated mode: &#9888; mandatory. Source: <a href="https://tobiasahlin.com/blog/chartjs-charts-to-get-you-started/" target="_blank">Tobias Ahlin</a>.</p>
<div style="width:500px; height:300px; background-color:#f7f7f7;">
<canvas id="bubble-chart"></canvas>
<div style="padding:10px;">Source: </div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.5.0/Chart.min.js" onload="drawBubble()"></script>
<script>
function drawBubble() {
new Chart(document.getElementById("bubble-chart"), {
type: 'bubble',
data: {
labels: "Africa",
datasets: [
{
label: ["China"],
backgroundColor: "rgba(255,221,50,0.2)",
borderColor: "rgba(255,221,50,1)",
data: [{
x: 21269017,
y: 5.245,
r: 15
}]
}, {
label: ["Denmark"],
backgroundColor: "rgba(60,186,159,0.2)",
borderColor: "rgba(60,186,159,1)",
data: [{
x: 258702,
y: 7.526,
r: 10
}]
}, {
label: ["Germany"],
backgroundColor: "rgba(0,0,0,0.2)",
borderColor: "#000",
data: [{
x: 3979083,
y: 6.994,
r: 15
}]
}, {
label: ["Japan"],
backgroundColor: "rgba(193,46,12,0.2)",
borderColor: "rgba(193,46,12,1)",
data: [{
x: 4931877,
y: 5.921,
r: 15
}]
}
]
},
options: {
title: {
display: true,
text: 'Predicted world population (millions) in 2050'
}, scales: {
yAxes: [{
scaleLabel: {
display: true,
labelString: "Happiness"
}
}],
xAxes: [{
scaleLabel: {
display: true,
labelString: "GDP (PPP)"
}
}]
}
}
});
}
</script>
</div>

View File

@ -0,0 +1,39 @@
<div style="background-color: CadetBlue; padding: 20px;">
<h3>Chartist Animated Lines</h3>
<p>Isolated mode: &#9888; mandatory. Source: <a href="https://gionkunz.github.io/chartist-js/" target="_blank">Chartist.js</a>.</p>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/chartist@0.11.4/dist/chartist.css">
<script src="https://cdn.jsdelivr.net/npm/chartist@0.11.4/dist/chartist.min.js" onload="drawChart()"></script>
<div class="ct-chart ct-perfect-fourth"></div>
</div>
<script>
function drawChart() {
var chart = new Chartist.Line('.ct-chart', {
labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
series: [
[1, 5, 2, 5, 4, 3],
[2, 3, 4, 8, 1, 2],
[5, 4, 3, 2, 1, 0.5]
]
}, {
low: 0,
showArea: true,
showPoint: false,
fullWidth: true
});
chart.on('draw', function(data) {
if(data.type === 'line' || data.type === 'area') {
data.element.animate({
d: {
begin: 2000 * data.index,
dur: 2000,
from: data.path.clone().scale(1, 0).translate(0, data.chartRect.height()).stringify(),
to: data.path.clone().stringify(),
easing: Chartist.Svg.Easing.easeOutQuint
}
});
}
});
}
</script>
</div>

View File

@ -0,0 +1,49 @@
<div style="background-color: CadetBlue; padding: 20px;">
<h3>Agenda Custom View - Microsoft Graph Toolkit</h3>
<p>Isolated mode: &#9888; mandatory. Find more samples on <a href="https://mgt.dev/?path=/story/overview--page" target="_blank">MGT Playground</a>. MGT components require API permissions, see the <a href="https://docs.microsoft.com/en-us/graph/toolkit/overview">Microsoft docs</a> for more info.</p>
<script>
function setProvider() {mgt.Providers.globalProvider = new mgt.SharePointProvider(props.context);}
</script>
<script src="https://unpkg.com/@microsoft/mgt/dist/bundle/wc/webcomponents-loader.js" type="text/javascript"></script>
<script src="https://unpkg.com/@microsoft/mgt/dist/bundle/mgt.es6.js" type="text/javascript" onload="setProvider()"></script>
<mgt-person person-query="me" view="twoLines"></mgt-person>
<mgt-agenda></mgt-agenda>
<!-- <mgt-agenda show-max="7" days="10">
<template data-type="event">
<div class="root">
<div class="time-container">
<div class="date">{{ dayFromDateTime(event.start.dateTime)}}</div>
<div class="time">{{ timeRangeFromEvent(event, '12') }}</div>
</div>
<div class="separator">
<div class="vertical-line top"></div>
<div class="circle">
<div data-if="!event.bodyPreview.includes('Join Microsoft Teams Meeting')" class="inner-circle">
</div>
</div>
<div class="vertical-line bottom"></div>
</div>
<div class="details">
<div class="subject">{{ event.subject }}</div>
<div class="location" data-if="event.location.displayName">
at
<a href="https://bing.com/maps/default.aspx?where1={{event.location.displayName}}"
target="_blank"><b>{{ event.location.displayName }}</b></a>
</div>
<div class="attendees" data-if="event.attendees.length">
<span class="attendee" data-for="attendee in event.attendees">
<mgt-person person-query="{{attendee.emailAddress.name}}"></mgt-person>
</span>
</div>
<div class="online-meeting" data-if="event.bodyPreview.includes('Join Microsoft Teams Meeting')">
<img class="online-meeting-icon" src="https://img.icons8.com/color/48/000000/microsoft-teams.png" />
<a class="online-meeting-link" href="{{ event.onlineMeetingUrl }}">Join Teams Meeting</a>
</div>
</div>
</div>
</template>
</mgt-agenda> -->
</div>

View File

@ -0,0 +1,70 @@
<div style="background-color: CadetBlue; padding: 20px;">
<h3>Emails Custom View - Microsoft Graph Toolkit</h3>
<p>Isolated mode: &#9888; mandatory. Find more samples on <a href="https://mgt.dev/?path=/story/overview--page" target="_blank">MGT Playground</a>. MGT components require API permissions, see the <a href="https://docs.microsoft.com/en-us/graph/toolkit/overview">Microsoft docs</a> for more info.</p>
<style>
.email {
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.3);
padding: 10px;
margin: 8px 4px;
font-family: Segoe UI, Frutiger, Frutiger Linotype, Dejavu Sans, Helvetica Neue, Arial, sans-serif;
}
.email:hover {
box-shadow: 0 3px 14px rgba(0, 0, 0, 0.3);
padding: 10px;
margin: 8px 4px;
}
.email h3 {
font-size: 12px;
margin-bottom: 4px;
}
.email h4 {
font-size: 10px;
margin-top: 0px;
margin-bottom: 4px;
}
.email mgt-person {
--font-size: 10px;
--avatar-size-s: 12px;
}
.email .preview {
font-size: 13px;
text-overflow: ellipsis;
word-wrap: break-word;
overflow: hidden;
max-height: 2.8em;
line-height: 1.4em;
}
</style>
<script>
function setProvider() {mgt.Providers.globalProvider = new mgt.SharePointProvider(props.context);}
</script>
<script src="https://unpkg.com/@microsoft/mgt/dist/bundle/wc/webcomponents-loader.js" type="text/javascript"></script>
<script src="https://unpkg.com/@microsoft/mgt/dist/bundle/mgt.es6.js" type="text/javascript" onload="setProvider()"></script>
<mgt-person person-query="me" view="twoLines"></mgt-person>
<mgt-get resource="/me/messages" version="beta" scopes="mail.read" max-pages="1">
<template>
<div class="email" data-for="email in value">
<h3>{{ email.subject }}</h3>
<h4>
<mgt-person person-query="{{email.sender.emailAddress.address}}" view="oneline" person-card="hover">
</mgt-person>
</h4>
<div data-if="email.bodyPreview" class="preview" innerHtml>{{email.bodyPreview}}</div>
<div data-else class="preview">
email body is empty
</div>
</div>
</template>
<template data-type="loading">
loading
</template>
<template data-type="error">
{{ this }}
</template>
</mgt-get>
</div>

View File

@ -0,0 +1,25 @@
<div style="background-color: CadetBlue; padding: 20px;">
<h3>People Components - Microsoft Graph Toolkit</h3>
<p>Isolated mode: &#9888; mandatory. Find more samples on the <a href="https://mgt.dev/?path=/story/overview--page" target="_blank">MGT Playground</a>. MGT components require API permissions, see the <a href="https://docs.microsoft.com/en-us/graph/toolkit/overview">Microsoft docs</a> for more info.</p>
<script>
function setProvider() {mgt.Providers.globalProvider = new mgt.SharePointProvider(props.context);}
</script>
<script src="https://unpkg.com/@microsoft/mgt/dist/bundle/wc/webcomponents-loader.js" type="text/javascript">
</script>
<script src="https://unpkg.com/@microsoft/mgt/dist/bundle/mgt.es6.js" type="text/javascript" onload="setProvider()">
</script>
<div style="display: flex;">
<div style="flex: 50%;">
<h4>mgt-login</h4>
<mgt-login></mgt-login>
<h4>mgt-person</h4>
<mgt-person person-query="me" view="twoLines"></mgt-person>
<h4>mgt-people</h4>
<mgt-people show-max="10"></mgt-people>
</div>
<div style="flex: 50%;">
<h4>mgt-person-card</h4>
<mgt-person-card person-query="me"></mgt-person-card>
</div>
</div>
</div>

View File

@ -0,0 +1,30 @@
<div style="background-color: CadetBlue; padding: 20px;">
<h3>TradingView Widget</h3>
<p>Isolated mode: recommended. Source: <a href="https://www.tradingview.com/widget/" target="_blank">TradingView widget</a>.</p>
<!-- TradingView Widget BEGIN -->
<div class="tradingview-widget-container">
<div id="tradingview_cd1aa"></div>
<div class="tradingview-widget-copyright"><a href="https://www.tradingview.com/symbols/NASDAQ-MSFT/" rel="noopener" target="_blank"><span class="blue-text">MSFT Chart</span></a> by TradingView</div>
<script type="text/javascript" src="https://s3.tradingview.com/tv.js" onload="drawChart()"></script>
<script type="text/javascript">
function drawChart() {
new TradingView.widget(
{
"autosize": true,
"symbol": "NASDAQ:MSFT",
"interval": "D",
"timezone": "Etc/UTC",
"theme": "light",
"style": "1",
"locale": "en",
"toolbar_bg": "#f1f3f6",
"enable_publishing": false,
"allow_symbol_change": true,
"container_id": "tradingview_cd1aa"
}
);
}
</script>
</div>
<!-- TradingView Widget END -->
</div>

View File

@ -0,0 +1,5 @@
<div style="background-color: CadetBlue; padding: 20px;">
<h3>Google Maps</h3>
<p>Isolated mode: not recommended. Source: Google Maps embed code.</p>
<iframe src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d85972.3820324134!2d-122.17073538560777!3d47.67204903850155!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x54900cad2000ee23%3A0x5e0390eac5d804f2!2sRedmond%2C%20WA!5e0!3m2!1sen!2sus!4v1645677983596!5m2!1sen!2sus" width="600" height="450" style="border:0;" allowfullscreen="" loading="lazy"></iframe>
</div>

5
samples/VideoSample.html Normal file
View File

@ -0,0 +1,5 @@
<div style="background-color: CadetBlue; padding: 20px;">
<h3>Embedded YouTube Video</h3>
<p>Isolated mode: not required. Source: YouTube embed code.</p>
<iframe width="560" height="315" src="https://www.youtube.com/embed/ozhbLz1gMi0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
</div>

View File

@ -22,7 +22,11 @@
"description": { "default": "Cherry-Picked-Content description" },
"officeFabricIconFontName": "BullseyeTargetEdit",
"properties": {
"description": "Cherry-Picked-Content"
"description": "Cherry-Picked-Content",
"isolated": true,
"iframeWidth": "100%",
"iframeHeight": "600px"
}
}]
}

View File

@ -4,6 +4,7 @@ import { Version } from '@microsoft/sp-core-library';
import {
IPropertyPaneConfiguration,
IPropertyPaneDropdownOption,
PropertyPaneCheckbox,
PropertyPaneDropdown,
PropertyPaneTextField
} from '@microsoft/sp-property-pane';
@ -13,12 +14,14 @@ import { IReadonlyTheme } from '@microsoft/sp-component-base';
import * as strings from 'CherryPickedContentWebPartStrings';
import CherryPickedContent from './components/CherryPickedContent';
import { ICherryPickedContentProps } from './components/ICherryPickedContentProps';
import { update } from '@microsoft/sp-lodash-subset';
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
import { approvedLibraries } from './components/ApprovedLibraries';
export interface ICherryPickedContentWebPartProps {
isolated: boolean;
iframeWidth: string;
iframeHeight: string;
description: string;
libraryPicker: string;
libraryItemPicker: string;
@ -43,6 +46,9 @@ export default class CherryPickedContentWebPart extends BaseClientSideWebPart<IC
libraryPicker: this.properties.libraryPicker,
libraryItemPicker: this.properties.libraryItemPicker,
approvedLibraries: this.approvedLibraries,
isolated: this.properties.isolated,
width: this.properties.iframeWidth,
height: this.properties.iframeHeight,
context: this.context,
isDarkTheme: this._isDarkTheme,
environmentMessage: this._environmentMessage,
@ -74,7 +80,6 @@ export default class CherryPickedContentWebPart extends BaseClientSideWebPart<IC
this.domElement.style.setProperty('--bodyText', semanticColors.bodyText);
this.domElement.style.setProperty('--link', semanticColors.link);
this.domElement.style.setProperty('--linkHovered', semanticColors.linkHovered);
}
protected onDispose(): void {
@ -88,14 +93,6 @@ export default class CherryPickedContentWebPart extends BaseClientSideWebPart<IC
// Only content from the approved libraries can be selected
private approvedLibraries = approvedLibraries;
private updateWebPartProperty(property, value, refreshWebPart = true, refreshPropertyPane = true) {
update(this.properties, property, () => value);
if (refreshWebPart) this.render();
if (refreshPropertyPane) this.context.propertyPane.refresh();
}
// Dropdown gets disabled while retrieving items asynchronously
private itemsDropdownDisabled: boolean = true;
@ -122,7 +119,7 @@ export default class CherryPickedContentWebPart extends BaseClientSideWebPart<IC
this.getLibraryItemsList(this.properties.libraryPicker)
.then((files): void => {
// store items
this.libraryItemsList = files.map(file => { return { key: file.Name, text: file.Name }; });
this.libraryItemsList = files.map(file => file.Name).sort().map(name => { return { key: name, text: name }; });
this.itemsDropdownDisabled = false;
})
.then(() => this.context.propertyPane.refresh());
@ -140,21 +137,18 @@ export default class CherryPickedContentWebPart extends BaseClientSideWebPart<IC
this.itemsDropdownDisabled = true;
// push new item value
this.onPropertyPaneFieldChanged('libraryItemPicker', previousItem, this.properties.libraryItemPicker);
// this.render();
// refresh the item selector control by repainting the property pane
this.context.propertyPane.refresh();
this.getLibraryItemsList(newValue)
.then((files): void => {
if (files.length) {
// store items
this.libraryItemsList = files.map(file => { return { key: file.Name, text: file.Name }; });
// enable item selector
this.itemsDropdownDisabled = false;
// this.render();
// refresh the item selector control by repainting the property pane
this.context.propertyPane.refresh();
// store items
this.libraryItemsList = files.map(file => { return { key: file.Name, text: file.Name }; });
// enable item selector
this.itemsDropdownDisabled = false;
// refresh the item selector control by repainting the property pane
this.context.propertyPane.refresh();
}
});
}
@ -179,8 +173,7 @@ export default class CherryPickedContentWebPart extends BaseClientSideWebPart<IC
PropertyPaneDropdown('libraryPicker', {
label: strings.LibraryPickerLabel,
options: this.approvedLibraries,
selectedKey: this.properties.libraryPicker,
selectedKey: this.properties.libraryPicker
}),
// Cascading Library Item Picker
PropertyPaneDropdown('libraryItemPicker', {
@ -190,6 +183,21 @@ export default class CherryPickedContentWebPart extends BaseClientSideWebPart<IC
disabled: this.itemsDropdownDisabled
})
]
},
{
groupName: strings.IsolatedMode,
groupFields: [
// Isolated options
PropertyPaneCheckbox('isolated', {
text: strings.Isolated,
}),
this.properties.isolated && PropertyPaneTextField('iframeWidth', {
label: strings.IframeWidth
}),
this.properties.isolated && PropertyPaneTextField('iframeHeight', {
label: strings.IframeHeight
}),
]
}
]
}

View File

@ -4,18 +4,24 @@ import { ICherryPickedContentProps } from './ICherryPickedContentProps';
import { escape } from '@microsoft/sp-lodash-subset';
import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
import PortalIframe from './PortalIframe';
const CherryPickedDiv = ({ htmlFragment }) =>
<div ref={ref => { if (ref) { ref.innerHTML = ""; ref.appendChild(document.createRange().createContextualFragment(htmlFragment)); } }}>
</div>;
const MemoDiv = React.memo(CherryPickedDiv);
const CherryPickedContent: React.FunctionComponent<ICherryPickedContentProps> = (props) => {
const message = props.libraryItemPicker? "Loading...":"Edit Web Part properties to select a file.";
const defaultNode = document.createRange().createContextualFragment("<div><h3>" + message + "</h3></div>");
const [appendedNode, setAppendedNode] = React.useState(defaultNode);
const message = "Loading...";
const [htmlFragment, setHtmlFragment] = React.useState(message);
// Get the file content
React.useEffect(() => {
async function fetchSnippet() {
// Validate that the library is approved
// Validate that the library is in the approved list
let filteredApprovedLibraries = props.approvedLibraries.filter(lib => lib.key == props.libraryPicker);
if ((filteredApprovedLibraries.length > 0) && (props.libraryItemPicker)) {
@ -23,62 +29,56 @@ const CherryPickedContent: React.FunctionComponent<ICherryPickedContentProps> =
const webURLQuery = props.context.pageContext.web.absoluteUrl + `/_api/sp.web.getweburlfrompageurl(@v)?@v=%27${window.location.origin}${fileURL}%27`;
// if (props.url)
// const htmlFragment: string = (props.url) ?
let webURL = await props.context.spHttpClient.get(webURLQuery, SPHttpClient.configurations.v1)
.then((response: SPHttpClientResponse) => response.json())
.then(data => data.value);
const snippetURLQuery = webURL + `/_api/web/getFileByServerRelativeUrl('${fileURL}')/$value`;
const htmlFragment = await props.context.spHttpClient.get(snippetURLQuery, SPHttpClient.configurations.v1)
const fragment = await props.context.spHttpClient.get(snippetURLQuery, SPHttpClient.configurations.v1)
.then((response: SPHttpClientResponse) => response.text());
// : "<div>No content loaded.</div>";
const node = document.createRange().createContextualFragment(htmlFragment);
setAppendedNode(node);
setHtmlFragment(fragment);
}
else {
setAppendedNode(defaultNode);
setHtmlFragment(message);
}
}
fetchSnippet();
}, [props.libraryPicker, props.libraryItemPicker]);
}, [props.libraryItemPicker]);
return (
if (!props.libraryItemPicker) {
return (
<section className={`${styles.cherryPickedContent} ${props.hasTeamsContext ? styles.teams : ''}`}>
<div ref={ref => { if (ref) { ref.innerHTML = ""; ref.appendChild(appendedNode); } }}>
</div>
<div>
<h3>Approved libraries:</h3>
<p>
<ul>
{props.approvedLibraries.map(lib => <li>{lib.text}</li>)}
</ul>
</p>
</div>
<div className={styles.welcome}>
<img alt="" src={props.isDarkTheme ? require('../assets/welcome-dark.png') : require('../assets/welcome-light.png')} className={styles.welcomeImage} />
<h2>Welcome, {escape(props.userDisplayName)}!</h2>
<div>{props.environmentMessage}</div>
</div>
<div>
<h3>Welcome to SharePoint Framework!</h3>
<p>
The SharePoint Framework (SPFx) is a extensibility model for Microsoft Viva, Microsoft Teams and SharePoint. It's the easiest way to extend Microsoft 365 with automatic Single Sign On, automatic hosting and industry standard tooling.
</p>
<h4>Learn more about SPFx development:</h4>
<ul className={styles.links}>
<li><a href="https://aka.ms/spfx" target="_blank">SharePoint Framework Overview</a></li>
<li><a href="https://aka.ms/spfx-yeoman-graph" target="_blank">Use Microsoft Graph in your solution</a></li>
<li><a href="https://aka.ms/spfx-yeoman-teams" target="_blank">Build for Microsoft Teams using SharePoint Framework</a></li>
<li><a href="https://aka.ms/spfx-yeoman-viva" target="_blank">Build for Microsoft Viva Connections using SharePoint Framework</a></li>
<li><a href="https://aka.ms/spfx-yeoman-store" target="_blank">Publish SharePoint Framework applications to the marketplace</a></li>
<li><a href="https://aka.ms/spfx-yeoman-api" target="_blank">SharePoint Framework API reference</a></li>
<li><a href="https://aka.ms/m365pnp" target="_blank">Microsoft 365 Developer Community</a></li>
</ul>
<div style={{ display: "flex" }}>
<div style={{ flex: "50%" }}>
<h3>Edit Web Part properties to select a file.</h3>
<h3>Approved libraries:</h3>
<p>
<ul>
{props.approvedLibraries.map(lib => <li>{lib.text}</li>)}
</ul>
</p>
</div>
<div style={{ flex: "50%" }} className={styles.welcome}>
<img alt="" src={props.isDarkTheme ? require('../assets/welcome-dark.png') : require('../assets/welcome-light.png')} className={styles.welcomeImage} />
<h2>Welcome, {escape(props.userDisplayName)}!</h2>
<div>{props.environmentMessage}</div>
</div>
</div>
</section>
);
);
}
else if (props.isolated) {
return (
<PortalIframe {...props}>
<MemoDiv htmlFragment={htmlFragment} />
</PortalIframe>
);
}
else {
return (
<MemoDiv htmlFragment={htmlFragment} />
);
}
};
export default CherryPickedContent;

View File

@ -5,6 +5,9 @@ export interface ICherryPickedContentProps {
libraryPicker: string;
libraryItemPicker: string;
approvedLibraries: any[];
isolated: boolean;
width: string;
height: string;
context: WebPartContext;
isDarkTheme: boolean;
environmentMessage: string;

View File

@ -0,0 +1,23 @@
import * as React from 'react';
import { createPortal } from 'react-dom';
const PortalIframe = ({
children,
...props
}) => {
const [contentRef, setContentRef] = React.useState(null);
const mountWindow = contentRef?.contentWindow;
const mountNode = contentRef?.contentWindow?.document?.body;
// Pass the props to the child iframe
if (mountWindow) mountWindow.props = props;
return (
<iframe width={props.width} height={props.height} style={{border:0}} ref={setContentRef}>
{mountNode && createPortal(children, mountNode)}
</iframe>
);
};
export default PortalIframe;

View File

@ -2,9 +2,13 @@ define([], function() {
return {
"PropertyPaneDescription": "Modern Content Editor Web Part with a twist: content can only be picked from approved locations.",
"BasicGroupName": "Web Part Properties",
"IsolatedMode": "Keep content isolated to prevent conflicts with other Web Parts.",
"DescriptionFieldLabel": "Title",
"LibraryPickerLabel": "Pick an approved library",
"LibraryItemPickerLabel":"Pick a file",
"LibraryItemPickerLabel": "Pick a file",
"Isolated": "Isolated Content",
"IframeWidth": "Width",
"IframeHeight": "Height",
"AppLocalEnvironmentSharePoint": "The app is running on your local environment as SharePoint web part",
"AppLocalEnvironmentTeams": "The app is running on your local environment as Microsoft Teams app",
"AppSharePointEnvironment": "The app is running on SharePoint page",

View File

@ -1,4 +1,8 @@
declare interface ICherryPickedContentWebPartStrings {
IsolatedMode: string;
IframeHeight: string;
IframeWidth: string;
Isolated: string;
PropertyPaneDescription: string;
BasicGroupName: string;
DescriptionFieldLabel: string;