diff --git a/app/Http/Controllers/TranslationsController.php b/app/Http/Controllers/TranslationsController.php index 9cab0716..9a05c9cf 100644 --- a/app/Http/Controllers/TranslationsController.php +++ b/app/Http/Controllers/TranslationsController.php @@ -9,13 +9,9 @@ use Spatie\TranslationLoader\LanguageLine; class TranslationsController extends Controller { - public function list(Application $app) + public function list() { - return LanguageLine::all()->map(function ($line) use ($app) { - $line->text = $line->getTranslation($app->getLocale()); - - return $line; - }); + return LanguageLine::paginate(10); } public function create(Request $request, Application $app, JavaScript $js) diff --git a/package.json b/package.json index e20742e1..bc1fef63 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "blessing-skin-shell": "^0.2.0", "commander": "^5.0.0", "echarts": "^4.6.0", + "immer": "^6.0.2", "jquery": "^3.4.1", "lodash.debounce": "^4.0.8", "nanoid": "^2.1.11", @@ -36,6 +37,7 @@ "skinview-utils": "^0.2.1", "skinview3d": "^1.2.1", "spectre.css": "^0.5.8", + "use-immer": "^0.3.5", "vue": "^2.6.11", "vue-good-table": "^2.18.1", "vue-recaptcha": "^1.2.0", diff --git a/resources/assets/src/scripts/route.tsx b/resources/assets/src/scripts/route.tsx index 0493ed1b..c6977092 100644 --- a/resources/assets/src/scripts/route.tsx +++ b/resources/assets/src/scripts/route.tsx @@ -72,7 +72,7 @@ export default [ }, { path: 'admin/i18n', - component: () => import('../views/admin/Translations.vue'), + react: () => import('../views/admin/Translations'), el: '#table', }, { diff --git a/resources/assets/src/views/admin/Translations.vue b/resources/assets/src/views/admin/Translations.vue deleted file mode 100644 index 53bb5f73..00000000 --- a/resources/assets/src/views/admin/Translations.vue +++ /dev/null @@ -1,112 +0,0 @@ - - - diff --git a/resources/assets/src/views/admin/Translations/Row.module.scss b/resources/assets/src/views/admin/Translations/Row.module.scss new file mode 100644 index 00000000..b2a08c44 --- /dev/null +++ b/resources/assets/src/views/admin/Translations/Row.module.scss @@ -0,0 +1,11 @@ +.group { + width: 15%; +} + +.key { + width: 20%; +} + +.operations { + width: 25%; +} diff --git a/resources/assets/src/views/admin/Translations/Row.tsx b/resources/assets/src/views/admin/Translations/Row.tsx new file mode 100644 index 00000000..30a3f892 --- /dev/null +++ b/resources/assets/src/views/admin/Translations/Row.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { t } from '@/scripts/i18n' +import { Line } from './types' +import styles from './Row.module.scss' + +interface Props { + line: Line + onEdit(line: Line): void + onRemove(line: Line): void +} + +const Row: React.FC = (props) => { + const { line, onEdit, onRemove } = props + const text = line.text[blessing.locale] + + const handleEditClick = () => onEdit(line) + + const handleRemoveClick = () => onRemove(line) + + return ( + + {line.group} + {line.key} + {text || t('admin.i18n.empty')} + + + + + + ) +} + +export default Row diff --git a/resources/assets/src/views/admin/Translations/index.tsx b/resources/assets/src/views/admin/Translations/index.tsx new file mode 100644 index 00000000..d9cbcd87 --- /dev/null +++ b/resources/assets/src/views/admin/Translations/index.tsx @@ -0,0 +1,113 @@ +import React, { useState, useEffect } from 'react' +import { hot } from 'react-hot-loader/root' +import { useImmer } from 'use-immer' +import { t } from '@/scripts/i18n' +import * as fetch from '@/scripts/net' +import { showModal, toast } from '@/scripts/notify' +import type { Paginator } from '@/scripts/types' +import Loading from '@/components/Loading' +import Pagination from '@/components/Pagination' +import type { Line } from './types' +import Row from './Row' + +const Translations: React.FC = () => { + const [lines, setLines] = useImmer([]) + const [isLoading, setIsLoading] = useState(false) + const [page, setPage] = useState(1) + const [totalPages, setTotalPages] = useState(1) + + useEffect(() => { + const getLines = async () => { + setIsLoading(true) + const result = await fetch.get>('/admin/i18n/list', { + page, + }) + setLines(() => result.data) + setTotalPages(result.last_page) + setIsLoading(false) + } + getLines() + }, [page]) + + const handleEdit = async (line: Line, index: number) => { + let text: string + try { + const { value } = await showModal({ + mode: 'prompt', + text: t('admin.i18n.updating'), + input: line.text[blessing.locale], + }) + text = value + } catch { + return + } + + const { code, message } = await fetch.put( + '/admin/i18n', + { id: line.id, text }, + ) + if (code === 0) { + toast.success(message) + setLines((lines) => { + lines[index].text[blessing.locale] = text + }) + } else { + toast.error(message) + } + } + + const handleRemove = async (line: Line) => { + try { + await showModal({ + text: t('admin.i18n.confirmDelete'), + okButtonType: 'danger', + }) + } catch { + return + } + + const { message } = await fetch.del('/admin/i18n', { id: line.id }) + toast.success(message) + const { id } = line + setLines((lines) => lines.filter((line) => line.id !== id)) + } + + return ( + <> +
+ + + + + + + + + + + {lines.length === 0 ? ( + + + + ) : ( + lines.map((line, i) => ( + handleEdit(line, i)} + onRemove={handleRemove} + /> + )) + )} + +
{t('admin.i18n.group')}{t('admin.i18n.key')}{t('admin.i18n.text')}{t('admin.operationsTitle')}
+ {isLoading ? : 'Nothing here.'} +
+
+
+ +
+ + ) +} + +export default hot(Translations) diff --git a/resources/assets/src/views/admin/Translations/types.ts b/resources/assets/src/views/admin/Translations/types.ts new file mode 100644 index 00000000..20a08269 --- /dev/null +++ b/resources/assets/src/views/admin/Translations/types.ts @@ -0,0 +1,6 @@ +export type Line = { + id: number + group: string + key: string + text: Record +} diff --git a/resources/assets/tests/setup.ts b/resources/assets/tests/setup.ts index 932f8c96..0c8a67aa 100644 --- a/resources/assets/tests/setup.ts +++ b/resources/assets/tests/setup.ts @@ -7,6 +7,7 @@ import yaml from 'js-yaml' window.blessing = { base_url: '', + locale: 'en', site_name: 'Blessing Skin', version: '4.0.0', extra: {}, diff --git a/resources/assets/tests/types.d.ts b/resources/assets/tests/types.d.ts index 8615a8b6..4305012d 100644 --- a/resources/assets/tests/types.d.ts +++ b/resources/assets/tests/types.d.ts @@ -9,6 +9,7 @@ interface Window { blessing: { base_url: string + locale: string site_name: string version: string i18n: object diff --git a/resources/assets/tests/views/admin/Translations.test.tsx b/resources/assets/tests/views/admin/Translations.test.tsx new file mode 100644 index 00000000..6f52936d --- /dev/null +++ b/resources/assets/tests/views/admin/Translations.test.tsx @@ -0,0 +1,141 @@ +import React from 'react' +import { render, waitFor, fireEvent } from '@testing-library/react' +import { t } from '@/scripts/i18n' +import * as fetch from '@/scripts/net' +import type { Paginator } from '@/scripts/types' +import Translations from '@/views/admin/Translations' +import type { Line } from '@/views/admin/Translations/types' + +jest.mock('@/scripts/net') + +const fixtureLine: Readonly = Object.freeze({ + id: 1, + group: 'general', + key: 'submit', + text: { + en: 'Submit', + }, +}) + +function createPaginator(data: Line[]): Paginator { + return { + data, + total: data.length, + from: 1, + to: data.length, + current_page: 1, + last_page: 1, + } +} + +test('empty text', async () => { + const line = { ...fixtureLine, text: { en: '' } } + fetch.get.mockResolvedValue(createPaginator([line])) + + const { queryByText } = render() + await waitFor(() => expect(fetch.get).toBeCalledTimes(1)) + expect(queryByText(t('admin.i18n.empty'))).toBeInTheDocument() +}) + +describe('edit line', () => { + beforeEach(() => { + fetch.get.mockResolvedValue(createPaginator([fixtureLine])) + }) + + it('succeeded', async () => { + fetch.put.mockResolvedValue({ code: 0, message: 'ok' }) + + const { getByText, getByDisplayValue, getByRole, queryByText } = render( + , + ) + await waitFor(() => expect(fetch.get).toBeCalledTimes(1)) + + fireEvent.click(getByText(t('admin.i18n.modify'))) + fireEvent.input(getByDisplayValue(fixtureLine.text.en), { + target: { value: 'finish' }, + }) + fireEvent.click(getByText(t('general.confirm'))) + await waitFor(() => + expect(fetch.put).toBeCalledWith('/admin/i18n', { + id: 1, + text: 'finish', + }), + ) + expect(queryByText('finish')).toBeInTheDocument() + expect(queryByText('ok')).toBeInTheDocument() + expect(getByRole('status')).toHaveClass('alert-success') + }) + + it('failed', async () => { + fetch.put.mockResolvedValue({ code: 1, message: 'failed' }) + + const { getByText, getByDisplayValue, getByRole, queryByText } = render( + , + ) + await waitFor(() => expect(fetch.get).toBeCalledTimes(1)) + + fireEvent.click(getByText(t('admin.i18n.modify'))) + fireEvent.input(getByDisplayValue(fixtureLine.text.en), { + target: { value: 'finish' }, + }) + fireEvent.click(getByText(t('general.confirm'))) + await waitFor(() => + expect(fetch.put).toBeCalledWith('/admin/i18n', { + id: 1, + text: 'finish', + }), + ) + expect(queryByText(fixtureLine.text.en)).toBeInTheDocument() + expect(queryByText('failed')).toBeInTheDocument() + expect(getByRole('alert')).toHaveClass('alert-danger') + }) + + it('cancelled', async () => { + const { getByText, getByDisplayValue, queryByText } = render( + , + ) + await waitFor(() => expect(fetch.get).toBeCalledTimes(1)) + + fireEvent.click(getByText(t('admin.i18n.modify'))) + fireEvent.input(getByDisplayValue(fixtureLine.text.en), { + target: { value: 'finish' }, + }) + fireEvent.click(getByText(t('general.cancel'))) + await waitFor(() => expect(fetch.put).not.toBeCalled()) + expect(queryByText(fixtureLine.text.en)).toBeInTheDocument() + }) +}) + +describe('delete line', () => { + beforeEach(() => { + fetch.get.mockResolvedValue(createPaginator([fixtureLine])) + }) + + it('succeeded', async () => { + fetch.del.mockResolvedValue({ code: 0, message: 'ok' }) + + const { getByText, getByRole, queryByText } = render() + await waitFor(() => expect(fetch.get).toBeCalledTimes(1)) + + fireEvent.click(getByText(t('admin.i18n.delete'))) + fireEvent.click(getByText(t('general.confirm'))) + await waitFor(() => + expect(fetch.del).toBeCalledWith('/admin/i18n', { + id: 1, + }), + ) + expect(queryByText(fixtureLine.text.en)).not.toBeInTheDocument() + expect(queryByText('ok')).toBeInTheDocument() + expect(getByRole('status')).toHaveClass('alert-success') + }) + + it('cancelled', async () => { + const { getByText, queryByText } = render() + await waitFor(() => expect(fetch.get).toBeCalledTimes(1)) + + fireEvent.click(getByText(t('admin.i18n.delete'))) + fireEvent.click(getByText(t('general.cancel'))) + await waitFor(() => expect(fetch.del).not.toBeCalled()) + expect(queryByText(fixtureLine.text.en)).toBeInTheDocument() + }) +}) diff --git a/resources/views/admin/i18n.twig b/resources/views/admin/i18n.twig index 260a59f3..fcf32da7 100644 --- a/resources/views/admin/i18n.twig +++ b/resources/views/admin/i18n.twig @@ -4,7 +4,7 @@ {% block content %}
-
+
@@ -24,28 +24,26 @@
{% endif %} {{ csrf_field() }} - - - - - - - - - - - - - - - -
{{ trans('admin.i18n.group') }} - -
{{ trans('admin.i18n.key') }} - -
{{ trans('admin.i18n.text') }} - -
+
+
+
{{ trans('admin.i18n.group') }}
+
+ +
+
+
+
{{ trans('admin.i18n.key') }}
+
+ +
+
+
+
{{ trans('admin.i18n.text') }}
+
+ +
+
+