Commit d921522b authored by Alex Kakiris's avatar Alex Kakiris
Browse files

Merge branch 'feat/provider-ui' into 'dev'

Manage providers and publish APIs from the UI

See merge request !5
parents ce7e3a70 97bb9afe
Loading
Loading
Loading
Loading
+18 −3
Changes for capif_frontend/src/configs/apiService.js: 18 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -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 {
+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
@@ -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',
+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
+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