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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/content-production-dashboard/src/components/EntryLink.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<TextLink
href={entryUrl}
target="_blank"
rel="noopener noreferrer"
alignIcon="end"
icon={<ArrowSquareOutIcon />}>
{children}
</TextLink>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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 (
<Box marginTop="spacingXl">
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Table.Head>
<Table.Row>
<Table.Cell style={styles.titleCell}>Title</Table.Cell>
<Table.Cell style={styles.creatorCell}>Creator</Table.Cell>
<Table.Cell style={styles.contentTypeCell}>Content Type</Table.Cell>
<Table.Cell style={styles.publishedDateCell}>Published Date</Table.Cell>
<Table.Cell style={styles.scheduledDateCell}>Scheduled Date</Table.Cell>
<Table.Cell style={styles.publishedDateCell}>Published Date</Table.Cell>
<Table.Cell style={styles.statusCell}>Status</Table.Cell>
<Table.Cell style={styles.contentTypeCell}>Content Type</Table.Cell>
<Table.Cell style={styles.creatorCell}>Creator</Table.Cell>
</Table.Row>
</Table.Head>
);
};

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<HomeAppSDK | PageAppSDK>();
const [currentPage, setCurrentPage] = useState(0);
const { items, total, isFetching } = useScheduledContent(sdk.locales.default, currentPage);

if (isFetching) {
return (
<>
<Table>
<ScheduledContentTableHeader />
<Table.Body>
<Skeleton.Row rowCount={5} columnCount={6} />
</Table.Body>
</Table>
</>
);
}

if (ITEMS.length === 0) {
if (items.length === 0) {
return <EmptyStateTable />;
}

return (
<Table>
<ScheduledContentTableHeader />
<Table.Body>
<EmptyStateTable />
</Table.Body>
</Table>
<>
<Table>
<ScheduledContentTableHeader />
<Table.Body>
{items.map((item: ScheduledContentItem) => (
<Table.Row key={item.id}>
<Table.Cell style={styles.titleCell}>
<EntryLink entryId={item.id} spaceId={sdk.ids.space}>
{item.title}
</EntryLink>
</Table.Cell>
<Table.Cell style={styles.scheduledDateCell}>
{formatDateTimeWithTimezone(item.scheduledFor)}
</Table.Cell>
<Table.Cell style={styles.publishedDateCell}>
{formatDateTimeWithTimezone(item.publishedDate || '')}
</Table.Cell>
<Table.Cell style={styles.statusCell}>
<Badge variant={getStatusBadgeVariant(item.status)}>{item.status}</Badge>
</Table.Cell>
<Table.Cell style={styles.contentTypeCell}>{item.contentType}</Table.Cell>
<Table.Cell style={styles.creatorCell}>{formatUserName(item.creator)}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
{total > RELEASES_PER_PAGE && (
<Box marginTop="spacingL">
<Pagination
activePage={currentPage}
onPageChange={setCurrentPage}
totalItems={total}
itemsPerPage={RELEASES_PER_PAGE}
/>
</Box>
)}
</>
);
};
19 changes: 15 additions & 4 deletions apps/content-production-dashboard/src/hooks/useAllEntries.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<PageAppSDK>();

const { data, isFetching, error, refetch } = useQuery<FetchAllEntriesResult, Error>({
queryKey: ['entries', sdk.ids.space, getEnvironmentId(sdk)],
queryFn: () => fetchAllEntries(sdk),
queryKey: ['entries', sdk.ids.space, getEnvironmentId(sdk), query ?? {}],
queryFn: () => fetchAllEntries(sdk, query),
enabled,
});

return {
Expand All @@ -33,3 +40,7 @@ export function useAllEntries(): UseAllEntriesResult {
},
};
}

export function useAllEntries(): UseAllEntriesResult {
return useEntries();
}
Original file line number Diff line number Diff line change
@@ -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<string, string>;
contentTypes: Map<string, ContentTypeProps>;
isFetchingContentTypes: boolean;
fetchingContentTypesError: Error | null;
fetchedAt: Date | undefined;
refetchContentTypes: () => void;
}

export function useContentTypes(contentTypeIds?: string[]): UseContentTypesResult {
const sdk = useSDK<PageAppSDK>();

const { data, isFetching, error } = useQuery<FetchContentTypesResult, Error>({
const { data, isFetching, error, refetch } = useQuery<FetchContentTypesResult, Error>({
queryKey: ['contentTypes', sdk.ids.space, sdk.ids.environment, contentTypeIds],
queryFn: () => fetchContentTypes(sdk, contentTypeIds),
});
Expand All @@ -23,5 +25,8 @@ export function useContentTypes(contentTypeIds?: string[]): UseContentTypesResul
isFetchingContentTypes: isFetching,
fetchingContentTypesError: error,
fetchedAt: data?.fetchedAt,
refetchContentTypes: () => {
refetch();
},
};
}
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -14,12 +14,12 @@ export interface UseScheduledActionsResult {
refetchScheduledActions: () => void;
}

export function useScheduledActions(): UseScheduledActionsResult {
export function useScheduledActions(query: QueryOptions = {}): UseScheduledActionsResult {
const sdk = useSDK<PageAppSDK>();

const { data, isFetching, error, refetch } = useQuery<FetchScheduledActionsResult, Error>({
queryKey: ['scheduledActions', sdk.ids.space, getEnvironmentId(sdk)],
queryFn: () => fetchScheduledActions(sdk),
queryKey: ['scheduledActions', sdk.ids.space, getEnvironmentId(sdk), query],
queryFn: () => fetchScheduledActions(sdk, query),
});

return {
Expand Down
118 changes: 118 additions & 0 deletions apps/content-production-dashboard/src/hooks/useScheduledContent.ts
Original file line number Diff line number Diff line change
@@ -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<string, EntryProps> {
return entries.reduce((map, entry) => {
map.set(entry.sys.id, entry);
return map;
}, new Map<string, EntryProps>());
}

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();
},
};
}
Loading