Skip to content

Commit

Permalink
feat: add releases page (#269)
Browse files Browse the repository at this point in the history
  • Loading branch information
stevenle authored Mar 11, 2024
1 parent e5c5372 commit 159f8b2
Show file tree
Hide file tree
Showing 38 changed files with 2,228 additions and 142 deletions.
5 changes: 5 additions & 0 deletions .changeset/gentle-comics-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@blinkk/root-cms': minor
---

feat: add releases page
76 changes: 57 additions & 19 deletions packages/root-cms/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,11 @@ import {
Firestore,
Query,
Timestamp,
WriteBatch,
getFirestore,
} from 'firebase-admin/firestore';
import {CMSPlugin} from './plugin.js';

/**
* Max number of docs that can be published at once by `publishDocs()`.
*/
export const PUBLISH_DOCS_BATCH_LIMIT = 100;

export interface Doc<Fields = any> {
/** The id of the doc, e.g. "Pages/foo-bar". */
id: string;
Expand Down Expand Up @@ -118,6 +114,18 @@ export interface LoadTranslationsOptions {
tags?: string[];
}

export interface Release {
id: string;
description?: string;
docIds?: string[];
createdAt?: Timestamp;
createdBy?: string;
scheduledAt?: Timestamp;
scheduledBy?: string;
publishedAt?: Timestamp;
publishedBy?: string;
}

export class RootCMSClient {
private readonly rootConfig: RootConfig;
private readonly cmsPlugin: CMSPlugin;
Expand Down Expand Up @@ -236,7 +244,10 @@ export class RootCMSClient {
/**
* Batch publishes a set of docs by id.
*/
async publishDocs(docIds: string[], options?: {publishedBy: string}) {
async publishDocs(
docIds: string[],
options?: {publishedBy: string; batch?: WriteBatch}
) {
const projectCollectionsPath = `Projects/${this.projectId}/Collections`;
const publishedBy = options?.publishedBy || 'root-cms-client';

Expand All @@ -258,20 +269,15 @@ export class RootCMSClient {
// Remove docs that don't exist.
.filter((d) => !!d);

if (docs.length > PUBLISH_DOCS_BATCH_LIMIT) {
throw new Error(
`publishDocs() has a limit of ${PUBLISH_DOCS_BATCH_LIMIT}`
);
}

if (docs.length === 0) {
console.log('no docs to publish');
return [];
}

// // Each transaction or batch can write a max of 500 ops.
// // https://firebase.google.com/docs/firestore/manage-data/transactions
let batchCount = 0;
const batch = this.db.batch();
const batch = options?.batch || this.db.batch();
const publishedDocs: any[] = [];
for (const doc of docs) {
const {id, collection, slug, sys, fields} = doc;
Expand Down Expand Up @@ -324,12 +330,15 @@ export class RootCMSClient {

publishedDocs.push(doc);

if (batchCount >= 498) {
break;
if (batchCount >= 400) {
await batch.commit();
batchCount = 0;
}
}

await batch.commit();
if (batchCount > 0) {
await batch.commit();
}
console.log(`published ${publishedDocs.length} docs!`);
return publishedDocs;
}
Expand Down Expand Up @@ -433,16 +442,45 @@ export class RootCMSClient {

publishedDocs.push(doc);

if (batchCount >= 498) {
break;
if (batchCount >= 400) {
await batch.commit();
batchCount = 0;
continue;
}
}

await batch.commit();
if (batchCount > 0) {
await batch.commit();
}
console.log(`published ${publishedDocs.length} docs!`);
return publishedDocs;
}

/**
* Publishes docs in scheduled releases.
*/
async publishScheduledReleases() {
const releasesPath = `Projects/${this.projectId}/Releases`;
const now = Math.ceil(new Date().getTime());
const query: Query = this.db
.collection(releasesPath)
.where('scheduledAt', '<=', Timestamp.fromMillis(now));
const querySnapshot = await query.get();

for (const snapshot of querySnapshot.docs) {
const release = snapshot.data() as Release;
const batch = this.db.batch();
const publishedBy = release.scheduledBy || 'root-cms-client';
batch.update(snapshot.ref, {
publishedAt: Timestamp.now(),
publishedBy: publishedBy,
scheduledAt: FieldValue.delete(),
scheduledBy: FieldValue.delete(),
});
await this.publishDocs(release.docIds || [], {publishedBy, batch});
}
}

/**
* Loads translations saved in the translations collection, optionally
* filtered by tag.
Expand Down
6 changes: 4 additions & 2 deletions packages/root-cms/core/cron.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {RootConfig} from '@blinkk/root';
import {publishScheduledDocs} from './runtime.js';
import {RootCMSClient} from './client.js';
import {VersionsService} from './versions.js';

export async function runCronJobs(rootConfig: RootConfig) {
Expand All @@ -24,7 +24,9 @@ async function runCronJob(
}

async function runPublishScheduledDocs(rootConfig: RootConfig) {
await publishScheduledDocs(rootConfig);
const cmsClient = new RootCMSClient(rootConfig);
await cmsClient.publishScheduledDocs();
await cmsClient.publishScheduledReleases();
}

async function runSaveVersions(rootConfig: RootConfig) {
Expand Down
17 changes: 13 additions & 4 deletions packages/root-cms/ui/components/DataSourceForm/DataSourceForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
updateDataSource,
} from '../../utils/data-source.js';
import {parseSpreadsheetUrl} from '../../utils/gsheets.js';
import {notifyErrors} from '../../utils/notifications.js';
import {isSlugValid} from '../../utils/slug.js';
import './DataSourceForm.css';

Expand Down Expand Up @@ -53,10 +54,12 @@ export function DataSourceForm(props: DataSourceFormProps) {
}

async function fetchDataSource(id: string) {
const dataSource = await getDataSource(id);
setDataSource(dataSource);
setDataSourceType(dataSource?.type || 'http');
setDataFormat(dataSource?.dataFormat || 'map');
await notifyErrors(async () => {
const dataSource = await getDataSource(id);
setDataSource(dataSource);
setDataSourceType(dataSource?.type || 'http');
setDataFormat(dataSource?.dataFormat || 'map');
});
setLoading(false);
}

Expand Down Expand Up @@ -155,6 +158,12 @@ export function DataSourceForm(props: DataSourceFormProps) {
}
} catch (err) {
console.error(err);
showNotification({
title: 'Failed to save data source',
message: String(err),
color: 'red',
autoClose: false,
});
setSubmitting(false);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '../../utils/data-source.js';
import {getTimeAgo} from '../../utils/time.js';
import './DataSourceStatusButton.css';
import {TimeSinceActionTooltip} from '../TimeSinceActionTooltip/TimeSinceActionTooltip.js';

export interface DataSourceStatusButtonProps {
dataSource: DataSource;
Expand Down Expand Up @@ -92,7 +93,7 @@ export function DataSourceStatusButton(props: DataSourceStatusButtonProps) {
<div className="DataSourceStatusButton">
<div className="DataSourceStatusButton__label">
{!loading && (
<TimeSince timestamp={timestampToMillis(timestamp)} email={email} />
<TimeSinceActionTooltip timestamp={timestamp} email={email} />
)}
</div>
<div className="DataSourceStatusButton__button">
Expand All @@ -111,52 +112,3 @@ export function DataSourceStatusButton(props: DataSourceStatusButtonProps) {
</div>
);
}

interface TimeSinceProps {
timestamp?: number;
email?: string;
}

function TimeSince(props: TimeSinceProps) {
const [label, setLabel] = useState(
props.timestamp ? getTimeAgo(props.timestamp, {style: 'short'}) : 'never'
);

if (!props.timestamp) {
return <div>{label}</div>;
}

useEffect(() => {
const interval = window.setInterval(() => {
setLabel(getTimeAgo(props.timestamp!, {style: 'short'}));
}, 60000);
return () => window.clearInterval(interval);
}, []);

return (
<Tooltip
transition="pop"
label={`${formatDateTime(props.timestamp)} by ${props.email}`}
>
{getTimeAgo(props.timestamp, {style: 'short'})}
</Tooltip>
);
}

function formatDateTime(timestamp: number) {
const date = new Date(timestamp);
return date.toLocaleDateString('en', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}

function timestampToMillis(ts?: Timestamp) {
if (!ts) {
return 0;
}
return ts.toMillis();
}
12 changes: 1 addition & 11 deletions packages/root-cms/ui/components/DocEditor/DocEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import {joinClassNames} from '../../utils/classes.js';
import {getDefaultFieldValue} from '../../utils/fields.js';
import {flattenNestedKeys} from '../../utils/objects.js';
import {autokey} from '../../utils/rand.js';
import {getPlaceholderKeys, strFormat} from '../../utils/str-format.js';
import {
DocActionEvent,
Expand Down Expand Up @@ -771,14 +772,3 @@ function arrayPreview(

return `item ${index}`;
}

function autokey() {
const result = [];
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
const charsLength = chars.length;
for (let i = 0; i < 6; i++) {
result.push(chars.charAt(Math.floor(Math.random() * charsLength)));
}
return result.join('');
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@
}

.ReferenceField__DocCard__content__title {
line-height: 1.2;
font-size: 22px;
line-height: 1.2;
font-weight: 600;
overflow: hidden;
display: -webkit-box;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import {ActionIcon, Button, Image, Loader, Tooltip} from '@mantine/core';
import {IconTrash} from '@tabler/icons-preact';
import {getDoc} from 'firebase/firestore';
import {useEffect, useState} from 'preact/hooks';
import * as schema from '../../../../core/schema.js';
import {getDraftDocRef} from '../../../utils/doc.js';
import {getDocFromCacheOrFetch} from '../../../utils/doc-cache.js';
import {notifyErrors} from '../../../utils/notifications.js';
import {getNestedValue} from '../../../utils/objects.js';
import {useDocPickerModal} from '../../DocPickerModal/DocPickerModal.js';
import {FieldProps} from './FieldProps.js';
Expand Down Expand Up @@ -79,8 +79,6 @@ export function ReferenceField(props: FieldProps) {
);
}

const REF_PREVIEW_CACHE: Record<string, any> = {};

interface ReferencePreviewProps {
id: string;
}
Expand All @@ -91,21 +89,14 @@ ReferenceField.Preview = (props: ReferencePreviewProps) => {

async function fetchDocData() {
setLoading(true);
const docRef = getDraftDocRef(props.id);
const doc = await getDoc(docRef);
const docData = doc.data();
REF_PREVIEW_CACHE[props.id] = docData;
setPreviewDoc(docData);
await notifyErrors(async () => {
const docData = await getDocFromCacheOrFetch(props.id);
setPreviewDoc(docData);
});
setLoading(false);
}

useEffect(() => {
const cachedValue = REF_PREVIEW_CACHE[props.id];
if (cachedValue) {
setPreviewDoc(cachedValue);
setLoading(false);
return;
}
fetchDocData();
}, [props.id]);

Expand Down
Loading

0 comments on commit 159f8b2

Please sign in to comment.