diff --git a/common/changes/@lyvely/api/feat-md-task-list_2024-07-01-23-38.json b/common/changes/@lyvely/api/feat-md-task-list_2024-07-01-23-38.json new file mode 100644 index 000000000..3044c73ea --- /dev/null +++ b/common/changes/@lyvely/api/feat-md-task-list_2024-07-01-23-38.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@lyvely/api", + "comment": "feat: Adde new ConflictException", + "type": "none" + } + ], + "packageName": "@lyvely/api" +} \ No newline at end of file diff --git a/common/changes/@lyvely/api/feat-md-task-list_2024-07-01-23-40.json b/common/changes/@lyvely/api/feat-md-task-list_2024-07-01-23-40.json new file mode 100644 index 000000000..4f96f180d --- /dev/null +++ b/common/changes/@lyvely/api/feat-md-task-list_2024-07-01-23-40.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@lyvely/api", + "comment": "fix: Added ValidBody options only type", + "type": "none" + } + ], + "packageName": "@lyvely/api" +} \ No newline at end of file diff --git a/common/changes/@lyvely/api/feat-md-task-list_2024-07-01-23-42.json b/common/changes/@lyvely/api/feat-md-task-list_2024-07-01-23-42.json new file mode 100644 index 000000000..7786255e9 --- /dev/null +++ b/common/changes/@lyvely/api/feat-md-task-list_2024-07-01-23-42.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@lyvely/api", + "comment": "feat: Added task list markdown support", + "type": "none" + } + ], + "packageName": "@lyvely/api" +} \ No newline at end of file diff --git a/common/changes/@lyvely/ui/feat-md-task-list_2024-07-01-23-42.json b/common/changes/@lyvely/ui/feat-md-task-list_2024-07-01-23-42.json new file mode 100644 index 000000000..929a8f960 --- /dev/null +++ b/common/changes/@lyvely/ui/feat-md-task-list_2024-07-01-23-42.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@lyvely/ui", + "comment": "feat: Added task list markdown support", + "type": "minor" + } + ], + "packageName": "@lyvely/ui" +} \ No newline at end of file diff --git a/common/changes/@lyvely/ui/main_2024-07-01-23-34.json b/common/changes/@lyvely/ui/main_2024-07-01-23-34.json new file mode 100644 index 000000000..941d9ddbf --- /dev/null +++ b/common/changes/@lyvely/ui/main_2024-07-01-23-34.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@lyvely/ui", + "comment": "feat: Added task-list support to markdown view", + "type": "minor" + } + ], + "packageName": "@lyvely/ui" +} \ No newline at end of file diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index 60f346c9c..f63eb6829 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -134,6 +134,10 @@ "name": "@lyvely/web", "allowedCategories": [ "application", "feature", "lib" ] }, + { + "name": "@mdit/plugin-tasklist", + "allowedCategories": [ "lib" ] + }, { "name": "@mdx-js/react", "allowedCategories": [ "application" ] @@ -284,16 +288,20 @@ }, { "name": "remark-gfm", - "allowedCategories": [ "lib" ] + "allowedCategories": [ "core", "lib" ] }, { "name": "remark-parse", - "allowedCategories": [ "lib" ] + "allowedCategories": [ "core", "lib" ] }, { "name": "remark-rehype", "allowedCategories": [ "lib" ] }, + { + "name": "remark-stringify", + "allowedCategories": [ "core" ] + }, { "name": "rollup-plugin-visualizer", "allowedCategories": [ "application" ] @@ -328,7 +336,11 @@ }, { "name": "unified", - "allowedCategories": [ "lib" ] + "allowedCategories": [ "core", "lib" ] + }, + { + "name": "unist-util-visit", + "allowedCategories": [ "core" ] }, { "name": "vue", diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 6f230a42e..6f9056dfc 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -384,6 +384,15 @@ importers: reflect-metadata: specifier: ^0.1.12 || ^0.2.0 version: 0.2.2 + remark-gfm: + specifier: ^4.0.0 + version: 4.0.0 + remark-parse: + specifier: ^11.0.0 + version: 11.0.0 + remark-stringify: + specifier: ^11.0.0 + version: 11.0.0 rimraf: specifier: ^5.0 version: 5.0.7 @@ -396,6 +405,12 @@ importers: slugify: specifier: ^1.6 version: 1.6.6 + unified: + specifier: ^11.0.5 + version: 11.0.5 + unist-util-visit: + specifier: ^5.0.0 + version: 5.0.0 uuid: specifier: ^9.0 version: 9.0.1 @@ -427,6 +442,9 @@ importers: '@types/qs': specifier: ^6.9.15 version: 6.9.15 + '@types/unist': + specifier: ^3.0.2 + version: 3.0.2 '@typescript-eslint/eslint-plugin': specifier: ^7.9 version: 7.14.1(@typescript-eslint/parser@7.14.1(eslint@8.57.0)(typescript@5.5.2))(eslint@8.57.0)(typescript@5.5.2) @@ -3448,6 +3466,9 @@ importers: '@lyvely/dates': specifier: workspace:* version: link:../dates + '@mdit/plugin-tasklist': + specifier: ^0.12.0 + version: 0.12.0(markdown-it@14.1.0) '@vueuse/core': specifier: ^10.9 version: 10.11.0(vue@3.4.31(typescript@5.5.2)) @@ -5540,6 +5561,15 @@ packages: resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true + '@mdit/plugin-tasklist@0.12.0': + resolution: {integrity: sha512-MPmuLJrqHYR2xI7ST9Xtw/xj+6Xoq7kUvcGuXWdMMNT11DcU1KppkR8QBHov437NFYh6aGyjrHUVeM4T5Ls8yg==} + engines: {node: '>= 18'} + peerDependencies: + markdown-it: ^14.1.0 + peerDependenciesMeta: + markdown-it: + optional: true + '@mdx-js/mdx@3.0.1': resolution: {integrity: sha512-eIQ4QTrOWyL3LWEe/bu6Taqzq2HQvHcyTMaOrI95P2/LmJE7AsfPfgJGuFLPVqBUE1BC1rik3VIhU+s9u72arA==} @@ -14853,6 +14883,9 @@ packages: vue-component-type-helpers@2.0.22: resolution: {integrity: sha512-gPr2Ba7efUwy/Vfbuf735bHSVdN4ycoZUCHfypkI33M9DUH+ieRblLLVM2eImccFYaWNWwEzURx02EgoXDBmaQ==} + vue-component-type-helpers@2.0.24: + resolution: {integrity: sha512-Jr5N8QVYEcbQuMN1LRgvg61758G8HTnzUlQsAFOxx6Y6X8kmhJ7C+jOvWsQruYxi3uHhhS6BghyRlyiwO99DBg==} + vue-demi@0.14.8: resolution: {integrity: sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==} engines: {node: '>=12'} @@ -18134,6 +18167,12 @@ snapshots: - encoding - supports-color + '@mdit/plugin-tasklist@0.12.0(markdown-it@14.1.0)': + dependencies: + '@types/markdown-it': 14.1.1 + optionalDependencies: + markdown-it: 14.1.0 + '@mdx-js/mdx@3.0.1': dependencies: '@types/estree': 1.0.5 @@ -20194,7 +20233,7 @@ snapshots: ts-dedent: 2.2.0 type-fest: 2.19.0 vue: 3.4.31(typescript@5.5.2) - vue-component-type-helpers: 2.0.22 + vue-component-type-helpers: 2.0.24 transitivePeerDependencies: - encoding - prettier @@ -30022,6 +30061,8 @@ snapshots: vue-component-type-helpers@2.0.22: {} + vue-component-type-helpers@2.0.24: {} + vue-demi@0.14.8(vue@3.4.31(typescript@5.5.2)): dependencies: vue: 3.4.31(typescript@5.5.2) diff --git a/packages/core/api/.depcheckrc b/packages/core/api/.depcheckrc index 2f91722f4..cf0e3639c 100644 --- a/packages/core/api/.depcheckrc +++ b/packages/core/api/.depcheckrc @@ -7,5 +7,11 @@ ignores: [ "@types/multer", "@types/qs", "@types/express-serve-static-core", - "@types/jest" + "@types/jest", + "unified", + "remark-parse", + "remark-gfm", + "remark-stringify", + "unist-util-visit", + "@types/unist", ] diff --git a/packages/core/api/package.json b/packages/core/api/package.json index 7592fe73e..7d40896b9 100644 --- a/packages/core/api/package.json +++ b/packages/core/api/package.json @@ -9,7 +9,7 @@ "types": "dist/index.d.ts", "scripts": { "build": "gulp clean && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && gulp copyAssets", - "test": "jest --forceExit", + "test": "cross-env jest --forceExit", "lint": "eslint -c .eslintrc.cjs \"src/**/*.ts\" --fix", "format": "prettier src --write", "prettier": "prettier src --check", @@ -76,7 +76,12 @@ "uuid": "^9.0", "mongo-seeding": "^4.0", "rimraf": "^5.0", - "mongodb-memory-server": "^8.13.0" + "mongodb-memory-server": "^8.13.0", + "unified": "^11.0.5", + "remark-parse": "^11.0.0", + "remark-gfm": "^4.0.0", + "remark-stringify": "^11.0.0", + "unist-util-visit": "^5.0.0" }, "devDependencies": { "typescript": "^5.4", @@ -87,6 +92,7 @@ "@types/multer": "^1.4.11", "@types/pug": "2.0.6", "@types/express-serve-static-core": "^4.19.0", + "@types/unist": "^3.0.2", "del": "^6.1.1", "gulp": "^5.0.0", "@types/qs": "^6.9.15", diff --git a/packages/core/api/src/content/controllers/content.controller.ts b/packages/core/api/src/content/controllers/content.controller.ts index 7bcbf2c0d..486d69abd 100644 --- a/packages/core/api/src/content/controllers/content.controller.ts +++ b/packages/core/api/src/content/controllers/content.controller.ts @@ -3,8 +3,9 @@ import { ContentEndpoint, SetMilestoneModel, ContentEndpoints, + UpdateTaskListItemModel, } from '@lyvely/interface'; -import { Post, HttpCode, HttpStatus, Param, Request } from '@nestjs/common'; +import { Post, HttpCode, HttpStatus, Param, Request, Put } from '@nestjs/common'; import { Policies } from '@/policies'; import { ContentService } from '../services'; import { ContentDeletePolicy, ContentWritePolicy } from '../policies'; @@ -43,4 +44,17 @@ export class ContentController implements ContentEndpoint { const { context } = req; await this.contentService.setMilestone(context, model.mid); } + + @Put(ContentEndpoints.UPDATE_TASK_LIST_ITEM(':cid')) + @Policies(ContentWritePolicy) + async updateTaskListItem( + @Param('cid') cid: string, + @ValidBody({ transform: true }) model: UpdateTaskListItemModel, + @Request() req: ProtectedProfileContentRequest + ) { + const { context } = req; + await this.contentService.updateTaskListItem(context, model); + //await this.contentService.setMilestone(context, model.mid); + return context.content.toModel(context.user); + } } diff --git a/packages/core/api/src/content/daos/content.dao.ts b/packages/core/api/src/content/daos/content.dao.ts index fca3cb293..dd80b7d0c 100644 --- a/packages/core/api/src/content/daos/content.dao.ts +++ b/packages/core/api/src/content/daos/content.dao.ts @@ -6,16 +6,41 @@ import { ContentTypeDao } from './content-type.dao'; import { ProfileShardData } from '@/profiles'; import { TenancyIsolation } from '@/core/tenancy'; +/** + * A generic content DAO used for common content data access jobs. + * @extends ContentTypeDao + */ @Dao(Content, { isolation: TenancyIsolation.Profile }) export class ContentDao extends ContentTypeDao { @Inject() protected override typeRegistry: ContentTypeRegistry; - incrementChildCount(context: ProfileShardData, parent: DocumentIdentity) { + /** + * Increments the child count of a parent document by 1. + * + * @param {ProfileShardData} context - The profile shard data. + * @param {DocumentIdentity} parent - The identity of the parent document. + * @return {Promise} - A promise that resolves with the result of the update operation. + */ + async incrementChildCount( + context: ProfileShardData, + parent: DocumentIdentity + ): Promise { return this.updateOneByProfileAndId(context, parent, { $inc: { 'meta.childCount': 1 } }); } - decrementChildCount(context: ProfileShardData, parent: DocumentIdentity) { + /** + * Decrements the child count of a parent document. + * + * @param {ProfileShardData} context - The context of the operation. + * @param {DocumentIdentity} parent - The parent document to decrement the child count for. + * + * @return {Promise} A promise that resolves with the result of the update operation. + */ + async decrementChildCount( + context: ProfileShardData, + parent: DocumentIdentity + ): Promise { return this.updateOneByProfileAndFilter( context, parent, @@ -24,15 +49,34 @@ export class ContentDao extends ContentTypeDao { ); } - updateMilestone( + /** + * Updates a milestone document identified by the provided mid value. + * + * @param {ProfileShardData} context - The profile shard data to use for updating the milestone. + * @param {DocumentIdentity} content - The content of the milestone document. + * @param {TObjectId | string} mid - The id or ObjectId of the milestone to update. + * @returns {Promise} - A promise that resolves when the milestone is updated successfully. + */ + async updateMilestone( context: ProfileShardData, content: DocumentIdentity, mid: TObjectId | string - ) { + ): Promise { return this.updateOneByProfileAndIdSet(context, content, { 'meta.mid': mid }); } - getModuleId(): string { - return 'content'; + /** + * Update the text content of a document. + * + * @param {DocumentIdentity} identity - The identity of the document to update. + * @param {string} update - The new text content to set. + * @returns {Promise} - A promise that resolves when the update is complete. + */ + async updateTextContent( + context: ProfileShardData, + identity: DocumentIdentity, + update: string + ): Promise { + return this.updateOneByProfileAndId(context, identity, { 'content.text': update }); } } diff --git a/packages/core/api/src/content/services/content.service.ts b/packages/core/api/src/content/services/content.service.ts index 4d419b938..33b9e69a9 100644 --- a/packages/core/api/src/content/services/content.service.ts +++ b/packages/core/api/src/content/services/content.service.ts @@ -1,7 +1,11 @@ import { Content, ProfileContentContext } from '../schemas'; -import { Injectable, Logger } from '@nestjs/common'; +import { ConflictException, Injectable, Logger } from '@nestjs/common'; import { ContentDao, IContentSearchFilter } from '../daos'; -import { DocumentNotFoundException } from '@lyvely/interface'; +import { + DocumentNotFoundException, + FieldValidationException, + UpdateTaskListItemModel, +} from '@lyvely/interface'; import { ProfileContext } from '@/profiles'; import { assureObjectId, @@ -12,6 +16,7 @@ import { } from '@/core'; import { User } from '@/users'; import { ContentPolicyService } from './content-policy.service'; +import { updateMarkdownTaskListItem } from '@/markdown'; @Injectable() export class ContentService { @@ -130,4 +135,32 @@ export class ContentService { return this.contentDao.updateMilestone(profile, content, mid); } + + /** + * Updates a task list item of the main content. + * Throws a ConflictException if the provided update version does not match the last updated version of the item. + * + * @param {ProfileContentContext} context - The context in which the task list item needs to be updated. + * @param {UpdateTaskListItemModel} update - The updated details of the task list item. + * @return {Promise} A promise that resolves to true if the task list item is successfully updated. + * @throws {ConflictException} If the provided update version does not match the last updated version of the item. + */ + async updateTaskListItem( + context: ProfileContentContext, + update: UpdateTaskListItemModel + ): Promise { + const { profile, content } = context; + const { position, version, checked } = update; + + if (position.length < 2) + throw new FieldValidationException([{ property: 'position', errors: ['isValid'] }]); + + if (content.meta.updatedAt > version) throw new ConflictException(); + + return this.contentDao.updateTextContent( + profile, + content, + await updateMarkdownTaskListItem(content.content.getTextContent(), position, checked) + ); + } } diff --git a/packages/core/api/src/core/decorators/valid-body.decorator.ts b/packages/core/api/src/core/decorators/valid-body.decorator.ts index 4a29dceca..51e7a3cee 100644 --- a/packages/core/api/src/core/decorators/valid-body.decorator.ts +++ b/packages/core/api/src/core/decorators/valid-body.decorator.ts @@ -12,6 +12,7 @@ export interface IValidBodyOptions extends ValidationPipeOptions { } export function ValidBody(): ParameterDecorator; +export function ValidBody(options?: IValidBodyOptions): ParameterDecorator; export function ValidBody(property?: string, options?: IValidBodyOptions): ParameterDecorator; export function ValidBody( property?: string | IValidBodyOptions, diff --git a/packages/core/api/src/index.ts b/packages/core/api/src/index.ts index f721e7100..365ab576a 100644 --- a/packages/core/api/src/index.ts +++ b/packages/core/api/src/index.ts @@ -19,6 +19,7 @@ export * from './permissions'; export * from './ping'; export * from './policies'; export * from './profiles'; +export * from './markdown'; export * from './settings'; export * from './streams'; export * from './calendar'; diff --git a/packages/core/api/src/markdown/index.ts b/packages/core/api/src/markdown/index.ts new file mode 100644 index 000000000..e19895d7a --- /dev/null +++ b/packages/core/api/src/markdown/index.ts @@ -0,0 +1 @@ +export * from './markdown-list.helper'; diff --git a/packages/core/api/src/markdown/markdown-list.helper.ts b/packages/core/api/src/markdown/markdown-list.helper.ts new file mode 100644 index 000000000..c8d4f7496 --- /dev/null +++ b/packages/core/api/src/markdown/markdown-list.helper.ts @@ -0,0 +1,45 @@ +const dynamicImport = async (packageName: string) => + new Function(`return import('${packageName}')`)(); + +export async function updateMarkdownTaskListItem( + markdownText: string, + position: [number, number], + checked: boolean +): Promise { + const unified = (await dynamicImport('unified')).unified; + const parse = (await dynamicImport('remark-parse')).default; + const gfm = (await dynamicImport('remark-gfm')).default; + const stringify = (await dynamicImport('remark-stringify')).default; + const visit = (await dynamicImport('unist-util-visit')).visit; + + const tree = unified().use(parse).use(gfm).parse(markdownText); + + const [listIndex, itemIndex] = position; + + let currentListIndex = -1; + let currentItemIndex = -1; + + visit(tree, 'list', (node) => { + currentListIndex++; + if (currentListIndex === listIndex) { + currentItemIndex = -1; + node.children.forEach((item) => { + if (item.type !== 'listItem') return; + currentItemIndex++; + if (currentItemIndex === itemIndex) { + item.checked = checked; + } + }); + } + }); + + return ( + unified() + .use(gfm) + // https://github.com/remarkjs/remark/blob/main/packages/remark-stringify/readme.md + .use(stringify, { + bullet: '-', + }) + .stringify(tree) + ); +} diff --git a/packages/core/interface/src/content/endpoints/content.client.ts b/packages/core/interface/src/content/endpoints/content.client.ts index bfabdba7a..e8aad03fa 100644 --- a/packages/core/interface/src/content/endpoints/content.client.ts +++ b/packages/core/interface/src/content/endpoints/content.client.ts @@ -2,19 +2,40 @@ import { useSingleton } from '@lyvely/common'; import { IContentClient } from './content.endpoint'; import repository from './content.repository'; import { IProfileApiRequestOptions, unwrapResponse } from '@/endpoints'; +import { ContentModel, UpdateTaskListItemModel } from '../models'; +import { getContentModelType } from '../registries'; +import type { PropertiesOf } from '@lyvely/common'; export class ContentClient implements IContentClient { - setMilestone(id: string, mid: string, options?: IProfileApiRequestOptions): Promise { + async setMilestone(id: string, mid: string, options?: IProfileApiRequestOptions): Promise { return unwrapResponse(repository.setMilestone(id, mid, options)); } - archive(cid: string, options?: IProfileApiRequestOptions): Promise { + async archive(cid: string, options?: IProfileApiRequestOptions): Promise { return unwrapResponse(repository.archive(cid, options)); } - restore(cid: string, options?: IProfileApiRequestOptions): Promise { + async restore(cid: string, options?: IProfileApiRequestOptions): Promise { return unwrapResponse(repository.restore(cid, options)); } + + async updateTaskListItem( + cid: string, + update: UpdateTaskListItemModel, + options?: IProfileApiRequestOptions + ): Promise { + const model = await unwrapResponse(repository.updateTaskListItem(cid, update, options)); + return this.transformModel(model); + } + + private transformModel(model: PropertiesOf) { + const ModelClass = this.getModelClass(model.type); + return new ModelClass(model); + } + + private getModelClass(type: string) { + return getContentModelType(type) || ContentModel; + } } export const useContentClient = useSingleton(() => new ContentClient()); diff --git a/packages/core/interface/src/content/endpoints/content.endpoint.ts b/packages/core/interface/src/content/endpoints/content.endpoint.ts index b04c04e1c..ecfd74a98 100644 --- a/packages/core/interface/src/content/endpoints/content.endpoint.ts +++ b/packages/core/interface/src/content/endpoints/content.endpoint.ts @@ -1,11 +1,13 @@ import { Endpoint, profileApiPrefix } from '@/endpoints'; +import { UpdateTaskListItemModel, ContentModel } from '../models'; export const API_CONTENT = profileApiPrefix('content'); export interface IContentClient { - archive: (id: string) => Promise; - restore: (id: string) => Promise; - setMilestone: (id: string, mid: string) => Promise; + archive: (cid: string) => Promise; + restore: (cid: string) => Promise; + setMilestone: (cid: string, mid: string) => Promise; + updateTaskListItem: (cid: string, update: UpdateTaskListItemModel) => Promise; } export type ContentEndpoint = Endpoint; @@ -14,4 +16,5 @@ export const ContentEndpoints = { ARCHIVE: (cid: string) => `${cid}/archive`, RESTORE: (cid: string) => `${cid}/restore`, SET_MILESTONE: (cid: string) => `${cid}/set-milestone`, + UPDATE_TASK_LIST_ITEM: (cid: string) => `${cid}/update-task-list-item`, }; diff --git a/packages/core/interface/src/content/endpoints/content.repository.ts b/packages/core/interface/src/content/endpoints/content.repository.ts index 53b8ffe6c..d3adef9c0 100644 --- a/packages/core/interface/src/content/endpoints/content.repository.ts +++ b/packages/core/interface/src/content/endpoints/content.repository.ts @@ -1,4 +1,4 @@ -import { SetMilestoneModel } from '../models'; +import { SetMilestoneModel, UpdateTaskListItemModel } from '../models'; import { API_CONTENT, ContentEndpoints, IContentClient } from './content.endpoint'; import { useApi } from '@/repository'; import { IProfileApiRequestOptions } from '@/endpoints'; @@ -23,4 +23,16 @@ export default { restore(cid: string, options?: IProfileApiRequestOptions) { return api.post<'restore'>(ContentEndpoints.RESTORE(cid), {}, options); }, + + updateTaskListItem( + cid: string, + update: UpdateTaskListItemModel, + options?: IProfileApiRequestOptions + ) { + return api.put<'updateTaskListItem'>( + ContentEndpoints.UPDATE_TASK_LIST_ITEM(cid), + update, + options + ); + }, }; diff --git a/packages/core/interface/src/content/models/index.ts b/packages/core/interface/src/content/models/index.ts index 0e20fe846..8edb36356 100644 --- a/packages/core/interface/src/content/models/index.ts +++ b/packages/core/interface/src/content/models/index.ts @@ -4,3 +4,4 @@ export * from './create-content.model'; export * from './content-update-response.model'; export * from './set-milestone.model'; export * from './content-request.filter'; +export * from './update-task-list-item.model'; diff --git a/packages/core/interface/src/content/models/update-task-list-item.model.ts b/packages/core/interface/src/content/models/update-task-list-item.model.ts new file mode 100644 index 000000000..7fc9044d0 --- /dev/null +++ b/packages/core/interface/src/content/models/update-task-list-item.model.ts @@ -0,0 +1,24 @@ +import type { StrictBaseModelData } from '@lyvely/common'; +import { BaseModel, PropertyType } from '@lyvely/common'; +import { Exclude, Expose } from 'class-transformer'; +import { IsBoolean, IsDate, IsNumber } from 'class-validator'; + +@Exclude() +export class UpdateTaskListItemModel { + @Expose() + @IsBoolean() + checked: boolean; + + @Expose() + @IsNumber({}, { each: true }) + position: [number, number]; + + @Expose() + @IsDate() + @PropertyType(Date) + version: Date; + + constructor(data: StrictBaseModelData) { + BaseModel.init(this, data); + } +} diff --git a/packages/core/interface/src/exceptions/exception.helper.ts b/packages/core/interface/src/exceptions/exception.helper.ts index b0a2fcbfd..3b8b8e21c 100644 --- a/packages/core/interface/src/exceptions/exception.helper.ts +++ b/packages/core/interface/src/exceptions/exception.helper.ts @@ -8,6 +8,7 @@ import { UnauthorizedServiceException, RateLimitException, DocumentNotFoundException, + ConflictException, } from './exceptions'; import { IFieldValidationResult, IModelValidationResult } from '@lyvely/common'; @@ -57,6 +58,10 @@ export function isForbiddenError(error: any): error is AxiosError { return isAxiosErrorWithResponseData(error) && error.response.status === 403; } +export function isConflictError(error: any): error is AxiosError { + return isAxiosErrorWithResponseData(error) && error.response.status === 409; +} + export function isUnauthorizedForbidden(error: any): error is AxiosError { return isAxiosErrorWithResponseData(error) && error.response.status === 401; } @@ -101,6 +106,8 @@ export function errorToServiceException(error: any, throws = false): ServiceExce result = error; } else if (isAxiosErrorWithoutResponseData(error)) { result = new NetworkException(); + } else if (isConflictError(error)) { + result = new ConflictException(); } else if (isForbiddenError(error)) { result = new ForbiddenServiceException(error.response?.data); } else if (isUnauthorizedForbidden(error)) { diff --git a/packages/core/interface/src/exceptions/exceptions.ts b/packages/core/interface/src/exceptions/exceptions.ts index 69fc32004..8aa836dae 100644 --- a/packages/core/interface/src/exceptions/exceptions.ts +++ b/packages/core/interface/src/exceptions/exceptions.ts @@ -98,6 +98,13 @@ export class IntegrityException extends ServiceException { } } +export class ConflictException extends ServiceException { + constructor(msgOrData?: string | any, msg = 'The request is outdated.') { + super(msgOrData, msg); + this.status = 409; + } +} + export class MisconfigurationException extends ServiceException { constructor(msgOrData?: string | any, msg = 'An error due to misconfiguration occurred.') { super(msgOrData, msg); diff --git a/packages/core/web/src/content/components/ContentDetails.vue b/packages/core/web/src/content/components/ContentDetails.vue index 41649d5d4..044e0904b 100644 --- a/packages/core/web/src/content/components/ContentDetails.vue +++ b/packages/core/web/src/content/components/ContentDetails.vue @@ -8,7 +8,7 @@ import { computed } from 'vue'; import { translate } from '@/i18n'; import { getContentTypeOptions } from '../registries'; import { useUserInfo } from '@/profiles/composables'; -import { LyMarkdownView } from '@lyvely/ui'; +import ContentMarkdown from './ContentMarkdown.vue'; export interface IProps { model: ContentModel; @@ -71,7 +71,7 @@ const userInfo = useUserInfo(props.model.meta.createdBy);
- +
diff --git a/packages/core/web/src/content/components/ContentMarkdown.vue b/packages/core/web/src/content/components/ContentMarkdown.vue new file mode 100644 index 000000000..0871a58f1 --- /dev/null +++ b/packages/core/web/src/content/components/ContentMarkdown.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/packages/core/web/src/content/components/index.ts b/packages/core/web/src/content/components/index.ts index 4f6d7f479..c4e602d41 100644 --- a/packages/core/web/src/content/components/index.ts +++ b/packages/core/web/src/content/components/index.ts @@ -8,6 +8,7 @@ import ContentStreamFooter from './ContentStreamFooter.vue'; import UpsertContentModal from './UpsertContentModal.vue'; import DefaultStreamEntry from './DefaultStreamEntry.vue'; import ContentDropdown from './ContentDropdown.vue'; +import ContentMarkdown from './ContentMarkdown.vue'; export { ContentDetails, @@ -19,5 +20,6 @@ export { UpsertContentModal, DefaultStreamEntry, ContentDropdown, + ContentMarkdown, ContentToolbar, }; diff --git a/packages/core/web/src/content/stores/content.store.ts b/packages/core/web/src/content/stores/content.store.ts index 3f56b43f9..419c6c4db 100644 --- a/packages/core/web/src/content/stores/content.store.ts +++ b/packages/core/web/src/content/stores/content.store.ts @@ -2,6 +2,7 @@ import { defineStore } from 'pinia'; import { ContentModel, ContentUpdateResponse, IContent, useContentClient } from '@lyvely/interface'; import { useGlobalDialogStore, useEventBus } from '@/core'; import { useProfileStore } from '@/profiles/stores/profile.store'; +import type { TaskListClickEvent } from '@lyvely/ui'; type ContentEventType = 'archived' | 'restored' | 'created' | 'updated' | 'set-milestone'; @@ -36,6 +37,20 @@ export const useContentStore = defineStore('content', () => { .catch(globalDialog.showUnknownError); } + async function updateTaskListItem(content: ContentModel, event: TaskListClickEvent) { + return contentClient + .updateTaskListItem(content.id, { + ...event, + version: content.meta.updatedAt, + }) + .then((updated) => { + content.content.text = updated.content.text; + content.meta.updatedAt = updated.meta.updatedAt; + emitPostContentUpdateEvent(content.type, content); + }) + .catch(globalDialog.showUnknownError); + } + async function restore(content: ContentModel) { return contentClient .restore(content.id) @@ -147,6 +162,7 @@ export const useContentStore = defineStore('content', () => { archive, restore, toggleArchive, + updateTaskListItem, handleCreateContent, handleUpdateContent, emitPostContentEvent, diff --git a/packages/libs/ui/package.json b/packages/libs/ui/package.json index e71a44faa..fd064a04d 100644 --- a/packages/libs/ui/package.json +++ b/packages/libs/ui/package.json @@ -49,7 +49,8 @@ "randomcolor": "^0.6", "slugify": "^1.6", "tailwind-merge": "^2.3.0", - "markdown-it": "^14.1.0" + "markdown-it": "^14.1.0", + "@mdit/plugin-tasklist": "^0.12.0" }, "devDependencies": { "@lyvely/devtools": "workspace:*", diff --git a/packages/libs/ui/src/components/markdown/LyMarkdownView.vue b/packages/libs/ui/src/components/markdown/LyMarkdownView.vue index c8b3d457e..69852a5c8 100644 --- a/packages/libs/ui/src/components/markdown/LyMarkdownView.vue +++ b/packages/libs/ui/src/components/markdown/LyMarkdownView.vue @@ -4,7 +4,10 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'; import { getBackgroundColor, hasOverflow } from '@/helpers'; import { MARKDOWN_PRESET_DEFAULT, + MARKDOWN_PRESET_DEFAULT_EDITABLE, + MARKDOWN_VIEW_CLASS, renderMarkdown, + type TaskListClickEvent, } from '@/components/markdown/use-markdown.composable'; // TODO: Accessibility https://www.w3.org/WAI/WCAG21/Techniques/general/G201 @@ -15,24 +18,28 @@ interface IProps { preset?: string; maxWidth?: boolean; shadow?: boolean; + editable?: boolean; maxHeight?: boolean | string; } const props = withDefaults(defineProps(), { md: '', - preset: MARKDOWN_PRESET_DEFAULT, + preset: undefined, prose: true, maxWidth: false, + editable: false, shadow: true, maxHeight: false, }); const cssClass = { + [MARKDOWN_VIEW_CLASS]: true, 'overflow-hidden prose-a:text-blue-600 prose-a:no-underline dark:prose-a:text-blue-500': true, 'prose prose-sm dark:prose-invert': props.prose, 'max-w-none': !props.maxWidth, }; +const emits = defineEmits(['updateTaskListItem']); const isOverflow = ref(false); const showAll = ref(!props.maxHeight); const maxHeightState = computed(() => { @@ -57,14 +64,24 @@ function getShadowBackground() { return bgColor ? `linear-gradient(0deg, ${bgColor} 20%, transparent 100%)` : 'transparent'; } -const html = ref(''); +const output = ref(''); const render = () => { setTimeout(() => { try { - html.value = renderMarkdown(props.md, props.preset); - if (!html.value.length) return; - setTimeout(() => (isOverflow.value = hasOverflow(stage.value!, 20))); + const preset = + props.preset || props.editable ? MARKDOWN_PRESET_DEFAULT_EDITABLE : MARKDOWN_PRESET_DEFAULT; + const { html, addTaskListHandler } = renderMarkdown(props.md, preset); + output.value = html; + if (!output.value.length) return; + + setTimeout(() => { + isOverflow.value = hasOverflow(stage.value!, 20); + if (!props.editable) return; + addTaskListHandler(stage.value!, (event: TaskListClickEvent) => { + emits('updateTaskListItem', event); + }); + }); } catch (e) { return 'Error'; } @@ -80,11 +97,11 @@ onMounted(() => {