rewrite "Translations" page with React
This commit is contained in:
parent
0efcde3172
commit
9f560c67bd
@ -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)
|
||||
|
@ -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",
|
||||
|
@ -72,7 +72,7 @@ export default [
|
||||
},
|
||||
{
|
||||
path: 'admin/i18n',
|
||||
component: () => import('../views/admin/Translations.vue'),
|
||||
react: () => import('../views/admin/Translations'),
|
||||
el: '#table',
|
||||
},
|
||||
{
|
||||
|
@ -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>
|
@ -0,0 +1,11 @@
|
||||
.group {
|
||||
width: 15%;
|
||||
}
|
||||
|
||||
.key {
|
||||
width: 20%;
|
||||
}
|
||||
|
||||
.operations {
|
||||
width: 25%;
|
||||
}
|
37
resources/assets/src/views/admin/Translations/Row.tsx
Normal file
37
resources/assets/src/views/admin/Translations/Row.tsx
Normal 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
|
113
resources/assets/src/views/admin/Translations/index.tsx
Normal file
113
resources/assets/src/views/admin/Translations/index.tsx
Normal 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)
|
6
resources/assets/src/views/admin/Translations/types.ts
Normal file
6
resources/assets/src/views/admin/Translations/types.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export type Line = {
|
||||
id: number
|
||||
group: string
|
||||
key: string
|
||||
text: Record<string, string>
|
||||
}
|
@ -7,6 +7,7 @@ import yaml from 'js-yaml'
|
||||
|
||||
window.blessing = {
|
||||
base_url: '',
|
||||
locale: 'en',
|
||||
site_name: 'Blessing Skin',
|
||||
version: '4.0.0',
|
||||
extra: {},
|
||||
|
1
resources/assets/tests/types.d.ts
vendored
1
resources/assets/tests/types.d.ts
vendored
@ -9,6 +9,7 @@ interface Window {
|
||||
|
||||
blessing: {
|
||||
base_url: string
|
||||
locale: string
|
||||
site_name: string
|
||||
version: string
|
||||
i18n: object
|
||||
|
141
resources/assets/tests/views/admin/Translations.test.tsx
Normal file
141
resources/assets/tests/views/admin/Translations.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
@ -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
|
||||
|
@ -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()
|
||||
|
10
yarn.lock
10
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user