mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
feat(Microsoft SharePoint Node): Add a document library picker for the trigger (no-changelog) (#39848)
This commit is contained in:
parent
6438ead58d
commit
f7e82abcba
|
|
@ -0,0 +1,106 @@
|
|||
import type { ILoadOptionsFunctions, INode } from 'n8n-workflow';
|
||||
import type { Mock } from 'vitest';
|
||||
import type { DeepMockProxy } from 'vitest-mock-extended';
|
||||
import { mock, mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import { versionDescription } from '../../../v2/actions/versionDescription';
|
||||
import { getDrives } from '../../../v2/drive';
|
||||
import { MicrosoftSharePointV2 } from '../../../v2/MicrosoftSharePointV2.node';
|
||||
import * as transport from '../../../v2/transport';
|
||||
import type * as _importType0 from '../../../v2/transport';
|
||||
|
||||
vi.mock('../../../v2/transport', async () => {
|
||||
const originalModule = await vi.importActual<typeof _importType0>('../../../v2/transport');
|
||||
return {
|
||||
...originalModule,
|
||||
microsoftApiRequest: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const SITE_ID =
|
||||
'contoso.sharepoint.com,2C712604-1370-44E7-A1F5-426573FDA80A,2D2244C3-251A-49EA-93A8-39E1C3A060FE';
|
||||
const ENCODED_SITE_ID = encodeURIComponent(SITE_ID);
|
||||
|
||||
const MIXED = {
|
||||
value: [
|
||||
{ id: 'b!docs', name: 'Documents' },
|
||||
{ id: 'b!hold', name: 'Preservation Hold Library', system: {} },
|
||||
{ id: 'b!policies', name: 'Policies' },
|
||||
{ name: 'No id at all' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('Microsoft SharePoint v2 — drive search', () => {
|
||||
let ctx: DeepMockProxy<ILoadOptionsFunctions>;
|
||||
const apiRequest = transport.microsoftApiRequest as Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ctx = mockDeep<ILoadOptionsFunctions>();
|
||||
ctx.getNode.mockReturnValue(mock<INode>({ typeVersion: 2 }));
|
||||
ctx.getNodeParameter.mockImplementation(
|
||||
(name: string, fallback?: unknown) =>
|
||||
(name === 'site' ? { mode: 'id', value: SITE_ID } : fallback) as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('selects the system facet, which Graph omits unless asked for', async () => {
|
||||
apiRequest.mockResolvedValue({ value: [] });
|
||||
|
||||
await getDrives.call(ctx);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
`/v1.0/sites/${ENCODED_SITE_ID}/drives`,
|
||||
{},
|
||||
{ $select: 'id,name,system' },
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'drops system libraries',
|
||||
undefined,
|
||||
[
|
||||
{ name: 'Documents', value: 'b!docs' },
|
||||
{ name: 'Policies', value: 'b!policies' },
|
||||
],
|
||||
],
|
||||
['filters by typed text', 'pol', [{ name: 'Policies', value: 'b!policies' }]],
|
||||
['matches case-insensitively', 'DOCU', [{ name: 'Documents', value: 'b!docs' }]],
|
||||
['returns nothing when no name matches', 'zzz', []],
|
||||
])('%s', async (_name, filter, expected) => {
|
||||
apiRequest.mockResolvedValue(MIXED);
|
||||
|
||||
expect((await getDrives.call(ctx, filter)).results).toEqual(expected);
|
||||
});
|
||||
|
||||
it('requests a next page exactly as returned, without re-sending the query', async () => {
|
||||
const nextLink = 'https://graph.microsoft.com/v1.0/sites/x/drives?$skiptoken=abc';
|
||||
apiRequest.mockResolvedValueOnce({ value: [], '@odata.nextLink': nextLink });
|
||||
const firstPage = await getDrives.call(ctx);
|
||||
|
||||
apiRequest.mockResolvedValueOnce({ value: [{ id: 'b!next', name: 'Later' }] });
|
||||
const secondPage = await getDrives.call(ctx, undefined, firstPage.paginationToken);
|
||||
|
||||
expect(firstPage.paginationToken).toBe(nextLink);
|
||||
expect(apiRequest).toHaveBeenLastCalledWith('GET', '', {}, {}, nextLink);
|
||||
expect(secondPage.results).toEqual([{ name: 'Later', value: 'b!next' }]);
|
||||
});
|
||||
|
||||
it('rejects an empty Site value before calling Graph', async () => {
|
||||
ctx.getNodeParameter.mockImplementation(
|
||||
(name: string, fallback?: unknown) =>
|
||||
(name === 'site' ? { mode: 'list', value: '' } : fallback) as never,
|
||||
);
|
||||
|
||||
await expect(getDrives.call(ctx)).rejects.toThrow("The 'Site' parameter is empty");
|
||||
expect(apiRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is wired into the node as a list-search method', () => {
|
||||
const node = new MicrosoftSharePointV2(versionDescription);
|
||||
|
||||
expect(node.methods?.listSearch?.getDrives).toBe(getDrives);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import type { INodeListSearchResult } from 'n8n-workflow';
|
||||
|
||||
import { type GraphSearchReply } from '../helpers/utils';
|
||||
import { resolveSiteId } from '../site';
|
||||
import { microsoftApiRequest, type SharePointContext } from '../transport';
|
||||
|
||||
type SharePointDrive = { id?: string; name?: string; system?: object };
|
||||
type DriveSearchReply = GraphSearchReply<SharePointDrive>;
|
||||
|
||||
/**
|
||||
* Searches a site's document libraries. Each library is one drive, and a
|
||||
* drive id is what the file change feed is addressed by.
|
||||
*
|
||||
* Graph hides system-faceted drives unless `system` is selected, so it is
|
||||
* selected here to drop them deliberately rather than by omission.
|
||||
*/
|
||||
export async function getDrives(
|
||||
this: SharePointContext,
|
||||
filter?: string,
|
||||
paginationToken?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const siteId = await resolveSiteId.call(this, 0);
|
||||
|
||||
const response = (
|
||||
paginationToken
|
||||
? await microsoftApiRequest.call(this, 'GET', '', {}, {}, paginationToken)
|
||||
: await microsoftApiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`/v1.0/sites/${encodeURIComponent(siteId)}/drives`,
|
||||
{},
|
||||
{ $select: 'id,name,system' },
|
||||
)
|
||||
) as DriveSearchReply;
|
||||
|
||||
const filterLower = filter?.toLowerCase();
|
||||
const results = (response.value ?? [])
|
||||
.filter(
|
||||
(drive) =>
|
||||
drive.id &&
|
||||
drive.system === undefined &&
|
||||
(!filterLower || (drive.name ?? '').toLowerCase().includes(filterLower)),
|
||||
)
|
||||
.map((drive) => ({ name: drive.name ?? String(drive.id), value: String(drive.id) }));
|
||||
|
||||
return { results, paginationToken: response['@odata.nextLink'] };
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { getDrives } from '../drive';
|
||||
import { getFiles } from '../file';
|
||||
import { getFolders } from '../folder';
|
||||
import { getItems } from '../item';
|
||||
|
|
@ -9,6 +10,7 @@ export const listSearch = {
|
|||
getFiles,
|
||||
getFolders,
|
||||
getSites,
|
||||
getDrives,
|
||||
getLists,
|
||||
getItems,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type {
|
||||
IExecuteFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
INodeListSearchResult,
|
||||
INodeParameterResourceLocator,
|
||||
|
|
@ -13,6 +12,7 @@ import {
|
|||
getSharePointCredentialType,
|
||||
microsoftApiRequest,
|
||||
SERVICE_PRINCIPAL_AUTH,
|
||||
type SharePointContext,
|
||||
} from '../transport';
|
||||
|
||||
// The whole site-selection piece lives here — the field, the search behind
|
||||
|
|
@ -155,7 +155,7 @@ export async function getSites(
|
|||
* hoist and pass none — each dropdown open is its own one-off lookup.
|
||||
*/
|
||||
export async function resolveSiteId(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
this: SharePointContext,
|
||||
itemIndex: number,
|
||||
siteIdCache?: Map<string, string>,
|
||||
): Promise<string> {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user