Api tweaks (#5690)

* Improvements to API handling on react UI

- Do not force "/api/" prefix to the base URL of the server
- We will need to fetch media files from the server (at /media/)
- Extend API URL helper functions

* Update some more hard-coded URLs

* Fix search API endpoint

* Fix div for panel tab

* Fix debug msg
This commit is contained in:
Oliver 2023-10-12 23:25:17 +11:00 committed by GitHub
parent 0c519c6b98
commit 814322e512
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 114 additions and 70 deletions

View File

@ -26,7 +26,7 @@ export function InstanceOptions({
]);
const hostListData = Object.keys(hostList).map((key) => ({
value: key,
label: hostList[key].name
label: hostList[key]?.name
}));
function SaveOptions(newHostList: HostList): void {
@ -93,7 +93,7 @@ function ServerInfo({
return (
<Text>
{hostList[hostKey].host}
{hostList[hostKey]?.host}
<br />
<Trans>Version: {server.version}</Trans>
<br />

View File

@ -23,6 +23,7 @@ import { Html5QrcodeResult } from 'html5-qrcode/core';
import { useEffect, useState } from 'react';
import { api } from '../../App';
import { ApiPaths, apiUrl } from '../../states/ApiState';
export function QrCodeModal({
context,
@ -63,7 +64,9 @@ export function QrCodeModal({
qrCodeScanner?.pause();
handlers.append(decodedText);
api.post('/barcode/', { barcode: decodedText }).then((response) => {
api
.post(apiUrl(ApiPaths.barcode), { barcode: decodedText })
.then((response) => {
showNotification({
title: response.data?.success || t`Unknown response`,
message: JSON.stringify(response.data),

View File

@ -17,7 +17,10 @@ export function BreadcrumbList({ breadcrumbs }: { breadcrumbs: Breadcrumb[] }) {
<Breadcrumbs>
{breadcrumbs.map((breadcrumb, index) => {
return (
<Anchor onClick={() => breadcrumb.url && navigate(breadcrumb.url)}>
<Anchor
key={`breadcrumb-${index}`}
onClick={() => breadcrumb.url && navigate(breadcrumb.url)}
>
<Text size="sm">{breadcrumb.name}</Text>
</Anchor>
);

View File

@ -8,6 +8,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { api } from '../../App';
import { navTabs as mainNavTabs } from '../../defaults/links';
import { InvenTreeStyle } from '../../globalStyle';
import { ApiPaths, apiUrl } from '../../states/ApiState';
import { ScanButton } from '../items/ScanButton';
import { MainMenu } from './MainMenu';
import { NavHoverMenu } from './NavHoverMenu';
@ -36,7 +37,7 @@ export function Header() {
queryKey: ['notification-count'],
queryFn: async () => {
return api
.get('/notifications/', {
.get(apiUrl(ApiPaths.notifications_list), {
params: {
read: false,
limit: 1

View File

@ -34,7 +34,7 @@ export function NavHoverMenu({
useEffect(() => {
if (hostKey && hostList[hostKey]) {
setInstanceName(hostList[hostKey].name);
setInstanceName(hostList[hostKey]?.name);
}
}, [hostKey]);

View File

@ -15,6 +15,7 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../App';
import { ApiPaths, apiUrl } from '../../states/ApiState';
/**
* Construct a notification drawer.
@ -33,7 +34,7 @@ export function NotificationDrawer({
queryKey: ['notifications', opened],
queryFn: async () =>
api
.get('/notifications/', {
.get(apiUrl(ApiPaths.notifications_list), {
params: {
read: false,
limit: 10

View File

@ -68,6 +68,7 @@ export function PanelGroup({
(panel, idx) =>
!panel.hidden && (
<Tabs.Tab
key={`panel-tab-${panel.name}`}
p="xs"
value={panel.name}
icon={panel.icon}

View File

@ -30,6 +30,7 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../App';
import { ApiPaths, apiUrl } from '../../states/ApiState';
import { RenderInstance } from '../render/Instance';
import { ModelInformationDict, ModelType } from '../render/ModelType';
@ -264,7 +265,7 @@ export function SearchDrawer({
});
return api
.post(`/search/`, params)
.post(apiUrl(ApiPaths.api_search), params)
.then(function (response) {
return response.data;
})

View File

@ -2,7 +2,7 @@ import { Anchor, Loader } from '@mantine/core';
import { useQuery } from '@tanstack/react-query';
import { api } from '../../App';
import { ApiPaths, url } from '../../states/ApiState';
import { ApiPaths, apiUrl } from '../../states/ApiState';
import { ThumbnailHoverCard } from '../items/Thumbnail';
export function GeneralRenderer({
@ -33,7 +33,7 @@ export function GeneralRenderer({
queryKey: [ref, pk],
queryFn: () => {
return api
.get(url(api_key, pk))
.get(apiUrl(api_key, pk))
.then((res) => res.data)
.catch(() => {
{

View File

@ -4,7 +4,7 @@ import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, url } from '../../../states/ApiState';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { TableColumn } from '../Column';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
@ -142,7 +142,7 @@ export function BuildOrderTable({ params = {} }: { params?: any }) {
return (
<InvenTreeTable
url={url(ApiPaths.build_order_list)}
url={apiUrl(ApiPaths.build_order_list)}
tableKey={tableKey}
columns={tableColumns}
props={{

View File

@ -1,7 +1,7 @@
import { t } from '@lingui/macro';
import { useMemo } from 'react';
import { ApiPaths, url } from '../../../states/ApiState';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction } from '../RowActions';
@ -40,7 +40,7 @@ export function NotificationTable({
return (
<InvenTreeTable
url={url(ApiPaths.notifications_list)}
url={apiUrl(ApiPaths.notifications_list)}
tableKey={tableKey}
columns={columns}
props={{

View File

@ -3,7 +3,7 @@ import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, url } from '../../../states/ApiState';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
@ -46,7 +46,7 @@ export function PartCategoryTable({ params = {} }: { params?: any }) {
return (
<InvenTreeTable
url={url(ApiPaths.category_list)}
url={apiUrl(ApiPaths.category_list)}
tableKey={tableKey}
columns={tableColumns}
props={{

View File

@ -7,7 +7,7 @@ import { editPart } from '../../../functions/forms/PartForms';
import { notYetImplemented } from '../../../functions/notifications';
import { shortenString } from '../../../functions/tables';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, url } from '../../../states/ApiState';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { TableColumn } from '../Column';
import { TableFilter } from '../Filter';
import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable';
@ -221,7 +221,7 @@ export function PartListTable({ props }: { props: InvenTreeTableProps }) {
return (
<InvenTreeTable
url={url(ApiPaths.part_list)}
url={apiUrl(ApiPaths.part_list)}
tableKey={tableKey}
columns={tableColumns}
props={{

View File

@ -6,7 +6,7 @@ import { useNavigate } from 'react-router-dom';
import { openCreateApiForm, openDeleteApiForm } from '../../../functions/forms';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, url } from '../../../states/ApiState';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { Thumbnail } from '../../items/Thumbnail';
import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
@ -116,7 +116,7 @@ export function RelatedPartTable({ partId }: { partId: number }): ReactNode {
return (
<InvenTreeTable
url={url(ApiPaths.related_part_list)}
url={apiUrl(ApiPaths.related_part_list)}
tableKey={tableKey}
columns={tableColumns}
props={{

View File

@ -5,8 +5,7 @@ import { useNavigate } from 'react-router-dom';
import { notYetImplemented } from '../../../functions/notifications';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, url } from '../../../states/ApiState';
import { ThumbnailHoverCard } from '../../items/Thumbnail';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { TableColumn } from '../Column';
import { TableFilter } from '../Filter';
import { RowAction } from '../RowActions';
@ -126,7 +125,7 @@ export function StockItemTable({ params = {} }: { params?: any }) {
return (
<InvenTreeTable
url={url(ApiPaths.stock_item_list)}
url={apiUrl(ApiPaths.stock_item_list)}
tableKey={tableKey}
columns={tableColumns}
props={{

View File

@ -3,7 +3,7 @@ import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTableRefresh } from '../../../hooks/TableRefresh';
import { ApiPaths, url } from '../../../states/ApiState';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { TableColumn } from '../Column';
import { InvenTreeTable } from '../InvenTreeTable';
@ -67,7 +67,7 @@ export function StockLocationTable({ params = {} }: { params?: any }) {
return (
<InvenTreeTable
url={url(ApiPaths.stock_location_list)}
url={apiUrl(ApiPaths.stock_location_list)}
tableKey={tableKey}
columns={tableColumns}
props={{

View File

@ -1,10 +1,10 @@
import { t } from '@lingui/macro';
import { notifications } from '@mantine/notifications';
import { notifications, showNotification } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import axios from 'axios';
import { api } from '../App';
import { ApiPaths, url, useServerApiState } from '../states/ApiState';
import { ApiPaths, apiUrl, useServerApiState } from '../states/ApiState';
import { useLocalState } from '../states/LocalState';
import { useSessionState } from '../states/SessionState';
import {
@ -18,9 +18,17 @@ export const doClassicLogin = async (username: string, password: string) => {
// Get token from server
const token = await axios
.get(`${host}${url(ApiPaths.user_token)}`, { auth: { username, password } })
.get(apiUrl(ApiPaths.user_token), {
auth: { username, password },
baseURL: host.toString()
})
.then((response) => response.data.token)
.catch((error) => {
showNotification({
title: t`Login failed`,
message: t`Error fetching token from server.`,
color: 'red'
});
return false;
});
@ -50,7 +58,7 @@ export const doClassicLogout = async () => {
export const doSimpleLogin = async (email: string) => {
const { host } = useLocalState.getState();
const mail = await axios
.post(`${host}${url(ApiPaths.user_simple_login)}`, {
.post(apiUrl(ApiPaths.user_simple_login), {
email: email
})
.then((response) => response.data)
@ -77,7 +85,7 @@ export const doTokenLogin = (token: string) => {
export function handleReset(navigate: any, values: { email: string }) {
api
.post(url(ApiPaths.user_reset), values, {
.post(apiUrl(ApiPaths.user_reset), values, {
headers: { Authorization: '' }
})
.then((val) => {
@ -101,7 +109,7 @@ export function handleReset(navigate: any, values: { email: string }) {
export function checkLoginState(navigate: any) {
api
.get(url(ApiPaths.user_token))
.get(apiUrl(ApiPaths.user_token))
.then((val) => {
if (val.status === 200 && val.data.token) {
doTokenLogin(val.data.token);

View File

@ -6,7 +6,7 @@ import { AxiosResponse } from 'axios';
import { api } from '../App';
import { ApiForm, ApiFormProps } from '../components/forms/ApiForm';
import { ApiFormFieldType } from '../components/forms/fields/ApiFormField';
import { url } from '../states/ApiState';
import { apiUrl } from '../states/ApiState';
import { invalidResponse, permissionDenied } from './notifications';
import { generateUniqueId } from './uid';
@ -14,7 +14,7 @@ import { generateUniqueId } from './uid';
* Construct an API url from the provided ApiFormProps object
*/
export function constructFormUrl(props: ApiFormProps): string {
return url(props.url, props.pk);
return apiUrl(props.url, props.pk);
}
/**

View File

@ -2,7 +2,7 @@ import { useQuery } from '@tanstack/react-query';
import { useCallback, useState } from 'react';
import { api } from '../App';
import { ApiPaths, url } from '../states/ApiState';
import { ApiPaths, apiUrl } from '../states/ApiState';
/**
* Custom hook for loading a single instance of an instance from the API
@ -32,8 +32,10 @@ export function useInstance({
return null;
}
let url = apiUrl(endpoint, pk);
return api
.get(url(endpoint, pk), {
.get(url, {
params: params
})
.then((response) => {
@ -48,7 +50,7 @@ export function useInstance({
})
.catch((error) => {
setInstance({});
console.error(`Error fetching instance ${url}${pk}:`, error);
console.error(`Error fetching instance ${url}:`, error);
return null;
});
},

View File

@ -28,13 +28,13 @@ export const IS_DEV_OR_DEMO = IS_DEV || IS_DEMO;
window.INVENTREE_SETTINGS = {
server_list: {
'mantine-cqj63coxn': {
host: `${window.location.origin}/api/`,
host: `${window.location.origin}/`,
name: 'Current Server'
},
...(IS_DEV_OR_DEMO
? {
'mantine-u56l5jt85': {
host: 'https://demo.inventree.org/api/',
host: 'https://demo.inventree.org/',
name: 'InvenTree Demo'
}
}

View File

@ -22,12 +22,12 @@ export default function Login() {
state.fetchServerApiState
]);
const hostname =
hostList[hostKey] === undefined ? t`No selection` : hostList[hostKey].name;
hostList[hostKey] === undefined ? t`No selection` : hostList[hostKey]?.name;
const [hostEdit, setHostEdit] = useToggle([false, true] as const);
// Data manipulation functions
function ChangeHost(newHost: string): void {
setHost(hostList[newHost].host, newHost);
setHost(hostList[newHost]?.host, newHost);
setApiDefaults();
fetchServerApiState();
}

View File

@ -14,7 +14,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../../App';
import { LanguageContext } from '../../contexts/LanguageContext';
import { ApiPaths, url } from '../../states/ApiState';
import { ApiPaths, apiUrl } from '../../states/ApiState';
export default function Set_Password() {
const simpleForm = useForm({ initialValues: { password: '' } });
@ -57,7 +57,7 @@ export default function Set_Password() {
// Set password with call to backend
api
.post(
url(ApiPaths.user_reset_set),
apiUrl(ApiPaths.user_reset_set),
{
uid: uid,
token: token,

View File

@ -19,6 +19,7 @@ import { api, queryClient } from '../../../App';
import { ColorToggle } from '../../../components/items/ColorToggle';
import { EditButton } from '../../../components/items/EditButton';
import { LanguageSelect } from '../../../components/items/LanguageSelect';
import { ApiPaths, apiUrl } from '../../../states/ApiState';
import { useLocalState } from '../../../states/LocalState';
import { UserTheme } from './UserTheme';
@ -29,7 +30,8 @@ export function UserPanel() {
// data
function fetchData() {
return api.get('user/me/').then((res) => res.data);
// TODO: Replace this call with the global user state, perhaps?
return api.get(apiUrl(ApiPaths.user_me)).then((res) => res.data);
}
const { isLoading, data } = useQuery({
queryKey: ['user-me'],
@ -68,7 +70,7 @@ export function UserInfo({ data }: { data: any }) {
const form = useForm({ initialValues: data });
const [editing, setEditing] = useToggle([false, true] as const);
function SaveData(values: any) {
api.put('user/me/', values).then((res) => {
api.put(apiUrl(ApiPaths.user_me)).then((res) => {
if (res.status === 200) {
setEditing();
queryClient.invalidateQueries(['user-me']);

View File

@ -50,7 +50,7 @@ import { TitleWithDoc } from '../../components/items/TitleWithDoc';
import { Render, RenderTypes } from '../../components/renderers';
import { notYetImplemented } from '../../functions/notifications';
import { IS_DEV_OR_DEMO } from '../../main';
import { ApiPaths, url } from '../../states/ApiState';
import { ApiPaths, apiUrl } from '../../states/ApiState';
interface ScanItem {
id: string;
@ -136,7 +136,7 @@ export default function Scan() {
function runBarcode(value: string, id?: string) {
api
.post(url(ApiPaths.barcode), { barcode: value })
.post(apiUrl(ApiPaths.barcode), { barcode: value })
.then((response) => {
// update item in history
if (!id) return;

View File

@ -24,7 +24,7 @@ import { BuildOrderTable } from '../../components/tables/build/BuildOrderTable';
import { StockItemTable } from '../../components/tables/stock/StockItemTable';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { useInstance } from '../../hooks/UseInstance';
import { ApiPaths, url } from '../../states/ApiState';
import { ApiPaths, apiUrl } from '../../states/ApiState';
/**
* Detail page for a single Build Order
@ -121,7 +121,7 @@ export default function BuildDetail() {
icon: <IconNotes size="18" />,
content: (
<NotesEditor
url={url(ApiPaths.build_order_list, build.pk)}
url={apiUrl(ApiPaths.build_order_list, build.pk)}
data={build.notes ?? ''}
allowEdit={true}
/>

View File

@ -29,7 +29,7 @@ import { StockItemTable } from '../../components/tables/stock/StockItemTable';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { editPart } from '../../functions/forms/PartForms';
import { useInstance } from '../../hooks/UseInstance';
import { ApiPaths, url } from '../../states/ApiState';
import { ApiPaths, apiUrl } from '../../states/ApiState';
/**
* Detail view for a single Part instance
@ -157,7 +157,7 @@ export default function PartDetail() {
// TODO: Set edit permission based on user permissions
return (
<NotesEditor
url={url(ApiPaths.part_list, part.pk)}
url={apiUrl(ApiPaths.part_list, part.pk)}
data={part.notes ?? ''}
allowEdit={true}
/>

View File

@ -18,7 +18,7 @@ import { PanelGroup, PanelType } from '../../components/nav/PanelGroup';
import { AttachmentTable } from '../../components/tables/AttachmentTable';
import { NotesEditor } from '../../components/widgets/MarkdownEditor';
import { useInstance } from '../../hooks/UseInstance';
import { ApiPaths, url } from '../../states/ApiState';
import { ApiPaths, apiUrl } from '../../states/ApiState';
export default function StockDetail() {
const { id } = useParams();
@ -87,7 +87,7 @@ export default function StockDetail() {
icon: <IconNotes size="18" />,
content: (
<NotesEditor
url={url(ApiPaths.stock_item_list, stockitem.pk)}
url={apiUrl(ApiPaths.stock_item_list, stockitem.pk)}
data={stockitem.notes ?? ''}
allowEdit={true}
/>

View File

@ -15,13 +15,17 @@ export const useServerApiState = create<ServerApiStateProps>((set, get) => ({
setServer: (newServer: ServerAPIProps) => set({ server: newServer }),
fetchServerApiState: async () => {
// Fetch server data
await api.get('/').then((response) => {
await api.get(apiUrl(ApiPaths.api_server_info)).then((response) => {
set({ server: response.data });
});
}
}));
export enum ApiPaths {
api_server_info = 'api-server-info',
api_search = 'api-search',
// User information
user_me = 'api-user-me',
user_roles = 'api-user-roles',
@ -62,11 +66,21 @@ export enum ApiPaths {
sales_order_list = 'api-sales-order-list'
}
/**
* Function to return the API prefix.
* For now it is fixed, but may be configurable in the future.
*/
export function apiPrefix(): string {
return '/api/';
}
/**
* Return the endpoint associated with a given API path
*/
export function endpoint(path: ApiPaths): string {
export function apiEndpoint(path: ApiPaths): string {
switch (path) {
case ApiPaths.api_server_info:
return '';
case ApiPaths.user_me:
return 'user/me/';
case ApiPaths.user_roles:
@ -76,9 +90,13 @@ export function endpoint(path: ApiPaths): string {
case ApiPaths.user_simple_login:
return 'email/generate/';
case ApiPaths.user_reset:
// Note leading prefix here
return '/auth/password/reset/';
case ApiPaths.user_reset_set:
// Note leading prefix here
return '/auth/password/reset/confirm/';
case ApiPaths.api_search:
return 'search/';
case ApiPaths.settings_global_list:
return 'settings/global/';
case ApiPaths.settings_user_list:
@ -122,8 +140,13 @@ export function endpoint(path: ApiPaths): string {
/**
* Construct an API URL with an endpoint and (optional) pk value
*/
export function url(path: ApiPaths, pk?: any): string {
let _url = endpoint(path);
export function apiUrl(path: ApiPaths, pk?: any): string {
let _url = apiEndpoint(path);
// If the URL does not start with a '/', add the API prefix
if (!_url.startsWith('/')) {
_url = apiPrefix() + _url;
}
if (_url && pk) {
_url += `${pk}/`;

View File

@ -4,7 +4,7 @@
import { create } from 'zustand';
import { api } from '../App';
import { ApiPaths, url } from './ApiState';
import { ApiPaths, apiUrl } from './ApiState';
import { Setting } from './states';
interface SettingsStateProps {
@ -20,7 +20,7 @@ export const useGlobalSettingsState = create<SettingsStateProps>(
settings: [],
fetchSettings: async () => {
await api
.get(url(ApiPaths.settings_global_list))
.get(apiUrl(ApiPaths.settings_global_list))
.then((response) => {
set({ settings: response.data });
})
@ -38,7 +38,7 @@ export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({
settings: [],
fetchSettings: async () => {
await api
.get(url(ApiPaths.settings_user_list))
.get(apiUrl(ApiPaths.settings_user_list))
.then((response) => {
set({ settings: response.data });
})

View File

@ -1,7 +1,7 @@
import { create } from 'zustand';
import { api } from '../App';
import { ApiPaths, url } from './ApiState';
import { ApiPaths, apiUrl } from './ApiState';
import { UserProps } from './states';
interface UserStateProps {
@ -19,7 +19,7 @@ export const useUserState = create<UserStateProps>((set, get) => ({
fetchUserState: async () => {
// Fetch user data
await api
.get(url(ApiPaths.user_me))
.get(apiUrl(ApiPaths.user_me))
.then((response) => {
const user: UserProps = {
name: `${response.data.first_name} ${response.data.last_name}`,
@ -34,7 +34,7 @@ export const useUserState = create<UserStateProps>((set, get) => ({
// Fetch role data
await api
.get(url(ApiPaths.user_roles))
.get(apiUrl(ApiPaths.user_roles))
.then((response) => {
const user: UserProps = get().user as UserProps;