Skip to content

Commit 34446cc

Browse files
committed
feat: add review checkboxes feature using reviewedCheckboxesFieldName option. This option allows to enable Proofreading checkboxes near every translated string
1 parent dc4366e commit 34446cc

3 files changed

Lines changed: 266 additions & 13 deletions

File tree

custom/ListCell.vue

Lines changed: 187 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,200 @@
11
<template>
2-
<div class="text-sm text-gray-900 dark:text-white min-w-32">
3-
{{ limitedText }}
2+
<div class="relative group flex items-center" @click.stop>
3+
<!-- Normal value display -->
4+
<div v-if="!isEditing" class="flex items-center" :class="limitedText?.length > 50 ? 'min-w-48 max-w-full' : 'min-w-32'">
5+
{{ limitedText? limitedText : '-' }}
6+
7+
<span v-if="meta?.reviewedCheckboxesFieldName && limitedText" class="flex items-center ml-2">
8+
<Tooltip
9+
>
10+
<template #tooltip>
11+
{{ record[meta?.reviewedCheckboxesFieldName]?.[props.column.name] ? t('Translation is reviewed') : t('Translation is not reviewed') }}
12+
</template>
13+
<IconCheckOutline
14+
v-if="record[meta?.reviewedCheckboxesFieldName]?.[props.column.name]"
15+
class="w-5 h-5 text-green-500"
16+
/>
17+
<IconQuestionCircleSolid
18+
v-else
19+
class="w-5 h-5 text-yellow-500"
20+
/>
21+
</Tooltip>
22+
</span>
23+
24+
<button
25+
v-if="!column.editReadonly"
26+
@click="startEdit"
27+
class="ml-2 opacity-0 group-hover:opacity-100 transition-opacity"
28+
>
29+
<IconPenSolid class="w-5 h-5 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"/>
30+
</button>
31+
</div>
32+
33+
<!-- Edit mode -->
34+
<div v-else class="flex flex-col gap-2">
35+
<div class="flex items-center max-w-full gap-2"
36+
:class="limitedText?.length > 50 ? 'min-w-72' : 'min-w-64'"
37+
ref="inputHolder"
38+
>
39+
<ColumnValueInputWrapper
40+
class="flex-grow"
41+
ref="input"
42+
:source="'edit'"
43+
:column="column"
44+
:currentValues="currentValues"
45+
:mode="mode"
46+
:columnOptions="columnOptions"
47+
:unmasked="unmasked"
48+
:setCurrentValue="setCurrentValue"
49+
/>
50+
<div class="flex gap-1">
51+
<button
52+
@click="saveEdit"
53+
:disabled="saving || (originalReviewed === reviewed && originalValue === currentValues[props.column.name])"
54+
class="text-green-600 hover:text-green-700 dark:text-green-500 dark:hover:text-green-400 disabled:opacity-50"
55+
56+
>
57+
<IconCheckOutline class="w-5 h-5" />
58+
</button>
59+
<button
60+
@click="cancelEdit"
61+
:disabled="saving"
62+
class="text-red-600 hover:text-red-700 dark:text-red-500 dark:hover:text-red-400"
63+
>
64+
<IconXOutline class="w-5 h-5" />
65+
</button>
66+
</div>
67+
</div>
68+
<div v-if="meta?.reviewedCheckboxesFieldName">
69+
<label class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-0 cursor-pointer select-none">
70+
<Checkbox
71+
v-model="reviewed"
72+
:disabled="saving"
73+
/>
74+
{{ t('Translation is reviewed') }}
75+
</label>
76+
77+
</div>
78+
</div>
79+
480
</div>
581
</template>
682

783
<script setup lang="ts">
8-
import { computed } from 'vue';
9-
import { AdminForthResourceColumnCommon, AdminForthResourceCommon, AdminUser } from '@/types/Common';
84+
import { ref, Ref, computed, nextTick } from 'vue';
85+
import { IconPenSolid, IconCheckOutline, IconXOutline, IconQuestionCircleSolid } from '@iconify-prerendered/vue-flowbite';
86+
import { callAdminForthApi } from '@/utils';
87+
import { showErrorTost, showSuccesTost } from '@/composables/useFrontendApi';
88+
import ColumnValueInputWrapper from '@/components/ColumnValueInputWrapper.vue';
89+
import { useI18n } from 'vue-i18n';
90+
import Tooltip from '@/afcl/Tooltip.vue';
91+
import Checkbox from '@/afcl/Checkbox.vue';
1092
93+
const { t } = useI18n();
94+
const props = defineProps(['column', 'record', 'resource', 'adminUser', 'meta']);
95+
const isEditing = ref(false);
96+
const editValue = ref(null);
97+
const saving = ref(false);
98+
const input = ref(null);
99+
const columnOptions = ref({});
100+
const mode = ref('edit');
101+
const currentValues = ref({});
102+
const unmasked = ref({});
103+
104+
const inputHolder = ref(null);
11105
12106
const limitedText = computed(() => {
13107
const text = props.record[props.column.name];
14108
return text?.length > 50 ? text.slice(0, 50) + '...' : text;
15109
});
16110
17-
const props = defineProps<{
18-
column: AdminForthResourceColumnCommon;
19-
record: any;
20-
meta: any;
21-
resource: AdminForthResourceCommon;
22-
adminUser: AdminUser;
23-
}>();
111+
const reviewed: Ref<boolean> = ref(false);
112+
113+
114+
const originalReviewed = ref(false);
115+
const originalValue = ref(null);
116+
117+
async function startEdit() {
118+
const value = props.record[props.column.name];
119+
currentValues.value = {
120+
[props.column.name]: props.column.isArray?.enabled
121+
? (Array.isArray(value) ? value : [value]).filter(v => v !== null && v !== undefined)
122+
: value,
123+
};
124+
reviewed.value = props.record[props.meta?.reviewedCheckboxesFieldName]?.[props.column.name] || false;
125+
originalReviewed.value = reviewed.value;
126+
originalValue.value = value;
127+
isEditing.value = true;
128+
await nextTick();
129+
if (inputHolder.value) {
130+
inputHolder.value.querySelector('input, textarea, select')?.focus();
131+
}
132+
}
133+
134+
function cancelEdit() {
135+
isEditing.value = false;
136+
editValue.value = null;
137+
}
138+
139+
function setCurrentValue(field, value, arrayIndex = undefined) {
140+
if (arrayIndex !== undefined && props.column.isArray?.enabled) {
141+
// Handle array updates
142+
if (!Array.isArray(currentValues.value[field])) {
143+
currentValues.value[field] = [];
144+
}
145+
146+
const newArray = [...currentValues.value[field]];
147+
148+
if (arrayIndex >= newArray.length) {
149+
// When adding a new item, always add null
150+
newArray.push(null);
151+
} else {
152+
// For existing items, handle type conversion
153+
if (props.column.isArray?.itemType && ['integer', 'float', 'decimal'].includes(props.column.isArray.itemType)) {
154+
newArray[arrayIndex] = value !== null && value !== '' ? +value : null;
155+
} else {
156+
newArray[arrayIndex] = value;
157+
}
158+
}
159+
160+
// Assign the new array
161+
currentValues.value[field] = newArray;
162+
editValue.value = newArray;
163+
} else {
164+
// Handle non-array updates
165+
currentValues.value[field] = value;
166+
editValue.value = value;
167+
}
168+
}
169+
170+
async function saveEdit() {
171+
saving.value = true;
172+
try {
173+
const result = await callAdminForthApi({
174+
method: 'POST',
175+
path: `/plugin/${props.meta.pluginInstanceId}/update-field`,
176+
body: {
177+
resourceId: props.resource.resourceId,
178+
recordId: props.record._primaryKeyValue,
179+
field: props.column.name,
180+
value: currentValues.value[props.column.name],
181+
reviewed: reviewed.value,
182+
}
183+
});
184+
185+
if (result.error) {
186+
showErrorTost(result.error);
187+
return;
188+
}
24189
25-
</script>
190+
showSuccesTost(t('Field updated successfully'));
191+
props.record[props.column.name] = result.record[props.column.name];
192+
if (props.meta?.reviewedCheckboxesFieldName) {
193+
props.record[props.meta?.reviewedCheckboxesFieldName] = result.record[props.meta?.reviewedCheckboxesFieldName];
194+
}
195+
isEditing.value = false;
196+
} finally {
197+
saving.value = false;
198+
}
199+
}
200+
</script>

index.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import AdminForth, { AdminForthPlugin, Filters, suggestIfTypo, AdminForthDataTypes } from "adminforth";
1+
import AdminForth, { AdminForthPlugin, Filters, suggestIfTypo, AdminForthDataTypes, RAMLock } from "adminforth";
22
import type { IAdminForth, IHttpServer, AdminForthComponentDeclaration, AdminForthResourceColumn, AdminForthResource, BeforeLoginConfirmationFunction, AdminForthConfigMenuItem } from "adminforth";
33
import type { PluginOptions } from './types.js';
44
import iso6391, { LanguageCode } from 'iso-639-1';
55
import path from 'path';
66
import fs from 'fs-extra';
77
import chokidar from 'chokidar';
88
import { AsyncQueue } from '@sapphire/async-queue';
9+
10+
911
console.log = (...args) => {
1012
process.stdout.write(args.join(" ") + "\n");
1113
};
@@ -182,6 +184,11 @@ export default class I18nPlugin extends AdminForthPlugin {
182184
// set ListCell for list
183185
column.components.list = {
184186
file: this.componentPath('ListCell.vue'),
187+
meta: {
188+
pluginInstanceId: this.pluginInstanceId,
189+
lang,
190+
reviewedCheckboxesFieldName: this.options.reviewedCheckboxesFieldName,
191+
},
185192
};
186193
}
187194

@@ -732,6 +739,19 @@ JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object =>
732739
}
733740
}
734741

742+
if (this.options.reviewedCheckboxesFieldName) {
743+
// ensure type is JSON
744+
const column = resourceConfig.columns.find(c => c.name === this.options.reviewedCheckboxesFieldName);
745+
if (!column) {
746+
const similar = suggestIfTypo(resourceConfig.columns.map((col) => col.name), this.options.reviewedCheckboxesFieldName);
747+
throw new Error(`Field ${this.options.reviewedCheckboxesFieldName} not found in resource ${resourceConfig.resourceId}${similar ? `Did you mean '${similar}'?` : ''}`);
748+
}
749+
if (column.type !== AdminForthDataTypes.JSON) {
750+
throw new Error(`Field ${this.options.reviewedCheckboxesFieldName} should be of type JSON in resource ${resourceConfig.resourceId}, but it is ${column.type}`);
751+
}
752+
}
753+
754+
735755
// ensure categoryFieldName defined and is string
736756
if (!this.options.categoryFieldName) {
737757
throw new Error(`categoryFieldName option is not defined. It is used to categorize translations and return only specific category e.g. to frontend`);
@@ -940,6 +960,57 @@ JSON.stringify(strings.reduce((acc: object, s: { en_string: string }): object =>
940960
}
941961
});
942962

963+
const lock = new RAMLock();
964+
965+
server.endpoint({
966+
method: 'POST',
967+
path: `/plugin/${this.pluginInstanceId}/update-field`,
968+
handler: async ({ body, adminUser, headers }) => {
969+
const { resourceId, recordId, field, value, reviewed } = body;
970+
971+
const resource = this.adminforth.config.resources.find(r => r.resourceId === resourceId);
972+
// Create update object with just the single field
973+
const updateRecord = { [field]: value };
974+
975+
// Use AdminForth's built-in update method
976+
const connector = this.adminforth.connectors[resource.dataSource];
977+
978+
let oldRecord;
979+
let result;
980+
await lock.run(`edit-trans-${recordId}`, async () => {
981+
// put into lock so 2 editors will not update the same record at the same time
982+
oldRecord = await connector.getRecordByPrimaryKey(resource, recordId)
983+
984+
if (this.options.reviewedCheckboxesFieldName) {
985+
let oldValue;
986+
if (!oldRecord[this.options.reviewedCheckboxesFieldName]) {
987+
oldValue = {}
988+
} else {
989+
oldValue = {...oldRecord[this.options.reviewedCheckboxesFieldName]};
990+
}
991+
oldValue[field] = reviewed;
992+
updateRecord[this.options.reviewedCheckboxesFieldName] = { ...oldValue };
993+
}
994+
995+
result = await this.adminforth.updateResourceRecord({
996+
resource,
997+
recordId,
998+
record: updateRecord,
999+
oldRecord,
1000+
adminUser
1001+
});
1002+
});
1003+
1004+
if (result.error) {
1005+
return { error: result.error };
1006+
}
1007+
1008+
const updatedRecord = await connector.getRecordByPrimaryKey(resource, recordId);
1009+
1010+
return { record: updatedRecord };
1011+
}
1012+
});
1013+
9431014
}
9441015

9451016
}

types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,11 @@ export interface PluginOptions {
4242
* not AdminForth applications
4343
*/
4444
externalAppOnly?: boolean;
45+
46+
47+
/**
48+
* You can enable "Reviewed" checkbox for each translation string by defing this field,
49+
* it should be a JSON field (underlyng database type should be TEXT or JSON)
50+
*/
51+
reviewedCheckboxesFieldName?: string;
4552
}

0 commit comments

Comments
 (0)