mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-28 17:22:01 +08:00
feat(Confluence Node): Add shared space and page selectors with list search (no-changelog) (#36293)
This commit is contained in:
@@ -2,10 +2,13 @@ import type { IExecuteFunctions, INodeType, INodeTypeDescription } from 'n8n-wor
|
||||
|
||||
import { confluenceNodeDescription } from './actions/description';
|
||||
import { router } from './actions/router';
|
||||
import { listSearch } from './methods';
|
||||
|
||||
export class Confluence implements INodeType {
|
||||
description: INodeTypeDescription = confluenceNodeDescription;
|
||||
|
||||
methods = { listSearch };
|
||||
|
||||
async execute(this: IExecuteFunctions) {
|
||||
return await router.call(this);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
ILoadOptionsFunctions,
|
||||
INodeProperties,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { CONFLUENCE_CREDENTIAL_NAME, confluenceApiRequest } from '../transport';
|
||||
|
||||
/**
|
||||
* Shared page-selection fields: operations spread `spaceRLC`/`pageRLC` and add
|
||||
* their own displayOptions. An empty space leaves page lookups site-wide.
|
||||
*/
|
||||
export const pageRLC: INodeProperties = {
|
||||
displayName: 'Page',
|
||||
name: 'page',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
required: true,
|
||||
description: 'The page to operate on',
|
||||
typeOptions: {
|
||||
loadOptionsDependsOn: ['space.value'],
|
||||
},
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'getPages',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By URL',
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. https://your-site.atlassian.net/wiki/spaces/DOCS/pages/123456/My+Page',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '.*/pages/(?:edit-v2/)?[0-9]+.*',
|
||||
errorMessage: 'The URL must contain /pages/<numeric page ID>',
|
||||
},
|
||||
},
|
||||
],
|
||||
extractValue: {
|
||||
type: 'regex',
|
||||
regex: '/pages/(?:edit-v2/)?([0-9]+)',
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. 123456',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^[0-9]+$',
|
||||
errorMessage: 'The page ID must be numeric',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
displayName: 'By Title',
|
||||
name: 'title',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. Project plan',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export type ConfluenceBodyFormat = 'storage' | 'atlas_doc_format' | 'plainText';
|
||||
|
||||
export const spaceRLC: INodeProperties = {
|
||||
displayName: 'Space',
|
||||
name: 'space',
|
||||
type: 'resourceLocator',
|
||||
default: { mode: 'list', value: '' },
|
||||
description: 'The Confluence space',
|
||||
modes: [
|
||||
{
|
||||
displayName: 'From List',
|
||||
name: 'list',
|
||||
type: 'list',
|
||||
typeOptions: {
|
||||
searchListMethod: 'searchSpaces',
|
||||
searchable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
displayName: 'By ID',
|
||||
name: 'id',
|
||||
type: 'string',
|
||||
placeholder: 'e.g. 98432',
|
||||
validation: [
|
||||
{
|
||||
type: 'regex',
|
||||
properties: {
|
||||
regex: '^[0-9]+$',
|
||||
errorMessage: 'The space ID must be numeric',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const spaceKeyCache = new Map<string, string>();
|
||||
|
||||
export function clearSpaceKeyCache(): void {
|
||||
spaceKeyCache.clear();
|
||||
}
|
||||
|
||||
export async function resolveSpaceKey(
|
||||
this: IExecuteFunctions | ILoadOptionsFunctions,
|
||||
spaceId: string,
|
||||
): Promise<string | undefined> {
|
||||
// Space IDs are only unique per site, so the cache is keyed per credential
|
||||
const rawCredentialId = this.getNode().credentials?.[CONFLUENCE_CREDENTIAL_NAME]?.id;
|
||||
const credentialId = typeof rawCredentialId === 'string' ? rawCredentialId : '';
|
||||
const cacheKey = `${credentialId}:${spaceId}`;
|
||||
|
||||
const cached = spaceKeyCache.get(cacheKey);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const space = await confluenceApiRequest.call(
|
||||
this,
|
||||
'GET',
|
||||
`/wiki/api/v2/spaces/${encodeURIComponent(spaceId)}`,
|
||||
);
|
||||
if (typeof space.key !== 'string' || space.key === '') return undefined;
|
||||
spaceKeyCache.set(cacheKey, space.key);
|
||||
return space.key;
|
||||
}
|
||||
|
||||
export function extractNextCursor(response: IDataObject): string | undefined {
|
||||
const next = (response._links as IDataObject | undefined)?.next;
|
||||
if (typeof next !== 'string' || next === '') return undefined;
|
||||
try {
|
||||
return new URL(next, 'https://api.atlassian.com').searchParams.get('cursor') ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * as listSearch from './listSearch';
|
||||
@@ -0,0 +1,162 @@
|
||||
import type {
|
||||
IDataObject,
|
||||
ILoadOptionsFunctions,
|
||||
INodeListSearchItems,
|
||||
INodeListSearchResult,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { extractNextCursor, resolveSpaceKey } from '../actions/common';
|
||||
import { confluenceApiRequest } from '../transport';
|
||||
|
||||
interface SearchPage {
|
||||
entries: IDataObject[];
|
||||
base: string;
|
||||
next?: string;
|
||||
}
|
||||
|
||||
const SEARCH_PAGE_SIZE = 50;
|
||||
const MAX_FILTERED_SEARCH_PAGES = 10;
|
||||
const EMPTY_PAGE: SearchPage = { entries: [], base: '' };
|
||||
|
||||
export async function searchSpaces(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
paginationToken?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const filterLower = (filter ?? '').trim().toLowerCase();
|
||||
const results: INodeListSearchItems[] = [];
|
||||
let cursor = paginationToken;
|
||||
|
||||
// No server-side text filter on the v2 spaces list; fetch ahead so matches
|
||||
// beyond the first page stay discoverable
|
||||
for (let fetched = 0; fetched < MAX_FILTERED_SEARCH_PAGES; fetched++) {
|
||||
const qs: IDataObject = { limit: SEARCH_PAGE_SIZE, sort: 'name', status: 'current' };
|
||||
if (cursor !== undefined) qs.cursor = cursor;
|
||||
|
||||
const response = await confluenceApiRequest.call(this, 'GET', '/wiki/api/v2/spaces', {}, qs);
|
||||
const entries = Array.isArray(response.results) ? (response.results as IDataObject[]) : [];
|
||||
|
||||
for (const space of entries) {
|
||||
if (typeof space.id !== 'string' && typeof space.id !== 'number') continue;
|
||||
if (typeof space.name !== 'string') continue;
|
||||
if (filterLower !== '' && !space.name.toLowerCase().includes(filterLower)) continue;
|
||||
const key = typeof space.key === 'string' && space.key !== '' ? ` (${space.key})` : '';
|
||||
results.push({ name: `${space.name}${key}`, value: String(space.id) });
|
||||
}
|
||||
|
||||
cursor = extractNextCursor(response);
|
||||
if (cursor === undefined || filterLower === '' || results.length > 0) break;
|
||||
}
|
||||
|
||||
return { results, paginationToken: cursor };
|
||||
}
|
||||
|
||||
async function fetchSearchPage(
|
||||
this: ILoadOptionsFunctions,
|
||||
cql: string,
|
||||
start?: number,
|
||||
): Promise<SearchPage> {
|
||||
const qs: IDataObject = { cql, limit: SEARCH_PAGE_SIZE };
|
||||
if (start !== undefined) qs.start = start;
|
||||
|
||||
const response = await confluenceApiRequest.call(this, 'GET', '/wiki/rest/api/search', {}, qs);
|
||||
const links = response._links as IDataObject | undefined;
|
||||
return {
|
||||
entries: Array.isArray(response.results) ? (response.results as IDataObject[]) : [],
|
||||
base: typeof links?.base === 'string' ? links.base : '',
|
||||
next: typeof links?.next === 'string' && links.next !== '' ? links.next : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function toPageItems(
|
||||
entries: IDataObject[],
|
||||
base: string,
|
||||
withSpaceLabel: boolean,
|
||||
): INodeListSearchItems[] {
|
||||
const results: INodeListSearchItems[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
const content = entry.content as IDataObject | undefined;
|
||||
if (content === undefined) continue;
|
||||
if (typeof content.id !== 'string' && typeof content.id !== 'number') continue;
|
||||
const id = String(content.id);
|
||||
if (seenIds.has(id)) continue;
|
||||
seenIds.add(id);
|
||||
const title = typeof content.title === 'string' && content.title !== '' ? content.title : id;
|
||||
// The space name disambiguates same-titled pages; redundant once scoped to one space
|
||||
const container = entry.resultGlobalContainer as IDataObject | undefined;
|
||||
const space =
|
||||
withSpaceLabel && typeof container?.title === 'string' && container.title !== ''
|
||||
? ` (${container.title})`
|
||||
: '';
|
||||
const webui = (content._links as IDataObject | undefined)?.webui;
|
||||
const url = base !== '' && typeof webui === 'string' ? `${base}${webui}` : undefined;
|
||||
results.push({ name: `${title}${space}`, value: id, url });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function nextStartToken(
|
||||
next: string | undefined,
|
||||
start: number,
|
||||
count: number,
|
||||
): string | undefined {
|
||||
if (next === undefined) return undefined;
|
||||
let parsed: string | null = null;
|
||||
try {
|
||||
parsed = new URL(next, 'https://api.atlassian.com').searchParams.get('start');
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
// A page can come back empty while next is still set; never repeat the same offset
|
||||
return parsed ?? String(start + Math.max(count, 1));
|
||||
}
|
||||
|
||||
function getScopedSpaceId(this: ILoadOptionsFunctions): string {
|
||||
try {
|
||||
const raw = this.getCurrentNodeParameter('space', { extractValue: true });
|
||||
return typeof raw === 'string' || typeof raw === 'number' ? String(raw).trim() : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPages(
|
||||
this: ILoadOptionsFunctions,
|
||||
filter?: string,
|
||||
paginationToken?: string,
|
||||
): Promise<INodeListSearchResult> {
|
||||
const start = paginationToken === undefined ? 0 : Number(paginationToken);
|
||||
const spaceId = getScopedSpaceId.call(this);
|
||||
|
||||
let spaceClause = '';
|
||||
if (spaceId !== '') {
|
||||
// CQL's space field matches by key, so the selected space ID is resolved first
|
||||
const spaceKey = await resolveSpaceKey.call(this, spaceId);
|
||||
if (spaceKey !== undefined) spaceClause = ` AND space = "${spaceKey}"`;
|
||||
}
|
||||
|
||||
const escaped = (filter ?? '').replace(/(["\\])/g, '\\$1');
|
||||
const cql =
|
||||
escaped === ''
|
||||
? `type=page${spaceClause} ORDER BY lastmodified DESC`
|
||||
: `type=page${spaceClause} AND title ~ "${escaped}*" ORDER BY lastmodified DESC`;
|
||||
|
||||
// Exact-title pages can be buried behind newer prefix matches, so page one
|
||||
// fetches them separately; toPageItems drops the overlap
|
||||
const exact =
|
||||
escaped !== '' && paginationToken === undefined
|
||||
? await fetchSearchPage.call(this, `type=page${spaceClause} AND title = "${escaped}"`)
|
||||
: EMPTY_PAGE;
|
||||
|
||||
const page = await fetchSearchPage.call(this, cql, start);
|
||||
|
||||
return {
|
||||
results: toPageItems(
|
||||
[...exact.entries, ...page.entries],
|
||||
page.base || exact.base,
|
||||
spaceId === '',
|
||||
),
|
||||
paginationToken: nextStartToken(page.next, start, page.entries.length),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import type { ILoadOptionsFunctions } from 'n8n-workflow';
|
||||
import { mockDeep } from 'vitest-mock-extended';
|
||||
|
||||
import { clearSpaceKeyCache } from '../../actions/common';
|
||||
import { getPages, searchSpaces } from '../../methods/listSearch';
|
||||
import { confluenceApiRequest } from '../../transport';
|
||||
|
||||
vi.mock('../../transport', () => ({
|
||||
CONFLUENCE_CREDENTIAL_NAME: 'confluenceCloudOAuth2Api',
|
||||
confluenceApiRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
const apiRequest = vi.mocked(confluenceApiRequest);
|
||||
|
||||
describe('Confluence listSearch.getPages', () => {
|
||||
let ctx: ILoadOptionsFunctions;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearSpaceKeyCache();
|
||||
ctx = mockDeep<ILoadOptionsFunctions>();
|
||||
vi.mocked(ctx.getNode).mockReturnValue({
|
||||
id: 'test-node',
|
||||
name: 'Test Confluence Node',
|
||||
type: 'n8n-nodes-base.confluence',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
credentials: { confluenceCloudOAuth2Api: { id: 'cred-1', name: 'account' } },
|
||||
});
|
||||
});
|
||||
|
||||
it('lists recently modified pages when no filter is given', async () => {
|
||||
apiRequest.mockResolvedValueOnce({
|
||||
_links: { base: 'https://example.atlassian.net/wiki' },
|
||||
results: [
|
||||
{
|
||||
content: { id: 123, title: 'Doc', _links: { webui: '/spaces/D/pages/123' } },
|
||||
resultGlobalContainer: { title: 'Docs Space' },
|
||||
},
|
||||
{ title: 'entry without content is skipped' },
|
||||
{ content: { id: 456 } },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getPages.call(ctx);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/wiki/rest/api/search',
|
||||
{},
|
||||
{ cql: 'type=page ORDER BY lastmodified DESC', limit: 50, start: 0 },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
results: [
|
||||
{
|
||||
name: 'Doc (Docs Space)',
|
||||
value: '123',
|
||||
url: 'https://example.atlassian.net/wiki/spaces/D/pages/123',
|
||||
},
|
||||
// Title falls back to the ID, no space suffix, no URL without webui link
|
||||
{ name: '456', value: '456', url: undefined },
|
||||
],
|
||||
paginationToken: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('scopes the search to the selected space and drops the space label', async () => {
|
||||
vi.mocked(ctx.getCurrentNodeParameter).mockReturnValue('999');
|
||||
apiRequest.mockImplementation(async (_method, endpoint, _body, qs) => {
|
||||
if (endpoint === '/wiki/api/v2/spaces/999') return { id: 999, key: 'DOCS' };
|
||||
if (endpoint === '/wiki/rest/api/search') {
|
||||
const cql = (qs as { cql: string }).cql;
|
||||
if (cql === 'type=page AND space = "DOCS" AND title = "plan"') return { results: [] };
|
||||
expect(cql).toBe(
|
||||
'type=page AND space = "DOCS" AND title ~ "plan*" ORDER BY lastmodified DESC',
|
||||
);
|
||||
return {
|
||||
results: [
|
||||
{
|
||||
content: { id: 123, title: 'Project Plan' },
|
||||
resultGlobalContainer: { title: 'Docs Space' },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected endpoint ${endpoint}`);
|
||||
});
|
||||
|
||||
const result = await getPages.call(ctx, 'plan');
|
||||
|
||||
expect(ctx.getCurrentNodeParameter).toHaveBeenCalledWith('space', { extractValue: true });
|
||||
expect(result.results).toEqual([{ name: 'Project Plan', value: '123', url: undefined }]);
|
||||
|
||||
await getPages.call(ctx, 'plan');
|
||||
const spaceLookups = apiRequest.mock.calls.filter(
|
||||
([, endpoint]) => endpoint === '/wiki/api/v2/spaces/999',
|
||||
);
|
||||
expect(spaceLookups).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not reuse cached space keys across credentials', async () => {
|
||||
apiRequest.mockImplementation(async (_method, endpoint) => {
|
||||
if (endpoint === '/wiki/api/v2/spaces/999') return { id: 999, key: 'DOCS' };
|
||||
if (endpoint === '/wiki/rest/api/search') return { results: [] };
|
||||
throw new Error(`unexpected endpoint ${endpoint}`);
|
||||
});
|
||||
const createCtx = (credentialId: string) => {
|
||||
const scopedCtx = mockDeep<ILoadOptionsFunctions>();
|
||||
vi.mocked(scopedCtx.getCurrentNodeParameter).mockReturnValue('999');
|
||||
scopedCtx.getNode.mockReturnValue({
|
||||
id: 'test-node',
|
||||
name: 'Test Confluence Node',
|
||||
type: 'n8n-nodes-base.confluence',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
credentials: { confluenceCloudOAuth2Api: { id: credentialId, name: 'account' } },
|
||||
});
|
||||
return scopedCtx;
|
||||
};
|
||||
|
||||
await getPages.call(createCtx('cred-1'));
|
||||
await getPages.call(createCtx('cred-1'));
|
||||
await getPages.call(createCtx('cred-2'));
|
||||
|
||||
const spaceLookups = apiRequest.mock.calls.filter(
|
||||
([, endpoint]) => endpoint === '/wiki/api/v2/spaces/999',
|
||||
);
|
||||
expect(spaceLookups).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('advances the offset even when a page comes back empty with a next link', async () => {
|
||||
apiRequest.mockResolvedValueOnce({
|
||||
results: [],
|
||||
_links: { next: '/rest/api/search?cql=type%3Dpage' },
|
||||
});
|
||||
|
||||
const result = await getPages.call(ctx, undefined, '50');
|
||||
|
||||
expect(result.paginationToken).toBe('51');
|
||||
});
|
||||
|
||||
it('escapes quotes and backslashes in the CQL title filter', async () => {
|
||||
apiRequest.mockResolvedValue({ results: [] });
|
||||
|
||||
await getPages.call(ctx, 'He said "hi" \\ back');
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/wiki/rest/api/search',
|
||||
{},
|
||||
expect.objectContaining({
|
||||
cql: 'type=page AND title = "He said \\"hi\\" \\\\ back"',
|
||||
}),
|
||||
);
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/wiki/rest/api/search',
|
||||
{},
|
||||
expect.objectContaining({
|
||||
cql: 'type=page AND title ~ "He said \\"hi\\" \\\\ back*" ORDER BY lastmodified DESC',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('puts the exact-title match ahead of prefix matches on the first page', async () => {
|
||||
apiRequest.mockImplementation(async (_method, endpoint, _body, qs) => {
|
||||
if (endpoint !== '/wiki/rest/api/search') throw new Error(`unexpected endpoint ${endpoint}`);
|
||||
const cql = (qs as { cql: string }).cql;
|
||||
if (cql === 'type=page AND title = "Notes"') {
|
||||
return { results: [{ content: { id: 1, title: 'Notes' } }] };
|
||||
}
|
||||
expect(cql).toBe('type=page AND title ~ "Notes*" ORDER BY lastmodified DESC');
|
||||
return {
|
||||
results: [
|
||||
{ content: { id: 2, title: 'Notes 2026' } },
|
||||
{ content: { id: 1, title: 'Notes' } },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const result = await getPages.call(ctx, 'Notes');
|
||||
|
||||
expect(result.results.map((item) => item.value)).toEqual(['1', '2']);
|
||||
});
|
||||
|
||||
it('skips the exact-title query on later pages', async () => {
|
||||
apiRequest.mockResolvedValue({ results: [] });
|
||||
|
||||
await getPages.call(ctx, 'Notes', '50');
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(1);
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/wiki/rest/api/search',
|
||||
{},
|
||||
expect.objectContaining({ start: 50 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('resumes from the pagination token and returns the next one while more pages exist', async () => {
|
||||
apiRequest.mockResolvedValueOnce({
|
||||
_links: { next: '/rest/api/search?cql=…&start=52' },
|
||||
results: [{ content: { id: 1 } }, { content: { id: 2 } }],
|
||||
});
|
||||
|
||||
const result = await getPages.call(ctx, undefined, '50');
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/wiki/rest/api/search',
|
||||
{},
|
||||
expect.objectContaining({ start: 50 }),
|
||||
);
|
||||
expect(result.paginationToken).toBe('52');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Confluence listSearch.searchSpaces', () => {
|
||||
let ctx: ILoadOptionsFunctions;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
ctx = mockDeep<ILoadOptionsFunctions>();
|
||||
});
|
||||
|
||||
it('lists current spaces sorted by name, labeled with their key', async () => {
|
||||
apiRequest.mockResolvedValueOnce({
|
||||
results: [
|
||||
{ id: 1, name: 'Docs', key: 'DOCS' },
|
||||
{ name: 'entry without id is skipped' },
|
||||
{ id: 2, name: 'Engineering' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchSpaces.call(ctx);
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/wiki/api/v2/spaces',
|
||||
{},
|
||||
{ limit: 50, sort: 'name', status: 'current' },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
results: [
|
||||
{ name: 'Docs (DOCS)', value: '1' },
|
||||
{ name: 'Engineering', value: '2' },
|
||||
],
|
||||
paginationToken: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters the typed text client-side, case-insensitively', async () => {
|
||||
apiRequest.mockResolvedValueOnce({
|
||||
results: [
|
||||
{ id: 1, name: 'Docs', key: 'DOCS' },
|
||||
{ id: 2, name: 'Engineering' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await searchSpaces.call(ctx, 'doc');
|
||||
|
||||
expect(result.results).toEqual([{ name: 'Docs (DOCS)', value: '1' }]);
|
||||
});
|
||||
|
||||
it('keeps fetching pages while a typed filter has no match yet', async () => {
|
||||
apiRequest
|
||||
.mockResolvedValueOnce({
|
||||
results: [{ id: 1, name: 'Docs', key: 'DOCS' }],
|
||||
_links: { next: '/wiki/api/v2/spaces?cursor=c2' },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
results: [{ id: 3, name: 'Sales' }],
|
||||
});
|
||||
|
||||
const result = await searchSpaces.call(ctx, 'sales');
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledTimes(2);
|
||||
expect(apiRequest).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'GET',
|
||||
'/wiki/api/v2/spaces',
|
||||
{},
|
||||
expect.objectContaining({ cursor: 'c2' }),
|
||||
);
|
||||
expect(result).toEqual({
|
||||
results: [{ name: 'Sales', value: '3' }],
|
||||
paginationToken: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('resumes from the pagination cursor and returns the next one', async () => {
|
||||
apiRequest.mockResolvedValueOnce({
|
||||
results: [{ id: 3, name: 'Sales' }],
|
||||
_links: { next: '/wiki/api/v2/spaces?cursor=xyz%3D%3D' },
|
||||
});
|
||||
|
||||
const result = await searchSpaces.call(ctx, undefined, 'abc==');
|
||||
|
||||
expect(apiRequest).toHaveBeenCalledWith(
|
||||
'GET',
|
||||
'/wiki/api/v2/spaces',
|
||||
{},
|
||||
expect.objectContaining({ cursor: 'abc==' }),
|
||||
);
|
||||
expect(result.paginationToken).toBe('xyz==');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user