From 93f64b034f0dc0973bbd5ae9817dfa0f1e766bcc Mon Sep 17 00:00:00 2001 From: Pig Fang Date: Wed, 5 Feb 2020 10:37:14 +0800 Subject: [PATCH] build OAuth mgmt page with React --- .../assets/src/components/ButtonEdit.tsx | 14 ++ resources/assets/src/scripts/route.ts | 2 +- resources/assets/src/views/user/OAuth.vue | 207 --------------- .../src/views/user/OAuth/ModalCreate.tsx | 70 ++++++ resources/assets/src/views/user/OAuth/Row.tsx | 43 ++++ .../assets/src/views/user/OAuth/index.tsx | 155 ++++++++++++ .../assets/src/views/user/OAuth/types.ts | 6 + .../assets/tests/views/user/OAuth.test.ts | 140 ----------- .../assets/tests/views/user/OAuth.test.tsx | 238 ++++++++++++++++++ 9 files changed, 527 insertions(+), 348 deletions(-) create mode 100644 resources/assets/src/components/ButtonEdit.tsx delete mode 100644 resources/assets/src/views/user/OAuth.vue create mode 100644 resources/assets/src/views/user/OAuth/ModalCreate.tsx create mode 100644 resources/assets/src/views/user/OAuth/Row.tsx create mode 100644 resources/assets/src/views/user/OAuth/index.tsx create mode 100644 resources/assets/src/views/user/OAuth/types.ts delete mode 100644 resources/assets/tests/views/user/OAuth.test.ts create mode 100644 resources/assets/tests/views/user/OAuth.test.tsx diff --git a/resources/assets/src/components/ButtonEdit.tsx b/resources/assets/src/components/ButtonEdit.tsx new file mode 100644 index 00000000..5c470f94 --- /dev/null +++ b/resources/assets/src/components/ButtonEdit.tsx @@ -0,0 +1,14 @@ +import React from 'react' + +interface Props { + title?: string + onClick: React.MouseEventHandler +} + +const ButtonEdit: React.FC = props => ( + + + +) + +export default ButtonEdit diff --git a/resources/assets/src/scripts/route.ts b/resources/assets/src/scripts/route.ts index 193c107f..1022eaea 100644 --- a/resources/assets/src/scripts/route.ts +++ b/resources/assets/src/scripts/route.ts @@ -36,7 +36,7 @@ export default [ }, { path: 'user/oauth/manage', - component: () => import('../views/user/OAuth.vue'), + react: () => import('../views/user/OAuth'), el: '.content > .container-fluid', }, { diff --git a/resources/assets/src/views/user/OAuth.vue b/resources/assets/src/views/user/OAuth.vue deleted file mode 100644 index 76a2910f..00000000 --- a/resources/assets/src/views/user/OAuth.vue +++ /dev/null @@ -1,207 +0,0 @@ - - - - - diff --git a/resources/assets/src/views/user/OAuth/ModalCreate.tsx b/resources/assets/src/views/user/OAuth/ModalCreate.tsx new file mode 100644 index 00000000..1d420055 --- /dev/null +++ b/resources/assets/src/views/user/OAuth/ModalCreate.tsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react' +import Modal from '../../../components/Modal' +import { trans } from '../../../scripts/i18n' + +interface Props { + onCreate(name: string, redirect: string): Promise +} + +const ModalCreate: React.FC = props => { + const [name, setName] = useState('') + const [url, setUrl] = useState('') + + const handleNameChange = (event: React.ChangeEvent) => { + setName(event.target.value) + } + + const handleUrlChange = (event: React.ChangeEvent) => { + setUrl(event.target.value) + } + + const handleComplete = () => { + props.onCreate(name, url) + } + + const handleDismiss = () => { + setName('') + setUrl('') + } + + return ( + + + + + + + + + + + + +
{trans('user.oauth.name')} + +
{trans('user.oauth.redirect')} + +
+
+ ) +} + +export default ModalCreate diff --git a/resources/assets/src/views/user/OAuth/Row.tsx b/resources/assets/src/views/user/OAuth/Row.tsx new file mode 100644 index 00000000..6a955a86 --- /dev/null +++ b/resources/assets/src/views/user/OAuth/Row.tsx @@ -0,0 +1,43 @@ +import React from 'react' +import { trans } from '../../../scripts/i18n' +import ButtonEdit from '../../../components/ButtonEdit' +import { App } from './types' + +interface Props { + app: App + onEditName: React.MouseEventHandler + onEditRedirect: React.MouseEventHandler + onDelete: React.MouseEventHandler +} + +const Row: React.FC = props => { + const { app } = props + + return ( + + {app.id} + + {app.name} + + + {app.secret} + + {app.redirect} + + + + + + + ) +} + +export default Row diff --git a/resources/assets/src/views/user/OAuth/index.tsx b/resources/assets/src/views/user/OAuth/index.tsx new file mode 100644 index 00000000..f9ca2463 --- /dev/null +++ b/resources/assets/src/views/user/OAuth/index.tsx @@ -0,0 +1,155 @@ +import React, { useState, useEffect } from 'react' +import { hot } from 'react-hot-loader/root' +import { trans } from '../../../scripts/i18n' +import * as fetch from '../../../scripts/net' +import { showModal, toast } from '../../../scripts/notify' +import Loading from '../../../components/Loading' +import Row from './Row' +import ModalCreate from './ModalCreate' +import { App } from './types' + +type Exception = { + message: string +} + +const OAuth: React.FC = () => { + const [apps, setApps] = useState([]) + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + const getApps = async () => { + setIsLoading(true) + const allApps = await fetch.get('/oauth/clients') + setApps(allApps) + setIsLoading(false) + } + getApps() + }, []) + + const handleAdd = async (name: string, redirect: string) => { + const result = await fetch.post('/oauth/clients', { + name, + redirect, + }) + if ('id' in result) { + setApps(apps => [...apps, result]) + } else { + toast.error(result.message) + } + } + + const editName = async (app: App, index: number) => { + let name: string + try { + ;({ value: name } = await showModal({ + mode: 'prompt', + title: trans('user.oauth.name'), + input: app.name, + })) + } catch { + return + } + + const result = await fetch.put( + `/oauth/clients/${app.id}`, + { ...app, name }, + ) + if ('id' in result) { + setApps(apps => { + apps[index].name = name + return apps.slice() + }) + } else { + toast.error(result.message) + } + } + + const editRedirect = async (app: App, index: number) => { + let redirect: string + try { + ;({ value: redirect } = await showModal({ + mode: 'prompt', + title: trans('user.oauth.redirect'), + input: app.redirect, + })) + } catch { + return + } + + const result = await fetch.put( + `/oauth/clients/${app.id}`, + { ...app, redirect }, + ) + if ('id' in result) { + setApps(apps => { + apps[index].redirect = redirect + return apps.slice() + }) + } else { + toast.error(result.message) + } + } + + const handleDelete = async (app: App) => { + try { + await showModal({ + text: trans('user.oauth.confirmRemove'), + okButtonType: 'danger', + }) + } catch { + return + } + + await fetch.del(`/oauth/clients/${app.id}`) + setApps(apps => apps.filter(a => a.id !== app.id)) + } + + return ( + <> + +
+
+ + + + + + + + + + + + {apps.length === 0 ? ( + + + + ) : ( + apps.map((app, i) => ( + editName(app, i)} + onEditRedirect={() => editRedirect(app, i)} + onDelete={() => handleDelete(app)} + /> + )) + )} + +
{trans('user.oauth.id')}{trans('user.oauth.name')}{trans('user.oauth.secret')}{trans('user.oauth.redirect')}{trans('admin.operationsTitle')}
+ {isLoading ? : 'Nothing here.'} +
+
+
+ + + ) +} + +export default hot(OAuth) diff --git a/resources/assets/src/views/user/OAuth/types.ts b/resources/assets/src/views/user/OAuth/types.ts new file mode 100644 index 00000000..5940893e --- /dev/null +++ b/resources/assets/src/views/user/OAuth/types.ts @@ -0,0 +1,6 @@ +export type App = { + id: number + name: string + secret: string + redirect: string +} diff --git a/resources/assets/tests/views/user/OAuth.test.ts b/resources/assets/tests/views/user/OAuth.test.ts deleted file mode 100644 index 25abc91c..00000000 --- a/resources/assets/tests/views/user/OAuth.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -import Vue from 'vue' -import { mount } from '@vue/test-utils' -import { flushPromises } from '../../utils' -import { walkFetch } from '@/scripts/net' -import { showModal, toast } from '@/scripts/notify' -import Modal from '@/components/Modal.vue' -import OAuth from '@/views/user/OAuth.vue' - -jest.mock('@/scripts/notify') - -jest.mock('@/scripts/net', () => ({ - walkFetch: jest.fn(), - init: {}, -})) - -test('basic render', async () => { - Vue.prototype.$http.get.mockResolvedValue([ - { id: 1 }, - ]) - const wrapper = mount(OAuth) - await flushPromises() - expect(wrapper.findAll('[data-test=remove]')).toHaveLength(1) -}) - -test('create app', async () => { - Vue.prototype.$http.get.mockResolvedValue([]) - Vue.prototype.$http.post - .mockResolvedValueOnce({ message: 'fail' }) - .mockResolvedValueOnce({ id: 1, name: 'name' }) - const wrapper = mount(OAuth) - await flushPromises() - - const modal = wrapper.find(Modal) - const inputs = wrapper.findAll('.value') - inputs.at(0).find('input') - .setValue('name') - inputs.at(1).find('input') - .setValue('https://example.com/') - - modal.vm.$emit('confirm') - await flushPromises() - expect(Vue.prototype.$http.post).toBeCalledWith( - '/oauth/clients', - { name: 'name', redirect: 'https://example.com/' }, - ) - expect(toast.error).toBeCalledWith('fail') - - modal.vm.$emit('confirm') - await flushPromises() - expect(wrapper.text()).toContain('name') -}) - -test('modify name', async () => { - Vue.prototype.$http.get.mockResolvedValue([ - { id: 1, name: 'old' }, - ]) - walkFetch - .mockResolvedValueOnce({ message: 'fail' }) - .mockResolvedValueOnce({ id: 1, name: 'new-name' }) - showModal - .mockRejectedValueOnce(null) - .mockResolvedValue({ value: 'new-name' }) - const wrapper = mount(OAuth) - await flushPromises() - const button = wrapper.find('[data-test=name]') - - button.trigger('click') - await flushPromises() - expect(walkFetch).not.toBeCalled() - - button.trigger('click') - await flushPromises() - expect(walkFetch).toBeCalledWith( - expect.objectContaining({ - url: '/oauth/clients/1', - body: JSON.stringify({ name: 'new-name' }), - method: 'PUT', - }), - ) - expect(toast.error).toBeCalledWith('fail') - - button.trigger('click') - await flushPromises() - expect(wrapper.text()).toContain('new-name') -}) - -test('modify redirect', async () => { - Vue.prototype.$http.get.mockResolvedValue([ - { id: 1, redirect: 'https://example.com/' }, - ]) - walkFetch - .mockResolvedValueOnce({ message: 'fail' }) - .mockResolvedValueOnce({ id: 1, redirect: 'https://example.net/' }) - showModal - .mockRejectedValueOnce(null) - .mockResolvedValue({ value: 'https://example.net/' }) - const wrapper = mount(OAuth) - await flushPromises() - const button = wrapper.find('[data-test=callback]') - - button.trigger('click') - await flushPromises() - expect(walkFetch).not.toBeCalled() - - button.trigger('click') - await flushPromises() - expect(walkFetch).toBeCalledWith( - expect.objectContaining({ - url: '/oauth/clients/1', - body: JSON.stringify({ redirect: 'https://example.net/' }), - method: 'PUT', - }), - ) - expect(toast.error).toBeCalledWith('fail') - - button.trigger('click') - await flushPromises() - expect(wrapper.text()).toContain('https://example.net/') -}) - -test('remove app', async () => { - Vue.prototype.$http.get.mockResolvedValue([ - { id: 1, name: 'name' }, - ]) - showModal - .mockRejectedValueOnce(null) - .mockResolvedValue({ value: '' }) - - const wrapper = mount(OAuth) - await flushPromises() - const button = wrapper.find('[data-test=remove]') - - button.trigger('click') - await flushPromises() - expect(walkFetch).not.toBeCalled() - - button.trigger('click') - await flushPromises() - expect(wrapper.text()).toContain('No data') -}) diff --git a/resources/assets/tests/views/user/OAuth.test.tsx b/resources/assets/tests/views/user/OAuth.test.tsx new file mode 100644 index 00000000..9154ddd5 --- /dev/null +++ b/resources/assets/tests/views/user/OAuth.test.tsx @@ -0,0 +1,238 @@ +import React from 'react' +import { render, fireEvent, wait } from '@testing-library/react' +import * as fetch from '@/scripts/net' +import { trans } from '@/scripts/i18n' +import { toast, showModal } from '@/scripts/notify' +import OAuth from '@/views/user/OAuth' +import { App } from '@/views/user/OAuth/types' + +jest.mock('@/scripts/net') +jest.mock('@/scripts/notify') + +const example: App = { + id: 1, + name: 'My App', + redirect: 'http://url.test/', + secret: 'abc', +} + +test('loading data', () => { + fetch.get.mockResolvedValue([]) + const { queryByTitle } = render() + expect(queryByTitle('Loading...')).toBeInTheDocument() +}) + +describe('create app', () => { + beforeEach(() => { + fetch.get.mockResolvedValue([]) + }) + + it('succeeded', async () => { + fetch.post.mockResolvedValue(example) + const { getByPlaceholderText, getByText, queryByText } = render() + await wait() + + fireEvent.click(getByText(trans('user.oauth.create'))) + fireEvent.input(getByPlaceholderText(trans('user.oauth.name')), { + target: { value: 'My App' }, + }) + fireEvent.input(getByPlaceholderText(trans('user.oauth.redirect')), { + target: { value: 'http://url.test/' }, + }) + fireEvent.click(getByText(trans('general.confirm'))) + await wait() + + expect(fetch.post).toBeCalledWith('/oauth/clients', { + name: 'My App', + redirect: 'http://url.test/', + }) + expect(queryByText(example.id.toString())).toBeInTheDocument() + expect(queryByText(example.name)).toBeInTheDocument() + expect(queryByText(example.redirect)).toBeInTheDocument() + expect(queryByText(example.secret)).toBeInTheDocument() + }) + + it('failed', async () => { + fetch.post.mockResolvedValue({ message: 'exception' }) + const { getByPlaceholderText, getByText, queryByText } = render() + await wait() + + fireEvent.click(getByText(trans('user.oauth.create'))) + fireEvent.input(getByPlaceholderText(trans('user.oauth.name')), { + target: { value: 'My App' }, + }) + fireEvent.input(getByPlaceholderText(trans('user.oauth.redirect')), { + target: { value: 'http://url.test/' }, + }) + fireEvent.click(getByText(trans('general.confirm'))) + + await wait() + expect(fetch.post).toBeCalledWith('/oauth/clients', { + name: 'My App', + redirect: 'http://url.test/', + }) + expect(toast.error).toBeCalledWith('exception') + expect(queryByText(example.name)).not.toBeInTheDocument() + expect(queryByText(example.redirect)).not.toBeInTheDocument() + }) + + it('cancel dialog', async () => { + const { getByPlaceholderText, getByText } = render() + await wait() + + fireEvent.click(getByText(trans('user.oauth.create'))) + fireEvent.input(getByPlaceholderText(trans('user.oauth.name')), { + target: { value: 'My App' }, + }) + fireEvent.input(getByPlaceholderText(trans('user.oauth.redirect')), { + target: { value: 'http://url.test/' }, + }) + fireEvent.click(getByText(trans('general.cancel'))) + + await wait() + expect(fetch.post).not.toBeCalled() + + fireEvent.click(getByText(trans('user.oauth.create'))) + expect(getByPlaceholderText(trans('user.oauth.name'))).toHaveValue('') + expect(getByPlaceholderText(trans('user.oauth.redirect'))).toHaveValue('') + }) +}) + +describe('edit app', () => { + beforeEach(() => { + fetch.get.mockResolvedValue([example]) + }) + + describe('edit name', () => { + it('succeeded', async () => { + fetch.put.mockResolvedValue({ ...example, name: 'new name' }) + showModal.mockResolvedValue({ value: 'new name' }) + + const { getByTitle, queryByText } = render() + await wait() + + fireEvent.click(getByTitle(trans('user.oauth.modifyName'))) + await wait() + + expect(fetch.put).toBeCalledWith(`/oauth/clients/${example.id}`, { + ...example, + name: 'new name', + }) + expect(queryByText('new name')).toBeInTheDocument() + }) + + it('failed', async () => { + fetch.put.mockResolvedValue({ message: 'exception' }) + showModal.mockResolvedValue({ value: 'new name' }) + + const { getByTitle, queryByText } = render() + await wait() + + fireEvent.click(getByTitle(trans('user.oauth.modifyName'))) + await wait() + + expect(fetch.put).toBeCalledWith(`/oauth/clients/${example.id}`, { + ...example, + name: 'new name', + }) + expect(queryByText(example.name)).toBeInTheDocument() + }) + + it('cancel dialog', async () => { + showModal.mockRejectedValue(null) + + const { getByTitle, queryByText } = render() + await wait() + + fireEvent.click(getByTitle(trans('user.oauth.modifyName'))) + await wait() + + expect(fetch.put).not.toBeCalled() + expect(queryByText(example.name)).toBeInTheDocument() + }) + }) + + describe('edit redirect url', () => { + it('succeeded', async () => { + fetch.put.mockResolvedValue({ ...example, redirect: 'http://new.test/' }) + showModal.mockResolvedValue({ value: 'http://new.test/' }) + + const { getByTitle, queryByText } = render() + await wait() + + fireEvent.click(getByTitle(trans('user.oauth.modifyUrl'))) + await wait() + + expect(fetch.put).toBeCalledWith(`/oauth/clients/${example.id}`, { + ...example, + redirect: 'http://new.test/', + }) + expect(queryByText('http://new.test/')).toBeInTheDocument() + }) + + it('failed', async () => { + fetch.put.mockResolvedValue({ message: 'exception' }) + showModal.mockResolvedValue({ value: 'http://new.test/' }) + + const { getByTitle, queryByText } = render() + await wait() + + fireEvent.click(getByTitle(trans('user.oauth.modifyUrl'))) + await wait() + + expect(fetch.put).toBeCalledWith(`/oauth/clients/${example.id}`, { + ...example, + redirect: 'http://new.test/', + }) + expect(toast.error).toBeCalledWith('exception') + expect(queryByText(example.redirect)).toBeInTheDocument() + }) + + it('cancel dialog', async () => { + showModal.mockRejectedValue(null) + + const { getByTitle, queryByText } = render() + await wait() + + fireEvent.click(getByTitle(trans('user.oauth.modifyUrl'))) + await wait() + + expect(fetch.put).not.toBeCalled() + expect(queryByText(example.redirect)).toBeInTheDocument() + }) + }) +}) + +describe('delete app', () => { + beforeEach(() => { + fetch.get.mockResolvedValue([example]) + }) + + it('succeeded', async () => { + showModal.mockResolvedValue({ value: '' }) + + const { getByText, queryByText } = render() + await wait() + + fireEvent.click(getByText(trans('report.delete'))) + await wait() + + expect(fetch.del).toBeCalledWith(`/oauth/clients/${example.id}`) + expect(queryByText(example.name)).not.toBeInTheDocument() + expect(queryByText(example.redirect)).not.toBeInTheDocument() + }) + + it('cancel dialog', async () => { + showModal.mockRejectedValue(null) + + const { getByText, queryByText } = render() + await wait() + + fireEvent.click(getByText(trans('report.delete'))) + await wait() + + expect(fetch.post).not.toBeCalled() + expect(queryByText(example.name)).toBeInTheDocument() + expect(queryByText(example.redirect)).toBeInTheDocument() + }) +})