Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions apps/admin-x-framework/src/api/snippets.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Meta, createMutation, createQuery } from '../utils/api/hooks';
import { InfiniteData } from '@tanstack/react-query';
import { Meta, createInfiniteQuery, createMutation } from '../utils/api/hooks';

// mobiledoc and lexical travel as JSON strings on the wire; callers parse/stringify
export type Snippet = {
Expand All @@ -24,10 +25,32 @@ const dataType = 'SnippetsResponseType';
// Without `formats` the API strips `lexical` from responses (mobiledoc is the default format)
const formats = 'mobiledoc,lexical';

const useBrowseSnippetsQuery = createQuery<SnippetsResponseType>({
const useBrowseSnippetsQuery = createInfiniteQuery<SnippetsResponseType & { isEnd: boolean }>({
dataType,
path: '/snippets/',
defaultSearchParams: { limit: 'all', formats },
defaultNextPageParams: (lastPage, otherParams) => {
const nextPage = lastPage.meta?.pagination.next;
if (!nextPage) {
return undefined;
}

return {
...otherParams,
page: nextPage.toString(),
};
},
returnData: (originalData) => {
const { pages } = originalData as InfiniteData<SnippetsResponseType>;
const snippets = pages.flatMap((page) => page.snippets);
const meta = pages[pages.length - 1].meta;

return {
snippets,
meta,
isEnd: meta ? meta.pagination.pages === meta.pagination.page : true,
};
},
});

export const useBrowseSnippets = ({
Expand Down
4 changes: 2 additions & 2 deletions apps/admin-x-framework/test/unit/api/snippets.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ describe('snippets api', () => {
const { result } = renderHookWithProviders(() => useBrowseSnippets());

await waitFor(() => {
expect(result.current.data).toEqual({ snippets: [existingSnippet] });
expect(result.current.data?.snippets).toEqual([existingSnippet]);
});

const call = findCall(mock, '/snippets/');
Expand All @@ -91,7 +91,7 @@ describe('snippets api', () => {
);

await waitFor(() => {
expect(result.current.data).toEqual({ snippets: [existingSnippet] });
expect(result.current.data?.snippets).toEqual([existingSnippet]);
});

const url = new URL(String(findCall(mock, '/snippets/')![0]));
Expand Down
28 changes: 27 additions & 1 deletion apps/admin-x-framework/test/unit/api/users.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,37 @@
import type { UserRoleType } from '../../../src/api/roles';
import { canManageAutomations } from '../../../src/api/users';
import { canAccessSettings, canManageAutomations, isEditorUser } from '../../../src/api/users';

const userWithRole = (roleName: UserRoleType) => ({
roles: [{ name: roleName }],
});

describe('users api helpers', () => {
describe('isEditorUser', () => {
it('returns true for Editor and Super Editor', () => {
expect(isEditorUser(userWithRole('Editor'))).toBe(true);
expect(isEditorUser(userWithRole('Super Editor'))).toBe(true);
});

it('returns false for Author and Contributor', () => {
expect(isEditorUser(userWithRole('Author'))).toBe(false);
expect(isEditorUser(userWithRole('Contributor'))).toBe(false);
});
});

describe('canAccessSettings', () => {
it('returns true for Owner, Administrator, Editor and Super Editor', () => {
expect(canAccessSettings(userWithRole('Owner'))).toBe(true);
expect(canAccessSettings(userWithRole('Administrator'))).toBe(true);
expect(canAccessSettings(userWithRole('Editor'))).toBe(true);
expect(canAccessSettings(userWithRole('Super Editor'))).toBe(true);
});

it('returns false for Author and Contributor', () => {
expect(canAccessSettings(userWithRole('Author'))).toBe(false);
expect(canAccessSettings(userWithRole('Contributor'))).toBe(false);
});
});

describe('canManageAutomations', () => {
it('returns true for Owner', () => {
expect(canManageAutomations(userWithRole('Owner'))).toBe(true);
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@
"jszip": "3.10.1",
"lucide-react": "catalog:",
"luxon": "catalog:",
"microdiff": "catalog:",
"mingo": "catalog:",
"moment": "catalog:",
"moment-timezone": "catalog:",
Expand Down Expand Up @@ -89,6 +88,7 @@
"@typescript/native": "catalog:",
"@vitejs/plugin-react": "catalog:",
"@vitest/browser-playwright": "catalog:",
"d3-geo": "catalog:",
"eslint": "catalog:",
"eslint-plugin-no-relative-import-paths": "1.6.1",
"ghost-admin": "workspace:*",
Expand Down
81 changes: 81 additions & 0 deletions apps/admin/src/editor/card-labels.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it, vi } from 'vitest';
import { type LabelsPage, fetchAllLabelNames } from './card-labels';

function labelsPage(names: string[], page: number, pages: number): LabelsPage {
return {
labels: names.map((name) => ({ name })),
meta: {
pagination: {
page,
limit: 100,
pages,
total: names.length,
next: page < pages ? page + 1 : null,
prev: page > 1 ? page - 1 : null,
},
},
};
}

function params(url: string) {
return Object.fromEntries(new URL(url).searchParams);
}

describe('fetchAllLabelNames', () => {
it('asks for one explicit page of names', async () => {
const fetchPage = vi.fn().mockResolvedValue(labelsPage(['VIP', 'Beta'], 1, 1));

await expect(fetchAllLabelNames(fetchPage)).resolves.toEqual(['VIP', 'Beta']);

expect(fetchPage).toHaveBeenCalledOnce();
const url = fetchPage.mock.calls[0][0] as string;
expect(new URL(url).pathname).toMatch(/\/labels\/$/);
expect(params(url)).toEqual({ limit: '100', fields: 'id,name', page: '1' });
});

it('follows the next page until the last one, keeping server order', async () => {
const fetchPage = vi
.fn()
.mockResolvedValueOnce(labelsPage(['A'], 1, 3))
.mockResolvedValueOnce(labelsPage(['B'], 2, 3))
.mockResolvedValueOnce(labelsPage(['C'], 3, 3));

await expect(fetchAllLabelNames(fetchPage)).resolves.toEqual(['A', 'B', 'C']);

expect(fetchPage.mock.calls.map(([url]) => params(url as string).page)).toEqual([
'1',
'2',
'3',
]);
});

it('does not request an eleventh page when the server offers more', async () => {
const fetchPage = vi.fn((url: string) => {
const page = Number(params(url).page);
return Promise.resolve(labelsPage([`Label ${page}`], page, 12));
});

const names = await fetchAllLabelNames(fetchPage);

expect(names).toHaveLength(10);
expect(fetchPage.mock.calls.map(([url]) => params(url).page)).toEqual(
Array.from({ length: 10 }, (_, index) => String(index + 1)),
);
});

it('stops after one page when the response has no pagination', async () => {
const fetchPage = vi.fn().mockResolvedValue({ labels: [{ name: 'VIP' }] });

await expect(fetchAllLabelNames(fetchPage)).resolves.toEqual(['VIP']);
expect(fetchPage).toHaveBeenCalledOnce();
});

it('rejects when a later page fails', async () => {
const fetchPage = vi
.fn()
.mockResolvedValueOnce(labelsPage(['A'], 1, 2))
.mockRejectedValueOnce(new Error('offline'));

await expect(fetchAllLabelNames(fetchPage)).rejects.toThrow('offline');
});
});
31 changes: 31 additions & 0 deletions apps/admin/src/editor/card-labels.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { apiUrl } from '@tryghost/admin-x-framework/helpers';
import type { LabelsResponseType } from '@tryghost/admin-x-framework/api/labels';

export type LabelsPage = Pick<LabelsResponseType, 'meta'> & { labels: { name: string }[] };

// Core caps every browse at 100 rows, `limit=all` included.
const LABELS_PAGE_SIZE = '100';
// Koenig's labels dropdown adds a typed name that is not in the list (`allowAdd`),
// so a label past this cap is typed rather than picked.
const MAX_LABELS_PAGES = 10;

/**
* Every label name, up to the page cap, for the Signup card's labels dropdown.
* Koenig asks with no query and filters as the writer types, so this walks the pages.
*/
export async function fetchAllLabelNames(
fetchPage: (url: string) => Promise<LabelsPage>,
): Promise<string[]> {
const names: string[] = [];
let page: number | null = 1;

while (page !== null && page <= MAX_LABELS_PAGES) {
const response: LabelsPage = await fetchPage(
apiUrl('/labels/', { limit: LABELS_PAGE_SIZE, fields: 'id,name', page: String(page) }),
);
names.push(...response.labels.map((label) => label.name));
page = response.meta?.pagination.next ?? null;
}

return names;
}
Loading
Loading