diff --git a/apps/content-production-dashboard/src/components/EntryLink.tsx b/apps/content-production-dashboard/src/components/EntryLink.tsx new file mode 100644 index 0000000000..46a0f2b6a5 --- /dev/null +++ b/apps/content-production-dashboard/src/components/EntryLink.tsx @@ -0,0 +1,23 @@ +import { TextLink } from '@contentful/f36-components'; +import { ArrowSquareOutIcon } from '@contentful/f36-icons'; + +interface EntryLinkProps { + entryId: string; + spaceId: string; + children: React.ReactNode; +} + +export const EntryLink = ({ entryId, spaceId, children }: EntryLinkProps) => { + const entryUrl = `https://app.contentful.com/spaces/${spaceId}/entries/${entryId}`; + + return ( + }> + {children} + + ); +}; diff --git a/apps/content-production-dashboard/src/components/ReleasesTable.tsx b/apps/content-production-dashboard/src/components/ReleasesTable.tsx index 306aadd172..01f6b6b1c7 100644 --- a/apps/content-production-dashboard/src/components/ReleasesTable.tsx +++ b/apps/content-production-dashboard/src/components/ReleasesTable.tsx @@ -9,6 +9,7 @@ import { ReleasesTableActions } from './ReleasesTableActions'; import { useSDK } from '@contentful/react-apps-toolkit'; import { HomeAppSDK, PageAppSDK } from '@contentful/app-sdk'; import { formatDateTimeWithTimezone } from '../utils/dateFormat'; +import { formatUserName } from '../utils/UserUtils'; const ReleasesTableHeader = () => { return ( @@ -35,15 +36,6 @@ export const ReleasesTable = () => { const { releases, total, isFetchingReleases, fetchingReleasesError, refetchReleases } = useReleases(currentPage); - const formatUserName = ( - user: { id: string; firstName?: string; lastName?: string } | null - ): string => { - if (!user) return '—'; - const firstName = user.firstName || ''; - const lastName = user.lastName || ''; - return `${firstName} ${lastName}`.trim() || '—'; - }; - if (fetchingReleasesError) { return ( diff --git a/apps/content-production-dashboard/src/components/ScheduledContentTable.tsx b/apps/content-production-dashboard/src/components/ScheduledContentTable.tsx index e2c76ca8b5..47a9031de3 100644 --- a/apps/content-production-dashboard/src/components/ScheduledContentTable.tsx +++ b/apps/content-production-dashboard/src/components/ScheduledContentTable.tsx @@ -1,35 +1,108 @@ -import { Table } from '@contentful/f36-components'; +import { useState } from 'react'; +import { Table, Box, Skeleton, Pagination, Badge } from '@contentful/f36-components'; +import { useSDK } from '@contentful/react-apps-toolkit'; +import { HomeAppSDK, PageAppSDK } from '@contentful/app-sdk'; import { styles } from './ScheduledContentTable.styles'; import { EmptyStateTable } from './EmptyStateTable'; +import { formatDateTimeWithTimezone } from '../utils/dateFormat'; +import { formatUserName } from '../utils/UserUtils'; +import { RELEASES_PER_PAGE } from '../utils/consts'; + +import { EntryLink } from './EntryLink'; +import { EntryStatus, ScheduledContentItem } from '../utils/types'; +import { useScheduledContent } from '../hooks/useScheduledContent'; + +enum BadgeVariant { + Primary = 'primary', + Positive = 'positive', + Warning = 'warning', +} + const ScheduledContentTableHeader = () => { return ( Title - Creator - Content Type - Published Date Scheduled Date + Published Date Status + Content Type + Creator ); }; +const getStatusBadgeVariant = (status: EntryStatus | undefined): BadgeVariant => { + if (status === EntryStatus.Published) { + return BadgeVariant.Positive; + } + if (status === EntryStatus.Changed) { + return BadgeVariant.Primary; + } + return BadgeVariant.Warning; +}; + export const ScheduledContentTable = () => { - const ITEMS = []; // TODO: Add actual items here + const sdk = useSDK(); + const [currentPage, setCurrentPage] = useState(0); + const { items, total, isFetching } = useScheduledContent(sdk.locales.default, currentPage); + + if (isFetching) { + return ( + <> + + + + + +
+ + ); + } - if (ITEMS.length === 0) { + if (items.length === 0) { return ; } return ( - - - - - -
+ <> + + + + {items.map((item: ScheduledContentItem) => ( + + + + {item.title} + + + + {formatDateTimeWithTimezone(item.scheduledFor)} + + + {formatDateTimeWithTimezone(item.publishedDate || '')} + + + {item.status} + + {item.contentType} + {formatUserName(item.creator)} + + ))} + +
+ {total > RELEASES_PER_PAGE && ( + + + + )} + ); }; diff --git a/apps/content-production-dashboard/src/hooks/useAllEntries.ts b/apps/content-production-dashboard/src/hooks/useAllEntries.ts index f03b183c4c..c70984d6d8 100644 --- a/apps/content-production-dashboard/src/hooks/useAllEntries.ts +++ b/apps/content-production-dashboard/src/hooks/useAllEntries.ts @@ -1,7 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { PageAppSDK } from '@contentful/app-sdk'; import { useSDK } from '@contentful/react-apps-toolkit'; -import { EntryProps } from 'contentful-management'; +import { EntryProps, QueryOptions } from 'contentful-management'; import { fetchAllEntries, FetchAllEntriesResult } from '../utils/fetchAllEntries'; import { getEnvironmentId } from '../utils/sdkUtils'; @@ -14,12 +14,19 @@ export interface UseAllEntriesResult { refetchEntries: () => void; } -export function useAllEntries(): UseAllEntriesResult { +export interface UseEntriesOptions { + query?: QueryOptions; + enabled?: boolean; +} + +export function useEntries(options: UseEntriesOptions = {}): UseAllEntriesResult { + const { query, enabled } = options; const sdk = useSDK(); const { data, isFetching, error, refetch } = useQuery({ - queryKey: ['entries', sdk.ids.space, getEnvironmentId(sdk)], - queryFn: () => fetchAllEntries(sdk), + queryKey: ['entries', sdk.ids.space, getEnvironmentId(sdk), query ?? {}], + queryFn: () => fetchAllEntries(sdk, query), + enabled, }); return { @@ -33,3 +40,7 @@ export function useAllEntries(): UseAllEntriesResult { }, }; } + +export function useAllEntries(): UseAllEntriesResult { + return useEntries(); +} diff --git a/apps/content-production-dashboard/src/hooks/useContentTypes.ts b/apps/content-production-dashboard/src/hooks/useContentTypes.ts index d307c84e8b..79829d39ea 100644 --- a/apps/content-production-dashboard/src/hooks/useContentTypes.ts +++ b/apps/content-production-dashboard/src/hooks/useContentTypes.ts @@ -1,19 +1,21 @@ import { PageAppSDK } from '@contentful/app-sdk'; import { useSDK } from '@contentful/react-apps-toolkit'; import { useQuery } from '@tanstack/react-query'; +import { ContentTypeProps } from 'contentful-management'; import { fetchContentTypes, FetchContentTypesResult } from '../utils/fetchContentTypes'; export interface UseContentTypesResult { - contentTypes: Map; + contentTypes: Map; isFetchingContentTypes: boolean; fetchingContentTypesError: Error | null; fetchedAt: Date | undefined; + refetchContentTypes: () => void; } export function useContentTypes(contentTypeIds?: string[]): UseContentTypesResult { const sdk = useSDK(); - const { data, isFetching, error } = useQuery({ + const { data, isFetching, error, refetch } = useQuery({ queryKey: ['contentTypes', sdk.ids.space, sdk.ids.environment, contentTypeIds], queryFn: () => fetchContentTypes(sdk, contentTypeIds), }); @@ -23,5 +25,8 @@ export function useContentTypes(contentTypeIds?: string[]): UseContentTypesResul isFetchingContentTypes: isFetching, fetchingContentTypesError: error, fetchedAt: data?.fetchedAt, + refetchContentTypes: () => { + refetch(); + }, }; } diff --git a/apps/content-production-dashboard/src/hooks/useScheduledActions.ts b/apps/content-production-dashboard/src/hooks/useScheduledActions.ts index d45b5c4430..e19cf67bad 100644 --- a/apps/content-production-dashboard/src/hooks/useScheduledActions.ts +++ b/apps/content-production-dashboard/src/hooks/useScheduledActions.ts @@ -1,7 +1,7 @@ import { PageAppSDK } from '@contentful/app-sdk'; import { useSDK } from '@contentful/react-apps-toolkit'; import { useQuery } from '@tanstack/react-query'; -import { ScheduledActionProps } from 'contentful-management'; +import { QueryOptions, ScheduledActionProps } from 'contentful-management'; import { fetchScheduledActions, FetchScheduledActionsResult } from '../utils/fetchScheduledActions'; import { getEnvironmentId } from '../utils/sdkUtils'; @@ -14,12 +14,12 @@ export interface UseScheduledActionsResult { refetchScheduledActions: () => void; } -export function useScheduledActions(): UseScheduledActionsResult { +export function useScheduledActions(query: QueryOptions = {}): UseScheduledActionsResult { const sdk = useSDK(); const { data, isFetching, error, refetch } = useQuery({ - queryKey: ['scheduledActions', sdk.ids.space, getEnvironmentId(sdk)], - queryFn: () => fetchScheduledActions(sdk), + queryKey: ['scheduledActions', sdk.ids.space, getEnvironmentId(sdk), query], + queryFn: () => fetchScheduledActions(sdk, query), }); return { diff --git a/apps/content-production-dashboard/src/hooks/useScheduledContent.ts b/apps/content-production-dashboard/src/hooks/useScheduledContent.ts new file mode 100644 index 0000000000..20ac96b8c2 --- /dev/null +++ b/apps/content-production-dashboard/src/hooks/useScheduledContent.ts @@ -0,0 +1,118 @@ +import { useMemo } from 'react'; +import { EntryProps } from 'contentful-management'; + +import { ScheduledContentItem } from '../utils/types'; +import { getCreatorFromEntry } from '../utils/UserUtils'; +import { getEntryStatus, getEntryTitle } from '../utils/EntryUtils'; +import { useEntries } from './useAllEntries'; +import { useContentTypes } from './useContentTypes'; +import { useUsers } from './useUsers'; +import { useScheduledActions } from './useScheduledActions'; +import { RELEASES_PER_PAGE } from '../utils/consts'; + +interface UseScheduledContentResult { + items: ScheduledContentItem[]; + total: number; + isFetching: boolean; + error: Error | null; + refetch: () => void; +} + +function generateEntriesMap(entries: EntryProps[]): Map { + return entries.reduce((map, entry) => { + map.set(entry.sys.id, entry); + return map; + }, new Map()); +} + +function getUserIdsFromEntries(entries: EntryProps[]): string[] { + const userIds = entries.map((entry) => entry.sys.createdBy?.sys?.id).filter(Boolean) as string[]; + + return [...new Set(userIds)]; +} + +export function useScheduledContent( + defaultLocale: string, + page: number = 0 +): UseScheduledContentResult { + const skip = page * RELEASES_PER_PAGE; + const { scheduledActions, isFetchingScheduledActions, refetchScheduledActions } = + useScheduledActions({ query: { 'sys.entity.sys.linkType': 'Entry' } }); + + const entryIds = useMemo( + () => scheduledActions.map((action) => action.entity?.sys?.id || '').filter(Boolean), + [scheduledActions] + ); + + const { + entries, + isFetchingEntries, + fetchingEntriesError: entriesError, + refetchEntries, + } = useEntries({ + query: { 'sys.id[in]': entryIds.join(',') }, + enabled: entryIds.length > 0, + }); + + const entriesMap = useMemo(() => generateEntriesMap(entries), [entries]); + const userIds = useMemo(() => getUserIdsFromEntries(entries), [entries]); + + const { contentTypes, isFetchingContentTypes, refetchContentTypes } = useContentTypes(); + const { usersMap, isFetching: isFetchingUsers } = useUsers(userIds); + + const scheduledItems = useMemo(() => { + const items: ScheduledContentItem[] = []; + + scheduledActions.forEach((action) => { + const entry = entriesMap.get(action.entity?.sys?.id || ''); + if (!entry) return; + + const contentType = contentTypes.get(entry.sys.contentType?.sys?.id || ''); + + items.push({ + id: entry.sys.id, + title: getEntryTitle(entry, contentType, defaultLocale), + contentType: contentType?.name || '', + contentTypeId: entry.sys.contentType?.sys?.id || '', + creator: getCreatorFromEntry(entry, usersMap), + publishedDate: entry.sys.publishedAt || null, + updatedDate: entry.sys.updatedAt, + status: getEntryStatus(entry), + scheduledActionId: action.sys.id, + scheduledFor: action.scheduledFor?.datetime || '', + }); + }); + + return items; + }, [scheduledActions, entriesMap, contentTypes, usersMap, defaultLocale]); + + const isFetching = + isFetchingScheduledActions || isFetchingEntries || isFetchingContentTypes || isFetchingUsers; + const error = entriesError; + + if (!scheduledActions.length) { + return { + items: [], + total: 0, + isFetching, + error, + refetch: () => { + refetchScheduledActions(); + refetchEntries(); + refetchContentTypes(); + }, + }; + } + + return { + items: scheduledItems.slice(skip, skip + RELEASES_PER_PAGE), + total: scheduledItems.length, + isFetching, + error, + refetch: () => { + refetchScheduledActions(); + refetchEntries(); + refetchContentTypes(); + }, + }; +} diff --git a/apps/content-production-dashboard/src/hooks/useUsers.ts b/apps/content-production-dashboard/src/hooks/useUsers.ts new file mode 100644 index 0000000000..01cfa7eca1 --- /dev/null +++ b/apps/content-production-dashboard/src/hooks/useUsers.ts @@ -0,0 +1,44 @@ +import { useQuery } from '@tanstack/react-query'; +import { HomeAppSDK, PageAppSDK } from '@contentful/app-sdk'; +import { useSDK } from '@contentful/react-apps-toolkit'; +import { UserProps } from 'contentful-management'; + +interface UseUsersResult { + usersMap: Map; + isFetching: boolean; + error: Error | null; + refetch: () => void; +} + +export function useUsers(userIds: string[]): UseUsersResult { + const sdk = useSDK(); + + const { data, isFetching, error, refetch } = useQuery({ + queryKey: ['users', sdk.ids.space, userIds.sort().join(',')], + enabled: userIds.length > 0, + queryFn: async () => { + if (userIds.length === 0) { + return []; + } + + const response = await sdk.cma.user.getManyForSpace({ + spaceId: sdk.ids.space, + query: { 'sys.id[in]': userIds.join(','), fields: 'firstName,lastName' }, + }); + + return response.items; + }, + }); + + const usersMap = new Map(); + (data ?? []).forEach((user) => { + usersMap.set(user.sys.id, user); + }); + + return { + usersMap, + isFetching, + error: error ?? null, + refetch, + }; +} diff --git a/apps/content-production-dashboard/src/utils/EntryUtils.ts b/apps/content-production-dashboard/src/utils/EntryUtils.ts new file mode 100644 index 0000000000..885ff5f3cb --- /dev/null +++ b/apps/content-production-dashboard/src/utils/EntryUtils.ts @@ -0,0 +1,37 @@ +import { ContentTypeProps, EntryProps } from 'contentful-management'; +import { EntryStatus } from '../utils/types'; + +export function getEntryStatus(entry: EntryProps): EntryStatus { + const { sys } = entry; + + if (!sys.publishedVersion) { + return EntryStatus.Draft; + } + + if (sys.version === sys.publishedVersion + 1) { + return EntryStatus.Published; + } + + if (sys.version >= sys.publishedVersion + 2) { + return EntryStatus.Changed; + } + + return EntryStatus.Draft; +} + +export function getEntryTitle( + entry: EntryProps, + contentType: ContentTypeProps | undefined, + defaultLocale: string +): string { + if (!entry.fields || !contentType?.displayField) { + return 'Untitled'; + } + + const fieldValue = entry.fields[contentType.displayField]; + if (typeof fieldValue === 'object' && fieldValue !== null) { + return String(fieldValue[defaultLocale] ?? ''); + } + + return 'Untitled'; +} diff --git a/apps/content-production-dashboard/src/utils/UserUtils.ts b/apps/content-production-dashboard/src/utils/UserUtils.ts new file mode 100644 index 0000000000..77f4caa3fe --- /dev/null +++ b/apps/content-production-dashboard/src/utils/UserUtils.ts @@ -0,0 +1,32 @@ +import { EntryProps, UserProps } from 'contentful-management'; +import { Creator } from './types'; + +export const formatUserName = (user: Creator | null): string => { + if (!user) return '—'; + const firstName = user.firstName || ''; + const lastName = user.lastName || ''; + return `${firstName} ${lastName}`.trim() || '—'; +}; + +export function getCreatorFromEntry( + entry: EntryProps, + usersMap: Map +): Creator | null { + const creatorId = entry.sys.createdBy?.sys?.id; + if (!creatorId) { + return null; + } + + const user = usersMap.get(creatorId); + if (user) { + return { + id: creatorId, + firstName: user.firstName, + lastName: user.lastName, + }; + } + + return { + id: creatorId, + }; +} diff --git a/apps/content-production-dashboard/src/utils/fetchAllEntries.ts b/apps/content-production-dashboard/src/utils/fetchAllEntries.ts index 01fe4fd250..5ad691a52f 100644 --- a/apps/content-production-dashboard/src/utils/fetchAllEntries.ts +++ b/apps/content-production-dashboard/src/utils/fetchAllEntries.ts @@ -1,5 +1,5 @@ import { BaseAppSDK } from '@contentful/app-sdk'; -import { EntryProps } from 'contentful-management'; +import { EntryProps, QueryOptions } from 'contentful-management'; import { FETCH_CONFIG } from './cacheConstants'; export interface FetchAllEntriesResult { @@ -8,7 +8,10 @@ export interface FetchAllEntriesResult { fetchedAt: Date; } -export async function fetchAllEntries(sdk: BaseAppSDK): Promise { +export async function fetchAllEntries( + sdk: BaseAppSDK, + query: QueryOptions = {} +): Promise { const allEntries: EntryProps[] = []; let batchSkip = 0; let total = 0; @@ -21,6 +24,7 @@ export async function fetchAllEntries(sdk: BaseAppSDK): Promise; + contentTypes: Map; fetchedAt: Date; } @@ -51,9 +51,9 @@ export async function fetchContentTypes( } } - const contentTypes = new Map(); + const contentTypes = new Map(); allContentTypes.forEach((contentType) => { - contentTypes.set(contentType.sys.id, contentType.name); + contentTypes.set(contentType.sys.id, contentType); }); return { diff --git a/apps/content-production-dashboard/src/utils/trendsDataProcessor.ts b/apps/content-production-dashboard/src/utils/trendsDataProcessor.ts index 8e2e63d479..e22638794e 100644 --- a/apps/content-production-dashboard/src/utils/trendsDataProcessor.ts +++ b/apps/content-production-dashboard/src/utils/trendsDataProcessor.ts @@ -1,4 +1,4 @@ -import { EntryProps } from 'contentful-management'; +import { EntryProps, ContentTypeProps } from 'contentful-management'; import { parseDate } from './dateCalculator'; import { formatMonthYear, formatMonthYearDisplay } from './dateFormat'; import type { ChartDataPoint, TrendsDataProcessorOptions } from './types'; @@ -50,7 +50,7 @@ export function generateMonthRange(startDate: Date, endDate: Date): string[] { function filterEntriesByContentTypes( entries: EntryProps[], - contentTypes?: Map + contentTypes?: Map ): EntryProps[] { if (!contentTypes || contentTypes.size === 0) { return entries; @@ -64,7 +64,7 @@ function filterEntriesByContentTypes( export function generateNewEntriesChartData( entries: EntryProps[], options: TrendsDataProcessorOptions, - contentTypes?: Map + contentTypes?: Map ): ChartDataPoint[] { const startDate = getStartDateForTimeRange(options.timeRange); const now = new Date(); @@ -93,7 +93,7 @@ export function generateNewEntriesChartData( export function generateContentTypeChartData( entries: EntryProps[], options: TrendsDataProcessorOptions, - contentTypes?: Map + contentTypes?: Map ): { data: ChartDataPoint[]; contentTypes: string[] } { const startDate = getStartDateForTimeRange(options.timeRange); const now = new Date(); @@ -122,7 +122,9 @@ export function generateContentTypeChartData( // Generate all months in range const allMonths = generateMonthRange(startDate, now); - const contentTypeNamesArray = Array.from(contentTypes?.values() || []).sort(); + const contentTypeNamesArray = Array.from(contentTypes?.values() || []) + .map((ct) => ct.name) + .sort(); // Convert to chart data format const data = allMonths.map((monthYear) => { @@ -132,9 +134,8 @@ export function generateContentTypeChartData( }; contentTypeNamesArray.forEach((contentTypeName) => { - // Find the key (contentTypeId) that has this value (contentTypeName) const contentTypeId = Array.from(contentTypes?.entries() || []).find( - ([, value]) => value === contentTypeName + ([, contentType]) => contentType.name === contentTypeName )?.[0]; dataPoint[contentTypeName] = monthData.get(contentTypeId || '') || 0; }); @@ -149,7 +150,7 @@ export function generateCreatorChartData( entries: EntryProps[], options: TrendsDataProcessorOptions, creatorsNames?: Map, - contentTypes?: Map + contentTypes?: Map ): { data: ChartDataPoint[]; creators: string[] } { const startDate = getStartDateForTimeRange(options.timeRange); const now = new Date(); diff --git a/apps/content-production-dashboard/src/utils/types.ts b/apps/content-production-dashboard/src/utils/types.ts index 5a03967c9b..77bd1419d2 100644 --- a/apps/content-production-dashboard/src/utils/types.ts +++ b/apps/content-production-dashboard/src/utils/types.ts @@ -19,3 +19,30 @@ export enum TimeRange { export interface TrendsDataProcessorOptions { timeRange: TimeRange; } +export interface Creator { + id: string; + firstName?: string; + lastName?: string; +} + +export enum EntryStatus { + Draft = 'Draft', + Published = 'Published', + Changed = 'Changed', +} + +export interface ScheduledEntry { + id: string; + title: string; + contentType: string; + contentTypeId: string; + creator: Creator | null; + publishedDate: string | null; + updatedDate: string; + status: EntryStatus; +} + +export interface ScheduledContentItem extends ScheduledEntry { + scheduledActionId: string; + scheduledFor: string; +} diff --git a/apps/content-production-dashboard/test/components/Dashboard.spec.tsx b/apps/content-production-dashboard/test/components/Dashboard.spec.tsx index 736f567ec0..b2eac512b0 100644 --- a/apps/content-production-dashboard/test/components/Dashboard.spec.tsx +++ b/apps/content-production-dashboard/test/components/Dashboard.spec.tsx @@ -43,6 +43,16 @@ vi.mock('../../src/hooks/useScheduledActions', () => ({ }), })); +vi.mock('../../src/hooks/useScheduledContent', () => ({ + useScheduledContent: () => ({ + items: [], + total: 0, + isFetching: false, + error: null, + refetch: vi.fn(), + }), +})); + const createWrapper = () => { const TestWrapper = ({ children }: { children: React.ReactNode }) => ( {children} diff --git a/apps/content-production-dashboard/test/components/ScheduledContentTable.spec.tsx b/apps/content-production-dashboard/test/components/ScheduledContentTable.spec.tsx new file mode 100644 index 0000000000..e09664bc2b --- /dev/null +++ b/apps/content-production-dashboard/test/components/ScheduledContentTable.spec.tsx @@ -0,0 +1,213 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { ScheduledContentTable } from '../../src/components/ScheduledContentTable'; +import { EntryStatus, ScheduledContentItem } from '../../src/utils/types'; +import { mockSdk } from '../mocks'; +import { createQueryProviderWrapper } from '../utils/createQueryProviderWrapper'; + +vi.mock('@contentful/react-apps-toolkit', () => ({ + useSDK: () => mockSdk, + useCMA: () => ({}), +})); + +const mockRefetch = vi.fn(); +const mockUseScheduledContent = vi.fn(); + +vi.mock('../../src/hooks/useScheduledContent', () => ({ + useScheduledContent: (defaultLocale: string, page: number) => + mockUseScheduledContent(defaultLocale, page), +})); + +const createMockScheduledContentItem = ( + overrides?: Partial +): ScheduledContentItem => { + const now = new Date(); + const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); + + return { + id: 'entry-1', + title: 'Test Entry', + contentType: 'Blog Post', + contentTypeId: 'blogPost', + creator: { + id: 'user-1', + firstName: 'John', + lastName: 'Doe', + }, + publishedDate: now.toISOString(), + updatedDate: now.toISOString(), + status: EntryStatus.Published, + scheduledActionId: 'action-1', + scheduledFor: futureDate.toISOString(), + ...overrides, + }; +}; + +describe('ScheduledContentTable component', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRefetch.mockClear(); + mockSdk.locales = { default: 'en-US' }; + mockSdk.ids.space = 'test-space'; + }); + + describe('Loading state', () => { + it('renders skeleton loader when fetching', () => { + mockUseScheduledContent.mockReturnValue({ + items: [], + total: 0, + isFetching: true, + error: null, + refetch: mockRefetch, + }); + + render(, { wrapper: createQueryProviderWrapper() }); + + expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.getByText('Title')).toBeInTheDocument(); + expect(screen.getByText('Scheduled Date')).toBeInTheDocument(); + expect(screen.getByText('Published Date')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Content Type')).toBeInTheDocument(); + expect(screen.getByText('Creator')).toBeInTheDocument(); + }); + }); + + describe('Empty state', () => { + it('renders empty state when no items', () => { + mockUseScheduledContent.mockReturnValue({ + items: [], + total: 0, + isFetching: false, + error: null, + refetch: mockRefetch, + }); + + render(, { wrapper: createQueryProviderWrapper() }); + + expect(screen.getByText('No entries found')).toBeInTheDocument(); + }); + }); + + describe('Table rendering', () => { + it('renders table with correct columns', () => { + const mockItem = createMockScheduledContentItem(); + mockUseScheduledContent.mockReturnValue({ + items: [mockItem], + total: 1, + isFetching: false, + error: null, + refetch: mockRefetch, + }); + + render(, { wrapper: createQueryProviderWrapper() }); + + expect(screen.getByText('Title')).toBeInTheDocument(); + expect(screen.getByText('Scheduled Date')).toBeInTheDocument(); + expect(screen.getByText('Published Date')).toBeInTheDocument(); + expect(screen.getByText('Status')).toBeInTheDocument(); + expect(screen.getByText('Content Type')).toBeInTheDocument(); + expect(screen.getByText('Creator')).toBeInTheDocument(); + }); + + it('renders scheduled content items with correct data', () => { + const mockItem = createMockScheduledContentItem({ + id: 'entry-1', + title: 'My Blog Post', + contentType: 'Blog Post', + scheduledFor: '2024-01-15T10:00:00Z', + publishedDate: '2024-01-01T00:00:00Z', + }); + + mockUseScheduledContent.mockReturnValue({ + items: [mockItem], + total: 1, + isFetching: false, + error: null, + refetch: mockRefetch, + }); + + render(, { wrapper: createQueryProviderWrapper() }); + + expect(screen.getByText('My Blog Post')).toBeInTheDocument(); + expect(screen.getByText('Blog Post')).toBeInTheDocument(); + expect(screen.getByText('John Doe')).toBeInTheDocument(); + expect(screen.getByText('Jan 15, 2024, 07:00 AM GMT-3')).toBeInTheDocument(); + expect(screen.getByText('Dec 31, 2023, 09:00 PM GMT-3')).toBeInTheDocument(); + expect(screen.getByText(EntryStatus.Published)).toBeInTheDocument(); + }); + + it('renders multiple scheduled content items', () => { + const mockItems = [ + createMockScheduledContentItem({ id: 'entry-1', title: 'Entry 1' }), + createMockScheduledContentItem({ id: 'entry-2', title: 'Entry 2' }), + createMockScheduledContentItem({ id: 'entry-3', title: 'Entry 3' }), + ]; + + mockUseScheduledContent.mockReturnValue({ + items: mockItems, + total: 3, + isFetching: false, + error: null, + refetch: mockRefetch, + }); + + render(, { wrapper: createQueryProviderWrapper() }); + + expect(screen.getByText('Entry 1')).toBeInTheDocument(); + expect(screen.getByText('Entry 2')).toBeInTheDocument(); + expect(screen.getByText('Entry 3')).toBeInTheDocument(); + }); + }); + + describe('Status badges', () => { + it('displays correct status badges for all status types', () => { + const mockItems = [ + createMockScheduledContentItem({ id: 'entry-1', status: EntryStatus.Published }), + createMockScheduledContentItem({ id: 'entry-2', status: EntryStatus.Changed }), + createMockScheduledContentItem({ id: 'entry-3', status: EntryStatus.Draft }), + ]; + + mockUseScheduledContent.mockReturnValue({ + items: mockItems, + total: 3, + isFetching: false, + error: null, + refetch: mockRefetch, + }); + + render(, { wrapper: createQueryProviderWrapper() }); + + expect(screen.getByText(EntryStatus.Published)).toBeInTheDocument(); + expect(screen.getByText(EntryStatus.Changed)).toBeInTheDocument(); + expect(screen.getByText(EntryStatus.Draft)).toBeInTheDocument(); + }); + }); + + describe('EntryLink rendering', () => { + it('renders EntryLink with correct URL', () => { + const mockItem = createMockScheduledContentItem({ + id: 'entry-123', + title: 'My Entry', + }); + + mockUseScheduledContent.mockReturnValue({ + items: [mockItem], + total: 1, + isFetching: false, + error: null, + refetch: mockRefetch, + }); + + render(, { wrapper: createQueryProviderWrapper() }); + + const link = screen.getByText('My Entry').closest('a'); + expect(link).toHaveAttribute( + 'href', + 'https://app.contentful.com/spaces/test-space/entries/entry-123' + ); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); + }); + }); +}); diff --git a/apps/content-production-dashboard/test/hooks/useContentTypes.spec.tsx b/apps/content-production-dashboard/test/hooks/useContentTypes.spec.tsx index 56011834a8..fcd8c92af3 100644 --- a/apps/content-production-dashboard/test/hooks/useContentTypes.spec.tsx +++ b/apps/content-production-dashboard/test/hooks/useContentTypes.spec.tsx @@ -1,10 +1,10 @@ -import React from 'react'; import { describe, expect, it, vi, beforeEach } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ContentTypeProps } from 'contentful-management'; import { mockSdk } from '../mocks'; import { useContentTypes } from '../../src/hooks/useContentTypes'; import { fetchContentTypes } from '../../src/utils/fetchContentTypes'; +import { createQueryProviderWrapper } from '../utils/createQueryProviderWrapper'; vi.mock('@contentful/react-apps-toolkit', () => ({ useSDK: () => mockSdk, @@ -12,32 +12,29 @@ vi.mock('@contentful/react-apps-toolkit', () => ({ vi.mock('../../src/utils/fetchContentTypes'); -const createWrapper = () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { - retry: false, - }, - }, - }); - - const TestWrapper = ({ children }: { children: React.ReactNode }) => ( - {children} - ); - TestWrapper.displayName = 'TestWrapper'; - return TestWrapper; -}; - describe('useContentTypes', () => { beforeEach(() => { vi.clearAllMocks(); }); it('fetches and returns content types successfully', async () => { + const mockBlogPost: ContentTypeProps = { + sys: { id: 'blogPost', type: 'ContentType' } as any, + name: 'Blog Post', + } as ContentTypeProps; + const mockArticle: ContentTypeProps = { + sys: { id: 'article', type: 'ContentType' } as any, + name: 'Article', + } as ContentTypeProps; + const mockPage: ContentTypeProps = { + sys: { id: 'page', type: 'ContentType' } as any, + name: 'Page', + } as ContentTypeProps; + const mockContentTypes = new Map([ - ['blogPost', 'Blog Post'], - ['article', 'Article'], - ['page', 'Page'], + ['blogPost', mockBlogPost], + ['article', mockArticle], + ['page', mockPage], ]); const mockFetchedAt = new Date('2024-01-01T00:00:00Z'); @@ -47,7 +44,7 @@ describe('useContentTypes', () => { }); const { result } = renderHook(() => useContentTypes(), { - wrapper: createWrapper(), + wrapper: createQueryProviderWrapper(), }); await waitFor(() => { @@ -62,9 +59,18 @@ describe('useContentTypes', () => { }); it('fetches specific content types when contentTypeIds are provided', async () => { + const mockBlogPost: ContentTypeProps = { + sys: { id: 'blogPost', type: 'ContentType' } as any, + name: 'Blog Post', + } as ContentTypeProps; + const mockArticle: ContentTypeProps = { + sys: { id: 'article', type: 'ContentType' } as any, + name: 'Article', + } as ContentTypeProps; + const mockContentTypes = new Map([ - ['blogPost', 'Blog Post'], - ['article', 'Article'], + ['blogPost', mockBlogPost], + ['article', mockArticle], ]); vi.mocked(fetchContentTypes).mockResolvedValue({ @@ -74,7 +80,7 @@ describe('useContentTypes', () => { const contentTypeIds = ['blogPost', 'article']; const { result } = renderHook(() => useContentTypes(contentTypeIds), { - wrapper: createWrapper(), + wrapper: createQueryProviderWrapper(), }); await waitFor(() => { @@ -90,7 +96,7 @@ describe('useContentTypes', () => { vi.mocked(fetchContentTypes).mockRejectedValue(mockError); const { result } = renderHook(() => useContentTypes(), { - wrapper: createWrapper(), + wrapper: createQueryProviderWrapper(), }); await waitFor(() => { @@ -110,7 +116,7 @@ describe('useContentTypes', () => { }); const { result } = renderHook(() => useContentTypes(), { - wrapper: createWrapper(), + wrapper: createQueryProviderWrapper(), }); await waitFor(() => { diff --git a/apps/content-production-dashboard/test/hooks/useScheduledContent.spec.tsx b/apps/content-production-dashboard/test/hooks/useScheduledContent.spec.tsx new file mode 100644 index 0000000000..59c7f48c2b --- /dev/null +++ b/apps/content-production-dashboard/test/hooks/useScheduledContent.spec.tsx @@ -0,0 +1,247 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { EntryStatus } from '../../src/utils/types'; +import { useScheduledContent } from '../../src/hooks/useScheduledContent'; +import { + createMockEntry, + createMockScheduledAction, + createMockContentType, + createMockUser, +} from '../utils/testHelpers'; +import { createQueryProviderWrapper } from '../utils/createQueryProviderWrapper'; + +vi.mock('@contentful/react-apps-toolkit', () => ({ + useSDK: () => ({ + ids: { + space: 'test-space', + environment: 'test-environment', + }, + }), +})); + +const mockRefetchScheduledActions = vi.fn(); +const mockRefetchEntries = vi.fn(); +const mockRefetchContentTypes = vi.fn(); + +const mockUseScheduledActions = vi.fn(); +const mockUseEntries = vi.fn(); +const mockUseContentTypes = vi.fn(); +const mockUseUsers = vi.fn(); + +vi.mock('../../src/hooks/useScheduledActions', () => ({ + useScheduledActions: () => mockUseScheduledActions(), +})); + +vi.mock('../../src/hooks/useAllEntries', () => ({ + useEntries: (options: any) => mockUseEntries(options), +})); + +vi.mock('../../src/hooks/useContentTypes', () => ({ + useContentTypes: () => mockUseContentTypes(), +})); + +vi.mock('../../src/hooks/useUsers', () => ({ + useUsers: (userIds: string[]) => mockUseUsers(userIds), +})); + +describe('useScheduledContent', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRefetchScheduledActions.mockClear(); + mockRefetchEntries.mockClear(); + mockRefetchContentTypes.mockClear(); + }); + + describe('Empty state', () => { + it('returns empty items when no scheduled actions exist', () => { + mockUseScheduledActions.mockReturnValue({ + scheduledActions: [], + isFetchingScheduledActions: false, + refetchScheduledActions: mockRefetchScheduledActions, + }); + + mockUseEntries.mockReturnValue({ + entries: [], + isFetchingEntries: false, + fetchingEntriesError: null, + refetchEntries: mockRefetchEntries, + }); + + mockUseContentTypes.mockReturnValue({ + contentTypes: new Map(), + isFetchingContentTypes: false, + refetchContentTypes: mockRefetchContentTypes, + }); + + mockUseUsers.mockReturnValue({ + usersMap: new Map(), + isFetching: false, + error: null, + refetch: vi.fn(), + }); + + const { result } = renderHook(() => useScheduledContent('en-US', 0), { + wrapper: createQueryProviderWrapper(), + }); + + expect(result.current.items).toEqual([]); + expect(result.current.total).toBe(0); + expect(result.current.isFetching).toBe(false); + expect(result.current.error).toBeNull(); + }); + }); + + describe('Data fetching and mapping', () => { + it('fetches and combines scheduled actions with entries', async () => { + const entry1 = createMockEntry({ + id: 'entry-1', + contentTypeId: 'blogPost', + createdById: 'user-1', + }); + const entry2 = createMockEntry({ + id: 'entry-2', + contentTypeId: 'article', + createdById: 'user-2', + }); + + const scheduledAction1 = createMockScheduledAction({ + id: 'action-1', + entityId: 'entry-1', + entityLinkType: 'Entry', + }); + const scheduledAction2 = createMockScheduledAction({ + id: 'action-2', + entityId: 'entry-2', + entityLinkType: 'Entry', + }); + + const contentType1 = createMockContentType({ id: 'blogPost', name: 'Blog Post' }); + const contentType2 = createMockContentType({ id: 'article', name: 'Article' }); + + const user1 = createMockUser({ id: 'user-1', firstName: 'John', lastName: 'Doe' }); + const user2 = createMockUser({ id: 'user-2', firstName: 'Jane', lastName: 'Smith' }); + + mockUseScheduledActions.mockReturnValue({ + scheduledActions: [scheduledAction1, scheduledAction2], + isFetchingScheduledActions: false, + refetchScheduledActions: mockRefetchScheduledActions, + }); + + mockUseEntries.mockReturnValue({ + entries: [entry1, entry2], + isFetchingEntries: false, + fetchingEntriesError: null, + refetchEntries: mockRefetchEntries, + }); + + const contentTypesMap = new Map([ + ['blogPost', contentType1], + ['article', contentType2], + ]); + mockUseContentTypes.mockReturnValue({ + contentTypes: contentTypesMap, + isFetchingContentTypes: false, + refetchContentTypes: mockRefetchContentTypes, + }); + + const usersMap = new Map([ + ['user-1', user1], + ['user-2', user2], + ]); + mockUseUsers.mockReturnValue({ + usersMap, + isFetching: false, + error: null, + refetch: vi.fn(), + }); + + const { result } = renderHook(() => useScheduledContent('en-US', 0), { + wrapper: createQueryProviderWrapper(), + }); + + await waitFor(() => { + expect(result.current.items).toHaveLength(2); + }); + + expect(result.current.items[0]).toMatchObject({ + id: 'entry-1', + contentType: 'Blog Post', + contentTypeId: 'blogPost', + scheduledActionId: 'action-1', + }); + expect(result.current.items[1]).toMatchObject({ + id: 'entry-2', + contentType: 'Article', + contentTypeId: 'article', + scheduledActionId: 'action-2', + }); + }); + + it('maps entries to ScheduledContentItem with correct data', async () => { + const entry = createMockEntry({ + id: 'entry-1', + contentTypeId: 'blogPost', + createdById: 'user-1', + publishedAt: '2024-01-01T00:00:00Z', + }); + entry.sys.version = 3; + entry.sys.publishedVersion = 1; + + const scheduledAction = createMockScheduledAction({ + id: 'action-1', + entityId: 'entry-1', + entityLinkType: 'Entry', + scheduledFor: '2024-01-15T10:00:00Z', + }); + + const contentType = createMockContentType({ id: 'blogPost', name: 'Blog Post' }); + const user = createMockUser({ id: 'user-1', firstName: 'John', lastName: 'Doe' }); + + mockUseScheduledActions.mockReturnValue({ + scheduledActions: [scheduledAction], + isFetchingScheduledActions: false, + refetchScheduledActions: mockRefetchScheduledActions, + }); + + mockUseEntries.mockReturnValue({ + entries: [entry], + isFetchingEntries: false, + fetchingEntriesError: null, + refetchEntries: mockRefetchEntries, + }); + + mockUseContentTypes.mockReturnValue({ + contentTypes: new Map([['blogPost', contentType]]), + isFetchingContentTypes: false, + refetchContentTypes: mockRefetchContentTypes, + }); + + mockUseUsers.mockReturnValue({ + usersMap: new Map([['user-1', user]]), + isFetching: false, + error: null, + refetch: vi.fn(), + }); + + const { result } = renderHook(() => useScheduledContent('en-US', 0), { + wrapper: createQueryProviderWrapper(), + }); + + await waitFor(() => { + expect(result.current.items).toHaveLength(1); + }); + + const item = result.current.items[0]; + expect(item.id).toBe('entry-1'); + expect(item.scheduledActionId).toBe('action-1'); + expect(item.scheduledFor).toBe('2024-01-15T10:00:00Z'); + expect(item.publishedDate).toBe('2024-01-01T00:00:00Z'); + expect(item.status).toBe(EntryStatus.Changed); + expect(item.creator).toEqual({ + id: 'user-1', + firstName: 'John', + lastName: 'Doe', + }); + }); + }); +}); diff --git a/apps/content-production-dashboard/test/mocks/mockCma.ts b/apps/content-production-dashboard/test/mocks/mockCma.ts index 8d5a1e093b..1be5ed4133 100644 --- a/apps/content-production-dashboard/test/mocks/mockCma.ts +++ b/apps/content-production-dashboard/test/mocks/mockCma.ts @@ -15,6 +15,17 @@ const mockCma: any = { entry: { getMany: vi.fn(), }, + user: { + getManyForSpace: vi.fn().mockResolvedValue({ + items: [], + total: 0, + }), + }, + appInstallation: { + getForOrganization: vi.fn().mockResolvedValue({ + items: [], + }), + }, }; export const getManyEntries = (entries: EntryProps[], total?: number) => { diff --git a/apps/content-production-dashboard/test/mocks/mockSdk.ts b/apps/content-production-dashboard/test/mocks/mockSdk.ts index 115ae2a1ba..b17b1ae039 100644 --- a/apps/content-production-dashboard/test/mocks/mockSdk.ts +++ b/apps/content-production-dashboard/test/mocks/mockSdk.ts @@ -12,6 +12,10 @@ const mockSdk: any = { app: 'test-app', space: 'test-space', environment: 'test-environment', + organization: 'test-organization', + }, + locales: { + default: 'en-US', }, parameters: { installation: {}, diff --git a/apps/content-production-dashboard/test/utils/createQueryProviderWrapper.tsx b/apps/content-production-dashboard/test/utils/createQueryProviderWrapper.tsx new file mode 100644 index 0000000000..f42dfd08dd --- /dev/null +++ b/apps/content-production-dashboard/test/utils/createQueryProviderWrapper.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +/** + * Creates a new QueryClient with retries disabled for each test to ensure isolation. + */ +export function createQueryProviderWrapper(): React.ComponentType<{ children: React.ReactNode }> { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + + const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + TestWrapper.displayName = 'TestWrapper'; + return TestWrapper; +} diff --git a/apps/content-production-dashboard/test/utils/fetchReleases.spec.ts b/apps/content-production-dashboard/test/utils/fetchReleases.spec.ts index f4c750ed4b..10369ef4da 100644 --- a/apps/content-production-dashboard/test/utils/fetchReleases.spec.ts +++ b/apps/content-production-dashboard/test/utils/fetchReleases.spec.ts @@ -3,6 +3,7 @@ import { HomeAppSDK, PageAppSDK } from '@contentful/app-sdk'; import { ScheduledActionProps, UserProps } from 'contentful-management'; import { fetchReleases } from '../../src/utils/fetchReleases'; import { mockCma } from '../mocks/mockCma'; +import { createMockScheduledAction, createMockUser } from './testHelpers'; describe('fetchReleases', () => { let mockSdk: HomeAppSDK | PageAppSDK; @@ -90,12 +91,12 @@ describe('fetchReleases', () => { title: 'My Launch Release', itemsCount: 10, }); - const user = createMockUser({ sys: { id: 'user-1' } as any }); + const user = createMockUser({ id: 'user-1', firstName: 'John', lastName: 'Doe' }); setupMocks({ scheduledActions: [action], launchReleases: [release], - users: [user], + users: [user as UserProps], }); const result = await fetchReleases(mockSdk); @@ -130,7 +131,7 @@ describe('fetchReleases', () => { setupMocks({ scheduledActions: [action], timelineReleases: [release], - users: [user], + users: [user as UserProps], }); const result = await fetchReleases(mockSdk); @@ -163,7 +164,7 @@ describe('fetchReleases', () => { scheduledActions: [launchAction, timelineAction], launchReleases: [launchRelease], timelineReleases: [timelineRelease], - users: [user], + users: [user as UserProps], }); const result = await fetchReleases(mockSdk); @@ -176,60 +177,6 @@ describe('fetchReleases', () => { }); }); -const createMockScheduledAction = ( - overrides?: Partial -): ScheduledActionProps => { - const now = new Date(); - const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); - - const defaultAction: ScheduledActionProps = { - sys: { - id: 'action-1', - type: 'ScheduledAction', - createdAt: now.toISOString(), - updatedAt: now.toISOString(), - version: 1, - space: { sys: { id: 'space-1', type: 'Link', linkType: 'Space' } }, - status: 'scheduled', - createdBy: { - sys: { - id: 'user-1', - type: 'Link', - linkType: 'User', - }, - }, - updatedBy: { - sys: { - id: 'user-1', - type: 'Link', - linkType: 'User', - }, - }, - }, - entity: { - sys: { - id: 'release-1', - type: 'Link', - linkType: 'Release', - }, - }, - scheduledFor: { - datetime: futureDate.toISOString(), - timezone: 'UTC', - }, - action: 'publish', - } as ScheduledActionProps; - - if (overrides?.sys) { - defaultAction.sys = { ...defaultAction.sys, ...(overrides.sys as any) }; - if ((overrides.sys as any).createdBy) { - defaultAction.sys.createdBy = (overrides.sys as any).createdBy; - } - } - - return { ...defaultAction, ...overrides, sys: defaultAction.sys } as ScheduledActionProps; -}; - const createMockRelease = ( type: 'launch' | 'timeline', overrides?: { id?: string; title?: string; itemsCount?: number } @@ -252,38 +199,10 @@ const createMockRelease = ( return baseRelease; }; -const createMockUser = (overrides?: Partial): UserProps => { - return { - sys: { - id: 'user-1', - type: 'User', - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - firstName: 'John', - lastName: 'Doe', - email: 'john.doe@example.com', - ...overrides, - } as UserProps; -}; - const createActionWithRelease = (actionId: string, releaseId: string, userId = 'user-1') => createMockScheduledAction({ - sys: { - id: actionId, - createdBy: { - sys: { - id: userId, - type: 'Link', - linkType: 'User', - }, - }, - } as any, - entity: { - sys: { - id: releaseId, - type: 'Link', - linkType: 'Release', - }, - }, + id: actionId, + entityId: releaseId, + entityLinkType: 'Release', + createdById: userId, }); diff --git a/apps/content-production-dashboard/test/utils/testHelpers.ts b/apps/content-production-dashboard/test/utils/testHelpers.ts index 15cf74d7fb..40e41ea792 100644 --- a/apps/content-production-dashboard/test/utils/testHelpers.ts +++ b/apps/content-production-dashboard/test/utils/testHelpers.ts @@ -1,4 +1,4 @@ -import { EntryProps } from 'contentful-management'; +import { EntryProps, ScheduledActionProps, ContentTypeProps } from 'contentful-management'; import type { ChartDataPoint } from '../../src/utils/types'; export interface MockEntryOverrides { @@ -117,3 +117,94 @@ export function createMockContentTypeNames( }); return map; } + +export interface MockScheduledActionOverrides { + id?: string; + entityId?: string; + entityLinkType?: 'Entry' | 'Release'; + scheduledFor?: string; + action?: 'publish' | 'unpublish'; + createdById?: string; +} + +export function createMockScheduledAction( + overrides: MockScheduledActionOverrides = {} +): ScheduledActionProps { + const now = new Date(); + const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); + const { + id = `action-${Math.random().toString(36).slice(2, 11)}`, + entityId = `entity-${Math.random().toString(36).slice(2, 11)}`, + entityLinkType = 'Entry', + scheduledFor = futureDate.toISOString(), + action = 'publish', + createdById = `user-${Math.random().toString(36).slice(2, 11)}`, + } = overrides; + + return { + sys: { + id, + type: 'ScheduledAction', + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + version: 1, + space: { sys: { id: 'test-space', type: 'Link', linkType: 'Space' } }, + status: 'scheduled', + createdBy: { + sys: { + id: createdById, + type: 'Link', + linkType: 'User', + }, + }, + updatedBy: { + sys: { + id: createdById, + type: 'Link', + linkType: 'User', + }, + }, + }, + entity: { + sys: { + id: entityId, + type: 'Link', + linkType: entityLinkType, + }, + }, + scheduledFor: { + datetime: scheduledFor, + timezone: 'UTC', + }, + action, + } as ScheduledActionProps; +} + +export interface MockContentTypeOverrides { + id?: string; + name?: string; + displayField?: string; +} + +export function createMockContentType(overrides: MockContentTypeOverrides = {}): ContentTypeProps { + const { + id = `contentType-${Math.random().toString(36).slice(2, 11)}`, + name = 'Blog Post', + displayField = 'title', + } = overrides; + + return { + sys: { + id, + type: 'ContentType', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + version: 1, + space: { sys: { id: 'test-space', type: 'Link', linkType: 'Space' } }, + environment: { sys: { id: 'test-environment', type: 'Link', linkType: 'Environment' } }, + }, + name, + displayField, + fields: [], + } as unknown as ContentTypeProps; +}