add frontend views (#11267)

This commit is contained in:
markkaylor 2021-10-14 10:48:29 -04:00 committed by Mark Kaylor
parent 6e5be7ac49
commit 39b76f140a
20 changed files with 1274 additions and 101 deletions

View File

@ -0,0 +1,14 @@
import styled from 'styled-components';
import { FieldAction } from '@strapi/parts/Field';
const FieldActionWrapper = styled(FieldAction)`
svg {
height: 1rem;
width: 1rem;
path {
fill: ${({ theme }) => theme.colors.neutral600};
}
}
`;
export default FieldActionWrapper;

View File

@ -25,7 +25,9 @@ export default {
},
permissions: pluginPermissions.main,
Component: async () => {
const component = await import(/* webpackChunkName: "documentation-page" */ './pages/App');
const component = await import(
/* webpackChunkName: "documentation-page" */ './pages/PluginPage'
);
return component;
},
@ -41,7 +43,24 @@ export default {
pluginLogo,
});
},
bootstrap() {},
bootstrap(app) {
app.addSettingsLink('global', {
intlLabel: {
id: `${pluginId}.plugin.name`,
defaultMessage: 'Documentation',
},
id: 'documentation',
to: `/settings/${pluginId}`,
Component: async () => {
const component = await import(
/* webpackChunkName: "documentation-settings" */ './pages/SettingsPage'
);
return component;
},
permissions: pluginPermissions.main,
});
},
async registerTrads({ locales }) {
const importedTrads = await Promise.all(
locales.map(locale => {

View File

@ -1,52 +0,0 @@
/**
*
* This component is the skeleton around the actual pages, and should only
* contain code that should be seen on all pages. (e.g. navigation bar)
*
*/
import React from 'react';
import { useIntl } from 'react-intl';
import { CheckPagePermissions, NoContent } from '@strapi/helper-plugin';
import { Layout, HeaderLayout, ContentLayout } from '@strapi/parts/Layout';
import { Main } from '@strapi/parts/Main';
import pluginPermissions from '../../permissions';
import { getTrad } from '../../utils';
// import HomePage from '../HomePage';
const ComingSoon = () => {
const { formatMessage } = useIntl();
return (
<Layout>
<Main>
<HeaderLayout
title={formatMessage({
id: getTrad('plugin.name'),
defaultMessage: 'Documentation',
})}
/>
<ContentLayout>
<NoContent
content={{
id: getTrad('coming.soon'),
defaultMessage:
'This content is currently under construction and will be back in a few weeks!',
}}
/>
</ContentLayout>
</Main>
</Layout>
);
};
function App() {
return (
<CheckPagePermissions permissions={pluginPermissions.main}>
{/* <HomePage /> */}
<ComingSoon />
</CheckPagePermissions>
);
}
export default App;

View File

@ -90,8 +90,6 @@ const HomePage = () => {
setVersionToDelete(null);
};
console.log(data);
if (isLoading) {
return <LoadingIndicatorPage />;
}

View File

@ -0,0 +1,193 @@
/**
*
* This component is the skeleton around the actual pages, and should only
* contain code that should be seen on all pages. (e.g. navigation bar)
*
*/
import React, { useState } from 'react';
import { useIntl } from 'react-intl';
import {
CheckPermissions,
ConfirmDialog,
LoadingIndicatorPage,
stopPropagation,
EmptyStateLayout,
} from '@strapi/helper-plugin';
import { Button } from '@strapi/parts/Button';
import { Layout, HeaderLayout, ContentLayout } from '@strapi/parts/Layout';
import { Main } from '@strapi/parts/Main';
import { IconButton } from '@strapi/parts/IconButton';
import { Text, TableLabel } from '@strapi/parts/Text';
import { Row } from '@strapi/parts/Row';
import { Table, Tr, Thead, Th, Tbody, Td } from '@strapi/parts/Table';
import DeleteIcon from '@strapi/icons/DeleteIcon';
import Show from '@strapi/icons/Show';
import Reload from '@strapi/icons/Reload';
import permissions from '../../permissions';
import { getTrad } from '../../utils';
import openWithNewTab from '../../utils/openWithNewTab';
import useReactQuery from '../utils/useReactQuery';
const PluginPage = () => {
const { formatMessage } = useIntl();
const { data, isLoading, deleteMutation, regenerateDocMutation } = useReactQuery();
const [showConfirmDelete, setShowConfirmDelete] = useState(false);
const [isConfirmButtonLoading, setIsConfirmButtonLoading] = useState(false);
const [versionToDelete, setVersionToDelete] = useState();
const colCount = 4;
const rowCount = (data?.docVersions?.length || 0) + 1;
const openDocVersion = () => {
const slash = data?.prefix.startsWith('/') ? '' : '/';
openWithNewTab(`${slash}${data?.prefix}/v${data?.currentVersion}`);
};
const handleRegenerateDoc = version => {
regenerateDocMutation.mutate({ version, prefix: data?.prefix });
};
const handleShowConfirmDelete = () => {
setShowConfirmDelete(!showConfirmDelete);
};
const handleConfirmDelete = async () => {
setIsConfirmButtonLoading(true);
await deleteMutation.mutateAsync({ prefix: data?.prefix, version: versionToDelete });
setShowConfirmDelete(!showConfirmDelete);
setIsConfirmButtonLoading(false);
};
const handleClickDelete = version => {
setVersionToDelete(version);
setShowConfirmDelete(!showConfirmDelete);
};
return (
<Layout>
<Main>
<HeaderLayout
title={formatMessage({
id: getTrad('plugin.name'),
defaultMessage: 'Documentation',
})}
subtitle={formatMessage({
id: getTrad('pages.PluginPage.header.description'),
defaultMessage: 'Configure the documentation plugin',
})}
primaryAction={
// eslint-disable-next-line
<CheckPermissions permissions={permissions.open}>
<Button onClick={openDocVersion} startIcon={<Show />}>
{formatMessage({
id: getTrad('pages.PluginPage.Button.open'),
defaultMessage: 'Open Documentation',
})}
</Button>
</CheckPermissions>
}
/>
<ContentLayout>
{isLoading && <LoadingIndicatorPage>Plugin is loading</LoadingIndicatorPage>}
{data?.docVersions.length ? (
<Table colCount={colCount} rowCount={rowCount}>
<Thead>
<Tr>
<Th>
<TableLabel textColor="neutral600">
{formatMessage({
id: getTrad('pages.PluginPage.table.version'),
defaultMessage: 'Version',
})}
</TableLabel>
</Th>
<Th>
<TableLabel textColor="neutral600">
{formatMessage({
id: getTrad('pages.PluginPage.table.generated'),
defaultMessage: 'Last Generated',
})}
</TableLabel>
</Th>
</Tr>
</Thead>
<Tbody>
{data.docVersions
.sort((a, b) => (a.generatedDate < b.generatedDate ? 1 : -1))
.map(doc => (
<Tr key={doc.version}>
<Td width="50%">
<Text>{doc.version}</Text>
</Td>
<Td width="50%">
<Text>{doc.generatedDate}</Text>
</Td>
<Td>
<Row justifyContent="end" {...stopPropagation}>
<IconButton
onClick={openDocVersion}
noBorder
icon={<Show />}
label={formatMessage(
{
id: getTrad('pages.PluginPage.table.icon.show'),
defaultMessage: 'Open {target}',
},
{ target: `${doc.version}` }
)}
/>
<CheckPermissions permissions={permissions.regenerate}>
<IconButton
onClick={() => handleRegenerateDoc(doc.version)}
noBorder
icon={<Reload />}
label={formatMessage(
{
id: getTrad('pages.PluginPage.table.icon.regenerate'),
defaultMessage: 'Regnerate {target}',
},
{ target: `${doc.version}` }
)}
/>
</CheckPermissions>
<CheckPermissions permissions={permissions.update}>
{doc.version !== data.currentVersion && (
<IconButton
onClick={() => handleClickDelete(doc.version)}
noBorder
icon={<DeleteIcon />}
label={formatMessage(
{
id: getTrad('pages.PluginPage.table.icon.delete'),
defaultMessage: 'Delete {target}',
},
{ target: `${doc.version}` }
)}
/>
)}
</CheckPermissions>
</Row>
</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<EmptyStateLayout />
)}
</ContentLayout>
<ConfirmDialog
isConfirmButtonLoading={isConfirmButtonLoading}
onConfirm={handleConfirmDelete}
onToggleDialog={handleShowConfirmDelete}
isOpen={showConfirmDelete}
/>
</Main>
</Layout>
);
};
export default PluginPage;

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,23 @@
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const handlers = [
rest.get('*/getInfos', (req, res, ctx) => {
return res(
ctx.delay(1000),
ctx.status(200),
ctx.json({
currentVersion: '1.0.0',
docVersions: [
{ version: '1.0.0', generatedDoc: '10/05/2021 2:52:44 PM' },
{ version: '1.2.0', generatedDoc: '11/05/2021 3:00:00 PM' },
],
prefix: '/documentation',
})
);
}),
];
const server = setupServer(...handlers);
export default server;

View File

@ -0,0 +1,169 @@
import React, { useState } from 'react';
import { useIntl } from 'react-intl';
import { Formik } from 'formik';
import { CheckPermissions, Form, LoadingIndicatorPage } from '@strapi/helper-plugin';
// Strapi Parts
import { ContentLayout, HeaderLayout } from '@strapi/parts/Layout';
import { Main } from '@strapi/parts/Main';
import { Button } from '@strapi/parts/Button';
import { Box } from '@strapi/parts/Box';
import { Stack } from '@strapi/parts/Stack';
import { H3 } from '@strapi/parts/Text';
import { ToggleInput } from '@strapi/parts/ToggleInput';
import { TextInput } from '@strapi/parts/TextInput';
import { Grid, GridItem } from '@strapi/parts/Grid';
// Strapi Icons
import Show from '@strapi/icons/Show';
import Hide from '@strapi/icons/Hide';
import Check from '@strapi/icons/Check';
import permissions from '../../permissions';
import { getTrad } from '../../utils';
import useReactQuery from '../utils/useReactQuery';
import FieldActionWrapper from '../../components/FieldActionWrapper';
import schema from '../utils/schema';
const SettingsPage = () => {
const { formatMessage } = useIntl();
const { submitMutation, data, isLoading } = useReactQuery();
const [passwordShown, setPasswordShown] = useState(false);
const handleUpdateSettingsSubmit = body => {
submitMutation.mutate({
prefix: data?.prefix,
body,
});
};
return (
<Main>
{isLoading ? (
<LoadingIndicatorPage>Plugin settings are loading</LoadingIndicatorPage>
) : (
<Formik
initialValues={{
restrictedAccess: data?.documentationAccess.restrictedAccess || false,
password: data?.documentationAccess.password,
}}
onSubmit={handleUpdateSettingsSubmit}
validationSchema={schema}
>
{({ handleSubmit, values, handleChange, errors }) => {
return (
<Form noValidate onSubmit={handleSubmit}>
<HeaderLayout
title={formatMessage({
id: getTrad('plugin.name'),
defaultMessage: 'Documentation',
})}
subtitle={formatMessage({
id: getTrad('pages.SettingsPage.header.description'),
defaultMessage: 'Configure the documentation plugin',
})}
primaryAction={
// eslint-disable-next-line
<CheckPermissions permissions={permissions.update}>
<Button type="submit" startIcon={<Check />}>
{formatMessage({
id: getTrad('pages.SettingsPage.Button.save'),
defaultMessage: 'Save',
})}
</Button>
</CheckPermissions>
}
/>
<ContentLayout>
<Box
background="neutral0"
hasRadius
shadow="filterShadow"
paddingTop={6}
paddingBottom={6}
paddingLeft={7}
paddingRight={7}
>
<Stack size={4}>
<H3 as="h2">
{formatMessage({
id: getTrad('pages.SettingsPage.title'),
defaultMessage: 'Settings',
})}
</H3>
<Grid gap={4}>
<GridItem col={6} s={12}>
<ToggleInput
name="restrictedAccess"
label={formatMessage({
id: getTrad('pages.SettingsPage.toggle.label'),
defaultMessage: 'Restricted Access',
})}
hint={formatMessage({
id: getTrad('pages.SettingsPage.toggle.hint'),
defaultMessage: 'Make the documentation endpoint private',
})}
checked={values.restrictedAccess}
onChange={handleChange}
onLabel="On"
offLabel="Off"
/>
</GridItem>
{values.restrictedAccess && (
<GridItem col={6} s={12}>
<TextInput
label={formatMessage({
id: getTrad('pages.SettingsPage.password.label'),
defaultMessage: 'Password',
})}
name="password"
type={passwordShown ? 'text' : 'password'}
value={values.password}
onChange={handleChange}
error={
errors.password
? formatMessage({
id: errors.password,
defaultMessage: 'Invalid value',
})
: null
}
endAction={
// eslint-disable-next-line
<FieldActionWrapper
onClick={e => {
e.stopPropagation();
setPasswordShown(prev => !prev);
}}
label={formatMessage(
passwordShown
? {
id: 'Auth.form.password.show-password',
defaultMessage: 'Show password',
}
: {
id: 'Auth.form.password.hide-password',
defaultMessage: 'Hide password',
}
)}
>
{passwordShown ? <Show /> : <Hide />}
</FieldActionWrapper>
}
/>
</GridItem>
)}
</Grid>
</Stack>
</Box>
</ContentLayout>
</Form>
);
}}
</Formik>
)}
</Main>
);
};
export default SettingsPage;

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,18 @@
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const handlers = [
rest.get('*/getInfos', (req, res, ctx) => {
return res(
ctx.delay(1000),
ctx.status(200),
ctx.json({
documentationAccess: { restrictedAccess: false, password: '' },
})
);
}),
];
const server = setupServer(...handlers);
export default server;

View File

@ -0,0 +1,31 @@
import { request } from '@strapi/helper-plugin';
import pluginId from '../../pluginId';
const deleteDoc = ({ prefix, version }) => {
return request(`${prefix}/deleteDoc/${version}`, { method: 'DELETE' });
};
const fetchDocumentationVersions = async toggleNotification => {
try {
const data = await request(`/${pluginId}/getInfos`, { method: 'GET' });
return data;
} catch (err) {
toggleNotification({
type: 'warning',
message: { id: 'notification.error' },
});
// FIXME
return null;
}
};
const regenerateDoc = ({ prefix, version }) => {
return request(`${prefix}/regenerateDoc`, { method: 'POST', body: { version } });
};
const updateSettings = ({ prefix, body }) =>
request(`${prefix}/updateSettings`, { method: 'PUT', body });
export { deleteDoc, fetchDocumentationVersions, regenerateDoc, updateSettings };

View File

@ -0,0 +1,11 @@
import { translatedErrors } from '@strapi/helper-plugin';
import * as yup from 'yup';
const schema = yup.object().shape({
restrictedAccess: yup.boolean(),
password: yup.string().when('restrictedAccess', (value, initSchema) => {
return value ? initSchema.required(translatedErrors.required) : initSchema;
}),
});
export default schema;

View File

@ -0,0 +1,46 @@
import { useQuery, useMutation, useQueryClient } from 'react-query';
import { useNotification } from '@strapi/helper-plugin';
import { fetchDocumentationVersions, deleteDoc, regenerateDoc, updateSettings } from './api';
import getTrad from '../../utils/getTrad';
const useReactQuery = () => {
const queryClient = useQueryClient();
const toggleNotification = useNotification();
const { isLoading, data } = useQuery('get-documentation', () =>
fetchDocumentationVersions(toggleNotification)
);
const handleError = err => {
toggleNotification({
type: 'warning',
message: err.response.payload.message,
});
};
const handleSuccess = (type, tradId) => {
queryClient.invalidateQueries('get-documentation');
toggleNotification({
type,
message: { id: getTrad(tradId) },
});
};
const deleteMutation = useMutation(deleteDoc, {
onSuccess: () => handleSuccess('info', 'notification.delete.success'),
onError: error => handleError(error),
});
const submitMutation = useMutation(updateSettings, {
onSuccess: () => handleSuccess('success', 'notification.update.success'),
onError: handleError,
});
const regenerateDocMutation = useMutation(regenerateDoc, {
onSuccess: () => handleSuccess('info', 'notification.generate.success'),
onError: error => handleError(error),
});
return { data, isLoading, deleteMutation, submitMutation, regenerateDocMutation };
};
export default useReactQuery;

View File

@ -1,12 +1,9 @@
{
"coming-soon": "This content is currently under construction and will be back in a few weeks!",
"components.Row.generatedDate": "Last generation",
"components.Row.open": "Open",
"components.Row.regenerate": "Regenerate",
"containers.HomePage.Block.title": "Versions",
"containers.HomePage.Button.open": "Open the documentation",
"containers.HomePage.Button.update": "Update",
"containers.HomePage.PluginHeader.description": "Configure the documentation plugin",
"containers.HomePage.PluginHeader.title": "Documentation - Settings",
"containers.HomePage.PopUpWarning.confirm": "I understand",
"containers.HomePage.PopUpWarning.message": "Are you sure you want to delete this version?",
@ -26,7 +23,20 @@
"notification.delete.success": "Doc deleted",
"notification.generate.success": "Doc generated",
"notification.update.success": "Settings updated successfully",
"pages.PluginPage.Button.open": "Open documentation",
"pages.PluginPage.header.description": "Configure the documentation plugin",
"pages.PluginPage.table.generated": "Last generated",
"pages.PluginPage.table.icon.delete": "Delete {target}",
"pages.PluginPage.table.icon.regnerate": "Regenerate {target}",
"pages.PluginPage.table.icon.show": "Open {target}",
"pages.PluginPage.table.version": "Version",
"pages.SettingPage.title": "Settings",
"pages.SettingsPage.Button.description": "Configure the documentation plugin",
"pages.SettingsPage.header.save": "Save",
"pages.SettingsPage.password.label": "Password",
"pages.SettingsPage.toggle.label": "Restricted Access",
"pages.SettingsPage.toggle.hint": "Make the documentation endpoint private",
"plugin.description.long": "Create an OpenAPI Document and visualize your API with SWAGGER UI.",
"plugin.description.short": "Create an OpenAPI Document and visualize your API with SWAGGER UI.",
"plugin.name": "Documentation"
}
}

View File

@ -20,6 +20,8 @@
"lodash": "4.17.21",
"moment": "^2.29.1",
"path-to-regexp": "6.2.0",
"pluralize": "8.0.0",
"koa-session": "6.2.0",
"react": "^17.0.2",
"react-copy-to-clipboard": "^5.0.3",
"react-dom": "^17.0.2",

View File

@ -1,8 +1,9 @@
'use strict';
const defaultConfig = require('./default-config');
const sessionConfig = require('./session-config');
module.exports = {
default: defaultConfig,
default: { ...defaultConfig, ...sessionConfig },
validator() {},
};

View File

@ -0,0 +1,19 @@
'use strict';
module.exports = {
session: {
client: 'cookie',
key: 'strapi.sid',
prefix: 'strapi:sess:',
ttl: 864000000,
rolling: false,
secretKeys: ['mySecretKey1', 'mySecretKey2'],
cookie: {
path: '/',
httpOnly: true,
maxAge: 864000000,
rewrite: true,
signed: false,
},
},
};

View File

@ -42,7 +42,11 @@ module.exports = {
const version =
major && minor && patch
? `${major}.${minor}.${patch}`
: strapi.plugins.documentation.config.info.version;
: strapi
.plugin('documentation')
.service('documentation')
.getDocumentationVersion();
const openAPISpecsPath = path.join(
strapi.config.appPath,
'src',

View File

@ -1,39 +1,15 @@
'use strict';
const path = require('path');
const _ = require('lodash');
const koaStatic = require('koa-static');
const initialRoutes = [];
const session = require('koa-session');
const swaggerUi = require('swagger-ui-dist');
// TODO: delete when refactoring documentation plugin for v4
module.exports = async ({ strapi }) => {
// strapi.config.middleware.load.before.push('documentation');
initialRoutes.push(..._.cloneDeep(strapi.plugins.documentation.routes));
const swaggerUi = require('swagger-ui-dist');
// Find the plugins routes.
strapi.plugins.documentation.routes = strapi.plugins.documentation.routes.map((route, index) => {
if (route.handler === 'Documentation.getInfos') {
return route;
}
if (route.handler === 'Documentation.index' || route.path === '/login') {
route.config.policies = initialRoutes[index].config.policies;
}
// Set prefix to empty to be able to customise it.
if (strapi.config.has('plugins.documentation.x-strapi-config.path')) {
route.config.prefix = '';
route.path = `/${strapi.config.get('plugin.documentation.x-strapi-config').path}${
route.path
}`.replace('//', '/');
}
return route;
});
const sessionConfig = strapi.config.get('plugin.documentation').session;
strapi.server.app.keys = sessionConfig.secretKeys;
strapi.server.app.use(session(sessionConfig, strapi.server.app));
strapi.server.routes([
{
@ -43,7 +19,7 @@ module.exports = async ({ strapi }) => {
ctx.url = path.basename(ctx.url);
return koaStatic(swaggerUi.getAbsoluteFSPath(), {
maxage: 6000,
maxage: sessionConfig.cookie.maxAge,
defer: true,
})(ctx, next);
},

View File

@ -1,4 +1,5 @@
'use strict';
const restrictAccess = require('../middlewares/restrict-access');
module.exports = [
{
@ -7,10 +8,15 @@ module.exports = [
handler: 'documentation.index',
config: {
auth: false,
// middlewares: [restrictAccess],
// policies: [
// { name: 'admin::hasPermissions', options: { actions: ['plugin::documentation.read'] } },
// ],
middlewares: [restrictAccess],
policies: [
{
name: 'admin::hasPermissions',
config: {
actions: ['plugin::documentation.read'],
},
},
],
},
},
{
@ -19,10 +25,15 @@ module.exports = [
handler: 'documentation.index',
config: {
auth: false,
// middlewares: [restrictAccess],
// policies: [
// { name: 'admin::hasPermissions', options: { actions: ['plugin::documentation.read'] } },
// ],
middlewares: [restrictAccess],
policies: [
{
name: 'admin::hasPermissions',
config: {
actions: ['plugin::documentation.read'],
},
},
],
},
},
{
@ -30,8 +41,14 @@ module.exports = [
path: '/login',
handler: 'documentation.loginView',
config: {
auth: false,
policies: [
{ name: 'admin::hasPermissions', config: { actions: ['plugin::documentation.read'] } },
{
name: 'admin::hasPermissions',
config: {
actions: ['plugin::documentation.read'],
},
},
],
},
},
@ -40,6 +57,7 @@ module.exports = [
path: '/login',
handler: 'documentation.login',
config: {
auth: false,
policies: [
{ name: 'admin::hasPermissions', config: { actions: ['plugin::documentation.read'] } },
],