rewrite "Translations" page with React

This commit is contained in:
Pig Fang 2020-03-28 18:25:12 +08:00
parent 0efcde3172
commit 9f560c67bd
14 changed files with 348 additions and 150 deletions

View File

@ -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)

View File

@ -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",

View File

@ -72,7 +72,7 @@ export default [
},
{
path: 'admin/i18n',
component: () => import('../views/admin/Translations.vue'),
react: () => import('../views/admin/Translations'),
el: '#table',
},
{

View File

@ -1,112 +0,0 @@
<template>
<div>
<vue-good-table
:rows="lines"
:columns="columns"
:search-options="tableOptions.search"
:pagination-options="tableOptions.pagination"
style-class="vgt-table striped"
>
<template #table-row="props">
<span v-if="props.column.field === 'operations'">
<button class="btn btn-default" @click="modify(props.row)">
{{ $t('admin.i18n.modify') }}
</button>
<button class="btn btn-danger" @click="remove(props.row)">
{{ $t('admin.i18n.delete') }}
</button>
</span>
<span v-else-if="props.column.field === 'text'">
<span v-if="props.row.text" v-text="props.formattedRow[props.column.field]" />
<i v-else>{{ $t('admin.i18n.empty') }}</i>
</span>
<span v-else v-text="props.formattedRow[props.column.field]" />
</template>
</vue-good-table>
</div>
</template>
<script>
import { VueGoodTable } from 'vue-good-table'
import 'vue-good-table/dist/vue-good-table.min.css'
import tableOptions from '../../components/mixins/tableOptions'
import emitMounted from '../../components/mixins/emitMounted'
import { showModal, toast } from '../../scripts/notify'
export default {
name: 'Translations',
components: {
VueGoodTable,
},
mixins: [
emitMounted,
tableOptions,
],
data() {
return {
lines: [],
columns: [
{
field: 'group',
label: this.$t('admin.i18n.group'),
width: '15%',
},
{ field: 'key', label: this.$t('admin.i18n.key') },
{ field: 'text', label: this.$t('admin.i18n.text') },
{
field: 'operations',
label: this.$t('admin.operationsTitle'),
sortable: false,
globalSearchDisabled: true,
width: '25%',
},
],
}
},
beforeMount() {
this.fetchData()
},
methods: {
async fetchData() {
this.lines = await this.$http.get('/admin/i18n/list')
},
async modify(line) {
let text = null
try {
({ value: text } = await showModal({
mode: 'prompt',
text: this.$t('admin.i18n.updating'),
input: line.text,
}))
} catch {
return
}
const { code, message } = await this.$http.put(
'/admin/i18n',
{ id: line.id, text },
)
if (code === 0) {
line.text = text
toast.success(message)
} else {
toast.error(message)
}
},
async remove({ id, originalIndex }) {
try {
await showModal({
text: this.$t('admin.i18n.confirmDelete'),
okButtonType: 'danger',
})
} catch {
return
}
const { message } = await this.$http.del('/admin/i18n', { id })
this.$delete(this.lines, originalIndex)
toast.success(message)
},
},
}
</script>

View File

@ -0,0 +1,11 @@
.group {
width: 15%;
}
.key {
width: 20%;
}
.operations {
width: 25%;
}

View File

@ -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> = (props) => {
const { line, onEdit, onRemove } = props
const text = line.text[blessing.locale]
const handleEditClick = () => onEdit(line)
const handleRemoveClick = () => onRemove(line)
return (
<tr>
<td className={styles.group}>{line.group}</td>
<td className={styles.key}>{line.key}</td>
<td>{text || t('admin.i18n.empty')}</td>
<td className={styles.operations}>
<button className="btn btn-default mr-2" onClick={handleEditClick}>
{t('admin.i18n.modify')}
</button>
<button className="btn btn-danger" onClick={handleRemoveClick}>
{t('admin.i18n.delete')}
</button>
</td>
</tr>
)
}
export default Row

View File

@ -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<Line[]>([])
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<Paginator<Line>>('/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<fetch.ResponseBody>(
'/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 (
<>
<div className="card-body p-0">
<table className="table table-striped">
<thead>
<tr>
<th>{t('admin.i18n.group')}</th>
<th>{t('admin.i18n.key')}</th>
<th>{t('admin.i18n.text')}</th>
<th>{t('admin.operationsTitle')}</th>
</tr>
</thead>
<tbody>
{lines.length === 0 ? (
<tr>
<td className="text-center" colSpan={4}>
{isLoading ? <Loading /> : 'Nothing here.'}
</td>
</tr>
) : (
lines.map((line, i) => (
<Row
line={line}
onEdit={(line) => handleEdit(line, i)}
onRemove={handleRemove}
/>
))
)}
</tbody>
</table>
</div>
<div className="card-footer d-flex flex-row-reverse">
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</div>
</>
)
}
export default hot(Translations)

View File

@ -0,0 +1,6 @@
export type Line = {
id: number
group: string
key: string
text: Record<string, string>
}

View File

@ -7,6 +7,7 @@ import yaml from 'js-yaml'
window.blessing = {
base_url: '',
locale: 'en',
site_name: 'Blessing Skin',
version: '4.0.0',
extra: {},

View File

@ -9,6 +9,7 @@ interface Window {
blessing: {
base_url: string
locale: string
site_name: string
version: string
i18n: object

View File

@ -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<Line> = Object.freeze<Line>({
id: 1,
group: 'general',
key: 'submit',
text: {
en: 'Submit',
},
})
function createPaginator(data: Line[]): Paginator<Line> {
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(<Translations />)
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(
<Translations />,
)
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(
<Translations />,
)
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(
<Translations />,
)
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(<Translations />)
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(<Translations />)
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()
})
})

View File

@ -4,7 +4,7 @@
{% block content %}
<div class="row">
<div class="col-lg-8"><div id="table"></div></div>
<div class="col-lg-8"><div class="card" id="table"></div></div>
<div class="col-lg-4">
<form action="{{ url('/admin/i18n') }}" method="post">
<div class="card card-primary">
@ -24,28 +24,26 @@
</div>
{% endif %}
{{ csrf_field() }}
<table class="table">
<tbody>
<tr>
<td>{{ trans('admin.i18n.group') }}</td>
<td>
<input type="text" class="form-control" name="group" required>
</td>
</tr>
<tr>
<td>{{ trans('admin.i18n.key') }}</td>
<td>
<input type="text" class="form-control" name="key" required>
</td>
</tr>
<tr>
<td>{{ trans('admin.i18n.text') }}</td>
<td>
<input type="text" class="form-control" name="text" required>
</td>
</tr>
</tbody>
</table>
<div class="container-fluid my-4 px-2">
<div class="row mb-3">
<div class="col-sm-3">{{ trans('admin.i18n.group') }}</div>
<div class="col-sm-9">
<input type="text" class="form-control" name="group" required>
</div>
</div>
<div class="row my-3">
<div class="col-sm-3">{{ trans('admin.i18n.key') }}</div>
<div class="col-sm-9">
<input type="text" class="form-control" name="key" required>
</div>
</div>
<div class="row mt-3">
<div class="col-sm-3">{{ trans('admin.i18n.text') }}</div>
<div class="col-sm-9">
<input type="text" class="form-control" name="text" required>
</div>
</div>
</div>
</div>
<div class="card-footer">
<input

View File

@ -18,20 +18,14 @@ class TranslationsControllerTest extends TestCase
public function testList()
{
LanguageLine::create([
$line = LanguageLine::create([
'group' => 'general',
'key' => 'submit',
'text' => ['en' => 'submit'],
]);
$this->getJson('/admin/i18n/list')
->assertJson([
[
'group' => 'general',
'key' => 'submit',
'text' => 'submit',
],
]);
->assertJson(['data' => [$line->toArray()]]);
}
public function testCreate()

View File

@ -5633,6 +5633,11 @@ immediate@~3.0.5:
resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b"
integrity sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=
immer@^6.0.2:
version "6.0.2"
resolved "https://registry.yarnpkg.com/immer/-/immer-6.0.2.tgz#5bc08dc4930c756d0749533a2afbd88c8de0cd19"
integrity sha512-56CMvUMZl4kkWJFFUe1TjBgGbyb9ibzpLyHD+RSKSVdytuDXgT/HXO1S+GJVywMVl5neGTdAogoR15eRVEd10Q==
import-cwd@^2.0.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/import-cwd/-/import-cwd-2.1.0.tgz#aa6cf36e722761285cb371ec6519f53e2435b0a9"
@ -10616,6 +10621,11 @@ url@^0.11.0:
punycode "1.3.2"
querystring "0.2.0"
use-immer@^0.3.5:
version "0.3.5"
resolved "https://registry.yarnpkg.com/use-immer/-/use-immer-0.3.5.tgz#e9aa5a4e82ef7eab4fced977d4daa1c3ef466725"
integrity sha512-0CNaJ/CtnF8/OmM4NBG7R0rhWMMQtgXPmWbakv8CK5z8dXeMaBYValFDXpkjBnToknjKJZuEPcoUZYd0rnQL2Q==
use@^3.1.0:
version "3.1.1"
resolved "https://registry.npmjs.org/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"