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:
fahed
2026-03-11 17:27:57 +03:00
parent 7ace32a070
commit ba3900bc33
8 changed files with 826 additions and 193 deletions
+37
View File
@@ -0,0 +1,37 @@
const n = require('./nocodb');
const OLD_ID = 6;
const NEW_ID = 1;
const TABLES_AND_FIELDS = [
{ table: 'Posts', fields: ['assigned_to_id', 'created_by_user_id'] },
{ table: 'Tasks', fields: ['assigned_to_id', 'created_by_user_id'] },
{ table: 'Projects', fields: ['owner_id', 'created_by_user_id'] },
{ table: 'Campaigns', fields: ['created_by_user_id'] },
{ table: 'Assets', fields: ['uploader_id'] },
{ table: 'Comments', fields: ['user_id'] },
{ table: 'CampaignAssignments', fields: ['member_id', 'assigner_id'] },
{ table: 'Artefacts', fields: ['created_by_user_id'] },
{ table: 'ArtefactVersions', fields: ['created_by_user_id'] },
{ table: 'Issues', fields: ['assigned_to_id'] },
{ table: 'TeamMembers', fields: ['user_id'] },
{ table: 'BudgetEntries', fields: [] },
];
(async () => {
for (const { table, fields } of TABLES_AND_FIELDS) {
for (const field of fields) {
try {
const records = await n.list(table, { where: `(${field},eq,${OLD_ID})`, limit: 200 });
if (records.length === 0) continue;
console.log(`${table}.${field}: ${records.length} records to update`);
for (const r of records) {
await n.update(table, r.Id, { [field]: NEW_ID });
}
console.log(` -> done`);
} catch (err) {
console.log(`${table}.${field}: skipped (${err.message})`);
}
}
}
console.log('Reassignment complete.');
})();
+119 -21
View File
@@ -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 {