Loading capif_frontend/src/configs/apiService.js +18 −3 Changes for capif_frontend/src/configs/apiService.js: 18 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -2,13 +2,28 @@ import axiosInstance from './axiosInstance' import axiosMockAdapter from 'axios-mock-adapter' import axios from 'axios' // INVOKERS // GET all invokers json export const getInvokers = async () => { return axiosInstance.get(`/invokers/invokersTotal`) } export const getMyProviders = async () => { const response = await axiosInstance.get('/me/providers') return response.data } export const onboardProvider = async ({ apiProvDomInfo }) => { const response = await axiosInstance.post('/me/provider', { apiProvDomInfo }) return response.data } export const publishApi = async (providerId, api) => { const response = await axiosInstance.post(`/me/providers/${encodeURIComponent(providerId)}/apis`, api) return response.data } // GET invokers with pagination and date order export const getInvokersPagesOrder = async () => { try { Loading capif_frontend/src/navigation/vertical/index.js +8 −1 Changes for capif_frontend/src/navigation/vertical/index.js: 8 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -9,11 +9,18 @@ const navigation = () => { }, { title: 'Invokers', icon: 'mdi:baseline-people', icon: 'mdi:account-group-outline', path: '/me/invokers', action: 'read', subject: 'user-area' }, { title: 'Providers', icon: 'mdi:server', path: '/me/providers', action: 'read', subject: 'user-area' }, { title: 'Dashboard', icon: 'mdi:home-outline', Loading capif_frontend/src/pages/me/providers/[id].js 0 → 100644 +191 −0 Changes for capif_frontend/src/pages/me/providers/[id].js: 191 added lines, 0 removed lines. Original line number Diff line number Diff line import { useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/router' import Box from '@mui/material/Box' import Button from '@mui/material/Button' import Card from '@mui/material/Card' import CardHeader from '@mui/material/CardHeader' import CardContent from '@mui/material/CardContent' import CircularProgress from '@mui/material/CircularProgress' import Alert from '@mui/material/Alert' import TextField from '@mui/material/TextField' import MenuItem from '@mui/material/MenuItem' import { useMutation, useQueryClient } from '@tanstack/react-query' import { publishApi } from 'src/configs/apiService' import { errorMessage } from 'src/configs/queryHelpers' const OPERATIONS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] const REQUIRED_TEXT = ['apiName', 'apiVersion', 'resourceName', 'uri', 'address'] const EMPTY_API = { apiName: '', apiVersion: 'v1', description: '', resourceName: '', resourceDescription: '', uri: '', operations: [], address: '', port: '' } // Mirrors the backend's parse_api_request, so invalid input never leaves the browser. const findInvalid = form => { const invalid = {} REQUIRED_TEXT.forEach(field => { if (!form[field].trim()) invalid[field] = true }) if (form.operations.length === 0) invalid.operations = true const port = Number(form.port) if (!/^\d+$/.test(form.port.trim()) || port < 1 || port > 65535) invalid.port = true return invalid } // A 400 from the backend lists the invalid fields; show them rather than a generic message. const publishErrorMessage = error => { const fields = error?.response?.data?.fields if (Array.isArray(fields) && fields.length > 0) return `Missing or invalid fields: ${fields.join(', ')}.` return errorMessage(error, 'Could not publish the API.') } const PublishApiForm = ({ providerId }) => { const queryClient = useQueryClient() const [form, setForm] = useState(EMPTY_API) const [submitted, setSubmitted] = useState(false) const [publishedId, setPublishedId] = useState(null) const mutation = useMutation({ mutationFn: api => publishApi(providerId, api), onSuccess: data => { // Invokers' discover results may now include this API. queryClient.invalidateQueries({ queryKey: ['invoker-apis'] }) setPublishedId(data?.apiId ?? null) setForm(EMPTY_API) setSubmitted(false) } }) const invalid = findInvalid(form) const showError = name => submitted && Boolean(invalid[name]) const handleChange = event => { const { name, value } = event.target setForm({ ...form, [name]: value }) } const handleSubmit = () => { setSubmitted(true) setPublishedId(null) if (Object.keys(invalid).length > 0) return mutation.mutate({ apiName: form.apiName.trim(), apiVersion: form.apiVersion.trim(), description: form.description.trim(), resourceName: form.resourceName.trim(), resourceDescription: form.resourceDescription.trim(), uri: form.uri.trim(), operations: form.operations, address: form.address.trim(), port: Number(form.port) }) } const textField = (name, label, props = {}) => ( <TextField margin='dense' fullWidth name={name} label={label} value={form[name]} onChange={handleChange} error={showError(name)} helperText={showError(name) ? 'Required' : ' '} {...props} /> ) return ( <> {publishedId && ( <Alert severity='success' sx={{ mb: 2 }}> API published: {publishedId} </Alert> )} {mutation.isError && ( <Alert severity='error' sx={{ mb: 2 }}> {publishErrorMessage(mutation.error)} </Alert> )} {textField('apiName', 'API name', { required: true, placeholder: 'hello_api' })} {textField('apiVersion', 'API version', { required: true })} {textField('description', 'Description')} {textField('resourceName', 'Resource name', { required: true, placeholder: 'hello-endpoint' })} {textField('resourceDescription', 'Resource description', { helperText: 'Defaults to the API description' })} {textField('uri', 'Resource URI', { required: true, placeholder: '/hello' })} <TextField select margin='dense' fullWidth required name='operations' label='Operations' value={form.operations} onChange={handleChange} SelectProps={{ multiple: true }} error={showError('operations')} helperText={showError('operations') ? 'Select at least one operation' : ' '} > {OPERATIONS.map(op => ( <MenuItem key={op} value={op}> {op} </MenuItem> ))} </TextField> {textField('address', 'Address', { required: true, placeholder: 'localhost' })} {textField('port', 'Port', { required: true, placeholder: '8088', helperText: showError('port') ? 'Enter a port between 1 and 65535' : ' ' })} <Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}> <Button variant='contained' onClick={handleSubmit} disabled={mutation.isPending}> {mutation.isPending ? <CircularProgress size={24} /> : 'Publish'} </Button> </Box> </> ) } const ProviderDetailPage = () => { const router = useRouter() const providerId = typeof router.query.id === 'string' ? router.query.id : undefined return ( <Box sx={{ p: 6 }}> <Button component={Link} href='/me/providers' sx={{ mb: 4 }}> Back to providers </Button> <Card sx={{ mb: 6 }}> <CardHeader title={providerId ?? ''} subheader='Provider' /> </Card> <Card> <CardHeader title='Publish an API' /> <CardContent> {/* router.query is empty on the first render of a dynamic route */} {providerId ? <PublishApiForm key={providerId} providerId={providerId} /> : <CircularProgress />} </CardContent> </Card> </Box> ) } ProviderDetailPage.acl = { action: 'read', subject: 'user-area' } export default ProviderDetailPage No newline at end of file capif_frontend/src/pages/me/providers/index.js 0 → 100644 +156 −0 Changes for capif_frontend/src/pages/me/providers/index.js: 156 added lines, 0 removed lines. Original line number Diff line number Diff line import { useState } from 'react' import Box from '@mui/material/Box' import Card from '@mui/material/Card' import CardHeader from '@mui/material/CardHeader' import CardContent from '@mui/material/CardContent' import CircularProgress from '@mui/material/CircularProgress' import Alert from '@mui/material/Alert' import List from '@mui/material/List' import ListItem from '@mui/material/ListItem' import ListItemText from '@mui/material/ListItemText' import Typography from '@mui/material/Typography' import Button from '@mui/material/Button' import Dialog from '@mui/material/Dialog' import DialogTitle from '@mui/material/DialogTitle' import DialogContent from '@mui/material/DialogContent' import DialogActions from '@mui/material/DialogActions' import TextField from '@mui/material/TextField' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { getMyProviders, onboardProvider } from 'src/configs/apiService' import { errorMessage, retryServerErrorsOnly } from 'src/configs/queryHelpers' import Link from 'next/link' import ListItemButton from '@mui/material/ListItemButton' const MY_PROVIDERS_KEY = ['my-providers'] const ProviderList = () => { const { data, isPending, isError, error } = useQuery({ queryKey: MY_PROVIDERS_KEY, queryFn: getMyProviders, retry: retryServerErrorsOnly }) // Derived during render: `data` is undefined until the first response arrives. const providers = data?.providers ?? [] if (isPending) { return ( <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}> <CircularProgress /> </Box> ) } if (isError) { return <Alert severity='error'>{errorMessage(error, 'Could not load your providers.')}</Alert> } if (providers.length === 0) { return <Typography>{"You don't have a provider yet."}</Typography> } return ( <List> {providers.map(id => ( <ListItem key={id} disablePadding> <ListItemButton component={Link} href={`/me/providers/${encodeURIComponent(id)}`}> <ListItemText primary={id} /> </ListItemButton> </ListItem> ))} </List> ) } const NewProviderDialog = ({ open, onClose }) => { const queryClient = useQueryClient() const [apiProvDomInfo, setApiProvDomInfo] = useState('') const [submitted, setSubmitted] = useState(false) const mutation = useMutation({ mutationFn: onboardProvider, onSuccess: () => { queryClient.invalidateQueries({ queryKey: MY_PROVIDERS_KEY }) resetAndClose() } }) const resetAndClose = () => { setApiProvDomInfo('') setSubmitted(false) mutation.reset() onClose() } const missing = !apiProvDomInfo.trim() const handleSubmit = () => { setSubmitted(true) if (missing) return mutation.mutate({ apiProvDomInfo: apiProvDomInfo.trim() }) } return ( <Dialog open={open} onClose={mutation.isPending ? undefined : resetAndClose} fullWidth maxWidth='sm'> <DialogTitle>New provider</DialogTitle> <DialogContent> {mutation.isError && ( <Alert severity='error' sx={{ mb: 2 }}> {errorMessage(mutation.error, 'Could not onboard the provider.')} </Alert> )} <TextField margin='dense' label='Provider domain information' fullWidth required value={apiProvDomInfo} onChange={event => setApiProvDomInfo(event.target.value)} error={submitted && missing} helperText={submitted && missing ? 'Required' : ' '} /> </DialogContent> <DialogActions> <Button onClick={resetAndClose} disabled={mutation.isPending}> Cancel </Button> <Button onClick={handleSubmit} disabled={mutation.isPending}> {mutation.isPending ? <CircularProgress size={24} /> : 'Create'} </Button> </DialogActions> </Dialog> ) } const MyProvidersPage = () => { const [dialogOpen, setDialogOpen] = useState(false) return ( <Box sx={{ p: 6 }}> <Card> <CardHeader title='Your providers' action={ <Button variant='contained' onClick={() => setDialogOpen(true)}> New provider </Button> } /> <CardContent> <ProviderList /> </CardContent> </Card> <NewProviderDialog open={dialogOpen} onClose={() => setDialogOpen(false)} /> </Box> ) } MyProvidersPage.acl = { action: 'read', subject: 'user-area' } export default MyProvidersPage No newline at end of file Loading
capif_frontend/src/configs/apiService.js +18 −3 Changes for capif_frontend/src/configs/apiService.js: 18 added lines, 3 removed lines. Original line number Diff line number Diff line Loading @@ -2,13 +2,28 @@ import axiosInstance from './axiosInstance' import axiosMockAdapter from 'axios-mock-adapter' import axios from 'axios' // INVOKERS // GET all invokers json export const getInvokers = async () => { return axiosInstance.get(`/invokers/invokersTotal`) } export const getMyProviders = async () => { const response = await axiosInstance.get('/me/providers') return response.data } export const onboardProvider = async ({ apiProvDomInfo }) => { const response = await axiosInstance.post('/me/provider', { apiProvDomInfo }) return response.data } export const publishApi = async (providerId, api) => { const response = await axiosInstance.post(`/me/providers/${encodeURIComponent(providerId)}/apis`, api) return response.data } // GET invokers with pagination and date order export const getInvokersPagesOrder = async () => { try { Loading
capif_frontend/src/navigation/vertical/index.js +8 −1 Changes for capif_frontend/src/navigation/vertical/index.js: 8 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -9,11 +9,18 @@ const navigation = () => { }, { title: 'Invokers', icon: 'mdi:baseline-people', icon: 'mdi:account-group-outline', path: '/me/invokers', action: 'read', subject: 'user-area' }, { title: 'Providers', icon: 'mdi:server', path: '/me/providers', action: 'read', subject: 'user-area' }, { title: 'Dashboard', icon: 'mdi:home-outline', Loading
capif_frontend/src/pages/me/providers/[id].js 0 → 100644 +191 −0 Changes for capif_frontend/src/pages/me/providers/[id].js: 191 added lines, 0 removed lines. Original line number Diff line number Diff line import { useState } from 'react' import Link from 'next/link' import { useRouter } from 'next/router' import Box from '@mui/material/Box' import Button from '@mui/material/Button' import Card from '@mui/material/Card' import CardHeader from '@mui/material/CardHeader' import CardContent from '@mui/material/CardContent' import CircularProgress from '@mui/material/CircularProgress' import Alert from '@mui/material/Alert' import TextField from '@mui/material/TextField' import MenuItem from '@mui/material/MenuItem' import { useMutation, useQueryClient } from '@tanstack/react-query' import { publishApi } from 'src/configs/apiService' import { errorMessage } from 'src/configs/queryHelpers' const OPERATIONS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] const REQUIRED_TEXT = ['apiName', 'apiVersion', 'resourceName', 'uri', 'address'] const EMPTY_API = { apiName: '', apiVersion: 'v1', description: '', resourceName: '', resourceDescription: '', uri: '', operations: [], address: '', port: '' } // Mirrors the backend's parse_api_request, so invalid input never leaves the browser. const findInvalid = form => { const invalid = {} REQUIRED_TEXT.forEach(field => { if (!form[field].trim()) invalid[field] = true }) if (form.operations.length === 0) invalid.operations = true const port = Number(form.port) if (!/^\d+$/.test(form.port.trim()) || port < 1 || port > 65535) invalid.port = true return invalid } // A 400 from the backend lists the invalid fields; show them rather than a generic message. const publishErrorMessage = error => { const fields = error?.response?.data?.fields if (Array.isArray(fields) && fields.length > 0) return `Missing or invalid fields: ${fields.join(', ')}.` return errorMessage(error, 'Could not publish the API.') } const PublishApiForm = ({ providerId }) => { const queryClient = useQueryClient() const [form, setForm] = useState(EMPTY_API) const [submitted, setSubmitted] = useState(false) const [publishedId, setPublishedId] = useState(null) const mutation = useMutation({ mutationFn: api => publishApi(providerId, api), onSuccess: data => { // Invokers' discover results may now include this API. queryClient.invalidateQueries({ queryKey: ['invoker-apis'] }) setPublishedId(data?.apiId ?? null) setForm(EMPTY_API) setSubmitted(false) } }) const invalid = findInvalid(form) const showError = name => submitted && Boolean(invalid[name]) const handleChange = event => { const { name, value } = event.target setForm({ ...form, [name]: value }) } const handleSubmit = () => { setSubmitted(true) setPublishedId(null) if (Object.keys(invalid).length > 0) return mutation.mutate({ apiName: form.apiName.trim(), apiVersion: form.apiVersion.trim(), description: form.description.trim(), resourceName: form.resourceName.trim(), resourceDescription: form.resourceDescription.trim(), uri: form.uri.trim(), operations: form.operations, address: form.address.trim(), port: Number(form.port) }) } const textField = (name, label, props = {}) => ( <TextField margin='dense' fullWidth name={name} label={label} value={form[name]} onChange={handleChange} error={showError(name)} helperText={showError(name) ? 'Required' : ' '} {...props} /> ) return ( <> {publishedId && ( <Alert severity='success' sx={{ mb: 2 }}> API published: {publishedId} </Alert> )} {mutation.isError && ( <Alert severity='error' sx={{ mb: 2 }}> {publishErrorMessage(mutation.error)} </Alert> )} {textField('apiName', 'API name', { required: true, placeholder: 'hello_api' })} {textField('apiVersion', 'API version', { required: true })} {textField('description', 'Description')} {textField('resourceName', 'Resource name', { required: true, placeholder: 'hello-endpoint' })} {textField('resourceDescription', 'Resource description', { helperText: 'Defaults to the API description' })} {textField('uri', 'Resource URI', { required: true, placeholder: '/hello' })} <TextField select margin='dense' fullWidth required name='operations' label='Operations' value={form.operations} onChange={handleChange} SelectProps={{ multiple: true }} error={showError('operations')} helperText={showError('operations') ? 'Select at least one operation' : ' '} > {OPERATIONS.map(op => ( <MenuItem key={op} value={op}> {op} </MenuItem> ))} </TextField> {textField('address', 'Address', { required: true, placeholder: 'localhost' })} {textField('port', 'Port', { required: true, placeholder: '8088', helperText: showError('port') ? 'Enter a port between 1 and 65535' : ' ' })} <Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}> <Button variant='contained' onClick={handleSubmit} disabled={mutation.isPending}> {mutation.isPending ? <CircularProgress size={24} /> : 'Publish'} </Button> </Box> </> ) } const ProviderDetailPage = () => { const router = useRouter() const providerId = typeof router.query.id === 'string' ? router.query.id : undefined return ( <Box sx={{ p: 6 }}> <Button component={Link} href='/me/providers' sx={{ mb: 4 }}> Back to providers </Button> <Card sx={{ mb: 6 }}> <CardHeader title={providerId ?? ''} subheader='Provider' /> </Card> <Card> <CardHeader title='Publish an API' /> <CardContent> {/* router.query is empty on the first render of a dynamic route */} {providerId ? <PublishApiForm key={providerId} providerId={providerId} /> : <CircularProgress />} </CardContent> </Card> </Box> ) } ProviderDetailPage.acl = { action: 'read', subject: 'user-area' } export default ProviderDetailPage No newline at end of file
capif_frontend/src/pages/me/providers/index.js 0 → 100644 +156 −0 Changes for capif_frontend/src/pages/me/providers/index.js: 156 added lines, 0 removed lines. Original line number Diff line number Diff line import { useState } from 'react' import Box from '@mui/material/Box' import Card from '@mui/material/Card' import CardHeader from '@mui/material/CardHeader' import CardContent from '@mui/material/CardContent' import CircularProgress from '@mui/material/CircularProgress' import Alert from '@mui/material/Alert' import List from '@mui/material/List' import ListItem from '@mui/material/ListItem' import ListItemText from '@mui/material/ListItemText' import Typography from '@mui/material/Typography' import Button from '@mui/material/Button' import Dialog from '@mui/material/Dialog' import DialogTitle from '@mui/material/DialogTitle' import DialogContent from '@mui/material/DialogContent' import DialogActions from '@mui/material/DialogActions' import TextField from '@mui/material/TextField' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { getMyProviders, onboardProvider } from 'src/configs/apiService' import { errorMessage, retryServerErrorsOnly } from 'src/configs/queryHelpers' import Link from 'next/link' import ListItemButton from '@mui/material/ListItemButton' const MY_PROVIDERS_KEY = ['my-providers'] const ProviderList = () => { const { data, isPending, isError, error } = useQuery({ queryKey: MY_PROVIDERS_KEY, queryFn: getMyProviders, retry: retryServerErrorsOnly }) // Derived during render: `data` is undefined until the first response arrives. const providers = data?.providers ?? [] if (isPending) { return ( <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}> <CircularProgress /> </Box> ) } if (isError) { return <Alert severity='error'>{errorMessage(error, 'Could not load your providers.')}</Alert> } if (providers.length === 0) { return <Typography>{"You don't have a provider yet."}</Typography> } return ( <List> {providers.map(id => ( <ListItem key={id} disablePadding> <ListItemButton component={Link} href={`/me/providers/${encodeURIComponent(id)}`}> <ListItemText primary={id} /> </ListItemButton> </ListItem> ))} </List> ) } const NewProviderDialog = ({ open, onClose }) => { const queryClient = useQueryClient() const [apiProvDomInfo, setApiProvDomInfo] = useState('') const [submitted, setSubmitted] = useState(false) const mutation = useMutation({ mutationFn: onboardProvider, onSuccess: () => { queryClient.invalidateQueries({ queryKey: MY_PROVIDERS_KEY }) resetAndClose() } }) const resetAndClose = () => { setApiProvDomInfo('') setSubmitted(false) mutation.reset() onClose() } const missing = !apiProvDomInfo.trim() const handleSubmit = () => { setSubmitted(true) if (missing) return mutation.mutate({ apiProvDomInfo: apiProvDomInfo.trim() }) } return ( <Dialog open={open} onClose={mutation.isPending ? undefined : resetAndClose} fullWidth maxWidth='sm'> <DialogTitle>New provider</DialogTitle> <DialogContent> {mutation.isError && ( <Alert severity='error' sx={{ mb: 2 }}> {errorMessage(mutation.error, 'Could not onboard the provider.')} </Alert> )} <TextField margin='dense' label='Provider domain information' fullWidth required value={apiProvDomInfo} onChange={event => setApiProvDomInfo(event.target.value)} error={submitted && missing} helperText={submitted && missing ? 'Required' : ' '} /> </DialogContent> <DialogActions> <Button onClick={resetAndClose} disabled={mutation.isPending}> Cancel </Button> <Button onClick={handleSubmit} disabled={mutation.isPending}> {mutation.isPending ? <CircularProgress size={24} /> : 'Create'} </Button> </DialogActions> </Dialog> ) } const MyProvidersPage = () => { const [dialogOpen, setDialogOpen] = useState(false) return ( <Box sx={{ p: 6 }}> <Card> <CardHeader title='Your providers' action={ <Button variant='contained' onClick={() => setDialogOpen(true)}> New provider </Button> } /> <CardContent> <ProviderList /> </CardContent> </Card> <NewProviderDialog open={dialogOpen} onClose={() => setDialogOpen(false)} /> </Box> ) } MyProvidersPage.acl = { action: 'read', subject: 'user-area' } export default MyProvidersPage No newline at end of file