= taskDetails?.checklist || {};
+
+ if (checklistItems) {
+ Object.keys(checklistItems).forEach((key: keyof typeof checklistItems) => {
+ const checklistItem: PlannerChecklistItem = checklistItems[key];
+ // if (!checklistItem.isChecked)
+ checklist.push(checklistItem);
+ });
+ };
+
+ if (task.bucketId) {
+ bucketName = task.bucketId.split(':')[1];
+ }
+
+ let aUsers: string = "";
+ // get the users assigned to the task
+ if (task.assignments) {
+ aUsers = "- ";
+ // loop through the assignments
+ Object.keys(task.assignments).forEach((assignmentId: string) => {
+ // find the user by the assignmentId
+ const user = renderSettings?.users.find(
+ (u) => u.id === assignmentId
+ );
+
+ // add the user's display name to the list of users
+ aUsers += user?.displayName + ' - ';
+ });
+ }
+
+ // get the labels
+ const labels: ICategoryData[] = [];
+ if (task.appliedCategories) {
+ for (let i = 1; i < 26; i++) {
+ const categoryKey = `category${i}` as keyof typeof task.appliedCategories;
+ if (task.appliedCategories[categoryKey] === true) {
+ if (categorySettings) {
+ const categoryData: ICategoryData = categorySettings[categoryKey];
+ labels.push(categoryData);
+ }
+ }
+ }
+ }
+
+ return (
+ <>
+
+ {task.title}
+
+ { labels.length > 0 &&
+
+
+ {labels.map((label, index) => (
+
+ ))}
+
+
+ }
+
+ Bucket: {bucketName}
+
+ { task.completedBy?.user?.displayName &&
+
+
+ Created by:
+
+
+ {task.completedBy?.user.displayName}
+
+
+ }
+
+
+
+ Progress:
+
+
+ {task.percentComplete === 100 ? "Completed" : task.percentComplete === 50 ? "In Progress" : "Not Started"}
+
+
+ { task.dueDateTime && (
+
+
+ Due:
+
+
+ {moment(new Date(task.dueDateTime)).format("MMM D, YYYY")}
+
+
+ )}
+ { aUsers.replace('- ', '') !== "" && (
+
+
+ Assigned to:
+
+
+ { aUsers === "- " ? "" : aUsers }
+
+
+ )}
+ { taskDetails?.description &&
+ <>
+
+ Notes:
+
+
+ {taskDetails?.description}
+
+ >
+ }
+ { task.percentComplete === 100 &&
+ <>
+
+ Completed:
+
+
+ By: {task.completedBy?.user?.displayName} on {completedDate}
+
+ >
+ }
+ { checklist && checklist.length > 0 &&
+ <>
+
+ Checklist:
+
+
+ {checklist.map((item: PlannerChecklistItem) => (
+ -
+
+ {item.isChecked &&
+
+
+
}
+
+ {item.title}
+
+
+
+ ))}
+
+ >
+ }
+ >
+ )
+}
\ No newline at end of file
diff --git a/samples/tab-planner-timeline/src/components/timelineItem/DatesandDetails.tsx b/samples/tab-planner-timeline/src/components/timelineItem/DatesandDetails.tsx
new file mode 100644
index 00000000..5b1f535b
--- /dev/null
+++ b/samples/tab-planner-timeline/src/components/timelineItem/DatesandDetails.tsx
@@ -0,0 +1,115 @@
+import { PlannerTask } from '@microsoft/microsoft-graph-types'
+import {
+ Callout,
+ DirectionalHint
+ } from "@fluentui/react";
+ import {
+ useBoolean,
+ useId
+} from '@fluentui/react-hooks';
+import moment from "moment";
+import { Info24Filled as InfoIcon } from "@fluentui/react-icons";
+import CalloutPane from './CalloutPane'; // Adjust the import path as necessary
+import {
+ DatesAndDetailsStyles,
+ TimelineItemStyles,
+ CalloutStyles
+} from '../../Styles';
+import {PriorityIcon } from '.';
+import { useContext } from 'react';
+import { TeamsFxContext } from '../Context';
+
+export default function TimelineDetails(task: PlannerTask) {
+ const { themeString } = useContext(TeamsFxContext);
+
+ const aline: string = "right";
+
+ const [isCalloutVisible, { toggle: toggleIsCalloutVisible }] = useBoolean(false);
+
+ const buttonId = useId('callout-button');
+ const labelId = useId('callout-label');
+ const descriptionId = useId('callout-description');
+
+ function isOverDue(task: PlannerTask) {
+ // if the task has a due date, get the due date
+ if (task.dueDateTime)
+ // check if the task is overdue by comparing the due date + 1 day to today's date
+ return moment(new Date(task.dueDateTime)).add(1, 'd').isBefore(new Date());
+
+ return false;
+ }
+
+ function dueDate(task: PlannerTask) {
+ // if the task has a due date, get the due date
+ if (task.dueDateTime)
+ return "Due: " + moment(new Date(task.dueDateTime)).format("MMM D, YYYY");
+
+ return "No due date";
+ }
+
+ function startDate(task: PlannerTask) {
+ // if the task has a start date, get the start date
+ if (task.startDateTime)
+ return "Start: " + moment(new Date(task.startDateTime)).format("MMM D, YYYY");
+ else
+ return "Start anytime";
+ }
+
+ function completedDate(task: PlannerTask) {
+ if (task.completedDateTime)
+ return "Completed: " + moment(new Date(task.completedDateTime)).format("MMM D, YYYY");
+
+ return ""
+ }
+
+ const [timelineMarkerClass, gridClass] = DatesAndDetailsStyles.timelineRenderStyles(themeString, task.percentComplete ?? 0, isOverDue(task));
+
+ return (
+ <>
+
+
+
+
+
+ {dueDate(task)}
+ { task.completedDateTime ?
+ {completedDate(task)}
+ :
+ {startDate(task)}
+ }
+
+
+
+
+ {isCalloutVisible ? (
+
+
+ ) : null }
+
+ >
+ )
+}
\ No newline at end of file
diff --git a/samples/tab-planner-timeline/src/components/timelineItem/Month.tsx b/samples/tab-planner-timeline/src/components/timelineItem/Month.tsx
new file mode 100644
index 00000000..71bbb845
--- /dev/null
+++ b/samples/tab-planner-timeline/src/components/timelineItem/Month.tsx
@@ -0,0 +1,37 @@
+import { useContext } from "react";
+import { PlannerTask } from '@microsoft/microsoft-graph-types'
+import { TeamsFxContext } from "../Context";
+import { MonthYearStyles } from "../../Styles";
+
+export default function Year(task: PlannerTask) {
+ const {renderSettings} = useContext(TeamsFxContext);
+
+ let dueDate: Date = new Date();
+ let dueMonth: string = "";
+ let renderMonth: boolean = false;
+
+ if (task.dueDateTime) {
+ dueDate = new Date(task.dueDateTime);
+
+ if (renderSettings)
+ if (renderSettings.currentMonth !== dueDate.getMonth()) {
+ renderSettings.currentMonth = dueDate.getMonth();
+
+ renderMonth = true;
+ }
+
+ dueMonth = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][dueDate.getMonth()];
+ }
+
+ return (
+ <>
+ {renderMonth && (
+
+
+
+ )}
+ >
+ )
+}
\ No newline at end of file
diff --git a/samples/tab-planner-timeline/src/components/timelineItem/PriorityIcon.tsx b/samples/tab-planner-timeline/src/components/timelineItem/PriorityIcon.tsx
new file mode 100644
index 00000000..89eb6fbe
--- /dev/null
+++ b/samples/tab-planner-timeline/src/components/timelineItem/PriorityIcon.tsx
@@ -0,0 +1,74 @@
+import {
+ ArrowDown16Filled as LowIcon,
+ Important16Filled as ImportantIcon,
+ AlertUrgent16Filled as UrgentIcon,
+ // Circle16Filled as MediumIcon,
+ } from "@fluentui/react-icons";
+ import { Tooltip } from '@fluentui/react-components';
+ import {
+ CalloutStyles,
+ DatesAndDetailsStyles
+ } from '../../Styles';
+
+export default function PriorityIcon(props: { priority: number, forTimeline: boolean } ) {
+
+
+ return (
+ <>
+ {props.priority === 1 && (
+
+ { props.forTimeline ?
+
+
+
+
+
+ :
+ <>
+
Priority:
+
+
+ Urgent
+
+ >
+ }
+
)}
+ {props.priority === 3 && (
+
+ { props.forTimeline ?
+
+
+
+
+
+ :
+ <>
+
Priority:
+
+
+ Important
+
+ >
+ }
+
)}
+ {props.priority === 9 && (
+
+ { props.forTimeline ?
+
+
+
+
+
+ :
+ <>
+
Priority:
+
+
+ Low
+
+ >
+ }
+
)}
+ >
+ )
+}
diff --git a/samples/tab-planner-timeline/src/components/timelineItem/Year.tsx b/samples/tab-planner-timeline/src/components/timelineItem/Year.tsx
new file mode 100644
index 00000000..cb454e6c
--- /dev/null
+++ b/samples/tab-planner-timeline/src/components/timelineItem/Year.tsx
@@ -0,0 +1,58 @@
+import { useContext } from "react";
+import { PlannerTask } from '@microsoft/microsoft-graph-types'
+import { TeamsFxContext } from "../Context";
+import moment from "moment";
+import { MonthYearStyles } from "../../Styles";
+
+export default function Year(task: PlannerTask) {
+ const {renderSettings} = useContext(TeamsFxContext);
+
+ let renderYear: boolean = false;
+ let dueDate: Date = new Date();
+
+ if (task.dueDateTime) {
+ dueDate = new Date(task.dueDateTime);
+ if (renderSettings) {
+ const dateDiff: number = moment(dueDate).diff(renderSettings.lastRenderedDate, 'seconds');
+
+ // Set the last rendered due date
+ renderSettings.lastRenderedDate = dueDate;
+
+ if (dateDiff < 0)
+ renderYear = true;
+
+ renderYear = renderSettings.renderYear
+
+ if (!renderYear) {
+ if (renderSettings.currentYear === 0) {
+ renderYear = true;
+ renderSettings.currentYear = dueDate.getFullYear() || 0;
+ } else {
+ if (renderSettings.currentYear === dueDate.getFullYear()) {
+ renderYear = false;
+ } else {
+ renderYear = true;
+
+ if (renderSettings) {
+ renderSettings.currentYear = dueDate.getFullYear() || 0;
+ renderSettings.currentMonth = -1;
+ renderSettings.renderMonth = true;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return (
+ <>
+ {renderYear && (
+
+
+ {dueDate?.getFullYear()}
+
+
+ )}
+ >
+ )
+}
diff --git a/samples/tab-planner-timeline/src/components/timelineItem/index.ts b/samples/tab-planner-timeline/src/components/timelineItem/index.ts
new file mode 100644
index 00000000..2c1c17be
--- /dev/null
+++ b/samples/tab-planner-timeline/src/components/timelineItem/index.ts
@@ -0,0 +1,6 @@
+export { default as TimelineItem } from './timelineItem';
+export { default as TimelineYear } from './Year';
+export { default as TimelineMonth } from './Month';
+export { default as TimelineDetails } from './DatesandDetails';
+export { default as CalloutPane } from './CalloutPane';
+export { default as PriorityIcon } from './PriorityIcon';
\ No newline at end of file
diff --git a/samples/tab-planner-timeline/src/components/timelineItem/timelineItem.tsx b/samples/tab-planner-timeline/src/components/timelineItem/timelineItem.tsx
new file mode 100644
index 00000000..5d9c3577
--- /dev/null
+++ b/samples/tab-planner-timeline/src/components/timelineItem/timelineItem.tsx
@@ -0,0 +1,25 @@
+import { PlannerTask } from '@microsoft/microsoft-graph-types'
+import {
+ TimelineYear,
+ TimelineMonth,
+ TimelineDetails
+} from '..';
+import {
+ TimelineItemStyles,
+ DatesAndDetailsStyles
+} from '../../Styles';
+
+export default function TimelineItem(task: PlannerTask) {
+ return (
+ <>
+
+
+
+ >
+ )
+}
diff --git a/samples/tab-planner-timeline/src/config.ts b/samples/tab-planner-timeline/src/config.ts
new file mode 100644
index 00000000..b086fde8
--- /dev/null
+++ b/samples/tab-planner-timeline/src/config.ts
@@ -0,0 +1,8 @@
+const config = {
+ initiateLoginEndpoint: process.env.REACT_APP_START_LOGIN_PAGE_URL,
+ clientId: process.env.REACT_APP_CLIENT_ID,
+// apiEndpoint: process.env.REACT_APP_FUNC_ENDPOINT,
+// apiName: process.env.REACT_APP_FUNC_NAME,
+};
+
+export default config;
diff --git a/samples/tab-planner-timeline/src/index.css b/samples/tab-planner-timeline/src/index.css
new file mode 100644
index 00000000..9d38e264
--- /dev/null
+++ b/samples/tab-planner-timeline/src/index.css
@@ -0,0 +1,18 @@
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
+ "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
+ sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+@media only screen and (max-width: 768px) {
+ body {
+ width: fit-content;
+ }
+}
diff --git a/samples/tab-planner-timeline/src/index.tsx b/samples/tab-planner-timeline/src/index.tsx
new file mode 100644
index 00000000..e9329c41
--- /dev/null
+++ b/samples/tab-planner-timeline/src/index.tsx
@@ -0,0 +1,8 @@
+import React from "react";
+import { createRoot } from "react-dom/client";
+import App from "./components/App";
+import "./index.css";
+
+const container = document.getElementById("root");
+const root = createRoot(container!);
+root.render();
diff --git a/samples/tab-planner-timeline/src/models/ICalloutPaneProps.ts b/samples/tab-planner-timeline/src/models/ICalloutPaneProps.ts
new file mode 100644
index 00000000..7bcfe1cb
--- /dev/null
+++ b/samples/tab-planner-timeline/src/models/ICalloutPaneProps.ts
@@ -0,0 +1,7 @@
+import { PlannerTask } from '@microsoft/microsoft-graph-types'
+import { ITimeLineService} from '../services';
+
+export interface ICalloutPaneProps {
+ task: PlannerTask;
+ timeLineService: ITimeLineService;
+}
\ No newline at end of file
diff --git a/samples/tab-planner-timeline/src/models/ICategoryData.ts b/samples/tab-planner-timeline/src/models/ICategoryData.ts
new file mode 100644
index 00000000..10ac6cbc
--- /dev/null
+++ b/samples/tab-planner-timeline/src/models/ICategoryData.ts
@@ -0,0 +1,8 @@
+
+export interface ICategoryData {
+ text: string;
+ backgroundColor: string;
+ color: string;
+}
+
+export interface IAppliedCategoryColors { [key: string]: ICategoryData }
\ No newline at end of file
diff --git a/samples/tab-planner-timeline/src/models/IConfigSettings.ts b/samples/tab-planner-timeline/src/models/IConfigSettings.ts
new file mode 100644
index 00000000..e11330c4
--- /dev/null
+++ b/samples/tab-planner-timeline/src/models/IConfigSettings.ts
@@ -0,0 +1,6 @@
+export interface IConfigSettings {
+ groupId: string;
+ pageId: string;
+ planId: string;
+ clientType: string;
+}
\ No newline at end of file
diff --git a/samples/tab-planner-timeline/src/models/IFilterSettings.ts b/samples/tab-planner-timeline/src/models/IFilterSettings.ts
new file mode 100644
index 00000000..0973d897
--- /dev/null
+++ b/samples/tab-planner-timeline/src/models/IFilterSettings.ts
@@ -0,0 +1,5 @@
+export interface IFilterSettings {
+ bucketId: string;
+ showActiveTasks: boolean;
+ refreshData?: boolean;
+ }
\ No newline at end of file
diff --git a/samples/tab-planner-timeline/src/models/IRenderSettings.ts b/samples/tab-planner-timeline/src/models/IRenderSettings.ts
new file mode 100644
index 00000000..c52c3b29
--- /dev/null
+++ b/samples/tab-planner-timeline/src/models/IRenderSettings.ts
@@ -0,0 +1,18 @@
+
+import {
+ PlannerBucket,
+ User
+} from "@microsoft/microsoft-graph-types";
+
+export interface IRenderSettings {
+ renderYear: boolean;
+ currentYear: number;
+ renderMonth: boolean;
+ currentMonth: number;
+ lastRenderedDate?: Date;
+ showBuckets: string[];
+ hideCompletedTasks: boolean;
+ orderBy: string; // "asc" or "desc"
+ buckets: PlannerBucket[];
+ users: User[];
+}
\ No newline at end of file
diff --git a/samples/tab-planner-timeline/src/models/IServices.ts b/samples/tab-planner-timeline/src/models/IServices.ts
new file mode 100644
index 00000000..69a7d91a
--- /dev/null
+++ b/samples/tab-planner-timeline/src/models/IServices.ts
@@ -0,0 +1,5 @@
+import { ITimeLineService } from "../services";
+
+export interface IServices {
+ timeLineService: ITimeLineService | undefined;
+}
diff --git a/samples/tab-planner-timeline/src/models/ITimeLineData.ts b/samples/tab-planner-timeline/src/models/ITimeLineData.ts
new file mode 100644
index 00000000..68c138a2
--- /dev/null
+++ b/samples/tab-planner-timeline/src/models/ITimeLineData.ts
@@ -0,0 +1,7 @@
+export interface ITimeLineData {
+ groupId: string;
+ groupName?: string;
+ planId: string | undefined;
+ error?: string;
+ refresh: boolean;
+}
diff --git a/samples/tab-planner-timeline/src/models/constant.ts b/samples/tab-planner-timeline/src/models/constant.ts
new file mode 100644
index 00000000..3d368f96
--- /dev/null
+++ b/samples/tab-planner-timeline/src/models/constant.ts
@@ -0,0 +1,31 @@
+import { IAppliedCategoryColors } from '.';
+
+export const Scopes = ['User.Read.All', 'Tasks.ReadWrite', 'GroupMember.Read.All'];
+
+export const AppliedCategoryColors: IAppliedCategoryColors = {
+ "category1": { text: "pink", backgroundColor: "#fbddf0", color: "#c85f7e" },
+ "category2": { text: "Red", backgroundColor: "#e9c7cd", color: "#9a0b55" },
+ "category3": { text: "yellow", backgroundColor: "#f5edce", color: "#b47800" },
+ "category4": { text: "green", backgroundColor: "#dbebc7", color: "#387882" },
+ "category5": { text: "blue", backgroundColor: "#d0e7f8", color: "#6c5ba9" },
+ "category6": { text: "purple", backgroundColor: "#d8cce7", color: "#801b80" },
+ "category7": { text: "Bronze", backgroundColor: "#f1d9cc", color: "#c16309" },
+ "category8": { text: "Lime", backgroundColor: "#e5f2d3", color: "#406014" },
+ "category9": { text: "Aqua", backgroundColor: "#c2e7e7", color: "#006666" },
+ "category10": { text: "Grey", backgroundColor: "#e5e4e3", color: "#5d5a58" },
+ "category11": { text: "Silver", backgroundColor: "#eaeeef", color: "#4b5356" },
+ "category12": { text: "Brown", backgroundColor: "#e2d1cb", color: "#4d291c" },
+ "category13": { text: "Cranberry", backgroundColor: "#c50f1f", color: "#ffffff" },
+ "category14": { text: "Orange", backgroundColor: "#da3b01", color: "#ffffff" },
+ "category15": { text: "Peach", backgroundColor: "#ff8c00", color: "#000000" },
+ "category16": { text: "Marigold", backgroundColor: "#eaa300", color: "#000000" },
+ "category17": { text: "Light Green", backgroundColor: "#13a10e", color: "#0c3600" },
+ "category18": { text: "Dark Green", backgroundColor: "#0b6a0b", color: "#ffffff" },
+ "category19": { text: "Teal", backgroundColor: "#00b7c3", color: "#00003f" },
+ "category20": { text: "Light Blue", backgroundColor: "#0078d4", color: "#ffffff" },
+ "category21": { text: "Dark Blue", backgroundColor: "#003966", color: "#ffffff" },
+ "category22": { text: "Lavender", backgroundColor: "#7160eb", color: "#ffffff" },
+ "category23": { text: "Plum", backgroundColor: "#77004d", color: "#ffffff" },
+ "category24": { text: "light grey", backgroundColor: "#7a7574", color: "#ffffff" },
+ "category25": { text: "Dark Grey", backgroundColor: "#394146", color: "#ffffff" }
+}
\ No newline at end of file
diff --git a/samples/tab-planner-timeline/src/models/index.ts b/samples/tab-planner-timeline/src/models/index.ts
new file mode 100644
index 00000000..461d71b0
--- /dev/null
+++ b/samples/tab-planner-timeline/src/models/index.ts
@@ -0,0 +1,8 @@
+export * from './ITimeLineData';
+export * from './IRenderSettings';
+export * from './IFilterSettings';
+export * from './IConfigSettings';
+export * from './constant';
+export * from './ICategoryData';
+export * from './ICalloutPaneProps';
+export * from './IServices';
\ No newline at end of file
diff --git a/samples/tab-planner-timeline/src/services/FilterService.ts b/samples/tab-planner-timeline/src/services/FilterService.ts
new file mode 100644
index 00000000..a409f384
--- /dev/null
+++ b/samples/tab-planner-timeline/src/services/FilterService.ts
@@ -0,0 +1,70 @@
+import { IFilterSettings } from '../models';
+import { IFilterService } from './IFilterService';
+
+// FilterService class implements IFilterService interface
+export class FilterService implements IFilterService {
+ // private variable to store filter settings
+ private _filterSettings: IFilterSettings;
+ private _cacheData: boolean = false;
+
+ // constructor to initialize filter settings
+ constructor(filterSettings: IFilterSettings, clientType: string) {
+ // set filter settings
+ this._filterSettings = filterSettings;
+ this._cacheData = "#web#desktop#".includes('#' + clientType + '#');
+ }
+
+ // save filter settings in session storage
+ public saveFilterSettings(pageId: string, filterSettings: IFilterSettings) {
+ if (this._cacheData) {
+ // save filter settings in session storage
+ this._filterSettings = filterSettings;
+
+ // save filter settings in session storage
+ sessionStorage.setItem( "_" + pageId + "TimeLineFilterData", JSON.stringify(this._filterSettings));
+ // save filter settings time in session storage
+ sessionStorage.setItem("_pms" + pageId + "FilterTime", JSON.stringify(new Date()));
+ }
+ }
+
+ // get filter settings from session storage
+ public getFilterSettings(pageId: string): IFilterSettings {
+ if (this._cacheData) {
+ // get filter settings from session storage
+ const filterData = sessionStorage.getItem("_" + pageId + "TimeLineFilterData");
+
+ // check if filter settings are available
+ if (filterData) {
+ // parse filter settings
+ const filterDataString = sessionStorage.getItem("_pms" + pageId + "FilterTime");
+
+ // check if filter settings time is available
+ if (filterDataString) {
+ const filter = JSON.parse(filterData);
+ // parse filter settings time
+ const dataTime: Date = new Date(filterDataString.replace(/"/g, ""));
+ // get current time
+ const nowTime: Date = new Date();
+ // calculate delay
+ const delay: number =
+ (nowTime.getTime() - dataTime.getTime()) / (1000 * 60);
+
+ // check if delay is less than 30 minutes
+ if (delay < 30) {
+ // create filter settings object
+ const filterSet: IFilterSettings = {
+ bucketId: filter.bucketId,
+ showActiveTasks: filter.showActiveTasks === true,
+ refreshData: filter.refreshData === true,
+ };
+
+ // return filter settings
+ this._filterSettings = filterSet;
+ }
+ }
+ }
+ }
+
+ return this._filterSettings;
+ }
+}
\ No newline at end of file
diff --git a/samples/tab-planner-timeline/src/services/IFilterService.ts b/samples/tab-planner-timeline/src/services/IFilterService.ts
new file mode 100644
index 00000000..d4e06e4c
--- /dev/null
+++ b/samples/tab-planner-timeline/src/services/IFilterService.ts
@@ -0,0 +1,9 @@
+import {
+ IFilterSettings
+ } from "../models";
+
+export interface IFilterService {
+ saveFilterSettings(pageId: string, filterSettings: IFilterSettings): void;
+
+ getFilterSettings(pageId: string): IFilterSettings;
+}
\ No newline at end of file
diff --git a/samples/tab-planner-timeline/src/services/ITimeLineService.ts b/samples/tab-planner-timeline/src/services/ITimeLineService.ts
new file mode 100644
index 00000000..1945db94
--- /dev/null
+++ b/samples/tab-planner-timeline/src/services/ITimeLineService.ts
@@ -0,0 +1,44 @@
+import {
+ ITimeLineData,
+ IFilterSettings
+} from "../models";
+import {
+ PlannerPlan,
+ PlannerBucket,
+ PlannerTask,
+ PlannerTaskDetails,
+ User
+} from "@microsoft/microsoft-graph-types";
+
+export interface ITimeLineService {
+ // all inventory items
+ getTimelineData(): Promise;
+
+ refreshTasks(): Promise;
+
+ getTimeLine(): ITimeLineData;
+
+ getBuckets(): PlannerBucket[];
+
+ getActiveTasks(sortBy: string): PlannerTask[];
+
+ getPlannerCategoryDescriptions(): { [key: string]: string };
+
+ getTasks(sortBy: string): PlannerTask[];
+
+ getTaskDetails(taskId: string): Promise;
+
+ getTaskUsers(): User[];
+
+ getTasksForBucket(filterSettings: IFilterSettings): PlannerTask[];
+
+ // New Advanced Service Methods
+ newPlannerPlan(title: string, groupId: string): Promise;
+
+ newPlannerBucket(planId: string, title: string, orderHint: string): Promise
+
+ newTask(planId: string, bucketId: string, title: string): Promise;
+
+ // Delete Methods
+ deletePlannerPlan(planId : string): Promise;
+}
diff --git a/samples/tab-planner-timeline/src/services/TimelineService.ts b/samples/tab-planner-timeline/src/services/TimelineService.ts
new file mode 100644
index 00000000..3cbc1b48
--- /dev/null
+++ b/samples/tab-planner-timeline/src/services/TimelineService.ts
@@ -0,0 +1,438 @@
+import {
+ IFilterSettings,
+ ITimeLineData,
+ IConfigSettings,
+} from '../models';
+import { ITimeLineService } from '.';
+import { Client } from "@microsoft/microsoft-graph-client";
+import {
+ PlannerPlan,
+ PlannerPlanDetails,
+ PlannerTaskDetails,
+ PlannerBucket,
+ PlannerTask,
+ User,
+} from '@microsoft/microsoft-graph-types'
+
+interface PlannerPlanDel extends PlannerPlan {
+ "@odata.etag": string;
+}
+
+export class TimeLineService implements ITimeLineService {
+ // Private members
+ private _graphClient: Client;
+ private _pageId = "";
+ private _buckets: PlannerBucket[] = [];
+ private _taskUsers: User[] = [];
+ private _tasks: PlannerTask[] = [];
+ private _planDetails: PlannerPlanDetails | undefined = undefined;
+ private _cacheData: boolean = false;
+
+ private _timeLine: ITimeLineData = {
+ groupId: "",
+ planId: "",
+ refresh: true,
+ };
+
+ // Constructor
+ constructor(graphClient: Client, configSettings: IConfigSettings) {
+ this._graphClient = graphClient;
+ this._timeLine.groupId = configSettings.groupId;
+ this._timeLine.planId = configSettings.planId;
+ this._pageId = configSettings.pageId;
+ this._cacheData = "#web#desktop#".includes('#' + configSettings.clientType + '#');
+ }
+
+ public async getTimelineData(): Promise {
+ // Check session timeline data, return true if no cashed data
+ this._timeLine.refresh = await this._getTimelineData();;
+
+ return this._timeLine;
+ }
+
+ public async refreshTasks(): Promise {
+ this._timeLine.refresh = false;
+
+ try {
+ // Get all users
+ const allUsers = await this._graphClient
+ .api("/groups/" + this._timeLine.groupId + "/members")
+ .select("id,displayName,mail")
+ .get();
+
+ // Set all users
+ this._taskUsers = allUsers.value;
+
+ if (this._timeLine.planId) {
+ // Get Plan Details
+ const planDetails = await this._graphClient
+ .api("/planner/plans/" + this._timeLine.planId + "/details")
+ .get();
+
+ this._planDetails = planDetails;
+
+ // Get all buckets
+ const bucketsData = await await this._graphClient
+ .api("/planner/plans/" + this._timeLine.planId + "/buckets")
+ .get();
+
+ // Set buckets
+ this._buckets = bucketsData.value.sort((a: PlannerBucket, b: PlannerBucket) => (a.name ?? "").localeCompare(b.name ?? ""));
+
+ // Get all tasks
+ if (this._timeLine.planId) {
+ const tasksData = await this._graphClient
+ .api("/planner/plans/" + this._timeLine.planId + "/Tasks")
+ .orderby("dueDateTime")
+ .get();
+
+ // Set tasks
+ const tasks: PlannerTask[] = tasksData.value;
+
+ // Get task details
+ this._tasks = await this._getTaskDetails(tasks);
+ }
+ }
+ } catch (error: unknown) {
+ // Set error message
+ this._timeLine.error = (error as Error)?.message;
+ }
+
+ // Save timeline data to session storage
+ this._saveTimelineData(
+ this._timeLine,
+ this._buckets,
+ this._taskUsers,
+ this._tasks,
+ this._planDetails
+ );
+
+ // Return timeline data
+ return this._timeLine;
+ }
+
+ private async _getTaskDetails(tasks: PlannerTask[]): Promise {
+ for (const task of tasks) {
+ if (task.completedBy) {
+ const user = this._taskUsers.find(
+ (u) => u.id === task.completedBy?.user?.id
+ );
+ if (user) {
+ if (task.completedBy.user) {
+ task.completedBy.user.displayName = user.displayName;
+ }
+ }
+ }
+
+ if (task.createdBy) {
+ const user = this._taskUsers.find(
+ (u) => u.id === task.createdBy?.user?.id
+ );
+ if (user) {
+ if (task.createdBy.user) {
+ task.createdBy.user.displayName = user.displayName;
+ }
+ }
+ }
+
+ if (task.bucketId) {
+ const bucket = this._buckets.find((b) => b.id === task.bucketId);
+ if (bucket) {
+ task.bucketId = bucket.id + ":" + bucket.name;
+ }
+ }
+ }
+
+ return tasks;
+ }
+
+ // Get timeline data
+ public getTimeLine(): ITimeLineData {
+ return this._timeLine;
+ }
+
+ // Get Planner buckets
+ public getBuckets(): PlannerBucket[] {
+ return this._buckets;
+ }
+
+ // Get tenant users
+ public getTaskUsers(): User[] {
+ return this._taskUsers;
+ }
+
+ public getPlannerCategoryDescriptions(): { [key: string]: string } {
+ return this._planDetails?.categoryDescriptions as { [key: string]: string } ?? {};
+ }
+
+
+ public async getTaskDetails(taskId: string): Promise {
+ let taskDetails: PlannerTaskDetails | undefined = undefined;
+ try {
+ const detail = await this._graphClient
+ .api("/planner/tasks/" + taskId + "/details")
+ .get();
+
+ taskDetails = detail;
+
+ } catch (error: unknown) {
+ // Set error message
+ this._timeLine.error = (error as Error)?.message;
+ }
+
+ return taskDetails;
+ }
+
+ // Get Planner tasks
+ public getTasks(sortBy: string): PlannerTask[] {
+ const tasksWithDate: PlannerTask[] = [];
+ const tasksNoDate: PlannerTask[] = [];
+
+ if (sortBy.toLowerCase() === "duedate") {
+ this._tasks.forEach((task) => {
+ if (task.dueDateTime) {
+ tasksWithDate.push(task);
+ } else {
+ tasksNoDate.push(task);
+ }
+ });
+
+ return tasksWithDate.length > 0 ? tasksNoDate.concat(this._sortTasksByDueDate(tasksWithDate)) : tasksNoDate;
+ } else if (sortBy.toLowerCase() === "stratdate") {
+ this._tasks.forEach((task) => {
+ if (task.startDateTime) {
+ tasksWithDate.push(task);
+ } else {
+ tasksNoDate.push(task);
+ }
+ });
+
+ return tasksWithDate.length > 0 ? tasksNoDate.concat(this._sortTasksByStartDate(tasksWithDate)) : tasksNoDate;
+ } else {
+ return this._tasks;
+ }
+ }
+
+ // Get active tasks
+ public getActiveTasks(sortBy: string): PlannerTask[] {
+ const orderedTasks = this.getTasks(sortBy);
+
+ return orderedTasks.filter((task) => {
+ return !task.completedDateTime;
+ });
+ }
+
+ // Get tasks sort by start date
+ private _sortTasksByStartDate(tasks: PlannerTask[]): PlannerTask[] {
+ tasks = tasks.sort((a, b) => {
+ if (a.startDateTime && b.startDateTime) {
+ return a.startDateTime.localeCompare(b.startDateTime);
+ } else {
+ return 0;
+ }
+ });
+
+ return tasks;
+ }
+
+ // get tasks sort by due date
+ private _sortTasksByDueDate(tasks: PlannerTask[]): PlannerTask[] {
+ tasks = tasks.sort((a, b) => {
+ if (a.dueDateTime && b.dueDateTime) {
+ return a.dueDateTime.localeCompare(b.dueDateTime);
+ } else {
+ return 0;
+ }
+ });
+
+ return tasks;
+ }
+
+ public getTasksForBucket(filterSettings: IFilterSettings): PlannerTask[] {
+ let tasks: PlannerTask[] = [];
+
+ if (filterSettings.showActiveTasks) {
+ tasks = this.getTasks("dueDate");
+ } else {
+ tasks = this.getActiveTasks("dueDate");
+ }
+
+ if (filterSettings.bucketId !== "All" && filterSettings.bucketId !== "") {
+ const filteredTasks: PlannerTask[] = [];
+
+ tasks.forEach((task) => {
+ if (
+ task.bucketId &&
+ task.bucketId.startsWith(filterSettings.bucketId)
+ ) {
+ filteredTasks.push(task);
+ }
+ });
+
+ return filteredTasks;
+ }
+
+ return tasks;
+ }
+
+ // Get timeline data from session storage
+ private async _getTimelineData(): Promise {
+ if (!this._cacheData) {
+ return true;
+ }
+
+ const timelineData = sessionStorage.getItem("_" + this._pageId + "TimelineData");
+
+ if (timelineData) {
+ const buckets = sessionStorage.getItem("_" + this._pageId + "buckets");
+ const Users = sessionStorage.getItem("_" + this._pageId + "Users");
+ const tasks = sessionStorage.getItem("_" + this._pageId + "tasks");
+ const planDetails = sessionStorage.getItem("_" + this._pageId + "planDetails");
+
+ this._timeLine = JSON.parse(timelineData) as ITimeLineData;
+ this._buckets = buckets ? (JSON.parse(buckets) as PlannerBucket[]) : [];
+ this._taskUsers = Users ? (JSON.parse(Users) as User[]) : [];
+ this._tasks = tasks ? (JSON.parse(tasks) as PlannerTask[]) : [];
+ this._planDetails = planDetails ? (JSON.parse(planDetails) as PlannerPlanDetails) : undefined;
+
+ return false;
+ } else {
+ return true;
+ }
+ }
+
+ // Save timeline data to session storage
+ private async _saveTimelineData(
+ timelineData: ITimeLineData,
+ buckets: PlannerBucket[],
+ Users: User[],
+ tasks: PlannerTask[],
+ planDetails: PlannerPlanDetails | undefined
+ ) {
+ if (this._cacheData) {
+ sessionStorage.setItem("_" + this._pageId + "TimelineData", JSON.stringify(timelineData));
+ sessionStorage.setItem("_" + this._pageId + "buckets", JSON.stringify(buckets));
+ sessionStorage.setItem("_" + this._pageId + "Users", JSON.stringify(Users));
+ sessionStorage.setItem("_" + this._pageId + "tasks", JSON.stringify(tasks));
+ sessionStorage.setItem("_" + this._pageId + "planDetails", JSON.stringify(planDetails));
+ }
+ }
+
+ // Advanced Service Methods
+ // Add new Planner Plan
+ public async newPlannerPlan(title: string, groupId: string): Promise {
+ // initialize plan to be returned
+ let plan: PlannerPlan | undefined = undefined;
+
+ try {
+ // create new plan object
+ const newPlan = {
+ owner: groupId,
+ title: title,
+ };
+
+ // create new plan
+ plan = await this._graphClient
+ .api("/planner/plans")
+ .post(newPlan);
+
+ } catch (error: unknown) {
+ console.error(error);
+ }
+
+ // return new plan
+ return plan;
+ }
+
+ // Add new Bucket to existing Plan
+ public async newPlannerBucket(planId: string, title: string, orderHint: string = " !"): Promise {
+ // initialize bucket to be returned
+ let bucket: PlannerBucket | undefined = undefined;
+
+ try {
+ // create new bucket object
+ const newBucket = {
+ name: title,
+ planId: planId,
+ orderHint: orderHint,
+ };
+
+ // create new bucket
+ bucket = await this._graphClient
+ .api("/planner/buckets")
+ .post(newBucket);
+
+ } catch (error: unknown) {
+ console.error(error);
+ }
+
+ // return new bucket
+ return bucket;
+ }
+
+ // Add new Task to existing Plan Bucket
+ public async newTask(planId: string, bucketId: string, title: string): Promise {
+ // initialize task to be returned
+ let task: PlannerTask | undefined = undefined;
+
+ try {
+ // create new task object
+ const newTask = {
+ planId: planId,
+ bucketId: bucketId,
+ title: title,
+ assignments: {}
+ };
+
+ // create new task
+ task = await this._graphClient
+ .api("/planner/tasks")
+ .post(newTask);
+
+ } catch (error: unknown) {
+ console.error(error);
+ }
+
+ // return new task
+ return task;
+ }
+
+ // Delete Exiting Planner Plan
+ public async deletePlannerPlan(planId : string): Promise {
+ // get plan @odata.etag
+ // NOTE: PlannerPlanDel extends PlannerPlan with @odata.etag
+ const plan: PlannerPlanDel | undefined = await this._getPlannerPlans(planId);
+
+ if (plan) {
+ try {
+ // delete plan using planId and @odata.etag
+ await this._graphClient
+ .api("/planner/plans/" + plan.id)
+ .header("If-Match", plan["@odata.etag"])
+ .delete();
+ } catch (error: unknown) {
+ console.error(error);
+ }
+ }
+ }
+
+ // Private Methods to get Planner Plans by PlanId
+ private async _getPlannerPlans(planId: string): Promise {
+ // initialize plan to be returned
+ // NOTE: PlannerPlanDel extends PlannerPlan with @odata.etag
+ let plan: PlannerPlanDel | undefined = undefined;
+
+ try {
+ // get plan by planId
+ plan = await this._graphClient
+ .api("/planner/plans/" + planId)
+ .select("id, title")
+ .get();
+ } catch (error: unknown) {
+ console.error(error);
+ }
+
+ // return plan with @odata.etag
+ return plan;
+ }
+}
\ No newline at end of file
diff --git a/samples/tab-planner-timeline/src/services/index.ts b/samples/tab-planner-timeline/src/services/index.ts
new file mode 100644
index 00000000..6ed4994a
--- /dev/null
+++ b/samples/tab-planner-timeline/src/services/index.ts
@@ -0,0 +1,4 @@
+export * from './TimelineService';
+export * from './ITimeLineService';
+export * from './FilterService';
+export * from './IFilterService';
\ No newline at end of file
diff --git a/samples/tab-planner-timeline/teamsapp.local.yml b/samples/tab-planner-timeline/teamsapp.local.yml
new file mode 100644
index 00000000..6d38523d
--- /dev/null
+++ b/samples/tab-planner-timeline/teamsapp.local.yml
@@ -0,0 +1,154 @@
+# yaml-language-server: $schema=https://aka.ms/teams-toolkit/v1.7/yaml.schema.json
+# Visit https://aka.ms/teamsfx-v5.0-guide for details on this file
+# Visit https://aka.ms/teamsfx-actions for details on actions
+version: v1.7
+
+provision:
+ # Creates a new Microsoft Entra app to authenticate users if
+ # the environment variable that stores clientId is empty
+ - uses: aadApp/create
+ with:
+ # Note: when you run aadApp/update, the Microsoft Entra app name will be updated
+ # based on the definition in manifest. If you don't want to change the
+ # name, make sure the name in Microsoft Entra manifest is the same with the name
+ # defined here.
+ name: PlannerTimeline
+ # If the value is false, the action will not generate client secret for you
+ generateClientSecret: true
+ # Authenticate users with a Microsoft work or school account in your
+ # organization's Microsoft Entra tenant (for example, single tenant).
+ signInAudience: AzureADMyOrg
+ # Write the information of created resources into environment file for the
+ # specified environment variable(s).
+ writeToEnvironmentFile:
+ clientId: AAD_APP_CLIENT_ID
+ # Environment variable that starts with `SECRET_` will be stored to the
+ # .env.{envName}.user environment file
+ clientSecret: SECRET_AAD_APP_CLIENT_SECRET
+ objectId: AAD_APP_OBJECT_ID
+ tenantId: AAD_APP_TENANT_ID
+ authority: AAD_APP_OAUTH_AUTHORITY
+ authorityHost: AAD_APP_OAUTH_AUTHORITY_HOST
+
+ # Creates a Teams app
+ - uses: teamsApp/create
+ with:
+ # Teams app name
+ name: PlannerTimeline-${{APP_NAME_SUFFIX}}
+ # Write the information of created resources into environment file for
+ # the specified environment variable(s).
+ writeToEnvironmentFile:
+ teamsAppId: TEAMS_APP_ID
+
+ # Set required variables for local launch
+ - uses: script
+ with:
+ run:
+ echo "::set-teamsfx-env TAB_HOSTNAME=localhost";
+ echo "::set-teamsfx-env TAB_DOMAIN=localhost:53000";
+ echo "::set-teamsfx-env TAB_ENDPOINT=https://localhost:53000";
+ # echo "::set-teamsfx-env FUNC_NAME=getUserProfile";
+ # echo "::set-teamsfx-env FUNC_ENDPOINT=http://localhost:7071";
+
+ # Apply the Microsoft Entra manifest to an existing Microsoft Entra app. Will use the object id in
+ # manifest file to determine which Microsoft Entra app to update.
+ - uses: aadApp/update
+ with:
+ # Relative path to this file. Environment variables in manifest will
+ # be replaced before apply to Microsoft Entra app
+ manifestPath: ./aad.manifest.json
+ outputFilePath: ./build/aad.manifest.${{TEAMSFX_ENV}}.json
+
+ # Validate using manifest schema
+ - uses: teamsApp/validateManifest
+ with:
+ # Path to manifest template
+ manifestPath: ./appPackage/manifest.json
+
+ # Build Teams app package with latest env value
+ - uses: teamsApp/zipAppPackage
+ with:
+ # Path to manifest template
+ manifestPath: ./appPackage/manifest.json
+ outputZipPath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip
+ outputFolder: ./appPackage/build
+
+ # Validate app package using validation rules
+ - uses: teamsApp/validateAppPackage
+ with:
+ # Relative path to this file. This is the path for built zip file.
+ appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip
+
+ # Apply the Teams app manifest to an existing Teams app in
+ # Teams Developer Portal.
+ # Will use the app id in manifest file to determine which Teams app to update.
+ - uses: teamsApp/update
+ with:
+ # Relative path to this file. This is the path for built zip file.
+ appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip
+
+ # Extend your Teams app to Outlook and the Microsoft 365 app
+ - uses: teamsApp/extendToM365
+ with:
+ # Relative path to the build app package.
+ appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip
+ # Write the information of created resources into environment file for
+ # the specified environment variable(s).
+ writeToEnvironmentFile:
+ titleId: M365_TITLE_ID
+ appId: M365_APP_ID
+
+deploy:
+ # Install development tool(s)
+ - uses: devTool/install
+ with:
+ devCert:
+ trust: true
+ func:
+ version: ~4.0.5455
+ symlinkDir: ./devTools/func
+ # Write the information of installed development tool(s) into environment
+ # file for the specified environment variable(s).
+ writeToEnvironmentFile:
+ sslCertFile: SSL_CRT_FILE
+ sslKeyFile: SSL_KEY_FILE
+ funcPath: FUNC_PATH
+
+ # Run npm command
+ - uses: cli/runNpmCommand
+ name: install dependencies
+ with:
+ args: install --no-audit
+
+ # Run npm command
+ # - uses: cli/runNpmCommand
+ # name: install dependencies
+ # with:
+ # workingDirectory: api
+ # args: install --no-audit
+
+ # Generate runtime environment variables for tab
+ - uses: file/createOrUpdateEnvironmentFile
+ with:
+ target: ./.localConfigs
+ envs:
+ BROWSER: none
+ HTTPS: true
+ PORT: 53000
+ SSL_CRT_FILE: ${{SSL_CRT_FILE}}
+ SSL_KEY_FILE: ${{SSL_KEY_FILE}}
+ REACT_APP_CLIENT_ID: ${{AAD_APP_CLIENT_ID}}
+ REACT_APP_START_LOGIN_PAGE_URL: ${{TAB_ENDPOINT}}/auth-start.html
+ # REACT_APP_FUNC_NAME: ${{FUNC_NAME}}
+ # REACT_APP_FUNC_ENDPOINT: ${{FUNC_ENDPOINT}}
+
+ # Generate runtime environment variables for backend
+ # - uses: file/createOrUpdateEnvironmentFile
+ # with:
+ # target: ./api/.localConfigs
+ # envs:
+ # M365_CLIENT_ID: ${{AAD_APP_CLIENT_ID}}
+ # M365_CLIENT_SECRET: ${{SECRET_AAD_APP_CLIENT_SECRET}}
+ # M365_TENANT_ID: ${{AAD_APP_TENANT_ID}}
+ # M365_AUTHORITY_HOST: ${{AAD_APP_OAUTH_AUTHORITY_HOST}}
+ # ALLOWED_APP_IDS: 1fec8e78-bce4-4aaf-ab1b-5451cc387264;5e3ce6c0-2b1f-4285-8d4b-75ee78787346;0ec893e0-5785-4de6-99da-4ed124e5296c;4345a7b9-9a63-4910-a426-35363201d503;4765445b-32c6-49b0-83e6-1d93765276ca;d3590ed6-52b3-4102-aeff-aad2292ab01c;00000002-0000-0ff1-ce00-000000000000;bc59ab01-8403-45c6-8796-ac3ef710b3e3;27922004-5251-4030-b22d-91ecd9a37ea4
diff --git a/samples/tab-planner-timeline/teamsapp.yml b/samples/tab-planner-timeline/teamsapp.yml
new file mode 100644
index 00000000..00bd21c5
--- /dev/null
+++ b/samples/tab-planner-timeline/teamsapp.yml
@@ -0,0 +1,207 @@
+# yaml-language-server: $schema=https://aka.ms/teams-toolkit/v1.7/yaml.schema.json
+# Visit https://aka.ms/teamsfx-v5.0-guide for details on this file
+# Visit https://aka.ms/teamsfx-actions for details on actions
+version: v1.7
+
+environmentFolderPath: ./env
+
+# Triggered when 'teamsapp provision' is executed
+provision:
+ # Creates a new Microsoft Entra app to authenticate users if
+ # the environment variable that stores clientId is empty
+ - uses: aadApp/create
+ with:
+ # Note: when you run aadApp/update, the Microsoft Entra app name will be updated
+ # based on the definition in manifest. If you don't want to change the
+ # name, make sure the name in Microsoft Entra manifest is the same with the name
+ # defined here.
+ name: PlannerTimeline
+ # If the value is false, the action will not generate client secret for you
+ generateClientSecret: true
+ # Authenticate users with a Microsoft work or school account in your
+ # organization's Microsoft Entra tenant (for example, single tenant).
+ signInAudience: AzureADMyOrg
+ # Write the information of created resources into environment file for the
+ # specified environment variable(s).
+ writeToEnvironmentFile:
+ clientId: AAD_APP_CLIENT_ID
+ # Environment variable that starts with `SECRET_` will be stored to the
+ # .env.{envName}.user environment file
+ clientSecret: SECRET_AAD_APP_CLIENT_SECRET
+ objectId: AAD_APP_OBJECT_ID
+ tenantId: AAD_APP_TENANT_ID
+ authority: AAD_APP_OAUTH_AUTHORITY
+ authorityHost: AAD_APP_OAUTH_AUTHORITY_HOST
+
+ # Creates a Teams app
+ - uses: teamsApp/create
+ with:
+ # Teams app name
+ name: PlannerTimeline${{APP_NAME_SUFFIX}}
+ # Write the information of created resources into environment file for
+ # the specified environment variable(s).
+ writeToEnvironmentFile:
+ teamsAppId: TEAMS_APP_ID
+
+ - uses: arm/deploy # Deploy given ARM templates parallelly.
+ with:
+ # AZURE_SUBSCRIPTION_ID is a built-in environment variable,
+ # if its value is empty, TeamsFx will prompt you to select a subscription.
+ # Referencing other environment variables with empty values
+ # will skip the subscription selection prompt.
+ subscriptionId: ${{AZURE_SUBSCRIPTION_ID}}
+ # AZURE_RESOURCE_GROUP_NAME is a built-in environment variable,
+ # if its value is empty, TeamsFx will prompt you to select or create one
+ # resource group.
+ # Referencing other environment variables with empty values
+ # will skip the resource group selection prompt.
+ resourceGroupName: ${{AZURE_RESOURCE_GROUP_NAME}}
+ templates:
+ - path: ./infra/azure.bicep # Relative path to this file
+ # Relative path to this yaml file.
+ # Placeholders will be replaced with corresponding environment
+ # variable before ARM deployment.
+ parameters: ./infra/azure.parameters.json
+ # Required when deploying ARM template
+ deploymentName: Create-resources-for-tab
+ # Teams Toolkit will download this bicep CLI version from github for you,
+ # will use bicep CLI in PATH if you remove this config.
+ bicepCliVersion: v0.9.1
+
+ # Get the deployment token from Azure Static Web Apps
+ - uses: azureStaticWebApps/getDeploymentToken
+ with:
+ resourceId: ${{AZURE_STATIC_WEB_APPS_RESOURCE_ID}}
+ # Save deployment token to the environment file for the deployment action
+ writeToEnvironmentFile:
+ deploymentToken: SECRET_TAB_SWA_DEPLOYMENT_TOKEN
+
+ # Apply the Microsoft Entra manifest to an existing Microsoft Entra app. Will use the object id in
+ # manifest file to determine which Microsoft Entra app to update.
+ - uses: aadApp/update
+ with:
+ # Relative path to this file. Environment variables in manifest will
+ # be replaced before apply to Microsoft Entra app
+ manifestPath: ./aad.manifest.json
+ outputFilePath: ./build/aad.manifest.${{TEAMSFX_ENV}}.json
+
+ # Validate using manifest schema
+ - uses: teamsApp/validateManifest
+ with:
+ # Path to manifest template
+ manifestPath: ./appPackage/manifest.json
+ # Build Teams app package with latest env value
+ - uses: teamsApp/zipAppPackage
+ with:
+ # Path to manifest template
+ manifestPath: ./appPackage/manifest.json
+ outputZipPath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip
+ outputFolder: ./appPackage/build
+ # Validate app package using validation rules
+ - uses: teamsApp/validateAppPackage
+ with:
+ # Relative path to this file. This is the path for built zip file.
+ appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip
+ # Apply the Teams app manifest to an existing Teams app in
+ # Teams Developer Portal.
+ # Will use the app id in manifest file to determine which Teams app to update.
+ - uses: teamsApp/update
+ with:
+ # Relative path to this file. This is the path for built zip file.
+ appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip
+ # Extend your Teams app to Outlook and the Microsoft 365 app
+ - uses: teamsApp/extendToM365
+ with:
+ # Relative path to the build app package.
+ appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip
+ # Write the information of created resources into environment file for
+ # the specified environment variable(s).
+ writeToEnvironmentFile:
+ titleId: M365_TITLE_ID
+ appId: M365_APP_ID
+
+# Triggered when 'teamsapp deploy' is executed
+deploy:
+ # Run npm command
+ - uses: cli/runNpmCommand
+ name: install dependencies
+ with:
+ args: install
+ - uses: cli/runNpmCommand
+ name: build app
+ with:
+ args: run build --if-present
+ env:
+ REACT_APP_CLIENT_ID: ${{AAD_APP_CLIENT_ID}}
+ REACT_APP_START_LOGIN_PAGE_URL: ${{TAB_ENDPOINT}}/auth-start.html
+ # REACT_APP_FUNC_NAME: getUserProfile
+ # REACT_APP_FUNC_ENDPOINT: ${{API_FUNCTION_ENDPOINT}}
+ # Deploy bits to Azure Static Web Apps
+ - uses: cli/runNpxCommand
+ name: deploy to Azure Static Web Apps
+ with:
+ args: '@azure/static-web-apps-cli deploy ./build -d
+ ${{SECRET_TAB_SWA_DEPLOYMENT_TOKEN}} --env production'
+ # Run npm command
+ # - uses: cli/runNpmCommand
+ # name: install dependencies
+ # with:
+ # workingDirectory: api
+ # args: install
+ # - uses: cli/runNpmCommand
+ # name: build app
+ # with:
+ # workingDirectory: api
+ # args: run build --if-present
+ # Deploy your application to Azure Functions using the zip deploy feature.
+ # For additional details, see at https://aka.ms/zip-deploy-to-azure-functions
+ # - uses: azureFunctions/zipDeploy
+ # with:
+ # workingDirectory: api
+ # # deploy base folder
+ # artifactFolder: .
+ # # Ignore file location, leave blank will ignore nothing
+ # ignoreFile: .funcignore
+ # # The resource id of the cloud resource to be deployed to.
+ # # This key will be generated by arm/deploy action automatically.
+ # # You can replace it with your existing Azure Resource id
+ # # or add it to your environment variable file.
+ # resourceId: ${{API_FUNCTION_RESOURCE_ID}}
+
+# Triggered when 'teamsapp publish' is executed
+publish:
+ # Validate using manifest schema
+ - uses: teamsApp/validateManifest
+ with:
+ # Path to manifest template
+ manifestPath: ./appPackage/manifest.json
+ # Build Teams app package with latest env value
+ - uses: teamsApp/zipAppPackage
+ with:
+ # Path to manifest template
+ manifestPath: ./appPackage/manifest.json
+ outputZipPath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip
+ outputFolder: ./appPackage/build
+ # Validate app package using validation rules
+ - uses: teamsApp/validateAppPackage
+ with:
+ # Relative path to this file. This is the path for built zip file.
+ appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip
+ # Apply the Teams app manifest to an existing Teams app in
+ # Teams Developer Portal.
+ # Will use the app id in manifest file to determine which Teams app to update.
+ - uses: teamsApp/update
+ with:
+ # Relative path to this file. This is the path for built zip file.
+ appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip
+ # Publish the app to
+ # Teams Admin Center (https://admin.teams.microsoft.com/policies/manage-apps)
+ # for review and approval
+ - uses: teamsApp/publishAppPackage
+ with:
+ appPackagePath: ./appPackage/build/appPackage.${{TEAMSFX_ENV}}.zip
+ # Write the information of created resources into environment file for
+ # the specified environment variable(s).
+ writeToEnvironmentFile:
+ publishedAppId: TEAMS_APP_PUBLISHED_APP_ID
+projectId: bf1a34fe-4780-4017-a093-3482650726cc
diff --git a/samples/tab-planner-timeline/tsconfig.json b/samples/tab-planner-timeline/tsconfig.json
new file mode 100644
index 00000000..7177aec5
--- /dev/null
+++ b/samples/tab-planner-timeline/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "compilerOptions": {
+ "target": "es5",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx"
+ },
+ "include": ["src"]
+}