diff --git a/CHANGELOG.md b/CHANGELOG.md index a2e73812c..cd8c0aabb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## 4.7.0 - 2024-Nov-18 + +- sp + - Introduces new filter lamda patterns as beta + +- graph + - Renamed OneNote Pages to OneNotePages + - Basic Pages API support as beta + - Site Open Extensions as beta + - Fixed #3136 for improving paging support for query params + +- queryable + - Introduced DebugHeaders behavior + ## 4.6.0 - 2024-Oct-14 - Only documentation and package updates diff --git a/debug/launch/main.ts b/debug/launch/main.ts index 230ee47d3..2a4fdec1e 100644 --- a/debug/launch/main.ts +++ b/debug/launch/main.ts @@ -19,7 +19,7 @@ import { Example } from "./sp.js"; // create a settings file using settings.example.js as a template import(findup("settings.js")).then((settings: { settings: ITestingSettings }) => { - Logger.activeLogLevel = LogLevel.Info; + Logger.activeLogLevel = LogLevel.Info; // // setup console logger Logger.subscribe(ConsoleListener("Debug", { @@ -27,7 +27,7 @@ import(findup("settings.js")).then((settings: { settings: ITestingSettings }) => error: "red", verbose: "lightslategray", warning: "yellow", - })); + })); Example(settings.settings); diff --git a/debug/launch/setup.ts b/debug/launch/setup.ts index 472041b8f..093deeede 100644 --- a/debug/launch/setup.ts +++ b/debug/launch/setup.ts @@ -3,17 +3,19 @@ import { SPDefault, GraphDefault } from "@pnp/nodejs"; import { spfi, SPFI } from "@pnp/sp"; import { GraphFI, graphfi } from "@pnp/graph"; import { LogLevel, PnPLogging } from "@pnp/logging"; -import { Queryable } from "@pnp/queryable"; +import { Queryable, DebugHeaders } from "@pnp/queryable"; export function spSetup(settings: ITestingSettings): SPFI { - const sp = spfi(settings.testing.sp.url).using(SPDefault({ - msal: { - config: settings.testing.sp.msal.init, - scopes: settings.testing.sp.msal.scopes, - }, - })).using( + const sp = spfi(settings.testing.sp.url).using( + SPDefault({ + msal: { + config: settings.testing.sp.msal.init, + scopes: settings.testing.sp.msal.scopes, + }, + }), PnPLogging(LogLevel.Verbose), + DebugHeaders(), function (instance: Queryable) { instance.on.pre(async (url, init, result) => { @@ -29,13 +31,15 @@ export function spSetup(settings: ITestingSettings): SPFI { export function graphSetup(settings: ITestingSettings): GraphFI { - const graph = graphfi().using(GraphDefault({ - msal: { - config: settings.testing.graph.msal.init, - scopes: settings.testing.graph.msal.scopes, - }, - })).using( + const graph = graphfi().using( + GraphDefault({ + msal: { + config: settings.testing.graph.msal.init, + scopes: settings.testing.graph.msal.scopes, + }, + }), PnPLogging(LogLevel.Verbose), + DebugHeaders(), function (instance: Queryable) { instance.on.pre(async (url, init, result) => { diff --git a/debug/launch/sp.ts b/debug/launch/sp.ts index dab257330..352e7b5cd 100644 --- a/debug/launch/sp.ts +++ b/debug/launch/sp.ts @@ -3,7 +3,6 @@ import { Logger, LogLevel } from "@pnp/logging"; import { spSetup } from "./setup.js"; import "@pnp/sp/webs"; import "@pnp/sp/lists"; -import "@pnp/sp/items"; declare var process: { exit(code?: number): void }; @@ -11,13 +10,13 @@ export async function Example(settings: ITestingSettings) { const sp = spSetup(settings); - const w = await sp.web(); + const w = await sp.web.lists(); Logger.log({ data: w, level: LogLevel.Info, message: "Web Data", }); - + process.exit(0); } diff --git a/docs/graph/site-openextensions.md b/docs/graph/site-openextensions.md new file mode 100644 index 000000000..fe8ff0c99 --- /dev/null +++ b/docs/graph/site-openextensions.md @@ -0,0 +1,3 @@ +# Site Open Extensions + +// TODO \ No newline at end of file diff --git a/docs/queryable/behaviors.md b/docs/queryable/behaviors.md index 5bc94e91f..e9b0962fa 100644 --- a/docs/queryable/behaviors.md +++ b/docs/queryable/behaviors.md @@ -491,3 +491,40 @@ setTimeout(() => { // this is awaiting the results of the request await p; ``` + +### DebugHeaders + +Adds logging for the request id and timestamp of the request, helpful when contacting Microsoft Support. It works for both Graph and SP libraries. + +```TypeScript +import { DebugHeaders } from "@pnp/queryable"; +import { spfi } from "@pnp/sp"; + +const sp = spfi().using(DebugHeaders()); + +sp.some_action(); + +// output to log: +// Server Request Id: {guid} +// Server Date: {date} +``` + +You can also supply additional headers to log from the response: + + +```TypeScript +import { DebugHeaders } from "@pnp/queryable"; +import { spfi } from "@pnp/sp"; + +const sp = spfi().using(DebugHeaders(["X-MyHeader", "client-request-id"])); + +sp.some_action(); + +// output to log: +// Server Request Id: {guid} +// Server Date: {date} +// X-MyHeader: {value} +// client-request-id: {guid} +``` + + diff --git a/docs/sp/items.md b/docs/sp/items.md index b0784e5da..126024646 100644 --- a/docs/sp/items.md +++ b/docs/sp/items.md @@ -113,6 +113,89 @@ const r = await sp.web.lists.getByTitle("TaxonomyList").getItemsByCAMLQuery({ }); ``` +### Filter using fluent filter + +>Note: This feature is currently in preview and may not work as expected. + +PnPjs supports a fluent filter for all OData endpoints, including the items endpoint. this allows you to write a strongly fluent filter that will be parsed into an OData filter. + +```TypeScript +import { spfi } from "@pnp/sp"; +import "@pnp/sp/webs"; +import "@pnp/sp/lists"; + +const sp = spfi(...); + +const r = await sp.web.lists.filter(l => l.number("ItemCount").greaterThan(5000))(); +``` + +The following field types are supported in the fluent filter: + +- Text +- Choice +- MultiChoice +- Number +- Date +- Boolean +- Lookup +- LookupId + +The following operations are supported in the fluent filter: + +| Field Type | Operators/Values | +| -------------------- | -------------------------------------------------------------------------------------------- | +| All field types | `equals`, `notEquals`, `in`, `notIn` | +| Text & choice fields | `startsWith`, `contains` | +| Numeric fields | `greaterThan`, `greaterThanOrEquals`, `lessThan`, `lessThanOrEquals` | +| Date fields | `greaterThan`, `greaterThanOrEquals`, `lessThan`, `lessThanOrEquals`, `isBetween`, `isToday` | +| Boolean fields | `isTrue`, `isFalse`, `isFalseOrNull` | +| Lookup | `id`, Text and Number field types | + +#### Complex Filter + +For all the regular endpoints, the fluent filter will infer the type automatically, but for the list items filter, you'll need to provide your own types to make the parser work. + +You can use the `and` and `or` operators to create complex filters that nest different grouping. + +```TypeScript +import { spfi } from "@pnp/sp"; +import "@pnp/sp/webs"; +import "@pnp/sp/lists"; +import "@pnp/sp/items"; + +const sp = spfi(...); + +interface ListItem extends IListItem { + FirstName: string; + LastName: string; + Age: number; + Manager: IListItem; + StartDate: Date; +} + + +// Get all employees named John +const r = await sp.web.lists.getByTitle("ListName").items.filter(f => f.text("FirstName").equal("John"))(); + +// Get all employees not named John who are over 30 +const r1 = await sp.web.lists.getByTitle("ListName").items.filter(f => f.text("FirstName").notEquals("John").and().number("Age").greaterThan(30))(); + +// Get all employees that are named John Doe or Jane Doe +const r2 = await sp.web.lists.getByTitle("ListName").items.filter(f => f.or( + f.and( + f.text("FirstName").equals("John"), + f.text("LastName").equals("Doe") + ), + f.and( + f.text("FirstName").equals("Jane"), + f.text("LastName").equals("Doe") + ) +))(); + +// Get all employees who are managed by John and start today +const r3 = await sp.web.lists.getByTitle("ListName").items.filter(f => f.lookup("Manager").text("FirstName").equals("John").and().date("StartDate").isToday())(); +``` + ### Retrieving PublishingPageImage The PublishingPageImage and some other publishing-related fields aren't stored in normal fields, rather in the MetaInfo field. To get these values you need to use the technique shown below, and originally outlined in [this thread](https://github.com/SharePoint/PnP-JS-Core/issues/178). Note that a lot of information can be stored in this field so will pull back potentially a significant amount of data, so limit the rows as possible to aid performance. @@ -326,6 +409,8 @@ const sp = spfi(...); // you are getting back a collection here const items: any[] = await sp.web.lists.getByTitle("MyList").items.top(1).filter("Title eq 'A Title'")(); +// Using fluent filter +const items1: any[] = await sp.web.lists.getByTitle("MyList").items.top(1).filter(f => f.text("Title").equals("A Title"))(); // see if we got something if (items.length > 0) { @@ -425,6 +510,9 @@ const sp = spfi(...); // first we need to get the hidden field's internal name. // The Title of that hidden field is, in my case and in the linked article just the visible field name with "_0" appended. const fields = await sp.web.lists.getByTitle("TestList").fields.filter("Title eq 'MultiMetaData_0'").select("Title", "InternalName")(); +// Using fluent filter +const fields1 = await sp.web.lists.getByTitle("TestList").fields.filter(f => f.text("Title").equals("MultiMetaData_0")).select("Title", "InternalName")(); + // get an item to update, here we just create one for testing const newItem = await sp.web.lists.getByTitle("TestList").items.add({ Title: "Testing", @@ -593,6 +681,15 @@ const response = .filter(`Hidden eq false and Title eq '[Field's_Display_Name]'`) (); +// Using fluent filter +const response1 = + await sp.web.lists + .getByTitle('[Lists_Title]') + .fields + .select('Title, EntityPropertyName') + .filter(l => l.boolean("Hidden").isFalse().and().text("Title").equals("[Field's_Display_Name]")) + (); + console.log(response.map(field => { return { Title: field.Title, diff --git a/docs/sp/webs.md b/docs/sp/webs.md index c9e6e1b40..b57627542 100644 --- a/docs/sp/webs.md +++ b/docs/sp/webs.md @@ -254,12 +254,15 @@ const infos2 = await web.webinfos.select("Title", "Description")(); // or filter const infos3 = await web.webinfos.filter("Title eq 'MyWebTitle'")(); +// Using fluent filter +const infos4 = await web.webinfos.filter(w => w.text("Title").equals('MyWebTitle'))(); + // or both -const infos4 = await web.webinfos.select("Title", "Description").filter("Title eq 'MyWebTitle'")(); +const infos5 = await web.webinfos.select("Title", "Description").filter(w => w.text("Title").equals('MyWebTitle'))(); // get the top 4 ordered by Title -const infos5 = await web.webinfos.top(4).orderBy("Title")(); +const infos6 = await web.webinfos.top(4).orderBy("Title")(); ``` > Note: webinfos returns [IWebInfosData](#IWebInfosData) which is a subset of all the available fields on IWebInfo. @@ -537,9 +540,12 @@ const folders = await sp.web.folders(); // you can also filter and select as with any collection const folders2 = await sp.web.folders.select("ServerRelativeUrl", "TimeLastModified").filter("ItemCount gt 0")(); +// Using fluent filter +const folders3 = await sp.web.folders.select("ServerRelativeUrl", "TimeLastModified").filter(f => f.number("ItemCount").greaterThan(0))(); + // or get the most recently modified folder -const folders2 = await sp.web.folders.orderBy("TimeLastModified").top(1)(); +const folders4 = await sp.web.folders.orderBy("TimeLastModified").top(1)(); ``` ### rootFolder @@ -856,6 +862,9 @@ const users = await sp.web.siteUsers(); const users2 = await sp.web.siteUsers.top(5)(); const users3 = await sp.web.siteUsers.filter(`startswith(LoginName, '${encodeURIComponent("i:0#.f|m")}')`)(); +// Using fluent filter +const user4 = await sp.web.siteUsers.filter(u => u.text("LoginName").startsWith(encodeURIComponent("i:0#.f|m")))(); + ``` ### currentUser diff --git a/mkdocs.yml b/mkdocs.yml index 1ee2e9f3a..7fd3b5115 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -78,6 +78,7 @@ nav: - search: 'graph/search.md' - shares: 'graph/shares.md' - sites: 'graph/sites.md' + - 'site openextensions': 'graph/site-openextensions.md' - subscriptions: 'graph/subscriptions.md' - taxonomy: 'graph/taxonomy.md' - teams: 'graph/teams.md' diff --git a/package.json b/package.json index cdad6fd5b..2806c7e1d 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "@pnp/monorepo", "private": true, "type": "module", - "version": "4.6.0", + "version": "4.7.0", "description": "A JavaScript library for SharePoint & Graph development.", "devDependencies": { "@azure/identity": "4.4.1", diff --git a/packages/core/util.ts b/packages/core/util.ts index 9bac79d7d..1a0bc2bb2 100644 --- a/packages/core/util.ts +++ b/packages/core/util.ts @@ -34,8 +34,7 @@ export function combine(...paths: (string | null | undefined)[]): string { return paths .filter(path => !stringIsNullOrEmpty(path)) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - .map(path => path!.replace(/^[\\|/]/, "").replace(/[\\|/]$/, "")) + .map(path => path.replace(/^[\\|/]/, "").replace(/[\\|/]$/, "")) .join("/") .replace(/\\/g, "/"); } diff --git a/packages/graph/decorators.ts b/packages/graph/decorators.ts index 53ec5b0be..158463061 100644 --- a/packages/graph/decorators.ts +++ b/packages/graph/decorators.ts @@ -147,8 +147,8 @@ export function getById(factory: (...args: any[]) => R) { return function (target: T) { return class extends target { - public getById(this: IGraphQueryable, id: string): R { - return factory(this, id); + public getById(this: IGraphQueryable, id: any): R { + return factory(this, `${id}`); } }; }; diff --git a/packages/graph/graphqueryable.ts b/packages/graph/graphqueryable.ts index 324640e83..c53bf3695 100644 --- a/packages/graph/graphqueryable.ts +++ b/packages/graph/graphqueryable.ts @@ -1,4 +1,4 @@ -import { isArray, objectDefinedNotNull } from "@pnp/core"; +import { isArray } from "@pnp/core"; import { IInvokable, Queryable, queryableFactory, op, get, post, patch, del, put } from "@pnp/queryable"; import { ConsistencyLevel } from "./behaviors/consistency-level.js"; import { IPagedResult, Paged } from "./behaviors/paged.js"; @@ -80,17 +80,12 @@ export class _GraphQueryable extends Queryable { * * @param factory The contructor for the class to create */ - protected getParent( - factory: IGraphConstructor, - base: GraphInit = this.parentUrl, - path?: string): T { + protected getParent( + factory: IGraphInvokableFactory, + path?: string, + base: string = this.parentUrl): T { - if (typeof base === "string") { - // we need to ensure the parent has observers, even if we are rebasing the url (#2435) - base = [this, base]; - } - - return new factory(base, path); + return factory([this, base], path); } } @@ -169,13 +164,9 @@ export class _GraphCollection extends _GraphQueryable const q = GraphCollection(this).using(Paged(), ConsistencyLevel()); - const queryParams = ["$search", "$top", "$select", "$expand", "$filter", "$orderby"]; - - for (let i = 0; i < queryParams.length; i++) { - const param = this.query.get(queryParams[i]); - if (objectDefinedNotNull(param)) { - q.query.set(queryParams[i], param); - } + // Issue #3136, some APIs take other query params that need to persist through the paging, so we just include everything + for (const [key, value] of this.query) { + q.query.set(key, value); } return >{ @@ -201,7 +192,6 @@ export class _GraphCollection extends _GraphQueryable }; } } - export interface IGraphCollection extends _GraphCollection { } export const GraphCollection = graphInvokableFactory(_GraphCollection); @@ -210,7 +200,6 @@ export const GraphCollection = graphInvokableFactory(_GraphCol * */ export class _GraphInstance extends _GraphQueryable { } - export interface IGraphInstance extends IInvokable, IGraphQueryable { } export const GraphInstance = graphInvokableFactory(_GraphInstance); diff --git a/packages/graph/onenote/index.ts b/packages/graph/onenote/index.ts index dec8f5774..0b6394cfe 100644 --- a/packages/graph/onenote/index.ts +++ b/packages/graph/onenote/index.ts @@ -6,15 +6,21 @@ export { INotebook, INotebooks, IOneNote, - IPages, IResources, ISection, ISections, Notebook, Notebooks, OneNote, - Page, - Pages, + ICopyProps, + IOnenotePage, + IOnenotePages, + ISectionGroup, + ISectionGroups, + OnenotePage, + OnenotePages, + SectionGroup, + SectionGroups, Resources, Section, Sections, diff --git a/packages/graph/onenote/types.ts b/packages/graph/onenote/types.ts index 94f4e12dc..946816c38 100644 --- a/packages/graph/onenote/types.ts +++ b/packages/graph/onenote/types.ts @@ -32,8 +32,8 @@ export class _OneNote extends _GraphInstance { return Notebooks(this); } - public get pages(): IPages { - return Pages(this); + public get pages(): IOnenotePages { + return OnenotePages(this); } public get resources(): IResources { @@ -107,8 +107,8 @@ export const Notebooks = graphInvokableFactory(_Notebooks); */ export class _Section extends _GraphInstance { - public get pages(): IPages { - return Pages(this); + public get pages(): IOnenotePages { + return OnenotePages(this); } /** @@ -187,7 +187,7 @@ export const SectionGroups = graphInvokableFactory(_SectionGroup * */ @deleteable() -export class _Page extends _GraphInstance { +export class _OnenotePage extends _GraphInstance { /** * Copy page to section * @param props of type ICopyPageProps. groupId (id of group to copy to. Use only when copying to M365 group), id of destination notebook @@ -202,7 +202,7 @@ export class _Page extends _GraphInstance { * @param includeIDs page html body */ public async content(includeIDs = false): Promise { - return Page(this, `content?includeIDs=${includeIDs}`).using(TextParse())(); + return OnenotePage(this, `content?includeIDs=${includeIDs}`).using(TextParse())(); } /** @@ -213,16 +213,16 @@ export class _Page extends _GraphInstance { return graphPatch(GraphQueryable(this, "content"), body(props)); } } -export interface IPage extends _Page, IDeleteable { } -export const Page = graphInvokableFactory(_Page); +export interface IOnenotePage extends _OnenotePage, IDeleteable { } +export const OnenotePage = graphInvokableFactory(_OnenotePage); /** * Describes a collection of page objects * */ @defaultPath("pages") -@getById(Page) -export class _Pages extends _GraphCollection { +@getById(OnenotePage) +export class _OnenotePages extends _GraphCollection { /** * Create a new page as specified in the request body. * @@ -237,8 +237,8 @@ export class _Pages extends _GraphCollection { return graphPost(q, { body: html }); } } -export interface IPages extends _Pages, IGetById { } -export const Pages = graphInvokableFactory(_Pages); +export interface IOnenotePages extends _OnenotePages, IGetById { } +export const OnenotePages = graphInvokableFactory(_OnenotePages); /** * Describes a resources diff --git a/packages/graph/open-extensions/index.ts b/packages/graph/open-extensions/index.ts new file mode 100644 index 000000000..cfb507ac3 --- /dev/null +++ b/packages/graph/open-extensions/index.ts @@ -0,0 +1,9 @@ +import "./site.js"; + +export { + IBaseExtensionData as IBaseOpenExtension, + IOpenExtension, + IOpenExtensions, + OpenExtension, + OpenExtensions, +} from "./types.js"; diff --git a/packages/graph/open-extensions/site.ts b/packages/graph/open-extensions/site.ts new file mode 100644 index 000000000..547143e79 --- /dev/null +++ b/packages/graph/open-extensions/site.ts @@ -0,0 +1,13 @@ +import { addProp } from "@pnp/queryable"; +import { _Site } from "../sites/types.js"; +import { IOpenExtensions, OpenExtensions } from "./types.js"; + +declare module "../sites/types" { + interface _Site { + readonly extensions: IOpenExtensions; + } + interface ISite { + readonly extensions: IOpenExtensions; + } +} +addProp(_Site, "extensions", OpenExtensions); diff --git a/packages/graph/open-extensions/types.ts b/packages/graph/open-extensions/types.ts new file mode 100644 index 000000000..2d7dde5da --- /dev/null +++ b/packages/graph/open-extensions/types.ts @@ -0,0 +1,40 @@ +import { body } from "@pnp/queryable"; +import { Extension as ExtensionType } from "@microsoft/microsoft-graph-types"; +import { _GraphCollection, graphInvokableFactory, graphPatch, graphPost } from "../graphqueryable.js"; +import { getById, IGetById, deleteable, IDeleteable, defaultPath } from "../decorators.js"; + +export interface IBaseExtensionData { + extensionName: string; +} + +/** + * Open Extension + */ +@deleteable() +export class _OpenExtension extends _GraphCollection { + + public update(extension: T): Promise { + return graphPatch(this, body(extension)); + } +} +export interface IOpenExtension extends _OpenExtension, IDeleteable { } +export const OpenExtension = graphInvokableFactory(_OpenExtension); + +/** + * Open Extensions + */ +@defaultPath("extensions") +@getById(OpenExtension) +export class _OpenExtensions extends _GraphCollection { + + public create(extension: T): Promise { + + if (extension.extensionName.length > 30) { + throw Error("Extension id length should be less than or equal to 30 characters."); + } + + return graphPost(this, body(extension)); + } +} +export interface IOpenExtensions extends _OpenExtensions, IGetById { } +export const OpenExtensions = graphInvokableFactory(_OpenExtensions); diff --git a/packages/graph/pages/index.ts b/packages/graph/pages/index.ts new file mode 100644 index 000000000..f19bdcd32 --- /dev/null +++ b/packages/graph/pages/index.ts @@ -0,0 +1,34 @@ +import "./site.js"; + +export * from "./webpart-types.js"; + +export { + IPage, + IPages, + Page, + Pages, + HorizontalSection, + HorizontalSectionColumn, + HorizontalSectionColumns, + HorizontalSections, + IHorizontalSection, + IHorizontalSectionColumn, + IHorizontalSectionColumnInfo, + IHorizontalSectionColumns, + IHorizontalSectionInfo, + IHorizontalSections, + IPageInfo, + IPageUserInfo, + ISitePage, + ISitePageInfo, + ISitePages, + IVerticalSection, + IVerticalSectionInfo, + IWebpart, + IWebparts, + SitePage, + SitePages, + VerticalSection, + Webpart, + Webparts, +} from "./types.js"; diff --git a/packages/graph/pages/site.ts b/packages/graph/pages/site.ts new file mode 100644 index 000000000..25c52ade8 --- /dev/null +++ b/packages/graph/pages/site.ts @@ -0,0 +1,14 @@ +import { addProp } from "@pnp/queryable"; +import { _Site } from "../sites/types.js"; +import { IPages, Pages } from "./types.js"; + +declare module "../sites/types" { + interface _Site { + readonly pages: IPages; + } + interface ISite { + readonly pages: IPages; + } +} + +addProp(_Site, "pages", Pages); diff --git a/packages/graph/pages/types.ts b/packages/graph/pages/types.ts new file mode 100644 index 000000000..cdbb61fa1 --- /dev/null +++ b/packages/graph/pages/types.ts @@ -0,0 +1,261 @@ + +import { combine } from "@pnp/core"; +import { body } from "@pnp/queryable"; +import { IDeleteable, IGetById, IUpdateable, defaultPath, deleteable, getById, updateable } from "../decorators.js"; +import { graphInvokableFactory, _GraphCollection, _GraphInstance, GraphInit, graphPost } from "../graphqueryable.js"; +import { ValidWebpart } from "./webpart-types.js"; + +/** + * Page + */ +@deleteable() +@updateable() +export class _Page extends _GraphInstance { } +export interface IPage extends _Page, IUpdateable>, IDeleteable { } +export const Page = graphInvokableFactory(_Page); + +/** + * Pages + */ +@defaultPath("pages") +@getById(Page) +export class _Pages extends _GraphCollection { + public get sitePages(): ISitePages { + return SitePages(this); + } +} +export interface IPages extends _Pages, IGetById { } +export const Pages = graphInvokableFactory(_Pages); + +/** + * Site Page + */ +@deleteable() +@updateable() +export class _SitePage extends _GraphInstance { + + /** + * Publishes the page + * @returns void + */ + public async publish(): Promise { + return graphPost(SitePage(this, "publish")); + } + + /** + * Gets the webparts in the page + * + * @returns array fo webpart information + */ + public async getWebPartsByPosition(): Promise { + return SitePage(this, "getWebPartsByPosition")(); + } + + /** + * Get a listing of all the webparts in this page + */ + public get webparts(): IWebparts { + return Webparts(this); + } + + /** + * Gets the set of horizontal sections + */ + public get horizontalSections(): IHorizontalSections { + return HorizontalSections(this); + } + + /** + * Gets the set of vertical section + */ + public get verticalSection(): IVerticalSection { + return VerticalSection(this); + } + + /** + * Creates a vertical section if none exists, returns the vertical section + */ + public ensureVerticalSection(): IVerticalSection { + + const y = this.select("verticalSection")(); + + console.log(y); + + + return null; + + } +} +export interface ISitePage extends _SitePage, IUpdateable>, IDeleteable { } +export const SitePage = graphInvokableFactory(_SitePage); + +const SitePageTypeString = "microsoft.graph.sitePage"; + +/** + * Site Pages + */ +@defaultPath(SitePageTypeString) +export class _SitePages extends _GraphCollection { + + private _pages: IPages; + + constructor(base: GraphInit, path?: string) { + super(base, path); + this._pages = this.getParent(Pages, ""); + } + + public getById(this: ISitePages, id: string): ISitePage { + return SitePage(this._pages, combine(id, SitePageTypeString)); + } + + public async add(pageInfo: Partial>): Promise { + return graphPost(this._pages, body({ "@odata.type": SitePageTypeString, ...pageInfo })); + } +} +export interface ISitePages extends _SitePages { } +export const SitePages = graphInvokableFactory(_SitePages); + +@updateable() +@deleteable() +export class _HorizontalSection extends _GraphInstance { + + public get columns(): IHorizontalSectionColumns { + return HorizontalSectionColumns(this); + } +} +export interface IHorizontalSection extends _HorizontalSection, IUpdateable, IDeleteable { } +export const HorizontalSection = graphInvokableFactory(_HorizontalSection); + +@defaultPath("canvasLayout/horizontalSections") +export class _HorizontalSections extends _GraphCollection { + + public async add(props: Partial): Promise { + return graphPost(this, body(props)); + } + + public getById(id: string | number): IHorizontalSection { + const section = HorizontalSection(this); + return section.concat(`('${id}')`); + } +} +export interface IHorizontalSections extends _HorizontalSections, IGetById { } +export const HorizontalSections = graphInvokableFactory(_HorizontalSections); + +export class _HorizontalSectionColumn extends _GraphInstance { + + public get webparts(): IWebparts { + return Webparts(this); + } +} +export interface IHorizontalSectionColumn extends _HorizontalSectionColumn { } +export const HorizontalSectionColumn = graphInvokableFactory(_HorizontalSectionColumn); + +@defaultPath("columns") +export class _HorizontalSectionColumns extends _GraphCollection { + + public getById(id: string | number): IHorizontalSectionColumn { + const column = HorizontalSectionColumn(this); + return column.concat(`('${id}')`); + } +} +export interface IHorizontalSectionColumns extends _HorizontalSectionColumns, IGetById { } +export const HorizontalSectionColumns = graphInvokableFactory(_HorizontalSectionColumns); + +@updateable() +@deleteable() +@defaultPath("canvasLayout/verticalSection") +export class _VerticalSection extends _GraphInstance { + /** + * Get a listing of all the webparts in this vertical section + */ + public get webparts(): IWebparts { + return Webparts(this); + } +} +export interface IVerticalSection extends _VerticalSection, IUpdateable, IDeleteable { } +export const VerticalSection = graphInvokableFactory(_VerticalSection); + +export class _Webpart extends _GraphInstance { } +export interface IWebpart extends _Webpart { } +export const Webpart = graphInvokableFactory(_Webpart); + +@defaultPath("webparts") +export class _Webparts extends _GraphCollection { + + /** + * Gets the webpart information by id from the page's collection + * @param id string id of the webpart + * @returns The IWebpart instance + */ + public getById(id: string): IWebpart { + + const url = this.toUrl(); + const base = url.slice(0, url.indexOf(SitePageTypeString) + SitePageTypeString.length); + return Webpart([this, base], `webparts/${id}`); + } +} +export interface IWebparts extends _Webparts, IGetById { } +export const Webparts = graphInvokableFactory(_Webparts); + + +/** + * Contains info representing a vertical section + */ +export interface IVerticalSectionInfo { + emphasis: "none" | "netural" | "soft" | "strong" | "unknownFutureValue"; + id: string; +} + +/** + * Contains info representing a horizontal section + */ +export interface IHorizontalSectionInfo { + emphasis: "none" | "netural" | "soft" | "strong" | "unknownFutureValue"; + id: string; + layout: "none" | "oneColumn" | "twoColumns" | "threeColumns" | "oneThirdLeftColumn" | "oneThirdRightColumn" | "fullWidth" | "unknownFutureValue"; + columns: IHorizontalSectionColumnInfo[]; +} + +/** + * Contains info representing a horizontal section column + */ +export interface IHorizontalSectionColumnInfo { + id: string; + width: string; + webparts: any[]; +} + +/** + * Contains info representing a path user + */ +export interface IPageUserInfo { + displayName: string; + email?: string; +} + +export interface ISitePageInfo extends IPageInfo { } + +export interface IPageInfo { + "@odata.type"?: string; + "@odata.etag"?: string; + contentType: { + id: string; + name: string; + }; + createdDateTime: string; + eTag: string; + id: string; + createdBy: { user: IPageUserInfo }; + lastModifiedBy: { user: IPageUserInfo }; + lastModifiedDateTime: string; + name: string; + pageLayout: string; + parentReference: { siteId: string }; + promotionKind: string; + publishingState: { level: string; versionId: string }; + reactions: any; + showComments: boolean; + showRecommendedPages: boolean; + title: string; + webUrl: string; +} diff --git a/packages/graph/pages/webpart-types.ts b/packages/graph/pages/webpart-types.ts new file mode 100644 index 000000000..0fe9c334a --- /dev/null +++ b/packages/graph/pages/webpart-types.ts @@ -0,0 +1,20 @@ +// We welcome contributions to filling out the available webpart types in the service. Do not add non-Microsoft webparts +// 1. Add an interface for the webpart definition +// 2. Add the interface to the ValidWebpart type at the top of the file +// 2. Add the interface to the ValidWebpartNoAny type at the top of the file + +/** + * Defines the schemas for valid webparts provided by Microsoft. Includes 'any' to avoid typing errors for undefined webparts + */ +export type ValidWebpart = MSTextWebPart | any; + +/** + * Defines the schemas for valid webparts provided by Microsoft. Does not allow 'any' + */ +export type ValidWebpartNoAny = MSTextWebPart; + +export interface MSTextWebPart { + "@odata.type": "#microsoft.graph.textWebPart"; + id: string; + innerHtml: string; +} diff --git a/packages/graph/presets/all.ts b/packages/graph/presets/all.ts index 6c08ef8c8..b74c17bdf 100644 --- a/packages/graph/presets/all.ts +++ b/packages/graph/presets/all.ts @@ -1,7 +1,9 @@ +import "../index.js"; import "../admin/index.js"; import "../analytics/index.js"; import "../appCatalog/index.js"; import "../attachments/index.js"; +import "../bookings/index.js"; import "../calendars/index.js"; import "../cloud-communications/index.js"; import "../columns/index.js"; @@ -20,6 +22,7 @@ import "../mail/index.js"; import "../members/index.js"; import "../onenote/index.js"; import "../operations/index.js"; +import "../pages/index.js"; import "../permissions/index.js"; import "../photos/index.js"; import "../places/index.js"; @@ -29,13 +32,16 @@ import "../shares/index.js"; import "../sites/index.js"; import "../subscriptions/index.js"; import "../taxonomy/index.js"; +import "../teams/index.js"; import "../to-do/index.js"; import "../users/index.js"; +export * from "../index.js"; export * from "../admin/index.js"; export * from "../analytics/index.js"; export * from "../appCatalog/index.js"; export * from "../attachments/index.js"; +export * from "../bookings/index.js"; export * from "../calendars/index.js"; export * from "../cloud-communications/index.js"; export * from "../columns/index.js"; @@ -54,6 +60,7 @@ export * from "../mail/index.js"; export * from "../members/index.js"; export * from "../onenote/index.js"; export * from "../operations/index.js"; +export * from "../pages/index.js"; export * from "../permissions/index.js"; export * from "../photos/index.js"; export * from "../places/index.js"; @@ -63,5 +70,6 @@ export * from "../shares/index.js"; export * from "../sites/index.js"; export * from "../subscriptions/index.js"; export * from "../taxonomy/index.js"; +export * from "../teams/index.js"; export * from "../to-do/index.js"; export * from "../users/index.js"; diff --git a/packages/queryable/behaviors/debug-headers.ts b/packages/queryable/behaviors/debug-headers.ts new file mode 100644 index 000000000..77478a1ce --- /dev/null +++ b/packages/queryable/behaviors/debug-headers.ts @@ -0,0 +1,27 @@ +import { TimelinePipe } from "@pnp/core"; +import { Queryable } from "../queryable.js"; + +/** + * + * @param otherHeaders Optional list of additional headers to log from the response + * @returns A timeline pipe + */ +export function DebugHeaders(otherHeaders: string[] = []): TimelinePipe { + + return (instance: Queryable) => { + + instance.on.parse.prepend(async function (this: Queryable, url, response, result) { + + // here we add logging for the request id and timestamp to assist in reporting issues to Microsoft + const searchHeaders = ["request-id", "sprequestguid", "date", ...otherHeaders]; + + for (let i = 0; i < searchHeaders.length; i++) { + this.log(`${searchHeaders[i]}: ${response.headers.get(searchHeaders[i]) ?? ""}`); + } + + return [url, response, result]; + }); + + return instance; + }; +} diff --git a/packages/queryable/behaviors/parsers.ts b/packages/queryable/behaviors/parsers.ts index 68b171310..a2873c221 100644 --- a/packages/queryable/behaviors/parsers.ts +++ b/packages/queryable/behaviors/parsers.ts @@ -127,7 +127,7 @@ export class HttpRequestError extends Error { } public static async init(r: Response): Promise { - const t = await r.clone().text(); + const t = await r.text(); return new HttpRequestError(`Error making HttpClient request in queryable [${r.status}] ${r.statusText} ::> ${t}`, r); } } diff --git a/packages/queryable/index.ts b/packages/queryable/index.ts index c0b73bae0..1fa5af152 100644 --- a/packages/queryable/index.ts +++ b/packages/queryable/index.ts @@ -11,6 +11,7 @@ export * from "./behaviors/caching.js"; export * from "./behaviors/caching-pessimistic.js"; export * from "./behaviors/cancelable.js"; export * from "./behaviors/inject-headers.js"; +export * from "./behaviors/debug-headers.js"; export * from "./behaviors/parsers.js"; export * from "./behaviors/timeout.js"; export * from "./behaviors/resolvers.js"; diff --git a/packages/queryable/queryable.ts b/packages/queryable/queryable.ts index afe87e4e0..722e652b4 100644 --- a/packages/queryable/queryable.ts +++ b/packages/queryable/queryable.ts @@ -52,6 +52,11 @@ export type QueryParams = { /** Returns a string containing a query string suitable for use in a URL. Does not include the question mark. */ toString(): string; + + /** + * Iterator accessor + */ + [Symbol.iterator](): Iterator<[string, string]>; }; @invokable() diff --git a/packages/sp/spqueryable.ts b/packages/sp/spqueryable.ts index 0eaa0669a..57096fc3c 100644 --- a/packages/sp/spqueryable.ts +++ b/packages/sp/spqueryable.ts @@ -141,8 +141,16 @@ export class _SPCollection extends _SPQueryable { * * @param filter The string representing the filter query */ - public filter(filter: string): this { - this.query.set("$filter", filter); + public filter>(filter: string | ComparisonResult | ((f: InitialFieldQuery) => ComparisonResult)): this { + if (typeof filter === "object") { + this.query.set("$filter", filter.toString()); + return this; + } + if (typeof filter === "function") { + this.query.set("$filter", filter(SPOData.Where()).toString()); + return this; + } + this.query.set("$filter", filter.toString()); return this; } @@ -254,3 +262,304 @@ export const spPostDeleteETag = (o: ISPQueryable, init?: RequestIn export const spDelete = (o: ISPQueryable, init?: RequestInit): Promise => op(o, del, init); export const spPatch = (o: ISPQueryable, init?: RequestInit): Promise => op(o, patch, init); + + + +type KeysMatching = { [K in keyof T]: T[K] extends V ? K : never }[keyof T]; +type KeysMatchingObjects = { [K in keyof T]: T[K] extends object ? (T[K] extends Date ? never : K) : never }[keyof T]; +type UnwrapArray = T extends (infer U)[] ? U : T; + +enum FilterOperation { + Equals = "eq", + NotEquals = "ne", + GreaterThan = "gt", + GreaterThanOrEqualTo = "ge", + LessThan = "lt", + LessThanOrEqualTo = "le", + StartsWith = "startswith", + SubstringOf = "substringof" +} + +enum FilterJoinOperator { + And = "and", + AndWithSpace = " and ", + Or = "or", + OrWithSpace = " or " +} + +class SPOData { + public static Where() { + return new InitialFieldQuery([]); + } +} + +// Linting complains that TBaseInterface is unused, but without it all the intellisense is lost since it's carrying it through the chain +class BaseQuery { + + protected query: string[] = []; + + constructor(query: string[]) { + this.query = query; + } +} + + +class QueryableFields extends BaseQuery { + constructor(q: string[]) { + super(q); + } + + public text(internalName: KeysMatching): TextField { + return new TextField([...this.query, (internalName as string)]); + } + + public choice(internalName: KeysMatching): TextField { + return new TextField([...this.query, (internalName as string)]); + } + + public multiChoice(internalName: KeysMatching): TextField { + return new TextField([...this.query, (internalName as string)]); + } + + public number(internalName: KeysMatching): NumberField { + return new NumberField([...this.query, (internalName as string)]); + } + + public date(internalName: KeysMatching): DateField { + return new DateField([...this.query, (internalName as string)]); + } + + public boolean(internalName: KeysMatching): BooleanField { + return new BooleanField([...this.query, (internalName as string)]); + } + + public lookup>(internalName: TKey): LookupQueryableFields { + return new LookupQueryableFields([...this.query], internalName as string); + } + + public lookupId>(internalName: TKey): NumberField { + const col: string = (internalName as string).endsWith("Id") ? internalName as string : `${internalName as string}Id`; + return new NumberField([...this.query, col]); + } +} + +class QueryableAndResult extends QueryableFields { + public or(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): ComparisonResult { + return new ComparisonResult([...this.query, `(${queries.map(x => x.toString()).join(FilterJoinOperator.OrWithSpace)})`]); + } +} + +class QueryableOrResult extends QueryableFields { + public and(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): ComparisonResult { + return new ComparisonResult([...this.query, `(${queries.map(x => x.toString()).join(FilterJoinOperator.AndWithSpace)})`]); + } +} + +class InitialFieldQuery extends QueryableFields { + public or(): QueryableFields; + public or(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): ComparisonResult; + public or(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): (ComparisonResult | QueryableFields) { + if (queries == null || queries.length === 0) { + return new QueryableFields([...this.query, FilterJoinOperator.Or]); + } + return new ComparisonResult([...this.query, `(${queries.map(x => x.toString()).join(FilterJoinOperator.OrWithSpace)})`]); + } + + public and(): QueryableFields; + public and(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): ComparisonResult; + public and(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): (ComparisonResult | QueryableFields) { + if (queries == null || queries.length === 0) { + return new QueryableFields([...this.query, FilterJoinOperator.And]); + } + return new ComparisonResult([...this.query, `(${queries.map(x => x.toString()).join(FilterJoinOperator.AndWithSpace)})`]); + } +} + + + +class LookupQueryableFields extends BaseQuery { + private LookupField: string; + constructor(q: string[], LookupField: string) { + super(q); + this.LookupField = LookupField; + } + + public Id(id: number): ComparisonResult { + return new ComparisonResult([...this.query, `${this.LookupField}/Id`, FilterOperation.Equals, id.toString()]); + } + + public text(internalName: KeysMatching): TextField { + return new TextField([...this.query, `${this.LookupField}/${internalName as string}`]); + } + + public number(internalName: KeysMatching): NumberField { + return new NumberField([...this.query, `${this.LookupField}/${internalName as string}`]); + } + + // Support has been announced, but is not yet available in SharePoint Online + // https://www.microsoft.com/en-ww/microsoft-365/roadmap?filters=&searchterms=100503 + // public boolean(InternalName: KeysMatching): BooleanField { + // return new BooleanField([...this.query, `${this.LookupField}/${InternalName as string}`]); + // } +} + +class NullableField extends BaseQuery { + protected LastIndex: number; + protected InternalName: string; + + constructor(q: string[]) { + super(q); + this.LastIndex = q.length - 1; + this.InternalName = q[this.LastIndex]; + } + + protected toODataValue(value: TInputValueType): string { + return `'${value}'`; + } + + public isNull(): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.Equals, "null"]); + } + + public isNotNull(): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.NotEquals, "null"]); + } +} + +class ComparableField extends NullableField { + public equals(value: TInputValueType): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.Equals, this.toODataValue(value)]); + } + + public notEquals(value: TInputValueType): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.NotEquals, this.toODataValue(value)]); + } + + public in(...values: TInputValueType[]): ComparisonResult { + return SPOData.Where().or(...values.map(x => this.equals(x))); + } + + public notIn(...values: TInputValueType[]): ComparisonResult { + return SPOData.Where().and(...values.map(x => this.notEquals(x))); + } +} + +class TextField extends ComparableField { + public startsWith(value: string): ComparisonResult { + const filter = `${FilterOperation.StartsWith}(${this.InternalName}, ${this.toODataValue(value)})`; + this.query[this.LastIndex] = filter; + return new ComparisonResult([...this.query]); + } + + public contains(value: string): ComparisonResult { + const filter = `${FilterOperation.SubstringOf}(${this.toODataValue(value)}, ${this.InternalName})`; + this.query[this.LastIndex] = filter; + return new ComparisonResult([...this.query]); + } +} + +class BooleanField extends NullableField { + protected override toODataValue(value: boolean | null): string { + return `${value == null ? "null" : value ? 1 : 0}`; + } + + public isTrue(): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.Equals, this.toODataValue(true)]); + } + + public isFalse(): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.Equals, this.toODataValue(false)]); + } + + public isFalseOrNull(): ComparisonResult { + const filter = `(${[ + this.InternalName, + FilterOperation.Equals, + this.toODataValue(null), + FilterJoinOperator.Or, + this.InternalName, + FilterOperation.Equals, + this.toODataValue(false), + ].join(" ")})`; + this.query[this.LastIndex] = filter; + return new ComparisonResult([...this.query]); + } +} + +class NumericField extends ComparableField { + public greaterThan(value: TInputValueType): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.GreaterThan, this.toODataValue(value)]); + } + + public greaterThanOrEquals(value: TInputValueType): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.GreaterThanOrEqualTo, this.toODataValue(value)]); + } + + public lessThan(value: TInputValueType): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.LessThan, this.toODataValue(value)]); + } + + public lessThanOrEquals(value: TInputValueType): ComparisonResult { + return new ComparisonResult([...this.query, FilterOperation.LessThanOrEqualTo, this.toODataValue(value)]); + } +} + + +class NumberField extends NumericField { + protected override toODataValue(value: number): string { + return `${value}`; + } +} + +class DateField extends NumericField { + protected override toODataValue(value: Date): string { + return `'${value.toISOString()}'`; + } + + public isBetween(startDate: Date, endDate: Date): ComparisonResult { + const filter = `(${[ + this.InternalName, + FilterOperation.GreaterThan, + this.toODataValue(startDate), + FilterJoinOperator.And, + this.InternalName, + FilterOperation.LessThan, + this.toODataValue(endDate), + ].join(" ")})`; + this.query[this.LastIndex] = filter; + return new ComparisonResult([...this.query]); + } + + public isToday(): ComparisonResult { + const StartToday = new Date(); StartToday.setHours(0, 0, 0, 0); + const EndToday = new Date(); EndToday.setHours(23, 59, 59, 999); + return this.isBetween(StartToday, EndToday); + } +} + +class ComparisonResult extends BaseQuery { + public and(): QueryableAndResult; + // eslint-disable-next-line @typescript-eslint/semi + public and(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): ComparisonResult + // eslint-disable-next-line max-len + public and(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): (ComparisonResult | QueryableAndResult) { + if (queries == null || queries.length === 0) { + return new QueryableAndResult([...this.query, FilterJoinOperator.And]); + } + return new ComparisonResult([...this.query, FilterJoinOperator.And, `(${queries.map(x => x.toString()).join(FilterJoinOperator.AndWithSpace)})`]); + } + + public or(): QueryableOrResult; + public or(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): ComparisonResult; + // eslint-disable-next-line max-len + public or(...queries: (ComparisonResult | ((f: QueryableFields) => ComparisonResult))[]): (ComparisonResult | QueryableOrResult) { + if (queries == null || queries.length === 0) { + return new QueryableOrResult([...this.query, FilterJoinOperator.Or]); + } + return new ComparisonResult([...this.query, FilterJoinOperator.Or, `(${queries.map(x => x.toString()).join(FilterJoinOperator.OrWithSpace)})`]); + } + + public toString(): string { + return this.query.join(" "); + } +} diff --git a/tsconfig.json b/tsconfig.json index 74384c7bf..5c26c456a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,7 @@ "lib": [ "ES2015", "dom", - "ES2017.Object", + "ES2017.Object" ], "baseUrl": ".", "rootDir": ".",