Skip to content

Commit e4a2896

Browse files
committed
Add Rich Text Sticky Toolbar
Add Content Folder Publish
1 parent 2a1a83a commit e4a2896

File tree

8 files changed

+153
-39
lines changed

8 files changed

+153
-39
lines changed

functions/src/contents.ts

Lines changed: 73 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { logger } from 'firebase-functions/v2';
22
import { HttpsError, onCall } from 'firebase-functions/v2/https';
3-
import { onDocumentDeleted, onDocumentUpdated, onDocumentWritten } from 'firebase-functions/v2/firestore';
3+
import { onDocumentDeleted, onDocumentUpdated, onDocumentWritten, DocumentSnapshot } from 'firebase-functions/v2/firestore';
44
import { FieldValue, UpdateData, WithFieldValue } from 'firebase-admin/firestore';
55
import { canPerform } from './utils/security-utils';
66
import { BATCH_MAX, bucket, firestoreService } from './config';
@@ -16,7 +16,16 @@ import {
1616
Space,
1717
UserPermission,
1818
} from './models';
19-
import { extractContent, findAllContentsByParentSlug, findContentById, findContentsHistory, findSchemas, findSpaceById } from './services';
19+
import {
20+
extractContent,
21+
findAllContentsByParentSlug,
22+
findContentById,
23+
findContentsHistory,
24+
findDocumentsToPublishByStartFullSlug,
25+
findSchemas,
26+
findSpaceById,
27+
} from './services';
28+
import { AuthData } from 'firebase-functions/lib/common/providers/https';
2029

2130
// Publish
2231
const publish = onCall<PublishContentData>(async request => {
@@ -30,50 +39,82 @@ const publish = onCall<PublishContentData>(async request => {
3039
const schemasSnapshot = await findSchemas(spaceId).get();
3140
if (spaceSnapshot.exists && contentSnapshot.exists) {
3241
const space: Space = spaceSnapshot.data() as Space;
33-
const content: ContentDocument = contentSnapshot.data() as ContentDocument;
42+
const content: Content = contentSnapshot.data() as Content;
3443
const schemas = new Map(schemasSnapshot.docs.map(it => [it.id, it.data() as Schema]));
35-
for (const locale of space.locales) {
36-
const documentStorage: ContentDocumentStorage = {
37-
id: contentSnapshot.id,
38-
name: content.name,
39-
kind: content.kind,
40-
locale: locale.id,
41-
slug: content.slug,
42-
fullSlug: content.fullSlug,
43-
parentSlug: content.parentSlug,
44-
createdAt: content.createdAt.toDate().toISOString(),
45-
updatedAt: content.updatedAt.toDate().toISOString(),
46-
publishedAt: new Date().toISOString(),
47-
};
48-
if (content.data) {
49-
if (typeof content.data === 'string') {
50-
documentStorage.data = extractContent(JSON.parse(content.data), schemas, locale.id);
51-
} else {
52-
documentStorage.data = extractContent(content.data, schemas, locale.id);
53-
}
44+
if (content.kind === ContentKind.DOCUMENT) {
45+
await publishDocument(spaceId, space, contentId, content, contentSnapshot, schemas, auth);
46+
} else if (content.kind === ContentKind.FOLDER) {
47+
const documentsSnapshot = await findDocumentsToPublishByStartFullSlug(spaceId, `${content.fullSlug}/`).get();
48+
for (const documentSnapshot of documentsSnapshot.docs) {
49+
const document = documentSnapshot.data() as ContentDocument;
50+
// SKIP if the page was already published, by comparing publishedAt and updatedAt
51+
if (document.publishedAt && document.publishedAt.seconds > document.updatedAt.seconds) continue;
52+
await publishDocument(spaceId, space, contentId, document, documentSnapshot, schemas, auth);
5453
}
55-
// Save generated JSON
56-
logger.info(`[Content::contentPublish] Save file to spaces/${spaceId}/contents/${contentId}/${locale.id}.json`);
57-
await bucket.file(`spaces/${spaceId}/contents/${contentId}/${locale.id}.json`).save(JSON.stringify(documentStorage));
5854
}
5955
// Save Cache
6056
logger.info(`[Content::contentPublish] Save file to spaces/${spaceId}/contents/${contentId}/cache.json`);
6157
await bucket.file(`spaces/${spaceId}/contents/${contentId}/cache.json`).save('');
58+
return;
59+
} else {
60+
logger.info(`[Content::contentPublish] Content ${contentId} does not exist.`);
61+
throw new HttpsError('not-found', 'Content not found');
62+
}
63+
});
64+
65+
/**
66+
* Publish Document
67+
* @param {string} spaceId space id
68+
* @param {Space} space
69+
* @param {string} documentId
70+
* @param {ContentDocument} document
71+
* @param {DocumentSnapshot} documentSnapshot
72+
* @param {Map<string, Schema>} schemas
73+
* @param {AuthData} auth
74+
*/
75+
async function publishDocument(
76+
spaceId: string,
77+
space: Space,
78+
documentId: string,
79+
document: ContentDocument,
80+
documentSnapshot: DocumentSnapshot,
81+
schemas: Map<string, Schema>,
82+
auth?: AuthData
83+
) {
84+
for (const locale of space.locales) {
85+
const documentStorage: ContentDocumentStorage = {
86+
id: documentId,
87+
name: document.name,
88+
kind: document.kind,
89+
locale: locale.id,
90+
slug: document.slug,
91+
fullSlug: document.fullSlug,
92+
parentSlug: document.parentSlug,
93+
createdAt: document.createdAt.toDate().toISOString(),
94+
updatedAt: document.updatedAt.toDate().toISOString(),
95+
publishedAt: new Date().toISOString(),
96+
};
97+
if (document.data) {
98+
if (typeof document.data === 'string') {
99+
documentStorage.data = extractContent(JSON.parse(document.data), schemas, locale.id);
100+
} else {
101+
documentStorage.data = extractContent(document.data, schemas, locale.id);
102+
}
103+
}
104+
// Save generated JSON
105+
logger.info(`[Content::contentPublish] Save file to spaces/${spaceId}/contents/${documentId}/${locale.id}.json`);
106+
await bucket.file(`spaces/${spaceId}/contents/${documentId}/${locale.id}.json`).save(JSON.stringify(documentStorage));
62107
// Update publishedAt
63-
await contentSnapshot.ref.update({ publishedAt: FieldValue.serverTimestamp() });
108+
await documentSnapshot.ref.update({ publishedAt: FieldValue.serverTimestamp() });
64109
const addHistory: WithFieldValue<ContentHistory> = {
65110
type: ContentHistoryType.PUBLISHED,
66111
name: auth?.token['name'] || FieldValue.delete(),
67112
email: auth?.token.email || FieldValue.delete(),
68113
createdAt: FieldValue.serverTimestamp(),
69114
};
70-
await findContentsHistory(spaceId, contentId).add(addHistory);
71-
return;
72-
} else {
73-
logger.info(`[Content::contentPublish] Content ${contentId} does not exist.`);
74-
throw new HttpsError('not-found', 'Content not found');
115+
await findContentsHistory(spaceId, documentId).add(addHistory);
75116
}
76-
});
117+
}
77118

78119
// Firestore events
79120
const onContentUpdate = onDocumentUpdated('spaces/{spaceId}/contents/{contentId}', async event => {

functions/src/services/content.service.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,21 @@ export function findContentsByStartFullSlug(spaceId: string, startFullSlug: stri
7070
.where('fullSlug', '<', `${startFullSlug}~`);
7171
}
7272

73+
/**
74+
* find Content by Full Slug
75+
* @param {string} spaceId Space identifier
76+
* @param {string} startFullSlug Start Full Slug path
77+
* @return {DocumentReference} document reference to the space
78+
*/
79+
export function findDocumentsToPublishByStartFullSlug(spaceId: string, startFullSlug: string): Query {
80+
logger.info(`[findDocumentsToPublishByStartFullSlug] spaceId=${spaceId} startFullSlug=${startFullSlug}`);
81+
return firestoreService
82+
.collection(`spaces/${spaceId}/contents`)
83+
.where('fullSlug', '>=', startFullSlug)
84+
.where('fullSlug', '<', `${startFullSlug}~`)
85+
.where('kind', '==', ContentKind.DOCUMENT);
86+
}
87+
7388
/**
7489
* find Content by ID
7590
* @param {string} spaceId Space identifier

src/app/features/spaces/contents/contents.component.html

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,11 @@
146146
<ng-container matColumnDef="actions">
147147
<mat-header-cell mat-header-cell *matHeaderCellDef> Actions</mat-header-cell>
148148
<mat-cell *matCellDef="let element">
149+
@if ('CONTENT_PUBLISH' | canUserPerform | async) {
150+
<button mat-icon-button (click)="openPublishDialog($event, element)" matTooltip="Publish">
151+
<mat-icon>publish</mat-icon>
152+
</button>
153+
}
149154
@if ('CONTENT_UPDATE' | canUserPerform | async) {
150155
<button mat-icon-button (click)="openEditDialog($event, element)" [disabled]="element.locked" matTooltip="Edit">
151156
<mat-icon>edit</mat-icon>

src/app/features/spaces/contents/contents.component.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,6 @@ mat-cell {
4040
}
4141

4242
&.mat-column-actions {
43-
max-width: 200px;
43+
max-width: 250px;
4444
}
4545
}

src/app/features/spaces/contents/contents.component.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -189,12 +189,18 @@ export class ContentsComponent {
189189
event.stopImmediatePropagation();
190190
let title = '';
191191
let content = '';
192+
let messageSuccess = '';
193+
let messageError = '';
192194
if (element.kind === ContentKind.FOLDER) {
193195
title = 'Delete Folder';
194196
content = `Are you sure about deleting Folder with name: ${element.name}.\n All sub folders and documents will be deleted.`;
197+
messageSuccess = `Folder '${element.name}' has been deleted.`;
198+
messageError = `Folder '${element.name}' can not be deleted.`;
195199
} else if (element.kind === ContentKind.DOCUMENT) {
196200
title = 'Delete Document';
197201
content = `Are you sure about deleting Document with name: ${element.name}.`;
202+
messageSuccess = `Document '${element.name}' has been deleted.`;
203+
messageError = `Document '${element.name}' can not be deleted.`;
198204
}
199205
this.dialog
200206
.open<ConfirmationDialogComponent, ConfirmationDialogModel, boolean>(ConfirmationDialogComponent, {
@@ -212,11 +218,11 @@ export class ContentsComponent {
212218
next: () => {
213219
this.selection.clear();
214220
this.cd.markForCheck();
215-
this.notificationService.success(`Content '${element.name}' has been deleted.`);
221+
this.notificationService.success(messageSuccess);
216222
},
217223
error: (err: unknown) => {
218224
console.error(err);
219-
this.notificationService.error(`Content '${element.name}' can not be deleted.`);
225+
this.notificationService.error(messageError);
220226
},
221227
});
222228
}
@@ -269,11 +275,53 @@ export class ContentsComponent {
269275
next: () => {
270276
this.selection.clear();
271277
this.cd.markForCheck();
272-
this.notificationService.success(`Content '${element.name}' has been cloned.`);
278+
this.notificationService.success(`Document '${element.name}' has been cloned.`);
273279
},
274280
error: (err: unknown) => {
275281
console.error(err);
276-
this.notificationService.error(`Content '${element.name}' can not be cloned.`);
282+
this.notificationService.error(`Document '${element.name}' can not be cloned.`);
283+
},
284+
});
285+
}
286+
287+
openPublishDialog(event: Event, element: Content): void {
288+
// Prevent Default
289+
event.preventDefault();
290+
event.stopImmediatePropagation();
291+
let title = '';
292+
let content = '';
293+
let messageSuccess = '';
294+
let messageError = '';
295+
if (element.kind === ContentKind.FOLDER) {
296+
title = 'Publish Folder';
297+
content = `Are you sure about publishing the Folder with the name: ${element.name}.\n All sub folders and documents will be published.`;
298+
messageSuccess = `Folder '${element.name}' has been published.`;
299+
messageError = `Folder '${element.name}' can not be published.`;
300+
} else if (element.kind === ContentKind.DOCUMENT) {
301+
title = 'Publish Document';
302+
content = `Are you sure about publishing the Document with the name: ${element.name}.`;
303+
messageSuccess = `Document '${element.name}' has been published.`;
304+
messageError = `Document '${element.name}' can not be published.`;
305+
}
306+
this.dialog
307+
.open<ConfirmationDialogComponent, ConfirmationDialogModel, boolean>(ConfirmationDialogComponent, {
308+
data: {
309+
title: title,
310+
content: content,
311+
},
312+
})
313+
.afterClosed()
314+
.pipe(
315+
filter(it => it || false),
316+
switchMap(() => this.contentService.publish(this.spaceId(), element.id)),
317+
)
318+
.subscribe({
319+
next: () => {
320+
this.notificationService.success(messageSuccess);
321+
},
322+
error: (err: unknown) => {
323+
console.error(err);
324+
this.notificationService.success(messageError);
277325
},
278326
});
279327
}

src/app/features/spaces/contents/shared/rich-text-editor/rich-text-editor.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
[attr.maxlength]="field.maxLength"
1010
[required]="field.required"
1111
autocomplete="off"></textarea>
12-
<div class="rounded-t-md p-1 flex">
12+
<div class="tool-bar-sticky">
1313
<button
1414
matTooltipClass="allow-cr"
1515
matTooltip="Normal text &#10; {{ fnKey }} + Alt + 0"

src/app/features/spaces/contents/shared/rich-text-editor/rich-text-editor.component.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export class RichTextEditorComponent implements OnDestroy {
7777
],
7878
editorProps: {
7979
attributes: {
80-
class: 'p-2 border-color border-t rounded-b-md outline-none',
80+
class: 'p-2 border-color rounded-b-md outline-none',
8181
spellcheck: 'false',
8282
},
8383
},

src/styles/_rich-text-editor.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ ll-rich-text-editor {
44
.border-color {
55
border-color: var(--sys-outline);
66
}
7+
.tool-bar-sticky {
8+
background-color: var(--sys-surface);
9+
border-color: var(--sys-outline);
10+
@apply sticky top-0 z-10 border-b border-x p-1 flex;
11+
}
712

813
mat-form-field {
914
.mdc-text-field--outlined {

0 commit comments

Comments
 (0)