Skip to content

Commit

Permalink
feat: Apply Templates to Drafts via the backend
Browse files Browse the repository at this point in the history
Instead of applying a template in the frontend by replacing the `README.md` contents, selecting a template now triggers a GraphQL mutation that copies files and metadata from the template to the draft, and reloads the Insight Editor
  • Loading branch information
baumandm committed Jun 23, 2022
1 parent 34b7a23 commit 1d34102
Show file tree
Hide file tree
Showing 9 changed files with 197 additions and 82 deletions.
1 change: 1 addition & 0 deletions packages/backend/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ type Mutation {
addCollaborator(insightId: ID!, permission: String, userId: ID!): Insight!
addComment(comment: CommentInput!): Comment!
addNews(news: NewsInput!): News!
applyTemplateToDraft(draftKey: String!, templateId: ID!): Draft!
cloneInsight(insightId: ID!): Draft!
createDraft(draftKey: String, itemType: String!): Draft!
deleteComment(commentId: ID!): Comment!
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/lib/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import * as turndownPluginGfm from 'turndown-plugin-gfm';
import { DraftDataInput, DraftKey } from '../models/draft';
import { AttachmentService } from '../services/attachment.service';
import { DraftService } from '../services/draft.service';
import { TemplateService } from '../services/template.service';

const logger = getLogger('import');

Expand Down Expand Up @@ -190,7 +191,7 @@ export function convertToDraft(request: ImportRequest): DraftDataInput {
export async function importToNewDraft(request: ImportRequest): Promise<DraftKey> {
logger.info(`Importing web page ${request.url}`);

const draftService = new DraftService(new AttachmentService());
const draftService = new DraftService(new AttachmentService(), new TemplateService());
const draftKey = draftService.newDraftKey();

const draftData = convertToDraft(request);
Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/resolvers/attachment.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { Arg, Authorized, Ctx, Mutation, Resolver } from 'type-graphql';
import { Service } from 'typedi';

import { Context } from '../models/context';
import { DraftKey } from '../models/draft';
import { InsightFile, InsightFileUploadInput } from '../models/insight-file';
import { Permission } from '../models/permission';
import { AvatarUploadResult } from '../models/user';
Expand All @@ -41,7 +42,7 @@ export class AttachmentResolver {
@Authorized<Permission>({ user: true })
@Mutation(() => InsightFile)
async uploadSingleFile(
@Arg('draftKey') draftKey: string,
@Arg('draftKey') draftKey: DraftKey,
@Arg('attachment') attachment: InsightFileUploadInput,
@Arg('file', () => GraphQLUpload) file: FileUpload
): Promise<InsightFile> {
Expand Down
30 changes: 29 additions & 1 deletion packages/backend/src/resolvers/draft.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import { Arg, Authorized, Ctx, FieldResolver, ID, Mutation, Query, Resolver, Roo
import { Service } from 'typedi';

import { Context } from '../models/context';
import { Draft, DraftKey, DraftInput } from '../models/draft';
import { DraftKey } from '../models/draft';
import { Draft, DraftInput } from '../models/draft';
import { Insight } from '../models/insight';
import { Permission } from '../models/permission';
import { User } from '../models/user';
Expand Down Expand Up @@ -169,4 +170,31 @@ export class DraftResolver {
throw error;
}
}

@Authorized<Permission>({ user: true, github: true })
@Mutation(() => Draft)
async applyTemplateToDraft(
@Arg('draftKey') draftKey: DraftKey,
@Arg('templateId', () => ID) templateId: string,
@Ctx() ctx: Context
): Promise<Draft> {
logger.debug('Applying template to Draft', draftKey);

try {
const [, dbTemplateId] = fromGlobalId(templateId);

// Load Draft and convert to DraftInput
const { draftData } = await Draft.query().where('draftKey', draftKey).first();
const draft: DraftInput = {
draftKey,
draftData
};

return await this.draftService.applyTemplateToDraft(draft, dbTemplateId, ctx.user!);
} catch (error: any) {
logger.error(error.message);
logger.error(JSON.stringify(error, null, 2));
throw error;
}
}
}
3 changes: 2 additions & 1 deletion packages/backend/src/resolvers/insight.resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { getInsight } from '../lib/elasticsearch';
import { Activity, ActivityType, IndexedActivityDetails } from '../models/activity';
import { CommentConnection } from '../models/comment';
import { Context } from '../models/context';
import { DraftKey } from '../models/draft';
import { Insight, DbInsight, ValidateInsightName, InsightChangeConnection } from '../models/insight';
import { Permission } from '../models/permission';
import { Repository } from '../models/repository';
Expand Down Expand Up @@ -310,7 +311,7 @@ export class InsightResolver {

@Authorized<Permission>({ user: true, github: true })
@Mutation(() => Insight)
async publishDraft(@Arg('draftKey') draftKey: string, @Ctx() ctx: Context): Promise<Insight> {
async publishDraft(@Arg('draftKey') draftKey: DraftKey, @Ctx() ctx: Context): Promise<Insight> {
logger.debug('Publishing Draft Key', draftKey);

try {
Expand Down
74 changes: 72 additions & 2 deletions packages/backend/src/services/draft.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,21 @@ import pMap from 'p-map';
import { Service } from 'typedi';

import { streamFromS3 } from '../lib/storage';
import { Draft, DraftInput, DraftKey } from '../models/draft';
import { DraftKey } from '../models/draft';
import { Draft, DraftInput } from '../models/draft';
import { User } from '../models/user';
import { AttachmentService } from '../services/attachment.service';
import { TemplateService } from '../services/template.service';
import { fromGlobalId } from '../shared/resolver-utils';

const logger = getLogger('draft.service');

@Service()
export class DraftService {
constructor(private readonly attachmentService: AttachmentService) {
constructor(
private readonly attachmentService: AttachmentService,
private readonly templateService: TemplateService
) {
logger.trace('[DRAFT.SERVICE] Constructing New Draft Service');
}

Expand Down Expand Up @@ -174,6 +179,71 @@ export class DraftService {
return upserted;
}

/**
* Updates a draft with the contents of a template.
*
* @param draftKey Draft key
* @param templateId Template ID
* @param user User
*/
async applyTemplateToDraft(draft: DraftInput, templateId: number, user: User): Promise<Draft> {
if (draft == null) {
throw new Error('Draft not found');
}

logger.info(`Applying template to Draft (${draft.draftKey})`);

// Load Template
const template = await this.templateService.getTemplate(templateId);

if (template == null) {
throw new Error('Template not found');
}

// Copy template metadata to draft
draft.draftData = {
...draft.draftData,
tags: template.tags,
creation: {
template: template.fullName
}
};

if (template.files) {
draft.draftData.files = await pMap(
template.files,
async (file) => {
// Copy each file from the template
// and upload to the draft
const key = `insights/${template.fullName}/files/${file.path}`;
const readable = await streamFromS3(key);

// Duplicate file with new ID and 'add' action
const newFile = {
...file,
action: InsightFileAction.ADD,
originalPath: file.path,
id: nanoid()
};

// Upload original file into the draft
await this.attachmentService.uploadToDraft(draft.draftKey, newFile, readable);

return newFile;
},
{ concurrency: 5 }
);
}

const upserted = await this.upsertDraft(draft, user);

if (upserted.updatedAt == null) {
upserted.updatedAt = new Date();
}

return upserted;
}

/**
* Clone an existing Insight into a new Draft.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,21 @@ const UPLOAD_SINGLE_FILE_MUTATION = gql`
}
`;

interface Props {
insight: Insight;
draft: DraftDataInput;
draftKey: string;
onApplyTemplate: (templateId: string) => Promise<void>;
onRefresh: () => void;
}

/**
* This component is rendered only after the following are available:
* - Insight
* - DraftKey
* - Draft (defaults to an empty object)
*/
export const InsightDraftContainer = ({ insight, draft, draftKey, onRefresh }) => {
export const InsightDraftContainer = ({ insight, draft, draftKey, onApplyTemplate, onRefresh }: Props) => {
const toast = useToast();

// Store copy of last saved draft for comparisons
Expand Down Expand Up @@ -217,6 +225,7 @@ export const InsightDraftContainer = ({ insight, draft, draftKey, onRefresh }) =
publish={publish}
isSavingDraft={isSavingDraft}
isPublishing={isPublishing}
onApplyTemplate={onApplyTemplate}
onRefresh={onRefresh}
uploadFile={uploadFile}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,13 @@
* limitations under the License.
*/

import { Flex, Spinner } from '@chakra-ui/react';
import { Flex, Spinner, useToast } from '@chakra-ui/react';
import { nanoid } from 'nanoid';
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { gql, useMutation } from 'urql';

import { InsightFileAction } from '../../models/file-tree';
import type { Insight } from '../../models/generated/graphql';
import type { ItemType } from '../../shared/item-type';
import type { RootState } from '../../store/store';
Expand Down Expand Up @@ -52,6 +51,17 @@ const CREATE_DRAFT_MUTATION = gql`
}
`;

const APPLY_TEMPLATE_MUTATION = gql`
mutation ApplyTemplateToDraft($draftKey: String!, $templateId: ID!) {
applyTemplateToDraft(draftKey: $draftKey, templateId: $templateId) {
draftKey
createdAt
updatedAt
draftData
}
}
`;

interface Props {
insight: Insight;
draftKey: string;
Expand All @@ -70,11 +80,17 @@ interface Props {
*/
export const InsightDraftEditor = ({ insight, draftKey, itemType, onRefresh }: Props) => {
const navigate = useNavigate();
const toast = useToast();

// This key can be updated to force a remount of the editor components
const remountKey = useRef(nanoid());

const [draft, setDraft] = useState<DraftDataInput | undefined>(undefined);

const { appSettings } = useSelector((state: RootState) => state.app);

const [, createDraft] = useMutation(CREATE_DRAFT_MUTATION);
const [, applyTemplateToDraft] = useMutation(APPLY_TEMPLATE_MUTATION);

useEffect(() => {
let cancelled = false;
Expand Down Expand Up @@ -114,17 +130,54 @@ export const InsightDraftEditor = ({ insight, draftKey, itemType, onRefresh }: P
};
}, [appSettings, createDraft, draft, draftKey, itemType, navigate]);

const onApplyTemplate = async (templateId: string) => {
const { data, error } = await applyTemplateToDraft({
draftKey,
templateId
});

if (error) {
toast({
position: 'bottom-right',
title: 'Unable to apply template.',
status: 'error',
duration: 9000,
isClosable: true
});

return;
}

remountKey.current = nanoid();
setDraft(data.applyTemplateToDraft.draftData);

toast({
position: 'bottom-right',
title: 'Template applied.',
status: 'success',
duration: 2000,
isClosable: true
});
};

// Merge draft changes (if any) with Insight
const mergedInsight = {
...insight,
...draft
};
} as Insight;

return (
<Flex mt="0" direction="column" justify="stretch" flexGrow={2}>
{!draft && <Spinner thickness="4px" speed="0.65s" emptyColor="gray.200" color="blue.500" size="xl" />}
{draft && (
<InsightDraftContainer insight={mergedInsight} draft={draft} draftKey={draftKey} onRefresh={onRefresh} />
<InsightDraftContainer
key={remountKey.current}
insight={mergedInsight}
draft={draft}
draftKey={draftKey}
onApplyTemplate={onApplyTemplate}
onRefresh={onRefresh}
/>
)}
</Flex>
);
Expand Down
Loading

0 comments on commit 1d34102

Please # to comment.