refactor: simplify translations — shared utils, deduplicated code
- Extract shared constants to client/src/utils/translations.js (AVAILABLE_LANGUAGES, TRANSLATION_STATUS_COLORS, isTextSelected, groupTextsByLanguage) - TranslationDetailPanel: deduplicate copy button JSX, hoist hasSelected - PublicTranslationReview: memoize textsByLanguage, use shared isTextSelected - Translations page: import from shared module - Server: translation schema updates, post_id linking - Add reassign-user utility script - Add new translation i18n keys to en.json and ar.json Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+119
-21
@@ -158,7 +158,7 @@ const FK_COLUMNS = {
|
||||
PostVersionTexts: ['version_id'],
|
||||
Issues: ['brand_id', 'assigned_to_id', 'team_id'],
|
||||
Users: ['role_id'],
|
||||
Translations: ['brand_id', 'created_by_user_id'],
|
||||
Translations: ['brand_id', 'post_id', 'created_by_user_id'],
|
||||
TranslationTexts: ['translation_id'],
|
||||
};
|
||||
|
||||
@@ -425,11 +425,11 @@ const REQUIRED_TABLES = {
|
||||
],
|
||||
Translations: [
|
||||
{ title: 'title', uidt: 'SingleLineText' },
|
||||
{ title: 'description', uidt: 'LongText' },
|
||||
{ title: 'source_language', uidt: 'SingleLineText' },
|
||||
{ title: 'source_content', uidt: 'LongText' },
|
||||
{ title: 'status', uidt: 'SingleSelect', dtxp: "'draft','pending_review','approved','rejected','revision_requested'" },
|
||||
{ title: 'brand_id', uidt: 'Number' },
|
||||
{ title: 'post_id', uidt: 'Number' },
|
||||
{ title: 'approver_ids', uidt: 'SingleLineText' },
|
||||
{ title: 'approval_token', uidt: 'SingleLineText' },
|
||||
{ title: 'token_expires_at', uidt: 'DateTime' },
|
||||
@@ -443,6 +443,8 @@ const REQUIRED_TABLES = {
|
||||
{ title: 'language_code', uidt: 'SingleLineText' },
|
||||
{ title: 'language_label', uidt: 'SingleLineText' },
|
||||
{ title: 'content', uidt: 'LongText' },
|
||||
{ title: 'option_number', uidt: 'Number' },
|
||||
{ title: 'is_selected', uidt: 'Checkbox' },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -4342,9 +4344,10 @@ app.get('/api/translations', requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
// Enrich with names
|
||||
const brandIds = new Set(), userIds = new Set();
|
||||
const brandIds = new Set(), userIds = new Set(), postIds = new Set();
|
||||
for (const t of translations) {
|
||||
if (t.brand_id) brandIds.add(t.brand_id);
|
||||
if (t.post_id) postIds.add(t.post_id);
|
||||
if (t.created_by_user_id) userIds.add(t.created_by_user_id);
|
||||
if (t.approver_ids) {
|
||||
for (const id of t.approver_ids.split(',').map(s => s.trim()).filter(Boolean)) {
|
||||
@@ -4355,6 +4358,7 @@ app.get('/api/translations', requireAuth, async (req, res) => {
|
||||
const names = await batchResolveNames({
|
||||
brand: { table: 'Brands', ids: [...brandIds] },
|
||||
user: { table: 'Users', ids: [...userIds] },
|
||||
post: { table: 'Posts', ids: [...postIds] },
|
||||
});
|
||||
|
||||
// Count translation texts per record
|
||||
@@ -4371,6 +4375,7 @@ app.get('/api/translations', requireAuth, async (req, res) => {
|
||||
return {
|
||||
...t,
|
||||
brand_name: names[`brand:${t.brand_id}`] || null,
|
||||
post_name: names[`post:${t.post_id}`] || null,
|
||||
creator_name: names[`user:${t.created_by_user_id}`] || null,
|
||||
approvers: approverIdList.map(id => ({ id: Number(id), name: names[`user:${Number(id)}`] || null })),
|
||||
translation_count: textCounts[t.Id] || 0,
|
||||
@@ -4384,7 +4389,7 @@ app.get('/api/translations', requireAuth, async (req, res) => {
|
||||
|
||||
// Create translation
|
||||
app.post('/api/translations', requireAuth, async (req, res) => {
|
||||
const { title, description, source_language, source_content, brand_id, approver_ids } = req.body;
|
||||
const { title, source_language, source_content, brand_id, post_id, approver_ids } = req.body;
|
||||
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||
if (!source_language) return res.status(400).json({ error: 'Source language is required' });
|
||||
if (!source_content) return res.status(400).json({ error: 'Source content is required' });
|
||||
@@ -4392,11 +4397,11 @@ app.post('/api/translations', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const created = await nocodb.create('Translations', {
|
||||
title,
|
||||
description: description || null,
|
||||
source_language,
|
||||
source_content,
|
||||
status: 'draft',
|
||||
brand_id: brand_id ? Number(brand_id) : null,
|
||||
post_id: post_id ? Number(post_id) : null,
|
||||
approver_ids: approver_ids || null,
|
||||
created_by_user_id: req.session.userId,
|
||||
});
|
||||
@@ -4410,6 +4415,7 @@ app.post('/api/translations', requireAuth, async (req, res) => {
|
||||
res.status(201).json({
|
||||
...record,
|
||||
brand_name: await getRecordName('Brands', record.brand_id),
|
||||
post_name: await getRecordName('Posts', record.post_id),
|
||||
creator_name: await getRecordName('Users', record.created_by_user_id),
|
||||
approvers,
|
||||
translation_count: 0,
|
||||
@@ -4448,10 +4454,11 @@ app.patch('/api/translations/:id', requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const data = {};
|
||||
for (const f of ['title', 'description', 'source_language', 'source_content', 'status', 'feedback']) {
|
||||
for (const f of ['title', 'source_language', 'source_content', 'status', 'feedback']) {
|
||||
if (req.body[f] !== undefined) data[f] = req.body[f];
|
||||
}
|
||||
if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null;
|
||||
if (req.body.post_id !== undefined) data.post_id = req.body.post_id ? Number(req.body.post_id) : null;
|
||||
if (req.body.approver_ids !== undefined) data.approver_ids = req.body.approver_ids || null;
|
||||
|
||||
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||
@@ -4467,6 +4474,7 @@ app.patch('/api/translations/:id', requireAuth, async (req, res) => {
|
||||
res.json({
|
||||
...record,
|
||||
brand_name: await getRecordName('Brands', record.brand_id),
|
||||
post_name: await getRecordName('Posts', record.post_id),
|
||||
creator_name: await getRecordName('Users', record.created_by_user_id),
|
||||
approvers,
|
||||
});
|
||||
@@ -4521,32 +4529,43 @@ app.post('/api/translations/:id/texts', requireAuth, async (req, res) => {
|
||||
const translation = await nocodb.get('Translations', req.params.id);
|
||||
if (!translation) return res.status(404).json({ error: 'Translation not found' });
|
||||
|
||||
// Check if text for this language already exists (upsert)
|
||||
// Count existing options for this language to assign option_number
|
||||
const existing = await nocodb.list('TranslationTexts', {
|
||||
where: `(translation_id,eq,${sanitizeWhereValue(req.params.id)})~and(language_code,eq,${sanitizeWhereValue(language_code)})`,
|
||||
limit: 1,
|
||||
limit: QUERY_LIMITS.large,
|
||||
});
|
||||
const optionNumber = existing.length + 1;
|
||||
|
||||
let result;
|
||||
if (existing.length > 0) {
|
||||
await nocodb.update('TranslationTexts', existing[0].Id, { content, language_label: language_label || language_code });
|
||||
result = await nocodb.get('TranslationTexts', existing[0].Id);
|
||||
} else {
|
||||
result = await nocodb.create('TranslationTexts', {
|
||||
translation_id: Number(req.params.id),
|
||||
language_code,
|
||||
language_label: language_label || language_code,
|
||||
content,
|
||||
});
|
||||
}
|
||||
const result = await nocodb.create('TranslationTexts', {
|
||||
translation_id: Number(req.params.id),
|
||||
language_code,
|
||||
language_label: language_label || language_code,
|
||||
content,
|
||||
option_number: optionNumber,
|
||||
is_selected: false,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('Add/update translation text error:', err);
|
||||
console.error('Add translation text error:', err);
|
||||
res.status(500).json({ error: 'Failed to save translation text' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update translation text content
|
||||
app.patch('/api/translations/:id/texts/:textId', requireAuth, async (req, res) => {
|
||||
const { content } = req.body;
|
||||
if (!content) return res.status(400).json({ error: 'Content is required' });
|
||||
try {
|
||||
await nocodb.update('TranslationTexts', req.params.textId, { content });
|
||||
const result = await nocodb.get('TranslationTexts', req.params.textId);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('Update translation text error:', err);
|
||||
res.status(500).json({ error: 'Failed to update translation text' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete translation text
|
||||
app.delete('/api/translations/:id/texts/:textId', requireAuth, async (req, res) => {
|
||||
try {
|
||||
@@ -4558,6 +4577,85 @@ app.delete('/api/translations/:id/texts/:textId', requireAuth, async (req, res)
|
||||
}
|
||||
});
|
||||
|
||||
// Select a translation option (used by approver via public review)
|
||||
app.post('/api/public/review-translation/:token/select', async (req, res) => {
|
||||
const { text_id } = req.body;
|
||||
if (!text_id) return res.status(400).json({ error: 'text_id is required' });
|
||||
|
||||
try {
|
||||
const translations = await nocodb.list('Translations', {
|
||||
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
|
||||
limit: 1,
|
||||
});
|
||||
if (translations.length === 0) return res.status(404).json({ error: 'Review link not found' });
|
||||
const translation = translations[0];
|
||||
|
||||
if (translation.token_expires_at && new Date(translation.token_expires_at) < new Date()) {
|
||||
return res.status(410).json({ error: 'Review link has expired' });
|
||||
}
|
||||
|
||||
// Get the text being selected to find its language
|
||||
const textToSelect = await nocodb.get('TranslationTexts', text_id);
|
||||
if (!textToSelect || textToSelect.translation_id !== translation.Id) {
|
||||
return res.status(400).json({ error: 'Invalid text selection' });
|
||||
}
|
||||
|
||||
// Deselect all options for this language, then select the chosen one
|
||||
const sameLanguage = await nocodb.list('TranslationTexts', {
|
||||
where: `(translation_id,eq,${translation.Id})~and(language_code,eq,${sanitizeWhereValue(textToSelect.language_code)})`,
|
||||
limit: QUERY_LIMITS.large,
|
||||
});
|
||||
for (const t of sameLanguage) {
|
||||
await nocodb.update('TranslationTexts', t.Id, { is_selected: t.Id === Number(text_id) });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Select translation error:', err);
|
||||
res.status(500).json({ error: 'Failed to select translation' });
|
||||
}
|
||||
});
|
||||
|
||||
// Public: Approver suggests a new/modified translation option
|
||||
app.post('/api/public/review-translation/:token/suggest', async (req, res) => {
|
||||
const { language_code, language_label, content, suggested_by } = req.body;
|
||||
if (!language_code || !content) return res.status(400).json({ error: 'Language code and content are required' });
|
||||
|
||||
try {
|
||||
const translations = await nocodb.list('Translations', {
|
||||
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
|
||||
limit: 1,
|
||||
});
|
||||
if (translations.length === 0) return res.status(404).json({ error: 'Review link not found' });
|
||||
const translation = translations[0];
|
||||
|
||||
if (translation.token_expires_at && new Date(translation.token_expires_at) < new Date()) {
|
||||
return res.status(410).json({ error: 'Review link has expired' });
|
||||
}
|
||||
|
||||
// Count existing options for this language
|
||||
const existing = await nocodb.list('TranslationTexts', {
|
||||
where: `(translation_id,eq,${translation.Id})~and(language_code,eq,${sanitizeWhereValue(language_code)})`,
|
||||
limit: QUERY_LIMITS.large,
|
||||
});
|
||||
const optionNumber = existing.length + 1;
|
||||
|
||||
const result = await nocodb.create('TranslationTexts', {
|
||||
translation_id: translation.Id,
|
||||
language_code,
|
||||
language_label: language_label || language_code,
|
||||
content: suggested_by ? `[Suggested by ${suggested_by}] ${content}` : content,
|
||||
option_number: optionNumber,
|
||||
is_selected: false,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('Suggest translation error:', err);
|
||||
res.status(500).json({ error: 'Failed to add suggestion' });
|
||||
}
|
||||
});
|
||||
|
||||
// Submit translation for review
|
||||
app.post('/api/translations/:id/submit-review', requireAuth, async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user