@raketa-cloud/admin 1.10.12 → 1.10.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/application.css +699 -244
- package/package.json +4 -3
- package/src/cloud/Comments.js +88 -0
- package/src/cloud/Editors.js +20 -0
- package/src/cloud/documents/commentsDocuments.js +103 -0
- package/src/cloud/documents/editorsDocuments.js +27 -0
- package/src/handlers/resources/DiscussionsHandler.js +203 -0
- package/src/handlers/resources/HistoryHandler.js +7 -5
- package/src/handlers/resources/ResourcesHandler.js +6 -5
- package/src/helpers/commentMarkdown.js +94 -0
- package/src/helpers/fetchCommentCounts.js +14 -0
- package/src/helpers/historyUtils.js +11 -0
- package/src/helpers/utils.js +24 -0
- package/src/index.js +1 -0
- package/src/routeResourceModule.js +42 -0
- package/src/setupCloud.js +4 -0
- package/src/styles/application.css +56 -0
- package/src/tests/cloud/Comments.test.js +141 -0
- package/src/tests/cloud/Editors.test.js +47 -0
- package/src/tests/handlers/resources/DiscussionsHandler.test/create.test.js +108 -0
- package/src/tests/handlers/resources/DiscussionsHandler.test/destroy.test.js +55 -0
- package/src/tests/handlers/resources/DiscussionsHandler.test/index.test.js +195 -0
- package/src/tests/handlers/resources/DiscussionsHandler.test/update.test.js +87 -0
- package/src/tests/handlers/resources/HistoryHandler.test/index.test.js +9 -0
- package/src/tests/handlers/resources/ResourcesHandler.test/show.test.js +33 -0
- package/src/tests/handlers/resources/ResourcesHandler.test/show_reports.test.js +3 -0
- package/src/tests/helpers/commentMarkdown.test.js +166 -0
- package/src/tests/helpers/fetchCommentCounts.test.js +42 -0
- package/src/tests/helpers/historyUtils.test.js +26 -0
- package/src/tests/helpers/utils.test.js +21 -0
- package/src/tests/routeResourceModule.test.js +82 -2
- package/src/tests/setupCloud.test.js +4 -0
- package/src/tests/views/discussions.test.js +519 -0
- package/src/views/resources/_shared/_history_toolbar.ejs +10 -0
- package/src/views/resources/_shared/_tabs.ejs +21 -0
- package/src/views/resources/_shared/_toolbar.ejs +1 -1
- package/src/views/resources/discussions/_avatar.ejs +13 -0
- package/src/views/resources/discussions/_discussion_tabs.ejs +22 -0
- package/src/views/resources/discussions/index.ejs +729 -0
- package/src/views/resources/show.ejs +17 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@raketa-cloud/admin",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.13",
|
|
4
4
|
"main": "src/index.js",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22"
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
"@babel/preset-env": "7.29.0",
|
|
27
27
|
"@babel/preset-typescript": "7.28.5",
|
|
28
28
|
"@repo/typescript-config": "*",
|
|
29
|
-
"@tailwindcss/cli": "4.3.
|
|
29
|
+
"@tailwindcss/cli": "4.3.0",
|
|
30
30
|
"@tailwindcss/forms": "0.5.11",
|
|
31
31
|
"@tailwindcss/typography": "0.5.20",
|
|
32
32
|
"@types/node": "22.20.5",
|
|
@@ -43,7 +43,8 @@
|
|
|
43
43
|
"google-auth-library": "11.3.1",
|
|
44
44
|
"graphql-request": "7.4.0",
|
|
45
45
|
"hono": "4.13.13",
|
|
46
|
-
"lru-cache": "11.5.2"
|
|
46
|
+
"lru-cache": "11.5.2",
|
|
47
|
+
"markdown-it": "15.0.3"
|
|
47
48
|
},
|
|
48
49
|
"overrides": {
|
|
49
50
|
"error-ex": "1.3.4"
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import RaketaClient from "./RaketaClient";
|
|
2
|
+
import {
|
|
3
|
+
CreateResourceCommentDocument,
|
|
4
|
+
DeleteResourceCommentDocument,
|
|
5
|
+
ResourceCommentCountsDocument,
|
|
6
|
+
ResourceCommentsDocument,
|
|
7
|
+
UpdateResourceCommentDocument,
|
|
8
|
+
} from "./documents/commentsDocuments";
|
|
9
|
+
|
|
10
|
+
export default class Comments extends RaketaClient {
|
|
11
|
+
// Without a revision id, lists the general discussion of the resource.
|
|
12
|
+
async list(query, resourceId, locale, resourceType, revisionId = null) {
|
|
13
|
+
const { resourceComments: items } = await this.query(
|
|
14
|
+
ResourceCommentsDocument,
|
|
15
|
+
{
|
|
16
|
+
input: {
|
|
17
|
+
id: resourceId,
|
|
18
|
+
locale,
|
|
19
|
+
resourceType,
|
|
20
|
+
revisionId,
|
|
21
|
+
pagination: this.buildPaginationInput(query),
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
return items;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// The number of comments per discussion, keyed by revision id and "general"
|
|
30
|
+
// for the general discussion. Discussions without comments are missing.
|
|
31
|
+
async counts(resourceId, locale, resourceType) {
|
|
32
|
+
const { resourceCommentCounts } = await this.query(
|
|
33
|
+
ResourceCommentCountsDocument,
|
|
34
|
+
{
|
|
35
|
+
input: { id: resourceId, locale, resourceType },
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return Object.fromEntries(
|
|
40
|
+
(resourceCommentCounts || []).map(({ revisionId, count }) => [
|
|
41
|
+
revisionId ?? "general",
|
|
42
|
+
count,
|
|
43
|
+
]),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async create(resourceId, locale, resourceType, params) {
|
|
48
|
+
const {
|
|
49
|
+
createResourceComment: { comment, errors },
|
|
50
|
+
} = await this.query(CreateResourceCommentDocument, {
|
|
51
|
+
input: {
|
|
52
|
+
id: resourceId,
|
|
53
|
+
locale,
|
|
54
|
+
resourceType,
|
|
55
|
+
revisionId: params.revisionId || null,
|
|
56
|
+
comment: {
|
|
57
|
+
body: params.body,
|
|
58
|
+
replyId: params.replyId || null,
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return { comment, errors };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async update(id, params) {
|
|
67
|
+
const {
|
|
68
|
+
updateResourceComment: { comment, errors },
|
|
69
|
+
} = await this.query(UpdateResourceCommentDocument, {
|
|
70
|
+
input: {
|
|
71
|
+
id,
|
|
72
|
+
comment: { body: params.body },
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return { comment, errors };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async delete(id) {
|
|
80
|
+
const {
|
|
81
|
+
deleteResourceComment: { comment, errors },
|
|
82
|
+
} = await this.query(DeleteResourceCommentDocument, {
|
|
83
|
+
input: { id },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return { comment, errors };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import RaketaClient from "./RaketaClient";
|
|
2
|
+
import {
|
|
3
|
+
CurrentEditorDocument,
|
|
4
|
+
WebsiteEditorsDocument,
|
|
5
|
+
} from "./documents/editorsDocuments";
|
|
6
|
+
|
|
7
|
+
export default class Editors extends RaketaClient {
|
|
8
|
+
async current() {
|
|
9
|
+
const { currentEditor } = await this.query(CurrentEditorDocument);
|
|
10
|
+
|
|
11
|
+
return currentEditor;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// The active editors which are part of the current website.
|
|
15
|
+
async website() {
|
|
16
|
+
const { websiteEditors } = await this.query(WebsiteEditorsDocument);
|
|
17
|
+
|
|
18
|
+
return websiteEditors?.list || [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { gql } from "graphql-request";
|
|
2
|
+
import { errorsFragment, paginationFragment } from "./common";
|
|
3
|
+
|
|
4
|
+
function editorFragment() {
|
|
5
|
+
return gql`
|
|
6
|
+
{
|
|
7
|
+
id
|
|
8
|
+
name
|
|
9
|
+
email
|
|
10
|
+
avatar
|
|
11
|
+
}
|
|
12
|
+
`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function mentionFragment() {
|
|
16
|
+
return gql`
|
|
17
|
+
{
|
|
18
|
+
editorId
|
|
19
|
+
name
|
|
20
|
+
email
|
|
21
|
+
}
|
|
22
|
+
`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function commentFragment() {
|
|
26
|
+
return gql`
|
|
27
|
+
{
|
|
28
|
+
id
|
|
29
|
+
body
|
|
30
|
+
resourceId
|
|
31
|
+
revisionId
|
|
32
|
+
replyId
|
|
33
|
+
mentions ${mentionFragment()}
|
|
34
|
+
editor ${editorFragment()}
|
|
35
|
+
reply {
|
|
36
|
+
id
|
|
37
|
+
body
|
|
38
|
+
mentions ${mentionFragment()}
|
|
39
|
+
editor ${editorFragment()}
|
|
40
|
+
deletedAt
|
|
41
|
+
insertedAt
|
|
42
|
+
}
|
|
43
|
+
deletedAt
|
|
44
|
+
insertedAt
|
|
45
|
+
updatedAt
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const ResourceCommentsDocument = gql`
|
|
51
|
+
query ResourceComments($input: ResourceCommentsInput!) {
|
|
52
|
+
resourceComments(input: $input) {
|
|
53
|
+
list ${commentFragment()}
|
|
54
|
+
pagination ${paginationFragment()}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
const ResourceCommentCountsDocument = gql`
|
|
60
|
+
query ResourceCommentCounts($input: IdTypeInput!) {
|
|
61
|
+
resourceCommentCounts(input: $input) {
|
|
62
|
+
revisionId
|
|
63
|
+
count
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
`;
|
|
67
|
+
|
|
68
|
+
const CreateResourceCommentDocument = gql`
|
|
69
|
+
mutation CreateResourceComment($input: CreateResourceCommentInput!) {
|
|
70
|
+
createResourceComment(input: $input) {
|
|
71
|
+
comment ${commentFragment()}
|
|
72
|
+
errors ${errorsFragment()}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
const UpdateResourceCommentDocument = gql`
|
|
78
|
+
mutation UpdateResourceComment($input: UpdateResourceCommentInput!) {
|
|
79
|
+
updateResourceComment(input: $input) {
|
|
80
|
+
comment ${commentFragment()}
|
|
81
|
+
errors ${errorsFragment()}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
const DeleteResourceCommentDocument = gql`
|
|
87
|
+
mutation DeleteResourceComment($input: SimpleIdInput!) {
|
|
88
|
+
deleteResourceComment(input: $input) {
|
|
89
|
+
comment {
|
|
90
|
+
id
|
|
91
|
+
}
|
|
92
|
+
errors ${errorsFragment()}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
export {
|
|
98
|
+
CreateResourceCommentDocument,
|
|
99
|
+
DeleteResourceCommentDocument,
|
|
100
|
+
ResourceCommentCountsDocument,
|
|
101
|
+
ResourceCommentsDocument,
|
|
102
|
+
UpdateResourceCommentDocument,
|
|
103
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { gql } from "graphql-request";
|
|
2
|
+
|
|
3
|
+
const CurrentEditorDocument = gql`
|
|
4
|
+
query CurrentEditor {
|
|
5
|
+
currentEditor {
|
|
6
|
+
id
|
|
7
|
+
name
|
|
8
|
+
email
|
|
9
|
+
avatar
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
`;
|
|
13
|
+
|
|
14
|
+
const WebsiteEditorsDocument = gql`
|
|
15
|
+
query WebsiteEditors {
|
|
16
|
+
websiteEditors {
|
|
17
|
+
list {
|
|
18
|
+
id
|
|
19
|
+
name
|
|
20
|
+
email
|
|
21
|
+
avatar
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
export { CurrentEditorDocument, WebsiteEditorsDocument };
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import Logger from "../../Logger";
|
|
2
|
+
|
|
3
|
+
// The general discussion, or the one of a revision when given its id.
|
|
4
|
+
function discussionsPath(
|
|
5
|
+
ctx,
|
|
6
|
+
resourceId,
|
|
7
|
+
{ locale = ctx.locale, revisionId = null } = {},
|
|
8
|
+
) {
|
|
9
|
+
const path = `/${locale}/${ctx.resourceConfig.resourceTypePlural}/${resourceId}/discussions`;
|
|
10
|
+
|
|
11
|
+
return revisionId ? `${path}/${revisionId}` : path;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseRevisionId(value) {
|
|
15
|
+
if (value === undefined || value === null || value === "") return null;
|
|
16
|
+
if (/^\d+$/.test(String(value))) return String(value);
|
|
17
|
+
|
|
18
|
+
throw new Error(`Missing discussion for revision #${value}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const BLANK_ERROR = "Comment can't be blank.";
|
|
22
|
+
|
|
23
|
+
const ERROR_MESSAGES = {
|
|
24
|
+
revisionId: "This revision was published, its discussion is read-only.",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function errorPath(path, message) {
|
|
28
|
+
return `${path}?comment_error=${encodeURIComponent(message)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function errorsMessage(errors) {
|
|
32
|
+
return errors
|
|
33
|
+
.map((error) => ERROR_MESSAGES[error.key] || error.message)
|
|
34
|
+
.join(", ");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The login token may hold a stale or missing avatar, while the DXP editor is
|
|
38
|
+
// kept in sync with the profile, so prefer it.
|
|
39
|
+
async function fetchCurrentEditor(ctx) {
|
|
40
|
+
const fallback = ctx.authentication?.user || {};
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
return (await ctx.client.editors.current()) || fallback;
|
|
44
|
+
} catch (e) {
|
|
45
|
+
Logger.error(`Failed to fetch the current editor: ${e.message}`);
|
|
46
|
+
return fallback;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The editors which can be mentioned. The discussion works without them.
|
|
51
|
+
async function fetchWebsiteEditors(ctx) {
|
|
52
|
+
try {
|
|
53
|
+
return await ctx.client.editors.website();
|
|
54
|
+
} catch (e) {
|
|
55
|
+
Logger.error(`Failed to fetch the website editors: ${e.message}`);
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const index = async (ctx) => {
|
|
61
|
+
const { parent_id } = ctx.params;
|
|
62
|
+
const { resourceType } = ctx.resourceConfig;
|
|
63
|
+
|
|
64
|
+
const resource = await ctx.client.resources.find(
|
|
65
|
+
parent_id,
|
|
66
|
+
ctx.locale,
|
|
67
|
+
resourceType,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (!resource) {
|
|
71
|
+
throw new Error(`Missing ${resourceType} #${parent_id}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const revisionId = parseRevisionId(ctx.params.revision_id);
|
|
75
|
+
|
|
76
|
+
const [comments, currentEditor, editors, revisions] = await Promise.all([
|
|
77
|
+
ctx.client.comments.list(
|
|
78
|
+
ctx.query,
|
|
79
|
+
parent_id,
|
|
80
|
+
ctx.locale,
|
|
81
|
+
resourceType,
|
|
82
|
+
revisionId,
|
|
83
|
+
),
|
|
84
|
+
fetchCurrentEditor(ctx),
|
|
85
|
+
fetchWebsiteEditors(ctx),
|
|
86
|
+
ctx.client.resourceRevisions.all(parent_id, ctx.locale, resourceType),
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
const openRevisions = revisions?.list || [];
|
|
90
|
+
const revision = revisionId
|
|
91
|
+
? openRevisions.find((item) => String(item.id) === revisionId) || null
|
|
92
|
+
: null;
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
assigns: {
|
|
96
|
+
resource,
|
|
97
|
+
comments,
|
|
98
|
+
currentEditor,
|
|
99
|
+
editors,
|
|
100
|
+
revisions: openRevisions,
|
|
101
|
+
// A revision which is no longer open was published, its discussion is
|
|
102
|
+
// read-only.
|
|
103
|
+
discussion: {
|
|
104
|
+
revisionId,
|
|
105
|
+
revision,
|
|
106
|
+
archived: Boolean(revisionId && !revision),
|
|
107
|
+
path: discussionsPath(ctx, parent_id, { revisionId }),
|
|
108
|
+
},
|
|
109
|
+
_translations: Object.keys(ctx.locales)
|
|
110
|
+
.map((locale) => {
|
|
111
|
+
const translation = resource.translations[locale];
|
|
112
|
+
|
|
113
|
+
if (translation) {
|
|
114
|
+
return {
|
|
115
|
+
locale,
|
|
116
|
+
path: discussionsPath(ctx, translation.id, { locale }),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return null;
|
|
121
|
+
})
|
|
122
|
+
.filter(Boolean),
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const create = async (ctx) => {
|
|
128
|
+
const { parent_id } = ctx.params;
|
|
129
|
+
const params = ctx.resourceParams.comment || {};
|
|
130
|
+
const body = (params.body || "").trim();
|
|
131
|
+
const revisionId = parseRevisionId(ctx.params.revision_id);
|
|
132
|
+
const path = discussionsPath(ctx, parent_id, { revisionId });
|
|
133
|
+
|
|
134
|
+
if (!body) {
|
|
135
|
+
return { redirect: errorPath(path, BLANK_ERROR) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { errors } = await ctx.client.comments.create(
|
|
139
|
+
parent_id,
|
|
140
|
+
ctx.locale,
|
|
141
|
+
ctx.resourceConfig.resourceType,
|
|
142
|
+
{ body, replyId: params.reply_id, revisionId },
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if (errors) {
|
|
146
|
+
return { redirect: errorPath(path, errorsMessage(errors)) };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
redirect: path,
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Submitted by htmx from the inline edit form, so it redirects with headers.
|
|
155
|
+
const update = async (ctx) => {
|
|
156
|
+
const { id, parent_id } = ctx.params;
|
|
157
|
+
const params = ctx.resourceParams.comment || {};
|
|
158
|
+
const body = (params.body || "").trim();
|
|
159
|
+
const path = discussionsPath(ctx, parent_id, {
|
|
160
|
+
revisionId: parseRevisionId(ctx.resourceParams.revision_id),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const respond = (location) => ({
|
|
164
|
+
headers: { "HX-Redirect": location },
|
|
165
|
+
json: {},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (!body) {
|
|
169
|
+
return respond(errorPath(path, BLANK_ERROR));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const { errors } = await ctx.client.comments.update(id, { body });
|
|
173
|
+
|
|
174
|
+
if (errors) {
|
|
175
|
+
return respond(errorPath(path, errorsMessage(errors)));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return respond(path);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const destroy = async (ctx) => {
|
|
182
|
+
const { id } = ctx.params;
|
|
183
|
+
|
|
184
|
+
const { errors } = await ctx.client.comments.delete(id);
|
|
185
|
+
|
|
186
|
+
if (errors) {
|
|
187
|
+
throw `Failed to delete comment #${id}: ${errorsMessage(errors)}`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
headers: {
|
|
192
|
+
"HX-Refresh": "true",
|
|
193
|
+
},
|
|
194
|
+
json: {},
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
export default {
|
|
199
|
+
index,
|
|
200
|
+
create,
|
|
201
|
+
update,
|
|
202
|
+
destroy,
|
|
203
|
+
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import fetchCommentCounts from "../../helpers/fetchCommentCounts";
|
|
2
|
+
|
|
1
3
|
const index = async (ctx) => {
|
|
2
4
|
const { parent_id } = ctx.params;
|
|
3
5
|
const { resourceType } = ctx.resourceConfig;
|
|
@@ -19,17 +21,17 @@ const index = async (ctx) => {
|
|
|
19
21
|
resourceType,
|
|
20
22
|
);
|
|
21
23
|
|
|
22
|
-
const editors = await
|
|
23
|
-
parent_id,
|
|
24
|
-
ctx
|
|
25
|
-
|
|
26
|
-
);
|
|
24
|
+
const [editors, commentCounts] = await Promise.all([
|
|
25
|
+
ctx.client.resourceChanges.all_editors(parent_id, ctx.locale, resourceType),
|
|
26
|
+
fetchCommentCounts(ctx, parent_id, resourceType),
|
|
27
|
+
]);
|
|
27
28
|
|
|
28
29
|
return {
|
|
29
30
|
assigns: {
|
|
30
31
|
resource,
|
|
31
32
|
changes: changes,
|
|
32
33
|
editors,
|
|
34
|
+
commentCounts,
|
|
33
35
|
},
|
|
34
36
|
};
|
|
35
37
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fetchCommentCounts from "../../helpers/fetchCommentCounts";
|
|
1
2
|
import { CWV_METRICS } from "../../helpers/reports/cwv";
|
|
2
3
|
import { reportsAvailability } from "../../helpers/reports/reportsUtils";
|
|
3
4
|
|
|
@@ -41,11 +42,10 @@ const show = async (ctx) => {
|
|
|
41
42
|
throw new Error(`Missing ${resourceType} #${id}`);
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
const revisions = await
|
|
45
|
-
id,
|
|
46
|
-
ctx
|
|
47
|
-
|
|
48
|
-
);
|
|
45
|
+
const [revisions, commentCounts] = await Promise.all([
|
|
46
|
+
ctx.client.resourceRevisions.all(id, ctx.locale, resourceType),
|
|
47
|
+
fetchCommentCounts(ctx, id, resourceType),
|
|
48
|
+
]);
|
|
49
49
|
|
|
50
50
|
const changes = await ctx.client.resourceChanges.list(
|
|
51
51
|
{
|
|
@@ -78,6 +78,7 @@ const show = async (ctx) => {
|
|
|
78
78
|
vitalsMeta: available.webVitals ? CWV_METRICS : [],
|
|
79
79
|
},
|
|
80
80
|
revisions: revisions.list,
|
|
81
|
+
commentCounts,
|
|
81
82
|
changes: changes,
|
|
82
83
|
_translations: Object.keys(ctx.locales)
|
|
83
84
|
.map((locale) => {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import MarkdownIt from "markdown-it";
|
|
2
|
+
|
|
3
|
+
// Comments support only bold, italics, lists, quotes and links. Links are
|
|
4
|
+
// detected in the text, the markdown link syntax stays plain text. Raw HTML is
|
|
5
|
+
// escaped, so the output is safe to render unescaped.
|
|
6
|
+
const md = new MarkdownIt("zero", {
|
|
7
|
+
html: false,
|
|
8
|
+
linkify: true,
|
|
9
|
+
breaks: true,
|
|
10
|
+
}).enable(["emphasis", "list", "blockquote", "newline", "linkify"]);
|
|
11
|
+
|
|
12
|
+
md.linkify.set({ fuzzyLink: true, fuzzyEmail: false });
|
|
13
|
+
|
|
14
|
+
// Bare domains are linked only for known TLDs. The defaults miss the newer
|
|
15
|
+
// generic ones, URLs with a protocol are linked regardless.
|
|
16
|
+
md.linkify.tlds(
|
|
17
|
+
[
|
|
18
|
+
"agency",
|
|
19
|
+
"app",
|
|
20
|
+
"blog",
|
|
21
|
+
"cloud",
|
|
22
|
+
"design",
|
|
23
|
+
"dev",
|
|
24
|
+
"digital",
|
|
25
|
+
"online",
|
|
26
|
+
"site",
|
|
27
|
+
"store",
|
|
28
|
+
"studio",
|
|
29
|
+
"tech",
|
|
30
|
+
],
|
|
31
|
+
true,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
md.renderer.rules.link_open = (tokens, idx, options, _env, self) => {
|
|
35
|
+
tokens[idx].attrSet("target", "_blank");
|
|
36
|
+
tokens[idx].attrSet("rel", "noopener noreferrer");
|
|
37
|
+
|
|
38
|
+
return self.renderToken(tokens, idx, options);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Editors are mentioned with "@editor-<id>" in the stored text. The comments
|
|
42
|
+
// carry the names of their mentioned editors.
|
|
43
|
+
const MENTION_PATTERN = /(?<![\p{L}\p{N}_@/])@editor-(\d+)(?![\p{L}\p{N}_])/gu;
|
|
44
|
+
|
|
45
|
+
const mentionName = (mention) =>
|
|
46
|
+
(mention.name || "").trim() || mention.email || "";
|
|
47
|
+
|
|
48
|
+
function mentionNames(mentions) {
|
|
49
|
+
return new Map(
|
|
50
|
+
(mentions || [])
|
|
51
|
+
.filter((mention) => mentionName(mention))
|
|
52
|
+
.map((mention) => [String(mention.editorId), mentionName(mention)]),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
md.renderer.rules.text = (tokens, idx, _options, env) => {
|
|
57
|
+
const content = tokens[idx].content;
|
|
58
|
+
const escape = md.utils.escapeHtml;
|
|
59
|
+
let html = "";
|
|
60
|
+
let last = 0;
|
|
61
|
+
|
|
62
|
+
for (const match of content.matchAll(MENTION_PATTERN)) {
|
|
63
|
+
const name = env.mentionNames?.get(match[1]);
|
|
64
|
+
|
|
65
|
+
html += escape(content.slice(last, match.index));
|
|
66
|
+
html += name
|
|
67
|
+
? `<span class="comment-mention">@${escape(name)}</span>`
|
|
68
|
+
: '<span class="comment-mention-unknown">@Unknown editor</span>';
|
|
69
|
+
last = match.index + match[0].length;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return html + escape(content.slice(last));
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// The text with the known mentions written as "@Name", for previews and for
|
|
76
|
+
// editing. Unknown mentions stay as they are.
|
|
77
|
+
export function commentMentionsText(text, mentions = []) {
|
|
78
|
+
const names = mentionNames(mentions);
|
|
79
|
+
|
|
80
|
+
return (text || "").replace(MENTION_PATTERN, (token, id) =>
|
|
81
|
+
names.has(id) ? `@${names.get(id)}` : token,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The editor ids of the mentions by their names, for editing.
|
|
86
|
+
export function commentMentionIds(mentions = []) {
|
|
87
|
+
return Object.fromEntries(
|
|
88
|
+
[...mentionNames(mentions)].map(([id, name]) => [name, id]),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default function commentMarkdown(text, { mentions = [] } = {}) {
|
|
93
|
+
return md.render(text || "", { mentionNames: mentionNames(mentions) });
|
|
94
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import Logger from "../Logger";
|
|
2
|
+
|
|
3
|
+
// The comment counts only decorate the revisions and history tables, so the
|
|
4
|
+
// pages render without them when they cannot be fetched.
|
|
5
|
+
export default async function fetchCommentCounts(ctx, id, resourceType) {
|
|
6
|
+
if (!ctx.resourceConfig?.comments) return {};
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
return await ctx.client.comments.counts(id, ctx.locale, resourceType);
|
|
10
|
+
} catch (e) {
|
|
11
|
+
Logger.error(`Failed to fetch the comment counts: ${e.message}`);
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -124,8 +124,19 @@ function isChangeRevertable(change, maxVersion) {
|
|
|
124
124
|
);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// Published revisions keep their discussions, which can be opened from the
|
|
128
|
+
// history when they have comments.
|
|
129
|
+
function hasDiscussion(change, commentCounts = {}) {
|
|
130
|
+
return (
|
|
131
|
+
change.changeType === "revision_publish" &&
|
|
132
|
+
Boolean(change.revisionId) &&
|
|
133
|
+
(commentCounts?.[change.revisionId] || 0) > 0
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
127
137
|
export default {
|
|
128
138
|
changeTypes,
|
|
139
|
+
hasDiscussion,
|
|
129
140
|
getMaxVersion,
|
|
130
141
|
changeAction,
|
|
131
142
|
isChangeRevertable,
|
package/src/helpers/utils.js
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import commentMarkdown, {
|
|
2
|
+
commentMentionIds,
|
|
3
|
+
commentMentionsText,
|
|
4
|
+
} from "./commentMarkdown";
|
|
5
|
+
|
|
1
6
|
function includedInCollection(value, collection) {
|
|
2
7
|
if (!collection) {
|
|
3
8
|
return false;
|
|
@@ -43,6 +48,21 @@ export function isFeatureEnabled(value) {
|
|
|
43
48
|
return value === true;
|
|
44
49
|
}
|
|
45
50
|
|
|
51
|
+
// Initials of the first two words of the name, e.g. "Jane van Doe" -> "JV".
|
|
52
|
+
// Falls back to the first letter of the email when there is no name.
|
|
53
|
+
function initials(name, email = "") {
|
|
54
|
+
const words = (name || "").trim().split(/\s+/).filter(Boolean);
|
|
55
|
+
|
|
56
|
+
if (words.length) {
|
|
57
|
+
return words
|
|
58
|
+
.slice(0, 2)
|
|
59
|
+
.map((word) => word[0].toUpperCase())
|
|
60
|
+
.join("");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (email || "?").trim().charAt(0).toUpperCase() || "?";
|
|
64
|
+
}
|
|
65
|
+
|
|
46
66
|
function feature(featureName, context) {
|
|
47
67
|
return isFeatureEnabled(context?.globalConfig?.features?.[featureName]);
|
|
48
68
|
}
|
|
@@ -52,9 +72,13 @@ function contentTaskProviderEnabled(provider, context) {
|
|
|
52
72
|
}
|
|
53
73
|
|
|
54
74
|
export default {
|
|
75
|
+
commentMarkdown,
|
|
76
|
+
commentMentionIds,
|
|
77
|
+
commentMentionsText,
|
|
55
78
|
includedInCollection,
|
|
56
79
|
formattedDate,
|
|
57
80
|
formattedDateTime,
|
|
58
81
|
feature,
|
|
59
82
|
contentTaskProviderEnabled,
|
|
83
|
+
initials,
|
|
60
84
|
};
|