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;
+}