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
10 changes: 7 additions & 3 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,13 @@ reviews:
code. Do not report formatting, naming, import ordering, type errors, or
other findings already owned by configured static tools or failing GitHub
checks. Do not request speculative abstractions, broad refactors, generic
documentation, or tests unrelated to changed behaviour. Treat nearby
AGENTS.md files and mapped codebase documentation as authoritative; do not
enforce proposals, plans, or historical guidance as current policy.
documentation, or tests unrelated to changed behaviour. For a move,
rename, TypeScript conversion, or explicitly behaviour-preserving refactor,
do not report pre-existing problems unless the diff introduces or worsens
them, makes them newly reachable, or prevents the stated transformation
from being correct. Treat nearby AGENTS.md files and mapped codebase
documentation as authoritative; do not enforce proposals, plans, or
historical guidance as current policy.
- path: '**/*.{ts,tsx,mts,cts}'
instructions: |
Review lens: "where does this data become trusted?"
Expand Down
26 changes: 14 additions & 12 deletions apps/admin-x-framework/src/api/email-verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,18 @@ export interface EmailVerificationResponseType {
}
const dataType = 'SettingsResponseType';

export const verifyEmailToken = createMutation<EmailVerificationResponseType, emailVerification>({
path: () => '/settings/verifications',
method: 'PUT',
body: ({ token }) => ({ token }),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: (newData) => ({
...newData,
settings: newData.settings,
}),
export const useVerifyEmailToken = createMutation<EmailVerificationResponseType, emailVerification>(
{
path: () => '/settings/verifications',
method: 'PUT',
body: ({ token }) => ({ token }),
updateQueries: {
dataType,
emberUpdateType: 'createOrUpdate',
update: (newData) => ({
...newData,
settings: newData.settings,
}),
},
},
});
);
2 changes: 1 addition & 1 deletion apps/admin-x-framework/src/api/featurebase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const baseFeaturebaseTokenQuery = createQuery<FeaturebaseTokenResponseType>({
path: '/featurebase/token/',
});

export const getFeaturebaseToken = (options: { enabled?: boolean } = {}) => {
export const useFeaturebaseToken = (options: { enabled?: boolean } = {}) => {
return baseFeaturebaseTokenQuery({
...FEATUREBASE_QUERY_OPTIONS,
...options,
Expand Down
2 changes: 1 addition & 1 deletion apps/admin-x-framework/src/api/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export interface FeedbackResponseType {

const dataType = 'FeedbackResponseType';

export const getPostFeedback = createQueryWithId<FeedbackResponseType>({
export const usePostFeedbackQuery = createQueryWithId<FeedbackResponseType>({
dataType,
path: (id) => `/feedback/${id}/`,
});
4 changes: 2 additions & 2 deletions apps/admin-x-framework/src/api/members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ export const useImportMembers = createMutation<ImportMembersResponseType, Import
invalidateQueries: { dataType },
});

export const getMember = createQueryWithId<MembersResponseType>({
export const useMember = createQueryWithId<MembersResponseType>({
dataType,
path: (id) => `/members/${id}/`,
});
Expand Down Expand Up @@ -575,7 +575,7 @@ export interface MemberSigninUrlResponseType {
// The Admin API wraps the controller payload in the `member_signin_urls` array
// envelope (see `member-signin-urls.js` + framework serializer). Unwrap here so
// consumers get the flat `{member_id, url}` object they actually want.
export const getMemberSigninUrl = createQueryWithId<MemberSigninUrlResponseType>({
export const useMemberSigninUrl = createQueryWithId<MemberSigninUrlResponseType>({
dataType: 'MemberSigninUrlResponseType',
path: (id) => `/members/${id}/signin_urls/`,
returnData: (originalData) => {
Expand Down
14 changes: 11 additions & 3 deletions apps/admin-x-framework/src/api/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export const useBrowsePostsInfinite = createInfiniteQuery<PostsResponseType & {
},
});

export const getPost = createQueryWithId<PostsResponseType>({
export const usePost = createQueryWithId<PostsResponseType>({
dataType,
path: (id) => `/posts/${id}/`,
});
Expand All @@ -86,13 +86,21 @@ export const useDeletePost = createMutation<unknown, string>({
path: (id) => `/posts/${id}/`,
});

export const useImportContentCSV = createMutation<unknown, File>({
export interface ImportContentCSVPayload {
file: File;
mapping: Record<string, string>;
}

export const useImportContentCSV = createMutation<unknown, ImportContentCSVPayload>({
method: 'POST',
retry: false,
path: () => '/posts/upload/',
body: (file) => {
body: ({ file, mapping }) => {
const formData = new FormData();
formData.append('postsfile', file);
for (const [header, field] of Object.entries(mapping)) {
formData.append(`mapping[${header}]`, field);
}
return formData;
},
});
4 changes: 2 additions & 2 deletions apps/admin-x-framework/src/api/staff-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,12 @@ export interface StaffTokenResponseType {

const dataType = 'StaffTokenResponseType';

export const getStaffToken = createQuery<StaffTokenResponseType>({
export const useStaffToken = createQuery<StaffTokenResponseType>({
dataType,
path: '/users/me/token/',
});

export const genStaffToken = createMutation<StaffTokenResponseType, []>({
export const useGenerateStaffToken = createMutation<StaffTokenResponseType, []>({
path: () => '/users/me/token/',
method: 'PUT',
});
2 changes: 1 addition & 1 deletion apps/admin-x-framework/src/api/tags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export const useBrowseTags = ({

// Mirrors Ember's `queryRecord('tag', {slug})`, which the admin API serves at
// the dedicated `/tags/slug/:slug/` route.
export const getTagBySlug = createQueryWithId<TagsResponseType>({
export const useTagBySlug = createQueryWithId<TagsResponseType>({
dataType,
path: (slug) => `/tags/slug/${slug}/`,
});
Expand Down
2 changes: 1 addition & 1 deletion apps/admin-x-framework/src/api/tinybird.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const baseTinybirdTokenQuery = createQuery<TinybirdTokenResponseType>({
path: '/tinybird/token/',
});

export const getTinybirdToken = (options: { enabled?: boolean } = {}) => {
export const useTinybirdTokenQuery = (options: { enabled?: boolean } = {}) => {
return baseTinybirdTokenQuery({
...TINYBIRD_QUERY_OPTIONS,
...options,
Expand Down
4 changes: 2 additions & 2 deletions apps/admin-x-framework/src/hooks/use-featurebase.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type Deferred, deferred } from '../utils/deferred';
import { getFeaturebaseToken } from '../api/featurebase';
import { useFeaturebaseToken } from '../api/featurebase';
import { useBrowseConfig } from '../api/config';
import { useCallback, useEffect, useRef, useState } from 'react';

Expand Down Expand Up @@ -126,7 +126,7 @@ export function useFeaturebase(): Featurebase {
const { organization, enabled } = config?.config.featurebase ?? {};
const isAvailable = !!enabled;

const { data: tokenData } = getFeaturebaseToken({
const { data: tokenData } = useFeaturebaseToken({
enabled: isAvailable && shouldLoad,
});
const token = tokenData?.featurebase?.token;
Expand Down
4 changes: 2 additions & 2 deletions apps/admin-x-framework/src/hooks/use-tinybird-token.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getTinybirdToken } from '../api/tinybird';
import { useTinybirdTokenQuery } from '../api/tinybird';
import { useWebAnalyticsEnabled } from '../providers/app-provider';

export interface UseTinybirdTokenResult {
Expand All @@ -20,7 +20,7 @@ export const useTinybirdToken = (options: UseTinybirdTokenOptions = {}): UseTiny
// Web analytics is a global kill-switch read from context, so no call site threads it.
const webAnalyticsEnabled = useWebAnalyticsEnabled();
const effectiveEnabled = enabled && webAnalyticsEnabled;
const tinybirdQuery = getTinybirdToken({ enabled: effectiveEnabled });
const tinybirdQuery = useTinybirdTokenQuery({ enabled: effectiveEnabled });

// A disabled React Query can keep cached data/errors, so return an idle
// result — else direct consumers (the providers) leak a stale token.
Expand Down
6 changes: 5 additions & 1 deletion apps/admin-x-framework/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// Framework
export type { StatsConfig, TopLevelFrameworkProps } from './providers/framework-provider';
export { FrameworkProvider, useFramework } from './providers/framework-provider';
export {
FrameworkProvider,
defaultUnsplashConfig,
useFramework,
} from './providers/framework-provider';

// App Context
export type { AppSettings, AppContextType } from './providers/app-provider';
Expand Down
9 changes: 9 additions & 0 deletions apps/admin-x-framework/src/providers/framework-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ export type TopLevelFrameworkProps = Omit<FrameworkProviderProps, 'children'>;

export type FrameworkContextType = Omit<FrameworkProviderProps, 'children'>;

// Ghost's registered Unsplash application; the Client-ID is a public API key
export const defaultUnsplashConfig: FrameworkProviderProps['unsplashConfig'] = {
Authorization: 'Client-ID 8672af113b0a8573edae3aa3713886265d9bb741d707f6c01a486cde8c278980',
'Accept-Version': 'v1',
'Content-Type': 'application/json',
'App-Pragma': 'no-cache',
'X-Unsplash-Cache': true,
};

const FrameworkContext = createContext<FrameworkContextType>({
ghostVersion: '',
externalNavigate: () => {},
Expand Down
4 changes: 2 additions & 2 deletions apps/admin-x-framework/test/unit/api/members.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { UserRoleType } from '../../../src/api/roles';
import { createTestQueryClient, renderHookWithProviders } from '../../../src/test/test-utils';
import {
getMemberCountQueryKey,
getMemberSigninUrl,
useMemberSigninUrl,
useAddMember,
useBrowseMembersInfinite,
useBulkDeleteMembers,
Expand Down Expand Up @@ -502,7 +502,7 @@ describe('members api', () => {
},
},
async (mock) => {
const { result } = renderHookWithProviders(() => getMemberSigninUrl('member-1'), {
const { result } = renderHookWithProviders(() => useMemberSigninUrl('member-1'), {
queryClient,
});

Expand Down
12 changes: 10 additions & 2 deletions apps/admin-x-framework/test/unit/api/posts.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,19 @@ describe('posts api', () => {
const { result } = renderHookWithProviders(() => useImportContentCSV());

await act(async () => {
await result.current.mutateAsync(file);
await result.current.mutateAsync({
file,
mapping: { Headline: 'title', Body: '', Published: 'published_at' },
});
});

expect(mock.calls[0][0]).toBe('http://localhost:3000/ghost/api/admin/posts/upload/');
expect(mock.calls[0][1].method).toBe('POST');
expect(mock.calls[0][1].body).toBeInstanceOf(FormData);
expect(mock.calls[0][1].body.get('postsfile')).toBe(file);
expect(mock.calls[0][1].body.get('mapping[Headline]')).toBe('title');
expect(mock.calls[0][1].body.get('mapping[Body]')).toBe('');
expect(mock.calls[0][1].body.get('mapping[Published]')).toBe('published_at');
expect(mock.calls[0][1].headers).not.toHaveProperty('content-type');
});
});
Expand All @@ -36,7 +42,9 @@ describe('posts api', () => {

try {
const { result } = renderHookWithProviders(() => useImportContentCSV(), { queryClient });
const importPromise = result.current.mutateAsync(file).catch((error) => error);
const importPromise = result.current
.mutateAsync({ file, mapping: { title: 'title' } })
.catch((error) => error);

await vi.advanceTimersByTimeAsync(600);

Expand Down
24 changes: 12 additions & 12 deletions apps/admin-x-framework/test/unit/api/tinybird.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
import React, { ReactNode } from 'react';
import { getTinybirdToken } from '../../../src/api/tinybird';
import { useTinybirdTokenQuery } from '../../../src/api/tinybird';
import { FrameworkProvider } from '../../../src/providers/framework-provider';
import { withMockFetch } from '../../utils/mock-fetch';

Expand Down Expand Up @@ -44,7 +44,7 @@ const wrapper: React.FC<{ children: ReactNode }> = ({ children }) => (
</FrameworkProvider>
);

describe('getTinybirdToken', () => {
describe('useTinybirdTokenQuery', () => {
afterEach(() => {
queryClient.clear();
vi.clearAllTimers();
Expand All @@ -57,7 +57,7 @@ describe('getTinybirdToken', () => {
json: { tinybird: { token: 'test-token-123' } },
},
async (mock) => {
const { result } = renderHook(() => getTinybirdToken(), { wrapper });
const { result } = renderHook(() => useTinybirdTokenQuery(), { wrapper });

await waitFor(() => expect(result.current.isLoading).toBe(false));

Expand All @@ -68,18 +68,18 @@ describe('getTinybirdToken', () => {
);
});

it('makes only one request for multiple getTinybirdToken calls (caching)', async () => {
it('makes only one request for multiple useTinybirdTokenQuery calls (caching)', async () => {
await withMockFetch(
{
json: { tinybird: { token: 'cached-token' } },
},
async (mock) => {
// First call
const { result: result1 } = renderHook(() => getTinybirdToken(), { wrapper });
const { result: result1 } = renderHook(() => useTinybirdTokenQuery(), { wrapper });
await waitFor(() => expect(result1.current.isLoading).toBe(false));

// Second call should use cache
const { result: result2 } = renderHook(() => getTinybirdToken(), { wrapper });
const { result: result2 } = renderHook(() => useTinybirdTokenQuery(), { wrapper });
await waitFor(() => expect(result2.current.isLoading).toBe(false));

// Both should have same data, but only 1 HTTP request
Expand All @@ -97,7 +97,7 @@ describe('getTinybirdToken', () => {
json: { tinybird: { token: 'initial-token' } },
},
async (mock) => {
const { result } = renderHook(() => getTinybirdToken(), { wrapper });
const { result } = renderHook(() => useTinybirdTokenQuery(), { wrapper });
await waitFor(() => expect(result.current.isLoading).toBe(false));

expect(mock.calls.length).toBe(1);
Expand All @@ -121,13 +121,13 @@ describe('getTinybirdToken', () => {
},
async (mock) => {
// First call
const { result: result1 } = renderHook(() => getTinybirdToken(), { wrapper });
const { result: result1 } = renderHook(() => useTinybirdTokenQuery(), { wrapper });
await waitFor(() => expect(result1.current.isLoading).toBe(false));

expect(mock.calls.length).toBe(1);

// Second call immediately after should use cache
const { result: result2 } = renderHook(() => getTinybirdToken(), { wrapper });
const { result: result2 } = renderHook(() => useTinybirdTokenQuery(), { wrapper });
await waitFor(() => expect(result2.current.isLoading).toBe(false));

// Should still be only 1 request due to built-in stale time
Expand All @@ -144,7 +144,7 @@ describe('getTinybirdToken', () => {
},
async () => {
// Should work without any parameters
const { result } = renderHook(() => getTinybirdToken(), { wrapper });
const { result } = renderHook(() => useTinybirdTokenQuery(), { wrapper });

await waitFor(() => expect(result.current.isLoading).toBe(false));

Expand All @@ -160,7 +160,7 @@ describe('getTinybirdToken', () => {
json: { tinybird: { token: 'interface-token' } },
},
async () => {
const { result } = renderHook(() => getTinybirdToken(), { wrapper });
const { result } = renderHook(() => useTinybirdTokenQuery(), { wrapper });

await waitFor(() => expect(result.current.isLoading).toBe(false));

Expand All @@ -182,7 +182,7 @@ describe('getTinybirdToken', () => {
ok: false,
},
async () => {
const { result } = renderHook(() => getTinybirdToken(), { wrapper });
const { result } = renderHook(() => useTinybirdTokenQuery(), { wrapper });

await waitFor(() => expect(result.current.isLoading).toBe(false));

Expand Down
Loading
Loading