Commit 97bb9afe authored by Alex Kakiris's avatar Alex Kakiris
Browse files

add provider detail page with publish form

parent e84bb2ad
Loading
Loading
Loading
Loading
+6 −0
Changes for capif_frontend/src/configs/apiService.js: 6 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -18,6 +18,12 @@ export const onboardProvider = async ({ 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 {
+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
+6 −2
Changes for capif_frontend/src/pages/me/providers/index.js: 6 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -20,6 +20,9 @@ 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 = () => {
@@ -48,12 +51,13 @@ const ProviderList = () => {
    return <Typography>{"You don't have a provider yet."}</Typography>
  }

  // Plain items for now: the detail page (/me/providers/[id]) comes with the publish form.
  return (
    <List>
      {providers.map(id => (
        <ListItem key={id} disableGutters>
        <ListItem key={id} disablePadding>
          <ListItemButton component={Link} href={`/me/providers/${encodeURIComponent(id)}`}>
            <ListItemText primary={id} />
          </ListItemButton>
        </ListItem>
      ))}
    </List>