diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index 46c926f7e2..06e7ef1e7b 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -17,6 +17,7 @@ import { useState } from 'react'; import { api } from '../../App'; import { constructFormUrl } from '../../functions/forms'; import { invalidResponse } from '../../functions/notifications'; +import { ApiPaths } from '../../states/ApiState'; import { ApiFormField, ApiFormFieldSet, @@ -45,8 +46,8 @@ import { */ export interface ApiFormProps { name: string; - url: string; - pk?: number; + url: ApiPaths; + pk?: number | string; title: string; fields?: ApiFormFieldSet; cancelText?: string; diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index de772fbeff..c03c456a64 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -41,9 +41,8 @@ export type ApiFormChangeCallback = { * @param value : The value of the field * @param default : The default value of the field * @param icon : An icon to display next to the field - * @param fieldType : The type of field to render + * @param field_type : The type of field to render * @param api_url : The API endpoint to fetch data from (for related fields) - * @param read_only : Whether the field is read-only * @param model : The model to use for related fields * @param filters : Optional API filters to apply to related fields * @param required : Whether the field is required @@ -61,9 +60,8 @@ export type ApiFormFieldType = { value?: any; default?: any; icon?: ReactNode; - fieldType?: string; + field_type?: string; api_url?: string; - read_only?: boolean; model?: ModelType; filters?: any; required?: boolean; @@ -99,8 +97,6 @@ export function constructField({ ...field }; - def.disabled = def.disabled || def.read_only; - // Retrieve the latest value from the form let value = form.values[fieldName]; @@ -109,7 +105,7 @@ export function constructField({ } // Change value to a date object if required - switch (def.fieldType) { + switch (def.field_type) { case 'date': if (def.value) { def.value = new Date(def.value); @@ -192,9 +188,23 @@ export function ApiFormField({ const value: any = useMemo(() => form.values[fieldName], [form.values]); + // Coerce the value to a numerical value + const numericalValue: number | undefined = useMemo(() => { + switch (definition.field_type) { + case 'integer': + return parseInt(value); + case 'decimal': + case 'float': + case 'number': + return parseFloat(value); + default: + return undefined; + } + }, [value]); + // Construct the individual field function buildField() { - switch (definition.fieldType) { + switch (definition.field_type) { case 'related field': return ( onChange(event.currentTarget.value)} @@ -260,7 +270,7 @@ export function ApiFormField({ {...definition} radius="sm" id={fieldId} - value={value} + value={numericalValue} error={error} onChange={(value: number) => onChange(value)} /> @@ -289,7 +299,8 @@ export function ApiFormField({ default: return ( - Invalid field type for field '{fieldName}': '{definition.fieldType}' + Invalid field type for field '{fieldName}': '{definition.field_type} + ' ); } diff --git a/src/frontend/src/components/forms/fields/ChoiceField.tsx b/src/frontend/src/components/forms/fields/ChoiceField.tsx index 93e2df3b1e..589cf83dd6 100644 --- a/src/frontend/src/components/forms/fields/ChoiceField.tsx +++ b/src/frontend/src/components/forms/fields/ChoiceField.tsx @@ -80,6 +80,7 @@ export function ChoiceField({ data={choices} value={value} onChange={(value) => onChange(value)} + withinPortal={true} /> ); } diff --git a/src/frontend/src/components/renderers/BuildOrderRenderer.tsx b/src/frontend/src/components/renderers/BuildOrderRenderer.tsx index 4b7283b3fc..c683365a66 100644 --- a/src/frontend/src/components/renderers/BuildOrderRenderer.tsx +++ b/src/frontend/src/components/renderers/BuildOrderRenderer.tsx @@ -21,7 +21,7 @@ export const BuildOrderRenderer = ({ pk }: { pk: string }) => { }; return ( { return ( { }; return ( { return ( { }; return ( { return ( { }; return ( { openDeleteApiForm({ name: 'delete-related-part', - url: '/part/related/', + url: ApiPaths.related_part_list, pk: record.pk, title: t`Delete Related Part`, successMessage: t`Related part deleted`, @@ -115,13 +116,13 @@ export function RelatedPartTable({ partId }: { partId: number }): ReactNode { return ( 0) { - url += `${props.pk}/`; - } - - return url; + return url(props.url, props.pk); } /** @@ -76,7 +67,7 @@ export function extractAvailableFields( fields[fieldName] = { ...field, name: fieldName, - fieldType: field.type, + field_type: field.type, description: field.help_text, value: field.value ?? field.default }; diff --git a/src/frontend/src/functions/forms/AttachmentForms.tsx b/src/frontend/src/functions/forms/AttachmentForms.tsx index 5be6ca3e0f..349df2e41d 100644 --- a/src/frontend/src/functions/forms/AttachmentForms.tsx +++ b/src/frontend/src/functions/forms/AttachmentForms.tsx @@ -2,6 +2,7 @@ import { t } from '@lingui/macro'; import { Text } from '@mantine/core'; import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField'; +import { ApiPaths } from '../../states/ApiState'; import { openCreateApiForm, openDeleteApiForm, @@ -31,7 +32,7 @@ export function addAttachment({ attachmentType, callback }: { - url: string; + url: ApiPaths; model: string; pk: number; attachmentType: 'file' | 'link'; @@ -77,7 +78,7 @@ export function editAttachment({ attachmentType, callback }: { - url: string; + url: ApiPaths; model: string; pk: number; attachmentType: 'file' | 'link'; @@ -116,7 +117,7 @@ export function deleteAttachment({ pk, callback }: { - url: string; + url: ApiPaths; pk: number; callback: () => void; }) { diff --git a/src/frontend/src/functions/forms/PartForms.tsx b/src/frontend/src/functions/forms/PartForms.tsx index 4e9d202156..fb8b327be3 100644 --- a/src/frontend/src/functions/forms/PartForms.tsx +++ b/src/frontend/src/functions/forms/PartForms.tsx @@ -4,6 +4,7 @@ import { ApiFormFieldSet, ApiFormFieldType } from '../../components/forms/fields/ApiFormField'; +import { ApiPaths } from '../../states/ApiState'; import { openCreateApiForm, openEditApiForm } from '../forms'; /** @@ -74,7 +75,7 @@ export function createPart() { openCreateApiForm({ name: 'part-create', title: t`Create Part`, - url: '/part/', + url: ApiPaths.part_list, successMessage: t`Part created`, fields: partFields({}) }); @@ -94,7 +95,7 @@ export function editPart({ openEditApiForm({ name: 'part-edit', title: t`Edit Part`, - url: '/part/', + url: ApiPaths.part_list, pk: part_id, successMessage: t`Part updated`, fields: partFields({ editing: true }), diff --git a/src/frontend/src/functions/forms/StockForms.tsx b/src/frontend/src/functions/forms/StockForms.tsx index 1ec19cf774..4b456055d3 100644 --- a/src/frontend/src/functions/forms/StockForms.tsx +++ b/src/frontend/src/functions/forms/StockForms.tsx @@ -6,6 +6,7 @@ import { ApiFormFieldSet, ApiFormFieldType } from '../../components/forms/fields/ApiFormField'; +import { ApiPaths } from '../../states/ApiState'; import { openCreateApiForm, openEditApiForm } from '../forms'; /** @@ -54,7 +55,7 @@ export function stockFields({}: {}): ApiFormFieldSet { }, serial_numbers: { // TODO: icon - fieldType: 'string', + field_type: 'string', label: t`Serial Numbers`, description: t`Enter serial numbers for new stock (or leave blank)`, required: false @@ -99,7 +100,7 @@ export function stockFields({}: {}): ApiFormFieldSet { export function createStockItem() { openCreateApiForm({ name: 'stockitem-create', - url: '/stock/', + url: ApiPaths.stock_item_list, fields: stockFields({}), title: t`Create Stock Item` }); @@ -112,7 +113,7 @@ export function createStockItem() { export function editStockItem(item: number) { openEditApiForm({ name: 'stockitem-edit', - url: '/stock/', + url: ApiPaths.stock_item_list, pk: item, fields: stockFields({}), title: t`Edit Stock Item` diff --git a/src/frontend/src/hooks/UseInstance.tsx b/src/frontend/src/hooks/UseInstance.tsx index 8f36c9b879..31e1023003 100644 --- a/src/frontend/src/hooks/UseInstance.tsx +++ b/src/frontend/src/hooks/UseInstance.tsx @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { useCallback, useState } from 'react'; import { api } from '../App'; +import { ApiPaths, url } from '../states/ApiState'; /** * Custom hook for loading a single instance of an instance from the API @@ -12,15 +13,19 @@ import { api } from '../App'; * To use this hook: * const { instance, refreshInstance } = useInstance(url: string, pk: number) */ -export function useInstance( - url: string, - pk: string | undefined, - params: any = {} -) { +export function useInstance({ + endpoint, + pk, + params = {} +}: { + endpoint: ApiPaths; + pk: string | undefined; + params?: any; +}) { const [instance, setInstance] = useState({}); const instanceQuery = useQuery({ - queryKey: ['instance', url, pk, params], + queryKey: ['instance', endpoint, pk, params], queryFn: async () => { if (pk == null || pk == undefined || pk.length == 0) { setInstance({}); @@ -28,7 +33,7 @@ export function useInstance( } return api - .get(url + pk + '/', { + .get(url(endpoint, pk), { params: params }) .then((response) => { diff --git a/src/frontend/src/pages/Index/Playground.tsx b/src/frontend/src/pages/Index/Playground.tsx index 5c3a7fdb37..e7c220bd91 100644 --- a/src/frontend/src/pages/Index/Playground.tsx +++ b/src/frontend/src/pages/Index/Playground.tsx @@ -15,6 +15,7 @@ import { partCategoryFields } from '../../functions/forms/PartForms'; import { createStockItem } from '../../functions/forms/StockForms'; +import { ApiPaths } from '../../states/ApiState'; // Generate some example forms using the modal API forms interface function ApiFormsPlayground() { @@ -22,7 +23,7 @@ function ApiFormsPlayground() { const editCategoryForm: ApiFormProps = { name: 'partcategory', - url: '/part/category/', + url: ApiPaths.category_list, pk: 2, title: 'Edit Category', fields: fields @@ -30,7 +31,7 @@ function ApiFormsPlayground() { const createAttachmentForm: ApiFormProps = { name: 'createattachment', - url: '/part/attachment/', + url: ApiPaths.part_attachment_list, title: 'Create Attachment', successMessage: 'Attachment uploaded', fields: { diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index f400862af0..e60237119f 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -6,16 +6,13 @@ import { IconInfoCircle, IconList, IconListCheck, - IconListTree, IconNotes, IconPaperclip, IconSitemap } from '@tabler/icons-react'; -import { useQuery } from '@tanstack/react-query'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; -import { api } from '../../App'; import { PlaceholderPanel, PlaceholderPill @@ -27,6 +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'; /** * Detail page for a single Build Order @@ -38,8 +36,12 @@ export default function BuildDetail() { instance: build, refreshInstance, instanceQuery - } = useInstance('/build/', id, { - part_detail: true + } = useInstance({ + endpoint: ApiPaths.build_order_list, + pk: id, + params: { + part_detail: true + } }); const buildPanels: PanelType[] = useMemo(() => { @@ -107,7 +109,7 @@ export default function BuildDetail() { icon: , content: ( @@ -119,7 +121,7 @@ export default function BuildDetail() { icon: , content: ( diff --git a/src/frontend/src/pages/part/CategoryDetail.tsx b/src/frontend/src/pages/part/CategoryDetail.tsx index 90129ca538..ea4f869d6e 100644 --- a/src/frontend/src/pages/part/CategoryDetail.tsx +++ b/src/frontend/src/pages/part/CategoryDetail.tsx @@ -16,6 +16,7 @@ import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { PartCategoryTable } from '../../components/tables/part/PartCategoryTable'; import { PartListTable } from '../../components/tables/part/PartTable'; import { useInstance } from '../../hooks/UseInstance'; +import { ApiPaths } from '../../states/ApiState'; /** * Detail view for a single PartCategory instance. @@ -29,7 +30,13 @@ export default function CategoryDetail({}: {}) { instance: category, refreshInstance, instanceQuery - } = useInstance('/part/category/', id, { path_detail: true }); + } = useInstance({ + endpoint: ApiPaths.category_list, + pk: id, + params: { + path_detail: true + } + }); const categoryPanels: PanelType[] = useMemo( () => [ diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index c813326ed2..79b3834501 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -29,6 +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'; /** * Detail view for a single Part instance @@ -40,7 +41,13 @@ export default function PartDetail() { instance: part, refreshInstance, instanceQuery - } = useInstance('/part/', id, { path_detail: true }); + } = useInstance({ + endpoint: ApiPaths.part_list, + pk: id, + params: { + path_detail: true + } + }); // Part data panels (recalculate when part data changes) const partPanels: PanelType[] = useMemo(() => { @@ -123,7 +130,7 @@ export default function PartDetail() { name: 'related_parts', label: t`Related Parts`, icon: , - content: partRelatedTab() + content: }, { name: 'attachments', @@ -131,7 +138,7 @@ export default function PartDetail() { icon: , content: ( @@ -146,14 +153,11 @@ export default function PartDetail() { ]; }, [part]); - function partRelatedTab(): React.ReactNode { - return ; - } function partNotesTab(): React.ReactNode { // TODO: Set edit permission based on user permissions return ( diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx index aa7672a2d9..d1587c7245 100644 --- a/src/frontend/src/pages/stock/LocationDetail.tsx +++ b/src/frontend/src/pages/stock/LocationDetail.tsx @@ -9,6 +9,7 @@ import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; import { StockItemTable } from '../../components/tables/stock/StockItemTable'; import { StockLocationTable } from '../../components/tables/stock/StockLocationTable'; import { useInstance } from '../../hooks/UseInstance'; +import { ApiPaths } from '../../states/ApiState'; export default function Stock() { const { id } = useParams(); @@ -17,7 +18,13 @@ export default function Stock() { instance: location, refreshInstance, instanceQuery - } = useInstance('/stock/location/', id, { path_detail: true }); + } = useInstance({ + endpoint: ApiPaths.stock_location_list, + pk: id, + params: { + path_detail: true + } + }); const locationPanels: PanelType[] = useMemo(() => { return [ diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 4ab59560a2..c096c757e2 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -7,10 +7,9 @@ import { IconInfoCircle, IconNotes, IconPaperclip, - IconSitemap, - IconTransferIn + IconSitemap } from '@tabler/icons-react'; -import { useEffect, useMemo } from 'react'; +import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { PlaceholderPanel } from '../../components/items/Placeholder'; @@ -19,6 +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'; export default function StockDetail() { const { id } = useParams(); @@ -27,10 +27,14 @@ export default function StockDetail() { instance: stockitem, refreshInstance, instanceQuery - } = useInstance('/stock/', id, { - part_detail: true, - location_detail: true, - path_detail: true + } = useInstance({ + endpoint: ApiPaths.stock_item_list, + pk: id, + params: { + part_detail: true, + location_detail: true, + path_detail: true + } }); const stockPanels: PanelType[] = useMemo(() => { @@ -71,7 +75,7 @@ export default function StockDetail() { icon: , content: ( @@ -83,7 +87,7 @@ export default function StockDetail() { icon: , content: ( diff --git a/src/frontend/src/states/ApiState.tsx b/src/frontend/src/states/ApiState.tsx index 0073e4ce45..76dd998527 100644 --- a/src/frontend/src/states/ApiState.tsx +++ b/src/frontend/src/states/ApiState.tsx @@ -47,6 +47,7 @@ export const useServerApiState = create((set, get) => ({ })); export enum ApiPaths { + // User information user_me = 'api-user-me', user_roles = 'api-user-roles', user_token = 'api-user-token', @@ -54,17 +55,40 @@ export enum ApiPaths { user_reset = 'api-user-reset', user_reset_set = 'api-user-reset-set', + notifications_list = 'api-notifications-list', + barcode = 'api-barcode', - part_detail = 'api-part-detail', - supplier_part_detail = 'api-supplier-part-detail', - stock_item_detail = 'api-stock-item-detail', - stock_location_detail = 'api-stock-location-detail', - purchase_order_detail = 'api-purchase-order-detail', - sales_order_detail = 'api-sales-order-detail', - build_order_detail = 'api-build-order-detail' + + // Build order URLs + build_order_list = 'api-build-list', + build_order_attachment_list = 'api-build-attachment-list', + + // Part URLs + part_list = 'api-part-list', + category_list = 'api-category-list', + related_part_list = 'api-related-part-list', + part_attachment_list = 'api-part-attachment-list', + + // Company URLs + company_list = 'api-company-list', + supplier_part_list = 'api-supplier-part-list', + + // Stock Item URLs + stock_item_list = 'api-stock-item-list', + stock_location_list = 'api-stock-location-list', + stock_attachment_list = 'api-stock-attachment-list', + + // Purchase Order URLs + purchase_order_list = 'api-purchase-order-list', + + // Sales Order URLs + sales_order_list = 'api-sales-order-list' } -export function url(path: ApiPaths, pk?: any): string { +/** + * Return the endpoint associated with a given API path + */ +export function endpoint(path: ApiPaths): string { switch (path) { case ApiPaths.user_me: return 'user/me/'; @@ -78,25 +102,51 @@ export function url(path: ApiPaths, pk?: any): string { return '/auth/password/reset/'; case ApiPaths.user_reset_set: return '/auth/password/reset/confirm/'; - + case ApiPaths.notifications_list: + return 'notifications/'; case ApiPaths.barcode: return 'barcode/'; - case ApiPaths.part_detail: - return `part/${pk}/`; - case ApiPaths.supplier_part_detail: - return `company/part/${pk}/`; - case ApiPaths.stock_item_detail: - return `stock/${pk}/`; - case ApiPaths.stock_location_detail: - return `stock/location/${pk}/`; - case ApiPaths.purchase_order_detail: - return `order/po/${pk}/`; - case ApiPaths.sales_order_detail: - return `order/so/${pk}/`; - case ApiPaths.build_order_detail: - return `build/${pk}/`; + case ApiPaths.build_order_list: + return 'build/'; + case ApiPaths.build_order_attachment_list: + return 'build/attachment/'; + case ApiPaths.part_list: + return 'part/'; + case ApiPaths.category_list: + return 'part/category/'; + case ApiPaths.related_part_list: + return 'part/related/'; + case ApiPaths.part_attachment_list: + return 'part/attachment/'; + case ApiPaths.company_list: + return 'company/'; + case ApiPaths.supplier_part_list: + return 'company/part/'; + case ApiPaths.stock_item_list: + return 'stock/'; + case ApiPaths.stock_location_list: + return 'stock/location/'; + case ApiPaths.stock_attachment_list: + return 'stock/attachment/'; + case ApiPaths.purchase_order_list: + return 'order/po/'; + case ApiPaths.sales_order_list: + return 'order/so/'; default: return ''; } } + +/** + * Construct an API URL with an endpoint and (optional) pk value + */ +export function url(path: ApiPaths, pk?: any): string { + let _url = endpoint(path); + + if (_url && pk) { + _url += `${pk}/`; + } + + return _url; +}