Merge branch 'inventree:master' into ci-only-in-inventree

This commit is contained in:
Matthias Mair 2024-03-20 22:43:29 +01:00 committed by GitHub
commit c48e333c63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
78 changed files with 20108 additions and 402 deletions

View File

@ -31,6 +31,8 @@ services:
INVENTREE_DB_USER: inventree_user
INVENTREE_DB_PASSWORD: inventree_password
INVENTREE_PLUGINS_ENABLED: True
INVENTREE_SITE_URL: http://localhost:8000
INVENTREE_CORS_ORIGIN_ALLOW_ALL: True
INVENTREE_PY_ENV: /home/inventree/dev/venv
depends_on:

View File

@ -44,6 +44,7 @@ jobs:
- docker-compose.yml
- docker.dev.env
- Dockerfile
- InvenTree/settings.py
- requirements.txt
- tasks.py

View File

@ -20,6 +20,7 @@ env:
INVENTREE_MEDIA_ROOT: ../test_inventree_media
INVENTREE_STATIC_ROOT: ../test_inventree_static
INVENTREE_BACKUP_DIR: ../test_inventree_backup
INVENTREE_SITE_URL: http://localhost:8000
jobs:
paths-filter:
@ -216,9 +217,10 @@ jobs:
INVENTREE_ADMIN_USER: testuser
INVENTREE_ADMIN_PASSWORD: testpassword
INVENTREE_ADMIN_EMAIL: test@test.com
INVENTREE_PYTHON_TEST_SERVER: http://localhost:12345
INVENTREE_PYTHON_TEST_SERVER: http://127.0.0.1:12345
INVENTREE_PYTHON_TEST_USERNAME: testuser
INVENTREE_PYTHON_TEST_PASSWORD: testpassword
INVENTREE_SITE_URL: http://127.0.0.1:12345
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pin@v4.1.1
@ -505,7 +507,7 @@ jobs:
- name: Install dependencies
run: cd src/frontend && yarn install
- name: Build frontend
run: cd src/frontend && npm run build
run: cd src/frontend && npm run compile && npm run build
- name: Zip frontend
run: |
cd InvenTree/web/static

View File

@ -37,7 +37,7 @@ jobs:
- name: Install dependencies
run: cd src/frontend && yarn install
- name: Build frontend
run: cd src/frontend && npm run build
run: cd src/frontend && npm run compile && npm run build
- name: Zip frontend
run: |
cd InvenTree/web/static/web

View File

@ -22,6 +22,7 @@ jobs:
INVENTREE_MEDIA_ROOT: ./media
INVENTREE_STATIC_ROOT: ./static
INVENTREE_BACKUP_DIR: ./backup
INVENTREE_SITE_URL: http://localhost:8000
steps:
- name: Checkout Code

View File

@ -16,7 +16,7 @@ repos:
- id: check-yaml
- id: mixed-line-ending
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.3.0
rev: v0.3.3
hooks:
- id: ruff-format
args: [--preview]
@ -60,7 +60,7 @@ repos:
- "prettier@^2.4.1"
- "@trivago/prettier-plugin-sort-imports"
- repo: https://github.com/pre-commit/mirrors-eslint
rev: "v9.0.0-beta.1"
rev: "v9.0.0-beta.2"
hooks:
- id: eslint
additional_dependencies:

View File

@ -23,7 +23,6 @@ ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK 1
ENV INVOKE_RUN_SHELL="/bin/ash"
ENV INVENTREE_LOG_LEVEL="WARNING"
ENV INVENTREE_DOCKER="true"
# InvenTree paths

View File

@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 183
INVENTREE_API_VERSION = 184
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v184 - 2024-03-17 : https://github.com/inventree/InvenTree/pull/10464
- Add additional fields for tests (start/end datetime, test station)
v183 - 2024-03-14 : https://github.com/inventree/InvenTree/pull/5972
- Adds "category_default_location" annotated field to part serializer
- Adds "part_detail.category_default_location" annotated field to stock item serializer

View File

@ -2,12 +2,14 @@
If a new language translation is supported, it must be added here
After adding a new language, run the following command:
python manage.py makemessages -l <language_code> -e html,js,py --no-wrap
where <language_code> is the code for the new language
- where <language_code> is the code for the new language
Additionally, update the following files with the new locale code:
- /src/frontend/.linguirc file
- /src/frontend/src/context/LanguageContext.tsx
- /src/frontend/src/contexts/LanguageContext.tsx
"""
from django.utils.translation import gettext_lazy as _
@ -30,6 +32,7 @@ LOCALES = [
('it', _('Italian')),
('ja', _('Japanese')),
('ko', _('Korean')),
('lv', _('Latvian')),
('nl', _('Dutch')),
('no', _('Norwegian')),
('pl', _('Polish')),

View File

@ -352,7 +352,12 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
try:
instance.full_clean()
except (ValidationError, DjangoValidationError) as exc:
data = exc.message_dict
if hasattr(exc, 'message_dict'):
data = exc.message_dict
elif hasattr(exc, 'message'):
data = {'non_field_errors': [str(exc.message)]}
else:
data = {'non_field_errors': [str(exc)]}
# Change '__all__' key (django style) to 'non_field_errors' (DRF style)
if '__all__' in data:

View File

@ -26,6 +26,7 @@ import pytz
from dotenv import load_dotenv
from InvenTree.config import get_boolean_setting, get_custom_file, get_setting
from InvenTree.ready import isInMainThread
from InvenTree.sentry import default_sentry_dsn, init_sentry
from InvenTree.version import checkMinPythonVersion, inventreeApiVersion
@ -1002,11 +1003,15 @@ if not ALLOWED_HOSTS:
'No ALLOWED_HOSTS specified. Defaulting to ["*"] for debug mode. This is not recommended for production use'
)
ALLOWED_HOSTS = ['*']
else:
elif not TESTING:
logger.error(
'No ALLOWED_HOSTS specified. Please provide a list of allowed hosts, or specify INVENTREE_SITE_URL'
)
# Server cannot run without ALLOWED_HOSTS
if isInMainThread():
sys.exit(-1)
# Ensure that the ALLOWED_HOSTS do not contain any scheme info
for i, host in enumerate(ALLOWED_HOSTS):
if '://' in host:
@ -1025,6 +1030,20 @@ CSRF_TRUSTED_ORIGINS = get_setting(
if SITE_URL and SITE_URL not in CSRF_TRUSTED_ORIGINS:
CSRF_TRUSTED_ORIGINS.append(SITE_URL)
if not TESTING and len(CSRF_TRUSTED_ORIGINS) == 0:
if DEBUG:
logger.warning(
'No CSRF_TRUSTED_ORIGINS specified. Defaulting to http://* for debug mode. This is not recommended for production use'
)
CSRF_TRUSTED_ORIGINS = ['http://*']
elif isInMainThread():
# Server thread cannot run without CSRF_TRUSTED_ORIGINS
logger.error(
'No CSRF_TRUSTED_ORIGINS specified. Please provide a list of trusted origins, or specify INVENTREE_SITE_URL'
)
sys.exit(-1)
USE_X_FORWARDED_HOST = get_boolean_setting(
'INVENTREE_USE_X_FORWARDED_HOST',
config_key='use_x_forwarded_host',
@ -1202,6 +1221,9 @@ PLUGIN_RETRY = get_setting(
) # How often should plugin loading be tried?
PLUGIN_FILE_CHECKED = False # Was the plugin file checked?
# Flag to allow table events during testing
TESTING_TABLE_EVENTS = False
# User interface customization values
CUSTOM_LOGO = get_custom_file(
'INVENTREE_CUSTOM_LOGO', 'customize.logo', 'custom logo', lookup_media=True
@ -1265,5 +1287,5 @@ SPECTACULAR_SETTINGS = {
'SCHEMA_PATH_PREFIX': '/api/',
}
if SITE_URL:
if SITE_URL and not TESTING:
SPECTACULAR_SETTINGS['SERVERS'] = [{'url': SITE_URL}]

View File

@ -574,6 +574,7 @@ class FormatTest(TestCase):
class TestHelpers(TestCase):
"""Tests for InvenTree helper functions."""
@override_settings(SITE_URL=None)
def test_absolute_url(self):
"""Test helper function for generating an absolute URL."""
base = 'https://demo.inventree.org:12345'
@ -1347,6 +1348,7 @@ class TestInstanceName(InvenTreeTestCase):
site_obj = Site.objects.all().order_by('id').first()
self.assertEqual(site_obj.name, 'Testing title')
@override_settings(SITE_URL=None)
def test_instance_url(self):
"""Test instance url settings."""
# Set up required setting

View File

@ -1750,6 +1750,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
},
'STOCK_ENFORCE_BOM_INSTALLATION': {
'name': _('Check BOM when installing items'),
'description': _(
'Installed stock items must exist in the BOM for the parent part'
),
'default': True,
'validator': bool,
},
'BUILDORDER_REFERENCE_PATTERN': {
'name': _('Build Order Reference Pattern'),
'description': _(
@ -2004,6 +2012,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False,
'validator': bool,
},
'TEST_STATION_DATA': {
'name': _('Enable Test Station Data'),
'description': _('Enable test station data collection for test results'),
'default': False,
'validator': bool,
},
}
typ = 'inventree'

View File

@ -12,6 +12,7 @@ from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase
from django.test.utils import override_settings
from django.urls import reverse
import PIL
@ -271,6 +272,7 @@ class SettingsTest(InvenTreeTestCase):
print(f"run_settings_check failed for user setting '{key}'")
raise exc
@override_settings(SITE_URL=None)
def test_defaults(self):
"""Populate the settings with default values."""
for key in InvenTreeSetting.SETTINGS.keys():

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.test import TestCase
from django.test.utils import override_settings
from allauth.account.models import EmailAddress
@ -63,6 +64,7 @@ class TemplateTagTest(InvenTreeTestCase):
"""Test the 'instance name' setting."""
self.assertEqual(inventree_extras.inventree_instance_name(), 'InvenTree')
@override_settings(SITE_URL=None)
def test_inventree_base_url(self):
"""Test that the base URL tag returns correctly."""
self.assertEqual(inventree_extras.inventree_base_url(), '')

View File

@ -10,7 +10,7 @@ from django.apps import AppConfig
from maintenance_mode.core import set_maintenance_mode
from InvenTree.ready import canAppAccessDatabase, isInMainThread
from InvenTree.ready import canAppAccessDatabase, isInMainThread, isInWorkerThread
from plugin import registry
logger = logging.getLogger('inventree')
@ -24,7 +24,8 @@ class PluginAppConfig(AppConfig):
def ready(self):
"""The ready method is extended to initialize plugins."""
# skip loading if we run in a background thread
if not isInMainThread():
if not isInMainThread() and not isInWorkerThread():
return
if not canAppAccessDatabase(

View File

@ -117,7 +117,7 @@ def allow_table_event(table_name):
return False # pragma: no cover
# Prevent table events when in testing mode (saves a lot of time)
if settings.TESTING:
if settings.TESTING and not settings.TESTING_TABLE_EVENTS:
return False
table_name = table_name.lower().strip()

View File

@ -0,0 +1,29 @@
# Generated by Django 3.2.23 on 2023-12-18 18:52
import datetime
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('stock', '0108_auto_20240219_0252'),
]
operations = [
migrations.AddField(
model_name='stockitemtestresult',
name='finished_datetime',
field=models.DateTimeField(blank=True, default=datetime.datetime.now, help_text='The timestamp of the test finish', verbose_name='Finished'),
),
migrations.AddField(
model_name='stockitemtestresult',
name='started_datetime',
field=models.DateTimeField(blank=True, default=datetime.datetime.now, help_text='The timestamp of the test start', verbose_name='Started'),
),
migrations.AddField(
model_name='stockitemtestresult',
name='test_station',
field=models.CharField(blank=True, help_text='The identifier of the test station where the test was performed', max_length=500, verbose_name='Test station'),
),
]

View File

@ -2363,6 +2363,9 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
value: Recorded test output value (optional)
attachment: Link to StockItem attachment (optional)
notes: Extra user notes related to the test (optional)
test_station: the name of the test station where the test was performed
started_datetime: Date when the test was started
finished_datetime: Date when the test was finished
user: User who uploaded the test result
date: Date the test result was recorded
"""
@ -2453,4 +2456,27 @@ class StockItemTestResult(InvenTree.models.InvenTreeMetadataModel):
user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True)
test_station = models.CharField(
blank=True,
max_length=500,
verbose_name=_('Test station'),
help_text=_('The identifier of the test station where the test was performed'),
)
started_datetime = models.DateTimeField(
default=datetime.now,
blank=True,
verbose_name=_('Started'),
help_text=_('The timestamp of the test start'),
)
finished_datetime = models.DateTimeField(
default=datetime.now,
blank=True,
verbose_name=_('Finished'),
help_text=_('The timestamp of the test finish'),
)
user = models.ForeignKey(User, on_delete=models.SET_NULL, blank=True, null=True)
date = models.DateTimeField(auto_now_add=True, editable=False)

View File

@ -64,6 +64,9 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
'value',
'attachment',
'notes',
'test_station',
'started_datetime',
'finished_datetime',
'user',
'user_detail',
'date',
@ -137,7 +140,18 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
part=stock_item.part, test_name=test_name
)
return super().validate(data)
data = super().validate(data)
started = data.get('started_datetime')
finished = data.get('finished_datetime')
if started is not None and finished is not None and started > finished:
raise ValidationError({
'finished_datetime': _(
'The test finished time cannot be earlier than the test started time'
)
})
return data
class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
@ -584,9 +598,14 @@ class InstallStockItemSerializer(serializers.Serializer):
parent_item = self.context['item']
parent_part = parent_item.part
# Check if the selected part is in the Bill of Materials of the parent item
if not parent_part.check_if_part_in_bom(stock_item.part):
raise ValidationError(_('Selected part is not in the Bill of Materials'))
if common.models.InvenTreeSetting.get_setting(
'STOCK_ENFORCE_BOM_INSTALLATION', backup_value=True, cache=False
):
# Check if the selected part is in the Bill of Materials of the parent item
if not parent_part.check_if_part_in_bom(stock_item.part):
raise ValidationError(
_('Selected part is not in the Bill of Materials')
)
return stock_item

View File

@ -183,7 +183,10 @@
$('#stock-item-install').click(function() {
{% settings_value "STOCK_ENFORCE_BOM_INSTALLATION" as enforce_bom %}
installStockItem({{ item.pk }}, {{ item.part.pk }}, {
enforce_bom: {% js_bool enforce_bom %},
onSuccess: function(response) {
$("#installed-table").bootstrapTable('refresh');
}
@ -228,10 +231,13 @@
});
});
{% settings_value "TEST_STATION_DATA" as test_station_fields %}
loadStockTestResultsTable(
$("#test-result-table"), {
part: {{ item.part.id }},
stock_item: {{ item.id }},
test_station_fields: {% js_bool test_station_fields %}
}
);

View File

@ -22,6 +22,8 @@
{% include "InvenTree/settings/setting.html" with key="STOCK_OWNERSHIP_CONTROL" icon="fa-users" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_LOCATION_DEFAULT_ICON" icon="fa-icons" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_SHOW_INSTALLED_ITEMS" icon="fa-sitemap" %}
{% include "InvenTree/settings/setting.html" with key="STOCK_ENFORCE_BOM_INSTALLATION" icon="fa-check-circle" %}
{% include "InvenTree/settings/setting.html" with key="TEST_STATION_DATA" icon="fa-network-wired" %}
</tbody>
</table>

View File

@ -2590,6 +2590,9 @@ function constructInput(name, parameters, options={}) {
case 'date':
func = constructDateInput;
break;
case 'datetime':
func = constructDateTimeInput;
break;
case 'candy':
func = constructCandyInput;
break;
@ -2860,6 +2863,19 @@ function constructDateInput(name, parameters) {
}
/*
* Construct a field for a datetime input
*/
function constructDateTimeInput(name, parameters) {
return constructInputOptions(
name,
'datetimeinput form-control',
'datetime',
parameters
);
}
/*
* Construct a "candy" field input
* No actual field data!

View File

@ -1367,11 +1367,11 @@ function noResultBadge() {
return `<span class='badge badge-right rounded-pill bg-info'>{% trans "NO RESULT" %}</span>`;
}
function formatDate(row) {
function formatDate(row, date, options={}) {
// Function for formatting date field
var html = renderDate(row.date);
var html = renderDate(date, options);
if (row.user_detail) {
if (row.user_detail && !options.no_user_detail) {
html += `<span class='badge badge-right rounded-pill bg-secondary'>${row.user_detail.username}</span>`;
}
@ -1392,6 +1392,13 @@ function stockItemTestResultFields(options={}) {
notes: {
icon: 'fa-sticky-note',
},
test_station: {},
started_datetime: {
icon: 'fa-calendar-alt',
},
finished_datetime: {
icon: 'fa-calendar-alt',
},
stock_item: {
hidden: true,
},
@ -1530,7 +1537,30 @@ function loadStockTestResultsTable(table, options) {
title: '{% trans "Test Date" %}',
sortable: true,
formatter: function(value, row) {
return formatDate(row);
return formatDate(row, row.date);
},
},
{
field: 'test_station',
title: '{% trans "Test station" %}',
visible: false,
},
{
field: 'started_timestamp',
title: '{% trans "Test started" %}',
sortable: true,
visible: false,
formatter: function(value, row) {
return formatDate(row, row.started_datetime, {showTime: true, no_user_detail: true});
},
},
{
field: 'finished_timestamp',
title: '{% trans "Test finished" %}',
sortable: true,
visible: false,
formatter: function(value, row) {
return formatDate(row, row.finished_datetime, {showTime: true, no_user_detail: true});
},
},
{
@ -1656,6 +1686,12 @@ function loadStockTestResultsTable(table, options) {
fields['template']['value'] = templateId;
fields['template']['filters']['part'] = options.part;
if (!options.test_station_fields) {
delete fields['test_station'];
delete fields['started_datetime'];
delete fields['finished_datetime'];
}
constructForm('{% url "api-stock-test-result-list" %}', {
method: 'POST',
fields: fields,
@ -3204,7 +3240,7 @@ function installStockItem(stock_item_id, part_id, options={}) {
auto_fill: true,
filters: {
trackable: true,
in_bom_for: part_id,
in_bom_for: options.enforce_bom ? part_id : undefined,
}
},
stock_item: {

View File

@ -56,12 +56,14 @@ The following basic options are available:
| INVENTREE_ADMIN_ENABLED | admin_enabled | Enable the [django administrator interface]({% include "django.html" %}/ref/contrib/admin/) | True |
| INVENTREE_ADMIN_URL | admin_url | URL for accessing [admin interface](../settings/admin.md) | admin |
| INVENTREE_LANGUAGE | language | Default language | en-us |
| INVENTREE_BASE_URL | base_url | Server base URL | *Not specified* |
| INVENTREE_AUTO_UPDATE | auto_update | Database migrations will be run automatically | False |
!!! tip "INVENTREE_SITE_URL"
The *INVENTREE_SITE_URL* option defines the base URL for the InvenTree server. This is a critical setting, and it is required for correct operation of the server. If not specified, the server will attempt to determine the site URL automatically - but this may not always be correct!
## Server Access
Depending on how your InvenTree installation is configured, you will need to pay careful attention to the following settings. If you are running your server behind a proxy, or want to adjust support for CORS requests, one or more of the following settings may need to be adjusted.
Depending on how your InvenTree installation is configured, you will need to pay careful attention to the following settings. If you are running your server behind a proxy, or want to adjust support for [CORS requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS), one or more of the following settings may need to be adjusted.
!!! warning "Advanced Users"
The following settings require a certain assumed level of knowledge. You should also refer to the [django documentation]({% include "django.html" %}/ref/settings/) for more information.
@ -86,6 +88,10 @@ Depending on how your InvenTree installation is configured, you will need to pay
| INVENTREE_USE_X_FORWARDED_PORT | use_x_forwarded_port | Use forwarded port header | False |
| INVENTREE_CORS_ALLOW_CREDENTIALS | cors.allow_credentials | Allow cookies in cross-site requests | True |
### Proxy Settings
If you are running InvenTree behind another proxy, you will need to ensure that the InvenTree server is configured to listen on the correct host and port. You will likely have to adjust the `INVENTREE_ALLOWED_HOSTS` setting to ensure that the server will accept requests from the proxy.
## Admin Site
Django provides a powerful [administrator interface]({% include "django.html" %}/ref/contrib/admin/) which can be used to manage the InvenTree database. This interface is enabled by default, and available at the `/admin/` URL.

View File

@ -27,7 +27,7 @@ The following guide provides a streamlined production InvenTree installation, wi
### Required Files
The following files required for this setup are provided with the InvenTree source, located in the `./docker/` directory of the [InvenTree source code](https://github.com/inventree/InvenTree/tree/master/docker/production):
The following files required for this setup are provided with the InvenTree source, located in the `./docker/` directory of the [InvenTree source code](https://github.com/inventree/InvenTree/tree/master/docker/):
| Filename | Description |
| --- | --- |
@ -109,6 +109,9 @@ This command launches the following containers:
!!! success "Up and Running!"
You should now be able to view the InvenTree login screen at [http://inventree.localhost](http://inventree.localhost) (or whatever custom domain you have configured in the `.env` file).
!!! tip "External Access"
Note that `http://inventree.localhost` will only be available from the machine you are running the code on. To make it accessible externally, change `INVENTREE_SITE_URL` to a host address which can be accessed by other computers on your network.
## Updating InvenTree
To update your InvenTree installation to the latest version, follow these steps:

View File

@ -23,7 +23,7 @@ cryptography==42.0.4
# via pdfminer-six
distlib==0.3.8
# via virtualenv
django==4.2.10
django==4.2.11
# via django-slowtests
django-slowtests==1.1.1
django-test-migrations==1.3.0
@ -57,7 +57,7 @@ pyyaml==6.0.1
# via pre-commit
requests==2.31.0
# via coveralls
setuptools==69.1.0
setuptools==69.2.0
# via
# nodeenv
# pip-tools

View File

@ -52,7 +52,7 @@ deprecated==1.2.14
diff-match-patch==20230430
# via django-import-export
dj-rest-auth==5.0.2
django==4.2.10
django==4.2.11
# via
# dj-rest-auth
# django-allauth
@ -296,7 +296,7 @@ rpds-py==0.17.1
# referencing
sentry-sdk==1.40.0
# via django-q-sentry
setuptools==69.1.0
setuptools==69.2.0
# via
# django-money
# opentelemetry-instrumentation

View File

@ -17,6 +17,7 @@
"it",
"ja",
"ko",
"lv",
"nl",
"no",
"pl",

View File

@ -22,12 +22,9 @@ export function setApiDefaults() {
api.defaults.baseURL = host;
api.defaults.timeout = 2500;
if (!!token) {
api.defaults.headers.common['Authorization'] = `Token ${token}`;
} else {
api.defaults.headers.common['Authorization'] = undefined;
}
api.defaults.headers.common['Authorization'] = token
? `Token ${token}`
: undefined;
if (!!getCsrfCookie()) {
api.defaults.withCredentials = true;

View File

@ -20,6 +20,7 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons';
import { getDetailUrl } from '../../functions/urls';
import { base_url } from '../../main';
import { apiUrl } from '../../states/ApiState';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { ProgressBar } from '../items/ProgressBar';
@ -198,7 +199,14 @@ function TableStringValue(props: FieldProps) {
}
return (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
wordBreak: 'break-word',
alignItems: 'flex-start'
}}
>
<Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}>
<span>
{value ? value : props.field_data?.unit && '0'}{' '}
@ -285,7 +293,7 @@ function TableAnchorValue(props: FieldProps) {
<Suspense fallback={<Skeleton width={200} height={20} radius="xl" />}>
{make_link ? (
<Anchor
href={`/platform${detailUrl}`}
href={`/${base_url}${detailUrl}`}
target={data?.external ? '_blank' : undefined}
rel={data?.external ? 'noreferrer noopener' : undefined}
>
@ -360,22 +368,19 @@ export function DetailsTableField({
const FieldType: any = getFieldType(field.type);
return (
<tr>
<tr style={{ verticalAlign: 'top' }}>
<td
style={{
display: 'flex',
alignItems: 'center',
gap: '20px',
width: '50',
justifyContent: 'flex-start'
width: '50'
}}
>
<InvenTreeIcon icon={(field.icon ?? field.name) as InvenTreeIconType} />
</td>
<td>
<td style={{ minWidth: '25%', maxWidth: '65%' }}>
<Text>{field.label}</Text>
</td>
<td style={{ minWidth: '40%' }}>
<td style={{ width: '100%' }}>
<FieldType field_data={field} field_value={item[field.name]} />
</td>
<td style={{ width: '50' }}>

View File

@ -17,6 +17,7 @@ import { useMemo, useState } from 'react';
import { api } from '../../App';
import { UserRoles } from '../../enums/Roles';
import { cancelEvent } from '../../functions/events';
import { InvenTreeIcon } from '../../functions/icons';
import { useUserState } from '../../states/UserState';
import { PartThumbTable } from '../../tables/part/PartThumbTable';
@ -267,9 +268,8 @@ function ImageActionButtons({
size="lg"
tooltipAlignment="top"
onClick={(event: any) => {
event?.preventDefault();
event?.stopPropagation();
event?.nativeEvent?.stopImmediatePropagation();
cancelEvent(event);
modals.open({
title: <StylishText size="xl">{t`Select Image`}</StylishText>,
size: 'xxl',
@ -288,9 +288,7 @@ function ImageActionButtons({
size="lg"
tooltipAlignment="top"
onClick={(event: any) => {
event?.preventDefault();
event?.stopPropagation();
event?.nativeEvent?.stopImmediatePropagation();
cancelEvent(event);
modals.open({
title: <StylishText size="xl">{t`Upload Image`}</StylishText>,
children: (
@ -310,9 +308,7 @@ function ImageActionButtons({
size="lg"
tooltipAlignment="top"
onClick={(event: any) => {
event?.preventDefault();
event?.stopPropagation();
event?.nativeEvent?.stopImmediatePropagation();
cancelEvent(event);
removeModal(apiPath, setImage);
}}
/>
@ -349,9 +345,7 @@ export function DetailsImage(props: DetailImageProps) {
}, [props.imageActions]);
const expandImage = (event: any) => {
event?.preventDefault();
event?.stopPropagation();
event?.nativeEvent?.stopImmediatePropagation();
cancelEvent(event);
modals.open({
children: <ApiImage src={img} />,
withCloseButton: false

View File

@ -2,15 +2,15 @@ import { t } from '@lingui/macro';
import {
Alert,
DefaultMantineColor,
Divider,
LoadingOverlay,
Paper,
Text
} from '@mantine/core';
import { Button, Group, Stack } from '@mantine/core';
import { useId } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useState } from 'react';
import {
FieldValues,
@ -114,16 +114,14 @@ export function OptionsApiForm({
props.pk,
props.pathParams
],
queryFn: () =>
api.options(url).then((res) => {
let fields: Record<string, ApiFormFieldType> | null = {};
if (!props.ignorePermissionCheck) {
fields = extractAvailableFields(res, props.method);
}
return fields;
}),
queryFn: async () => {
let response = await api.options(url);
let fields: Record<string, ApiFormFieldType> | null = {};
if (!props.ignorePermissionCheck) {
fields = extractAvailableFields(response, props.method);
}
return fields;
},
throwOnError: (error: any) => {
if (error.response) {
invalidResponse(error.response.status);
@ -134,7 +132,6 @@ export function OptionsApiForm({
color: 'red'
});
}
return false;
}
});
@ -142,6 +139,12 @@ export function OptionsApiForm({
const formProps: ApiFormProps = useMemo(() => {
const _props = { ...props };
// This forcefully overrides initial data
// Currently, most modals do not get pre-loaded correctly
if (!data) {
_props.fields = undefined;
}
if (!_props.fields) return _props;
for (const [k, v] of Object.entries(_props.fields)) {
@ -161,10 +164,6 @@ export function OptionsApiForm({
return _props;
}, [data, props]);
if (!data) {
return <LoadingOverlay visible={true} />;
}
return <ApiForm id={id} props={formProps} />;
}
@ -225,41 +224,46 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
props.pathParams
],
queryFn: async () => {
return api
.get(url)
.then((response) => {
const processFields = (fields: ApiFormFieldSet, data: NestedDict) => {
const res: NestedDict = {};
try {
// Await API call
let response = await api.get(url);
// Define function to process API response
const processFields = (fields: ApiFormFieldSet, data: NestedDict) => {
const res: NestedDict = {};
for (const [k, field] of Object.entries(fields)) {
const dataValue = data[k];
// TODO: replace with .map()
for (const [k, field] of Object.entries(fields)) {
const dataValue = data[k];
if (
field.field_type === 'nested object' &&
field.children &&
typeof dataValue === 'object'
) {
res[k] = processFields(field.children, dataValue);
} else {
res[k] = dataValue;
}
if (
field.field_type === 'nested object' &&
field.children &&
typeof dataValue === 'object'
) {
res[k] = processFields(field.children, dataValue);
} else {
res[k] = dataValue;
}
}
return res;
};
const initialData: any = processFields(
props.fields ?? {},
response.data
);
return res;
};
// Update form values, but only for the fields specified for this form
form.reset(initialData);
// Process API response
const initialData: any = processFields(
props.fields ?? {},
response.data
);
return response;
})
.catch((error) => {
console.error('Error fetching initial data:', error);
});
// Update form values, but only for the fields specified for this form
form.reset(initialData);
return response;
} catch (error) {
console.error('Error fetching initial data:', error);
// Re-throw error to allow react-query to handle error
throw error;
}
}
});
@ -380,8 +384,12 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
};
const isLoading = useMemo(
() => isFormLoading || initialDataQuery.isFetching || isSubmitting,
[isFormLoading, initialDataQuery.isFetching, isSubmitting]
() =>
isFormLoading ||
initialDataQuery.isFetching ||
isSubmitting ||
!props.fields,
[isFormLoading, initialDataQuery.isFetching, isSubmitting, props.fields]
);
const onFormError = useCallback<SubmitErrorHandler<FieldValues>>(() => {
@ -390,67 +398,81 @@ export function ApiForm({ id, props }: { id: string; props: ApiFormProps }) {
return (
<Stack>
<Stack spacing="sm">
<LoadingOverlay visible={isLoading} />
{(!isValid || nonFieldErrors.length > 0) && (
<Alert radius="sm" color="red" title={t`Form Errors Exist`}>
{nonFieldErrors.length > 0 && (
<Stack spacing="xs">
{nonFieldErrors.map((message) => (
<Text key={message}>{message}</Text>
))}
</Stack>
{/* Show loading overlay while fetching fields */}
{/* zIndex used to force overlay on top of modal header bar */}
<LoadingOverlay visible={isLoading} zIndex={1010} />
{/* Attempt at making fixed footer with scroll area */}
<Paper mah={'65vh'} style={{ overflowY: 'auto' }}>
<div>
{/* Form Fields */}
<Stack spacing="sm">
{(!isValid || nonFieldErrors.length > 0) && (
<Alert radius="sm" color="red" title={t`Form Errors Exist`}>
{nonFieldErrors.length > 0 && (
<Stack spacing="xs">
{nonFieldErrors.map((message) => (
<Text key={message}>{message}</Text>
))}
</Stack>
)}
</Alert>
)}
</Alert>
)}
{props.preFormContent}
{props.preFormSuccess && (
<Alert color="green" radius="sm">
{props.preFormSuccess}
</Alert>
)}
{props.preFormWarning && (
<Alert color="orange" radius="sm">
{props.preFormWarning}
</Alert>
)}
<FormProvider {...form}>
<Stack spacing="xs">
{Object.entries(props.fields ?? {}).map(([fieldName, field]) => (
<ApiFormField
key={fieldName}
fieldName={fieldName}
definition={field}
control={form.control}
/>
))}
{props.preFormContent}
{props.preFormSuccess && (
<Alert color="green" radius="sm">
{props.preFormSuccess}
</Alert>
)}
{props.preFormWarning && (
<Alert color="orange" radius="sm">
{props.preFormWarning}
</Alert>
)}
<FormProvider {...form}>
<Stack spacing="xs">
{Object.entries(props.fields ?? {}).map(
([fieldName, field]) => (
<ApiFormField
key={fieldName}
fieldName={fieldName}
definition={field}
control={form.control}
/>
)
)}
</Stack>
</FormProvider>
{props.postFormContent}
</Stack>
</FormProvider>
{props.postFormContent}
</Stack>
<Divider />
<Group position="right">
{props.actions?.map((action, i) => (
</div>
</Paper>
{/* Footer with Action Buttons */}
<div>
<Group position="right">
{props.actions?.map((action, i) => (
<Button
key={i}
onClick={action.onClick}
variant={action.variant ?? 'outline'}
radius="sm"
color={action.color}
>
{action.text}
</Button>
))}
<Button
key={i}
onClick={action.onClick}
variant={action.variant ?? 'outline'}
onClick={form.handleSubmit(submitForm, onFormError)}
variant="filled"
radius="sm"
color={action.color}
color={props.submitColor ?? 'green'}
disabled={isLoading || (props.fetchInitialData && !isDirty)}
>
{action.text}
{props.submitText ?? t`Submit`}
</Button>
))}
<Button
onClick={form.handleSubmit(submitForm, onFormError)}
variant="filled"
radius="sm"
color={props.submitColor ?? 'green'}
disabled={isLoading || (props.fetchInitialData && !isDirty)}
>
{props.submitText ?? t`Submit`}
</Button>
</Group>
</Group>
</div>
</Stack>
);
}

View File

@ -1,5 +1,5 @@
import { Group, Text } from '@mantine/core';
import { IconPhoto } from '@tabler/icons-react';
import { Anchor, Group, Text } from '@mantine/core';
import { IconLink, IconPhoto } from '@tabler/icons-react';
import {
IconFile,
IconFileTypeCsv,
@ -50,14 +50,20 @@ export function attachmentIcon(attachment: string): ReactNode {
* @param attachment : string - The attachment filename
*/
export function AttachmentLink({
attachment
attachment,
external
}: {
attachment: string;
external?: boolean;
}): ReactNode {
let text = external ? attachment : attachment.split('/').pop();
return (
<Group position="left" spacing="sm">
{attachmentIcon(attachment)}
<Text>{attachment.split('/').pop()}</Text>
{external ? <IconLink /> : attachmentIcon(attachment)}
<Anchor href={attachment} target="_blank" rel="noopener noreferrer">
{text}
</Anchor>
</Group>
);
}

View File

@ -37,20 +37,22 @@ export function Header() {
const notifications = useQuery({
queryKey: ['notification-count'],
queryFn: async () => {
return api
.get(apiUrl(ApiEndpoints.notifications_list), {
try {
const params = {
params: {
read: false,
limit: 1
}
})
.then((response) => {
setNotificationCount(response.data.count);
return response.data;
})
.catch((error) => {
return error;
});
};
let response = await api.get(
apiUrl(ApiEndpoints.notifications_list),
params
);
setNotificationCount(response.data.count);
return response.data;
} catch (error) {
return error;
}
},
refetchInterval: 30000,
refetchOnMount: true,

View File

@ -34,6 +34,7 @@ export const getSupportedLanguages = (): Record<string, string> => {
it: t`Italian`,
ja: t`Japanese`,
ko: t`Korean`,
lv: t`Latvian`,
nl: t`Dutch`,
no: t`Norwegian`,
pl: t`Polish`,

View File

@ -92,6 +92,7 @@ export enum ApiEndpoints {
stock_merge = 'stock/merge/',
stock_assign = 'stock/assign/',
stock_status = 'stock/status/',
stock_install = 'stock/:id/install',
// Order API endpoints
purchase_order_list = 'order/po/',

View File

@ -80,7 +80,7 @@ export function editAttachment({
model: string;
pk: number;
attachmentType: 'file' | 'link';
callback?: () => void;
callback?: (record: any) => void;
}) {
let formFields: ApiFormFieldSet = {
link: {},

View File

@ -0,0 +1,6 @@
// Helper function to cancel event propagation
export function cancelEvent(event: any) {
event?.preventDefault();
event?.stopPropagation();
event?.nativeEvent?.stopImmediatePropagation();
}

View File

@ -28,6 +28,13 @@ export type TableState = {
setHiddenColumns: (columns: string[]) => void;
searchTerm: string;
setSearchTerm: (term: string) => void;
recordCount: number;
setRecordCount: (count: number) => void;
page: number;
setPage: (page: number) => void;
records: any[];
setRecords: (records: any[]) => void;
updateRecord: (record: any) => void;
};
/**
@ -71,6 +78,12 @@ export function useTable(tableName: string): TableState {
setSelectedRecords([]);
}, []);
// Total record count
const [recordCount, setRecordCount] = useState<number>(0);
// Pagination data
const [page, setPage] = useState<number>(1);
// A list of hidden columns, saved to local storage
const [hiddenColumns, setHiddenColumns] = useLocalStorage<string[]>({
key: `inventree-hidden-table-columns-${tableName}`,
@ -80,6 +93,28 @@ export function useTable(tableName: string): TableState {
// Search term
const [searchTerm, setSearchTerm] = useState<string>('');
// Table records
const [records, setRecords] = useState<any[]>([]);
// Update a single record in the table, by primary key value
const updateRecord = useCallback(
(record: any) => {
let _records = [...records];
// Find the matching record in the table
const index = _records.findIndex((r) => r.pk === record.pk);
if (index >= 0) {
_records[index] = record;
} else {
_records.push(record);
}
setRecords(_records);
},
[records]
);
return {
tableKey,
refreshTable,
@ -94,6 +129,13 @@ export function useTable(tableName: string): TableState {
hiddenColumns,
setHiddenColumns,
searchTerm,
setSearchTerm
setSearchTerm,
recordCount,
setRecordCount,
page,
setPage,
records,
setRecords,
updateRecord
};
}

View File

@ -0,0 +1,4 @@
import { Messages } from '@lingui/core';
declare const messages: Messages;
export { messages };

File diff suppressed because it is too large Load Diff

View File

@ -132,7 +132,6 @@ export default function SystemSettings() {
<GlobalSettingList
keys={['CURRENCY_UPDATE_PLUGIN', 'CURRENCY_UPDATE_INTERVAL']}
/>
<StylishText size="xl">{t`Exchange Rates`}</StylishText>
</>
)
},
@ -212,7 +211,9 @@ export default function SystemSettings() {
'STOCK_ALLOW_EXPIRED_BUILD',
'STOCK_OWNERSHIP_CONTROL',
'STOCK_LOCATION_DEFAULT_ICON',
'STOCK_SHOW_INSTALLED_ITEMS'
'STOCK_SHOW_INSTALLED_ITEMS',
'STOCK_ENFORCE_BOM_INSTALLATION',
'TEST_STATION_DATA'
]}
/>
)

View File

@ -176,7 +176,6 @@ export default function CategoryDetail({}: {}) {
label: t`Category Details`,
icon: <IconInfoCircle />,
content: detailsPanel
// hidden: !category?.pk,
},
{
name: 'parts',
@ -200,7 +199,7 @@ export default function CategoryDetail({}: {}) {
},
{
name: 'parameters',
label: t`Parameters`,
label: t`Part Parameters`,
icon: <IconListDetails />,
content: <ParametricPartTable categoryId={id} />
}

View File

@ -111,7 +111,14 @@ export default function PartDetail() {
// Construct the details tables
let tl: DetailsField[] = [
{
type: 'text',
type: 'string',
name: 'name',
label: t`Name`,
icon: 'part',
copy: true
},
{
type: 'string',
name: 'description',
label: t`Description`,
copy: true

View File

@ -55,6 +55,7 @@ import { useInstance } from '../../hooks/UseInstance';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { AttachmentTable } from '../../tables/general/AttachmentTable';
import InstalledItemsTable from '../../tables/stock/InstalledItemsTable';
import { StockItemTable } from '../../tables/stock/StockItemTable';
import StockItemTestResultTable from '../../tables/stock/StockItemTestResultTable';
@ -164,6 +165,14 @@ export default function StockDetail() {
type: 'link',
name: 'belongs_to',
label: t`Installed In`,
model_formatter: (model: any) => {
let text = model?.part_detail?.full_name ?? model?.name;
if (model.serial && model.quantity == 1) {
text += `# ${model.serial}`;
}
return text;
},
model: ModelType.stockitem,
hidden: !stockitem.belongs_to
},
@ -259,7 +268,8 @@ export default function StockDetail() {
name: 'installed_items',
label: t`Installed Items`,
icon: <IconBoxPadding />,
hidden: !stockitem?.part_detail?.assembly
hidden: !stockitem?.part_detail?.assembly,
content: <InstalledItemsTable parentId={stockitem.pk} />
},
{
name: 'child_items',

View File

@ -16,4 +16,5 @@ export type TableColumn<T = any> = {
ellipsis?: boolean; // Whether the column should be ellipsized
textAlignment?: 'left' | 'center' | 'right'; // The text alignment of the column
cellsStyle?: any; // The style of the cells in the column
extra?: any; // Extra data to pass to the render function
};

View File

@ -2,6 +2,7 @@
* Common rendering functions for table column data.
*/
import { t } from '@lingui/macro';
import { Anchor } from '@mantine/core';
import { Thumbnail } from '../components/images/Thumbnail';
import { ProgressBar } from '../components/items/ProgressBar';
@ -10,6 +11,7 @@ import { TableStatusRenderer } from '../components/render/StatusRenderer';
import { RenderOwner } from '../components/render/User';
import { formatCurrency, renderDate } from '../defaults/formatters';
import { ModelType } from '../enums/ModelType';
import { cancelEvent } from '../functions/events';
import { TableColumn } from './Column';
import { ProjectCodeHoverCard } from './TableHoverCard';
@ -55,11 +57,36 @@ export function DescriptionColumn({
};
}
export function LinkColumn(): TableColumn {
export function LinkColumn({
accessor = 'link'
}: {
accessor?: string;
}): TableColumn {
return {
accessor: 'link',
sortable: false
// TODO: Custom URL hyperlink renderer?
accessor: accessor,
sortable: false,
render: (record: any) => {
let url = record[accessor];
if (!url) {
return '-';
}
return (
<Anchor
href={url}
target="_blank"
rel="noreferrer noopener"
onClick={(event: any) => {
cancelEvent(event);
window.open(url, '_blank', 'noopener,noreferrer');
}}
>
{url}
</Anchor>
);
}
};
}

View File

@ -191,7 +191,7 @@ export function FilterSelectDrawer({
>
<Stack spacing="xs">
{tableState.activeFilters &&
tableState.activeFilters.map((f) => (
tableState.activeFilters?.map((f) => (
<FilterItem key={f.name} flt={f} tableState={tableState} />
))}
{tableState.activeFilters && tableState.activeFilters.length > 0 && (

View File

@ -14,16 +14,24 @@ import { modals } from '@mantine/modals';
import { showNotification } from '@mantine/notifications';
import { IconFilter, IconRefresh, IconTrash } from '@tabler/icons-react';
import { IconBarcode, IconPrinter } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { DataTable, DataTableSortStatus } from 'mantine-datatable';
import { dataTagSymbol, useQuery } from '@tanstack/react-query';
import {
DataTable,
DataTableCellClickHandler,
DataTableSortStatus
} from 'mantine-datatable';
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../App';
import { ActionButton } from '../components/buttons/ActionButton';
import { ButtonMenu } from '../components/buttons/ButtonMenu';
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
import { ModelType } from '../enums/ModelType';
import { extractAvailableFields, mapFields } from '../functions/forms';
import { getDetailUrl } from '../functions/urls';
import { TableState } from '../hooks/UseTable';
import { base_url } from '../main';
import { useLocalState } from '../states/LocalState';
import { TableColumn } from './Column';
import { TableColumnSelect } from './ColumnSelect';
@ -57,6 +65,8 @@ const defaultPageSize: number = 25;
* @param dataFormatter : (data: any) => any - Callback function to reformat data returned by server (if not in default format)
* @param rowActions : (record: any) => RowAction[] - Callback function to generate row actions
* @param onRowClick : (record: any, index: number, event: any) => void - Callback function when a row is clicked
* @param onCellClick : (event: any, record: any, recordIndex: number, column: any, columnIndex: number) => void - Callback function when a cell is clicked
* @param modelType: ModelType - The model type for the table
*/
export type InvenTreeTableProps<T = any> = {
params?: any;
@ -79,6 +89,8 @@ export type InvenTreeTableProps<T = any> = {
dataFormatter?: (data: any) => any;
rowActions?: (record: T) => RowAction[];
onRowClick?: (record: T, index: number, event: any) => void;
onCellClick?: DataTableCellClickHandler<T>;
modelType?: ModelType;
};
/**
@ -119,6 +131,8 @@ export function InvenTreeTable<T = any>({
const { getTableColumnNames, setTableColumnNames } = useLocalState();
const [fieldNames, setFieldNames] = useState<Record<string, string>>({});
const navigate = useNavigate();
// Construct table filters - note that we can introspect filter labels from column names
const filters: TableFilter[] = useMemo(() => {
return (
@ -133,9 +147,10 @@ export function InvenTreeTable<T = any>({
// Request OPTIONS data from the API, before we load the table
const tableOptionQuery = useQuery({
enabled: false,
enabled: true,
queryKey: ['options', url, tableState.tableKey],
retry: 3,
refetchOnMount: true,
queryFn: async () => {
return api
.options(url, {
@ -259,15 +274,12 @@ export function InvenTreeTable<T = any>({
);
}
// Pagination
const [page, setPage] = useState(1);
// Filter list visibility
const [filtersVisible, setFiltersVisible] = useState<boolean>(false);
// Reset the pagination state when the search term changes
useEffect(() => {
setPage(1);
tableState.setPage(1);
}, [tableState.searchTerm]);
/*
@ -294,7 +306,7 @@ export function InvenTreeTable<T = any>({
if (tableProps.enablePagination && paginate) {
let pageSize = tableProps.pageSize ?? defaultPageSize;
queryParams.limit = pageSize;
queryParams.offset = (page - 1) * pageSize;
queryParams.offset = (tableState.page - 1) * pageSize;
}
// Ordering
@ -355,7 +367,7 @@ export function InvenTreeTable<T = any>({
);
const handleSortStatusChange = (status: DataTableSortStatus) => {
setPage(1);
tableState.setPage(1);
setSortStatus(status);
};
@ -387,7 +399,7 @@ export function InvenTreeTable<T = any>({
results = [];
}
setRecordCount(response.data?.count ?? results.length);
tableState.setRecordCount(response.data?.count ?? results.length);
return results;
case 400:
@ -419,7 +431,7 @@ export function InvenTreeTable<T = any>({
const { data, isFetching, refetch } = useQuery({
queryKey: [
page,
tableState.page,
props.params,
sortStatus.columnAccessor,
sortStatus.direction,
@ -432,7 +444,10 @@ export function InvenTreeTable<T = any>({
refetchOnMount: true
});
const [recordCount, setRecordCount] = useState<number>(0);
// Update tableState.records when new data received
useEffect(() => {
tableState.setRecords(data ?? []);
}, [data]);
// Callback function to delete the selected records in the table
const deleteSelectedRecords = useCallback(() => {
@ -494,6 +509,30 @@ export function InvenTreeTable<T = any>({
});
}, [tableState.selectedRecords]);
// Callback when a row is clicked
const handleRowClick = useCallback(
(record: any, index: number, event: any) => {
if (props.onRowClick) {
// If a custom row click handler is provided, use that
props.onRowClick(record, index, event);
} else if (tableProps.modelType && record?.pk) {
// If a model type is provided, navigate to the detail view for that model
let url = getDetailUrl(tableProps.modelType, record.pk);
// Should it be opened in a new tab?
if (event?.ctrlKey || event?.shiftKey) {
// Open in a new tab
url = `/${base_url}${url}`;
window.open(url, '_blank');
} else {
// Navigate internally
navigate(url);
}
}
},
[props.onRowClick]
);
return (
<>
{tableProps.enableFilters && (filters.length ?? 0) > 0 && (
@ -596,10 +635,10 @@ export function InvenTreeTable<T = any>({
pinLastColumn={tableProps.rowActions != undefined}
idAccessor={tableProps.idAccessor}
minHeight={300}
totalRecords={recordCount}
totalRecords={tableState.recordCount}
recordsPerPage={tableProps.pageSize ?? defaultPageSize}
page={page}
onPageChange={setPage}
page={tableState.page}
onPageChange={tableState.setPage}
sortStatus={sortStatus}
onSortStatusChange={handleSortStatusChange}
selectedRecords={
@ -613,9 +652,10 @@ export function InvenTreeTable<T = any>({
rowExpansion={tableProps.rowExpansion}
fetching={isFetching}
noRecordsText={missingRecordsText}
records={data}
records={tableState.records}
columns={dataColumns}
onRowClick={tableProps.onRowClick}
onRowClick={handleRowClick}
onCellClick={tableProps.onCellClick}
defaultColumnProps={{
noWrap: true,
textAlignment: 'left',

View File

@ -4,6 +4,7 @@ import { Menu } from '@mantine/core';
import { IconCopy, IconDots, IconEdit, IconTrash } from '@tabler/icons-react';
import { ReactNode, useMemo, useState } from 'react';
import { cancelEvent } from '../functions/events';
import { notYetImplemented } from '../functions/notifications';
// Type definition for a table row action
@ -93,9 +94,7 @@ export function RowActions({
// Prevent default event handling
// Ref: https://icflorescu.github.io/mantine-datatable/examples/links-or-buttons-inside-clickable-rows-or-cells
function openMenu(event: any) {
event?.preventDefault();
event?.stopPropagation();
event?.nativeEvent?.stopImmediatePropagation();
cancelEvent(event);
setOpened(!opened);
}
@ -118,9 +117,7 @@ export function RowActions({
icon={action.icon}
onClick={(event) => {
// Prevent clicking on the action from selecting the row itself
event?.preventDefault();
event?.stopPropagation();
event?.nativeEvent?.stopImmediatePropagation();
cancelEvent(event);
if (action.onClick) {
action.onClick();

View File

@ -16,7 +16,6 @@ import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { bomItemFields } from '../../forms/BomForms';
import { openDeleteApiForm, openEditApiForm } from '../../functions/forms';
import { getDetailUrl } from '../../functions/urls';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
@ -55,11 +54,9 @@ export function BomTable({
partId: number;
params?: any;
}) {
const navigate = useNavigate();
const user = useUserState();
const table = useTable('bom');
const navigate = useNavigate();
const tableColumns: TableColumn[] = useMemo(() => {
return [
@ -355,8 +352,7 @@ export function BomTable({
sub_part_detail: true
},
tableFilters: tableFilters,
onRowClick: (row) =>
navigate(getDetailUrl(ModelType.part, row.sub_part)),
modelType: ModelType.part,
rowActions: rowActions
}}
/>

View File

@ -1,11 +1,9 @@
import { t } from '@lingui/macro';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { PartHoverCard } from '../../components/images/Thumbnail';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { getDetailUrl } from '../../functions/urls';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { TableColumn } from '../Column';
@ -23,8 +21,6 @@ export function UsedInTable({
partId: number;
params?: any;
}) {
const navigate = useNavigate();
const table = useTable('usedin');
const tableColumns: TableColumn[] = useMemo(() => {
@ -87,7 +83,7 @@ export function UsedInTable({
sub_part_detail: true
},
tableFilters: tableFilters,
onRowClick: (row) => navigate(getDetailUrl(ModelType.part, row.part))
modelType: ModelType.part
}}
/>
);

View File

@ -178,7 +178,7 @@ export function BuildOrderTable({
},
tableActions: tableActions,
tableFilters: tableFilters,
onRowClick: (row) => navigate(getDetailUrl(ModelType.build, row.pk))
modelType: ModelType.build
}}
/>
</>

View File

@ -134,7 +134,7 @@ export function AddressTable({
pk: selectedAddress,
title: t`Edit Address`,
fields: addressFields,
onFormSuccess: table.refreshTable
onFormSuccess: (record: any) => table.updateRecord(record)
});
const deleteAddress = useDeleteApiFormModal({

View File

@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { Thumbnail } from '../../components/images/Thumbnail';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { companyFields } from '../../forms/CompanyForms';
import { useCreateApiFormModal } from '../../hooks/UseForm';

View File

@ -72,7 +72,7 @@ export function ContactTable({
pk: selectedContact,
title: t`Edit Contact`,
fields: contactFields,
onFormSuccess: table.refreshTable
onFormSuccess: (record: any) => table.updateRecord(record)
});
const newContact = useCreateApiFormModal({

View File

@ -34,8 +34,7 @@ function attachmentTableColumns(): TableColumn[] {
if (record.attachment) {
return <AttachmentLink attachment={record.attachment} />;
} else if (record.link) {
// TODO: Custom renderer for links
return record.link;
return <AttachmentLink attachment={record.link} external />;
} else {
return '-';
}
@ -121,7 +120,9 @@ export function AttachmentTable({
model: model,
pk: record.pk,
attachmentType: record.attachment ? 'file' : 'link',
callback: table.refreshTable
callback: (record: any) => {
table.updateRecord(record);
}
});
}
})

View File

@ -1,12 +1,21 @@
import { t } from '@lingui/macro';
import { ActionIcon, Group, Text, Tooltip } from '@mantine/core';
import { useHover } from '@mantine/hooks';
import { IconEdit } from '@tabler/icons-react';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useCallback, useMemo, useState } from 'react';
import { api } from '../../App';
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
import { YesNoButton } from '../../components/items/YesNoButton';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { getDetailUrl } from '../../functions/urls';
import { UserRoles } from '../../enums/Roles';
import { cancelEvent } from '../../functions/events';
import {
useCreateApiFormModal,
useEditApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
@ -15,6 +24,71 @@ import { DescriptionColumn, PartColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
import { TableHoverCard } from '../TableHoverCard';
// Render an individual parameter cell
function ParameterCell({
record,
template,
canEdit,
onEdit
}: {
record: any;
template: any;
canEdit: boolean;
onEdit: () => void;
}) {
const { hovered, ref } = useHover();
// Find matching template parameter
let parameter = record.parameters?.find(
(p: any) => p.template == template.pk
);
let extra: any[] = [];
let value: any = parameter?.data;
if (template?.checkbox && value != undefined) {
value = <YesNoButton value={parameter.data} />;
}
if (
template.units &&
parameter &&
parameter.data_numeric &&
parameter.data_numeric != parameter.data
) {
extra.push(`${parameter.data_numeric} [${template.units}]`);
}
const handleClick = useCallback((event: any) => {
cancelEvent(event);
onEdit();
}, []);
return (
<div>
<Group grow ref={ref} position="apart">
<Group grow style={{ flex: 1 }}>
<TableHoverCard
value={value ?? '-'}
extra={extra}
title={t`Internal Units`}
/>
</Group>
{hovered && canEdit && (
<div style={{ flex: 0 }}>
<Tooltip label={t`Edit parameter`}>
<ActionIcon size="xs" onClick={handleClick}>
<IconEdit />
</ActionIcon>
</Tooltip>
</div>
)}
</Group>
</div>
);
}
export default function ParametricPartTable({
categoryId
}: {
@ -22,7 +96,6 @@ export default function ParametricPartTable({
}) {
const table = useTable('parametric-parts');
const user = useUserState();
const navigate = useNavigate();
const categoryParmeters = useQuery({
queryKey: ['category-parameters', categoryId],
@ -39,6 +112,73 @@ export default function ParametricPartTable({
refetchOnMount: true
});
const [selectedPart, setSelectedPart] = useState<number>(0);
const [selectedTemplate, setSelectedTemplate] = useState<number>(0);
const [selectedParameter, setSelectedParameter] = useState<number>(0);
const partParameterFields: ApiFormFieldSet = useMemo(() => {
return {
part: {
disabled: true
},
template: {
disabled: true
},
data: {}
};
}, []);
const addParameter = useCreateApiFormModal({
url: ApiEndpoints.part_parameter_list,
title: t`Add Part Parameter`,
fields: partParameterFields,
onFormSuccess: (parameter: any) => {
updateParameterRecord(selectedPart, parameter);
},
initialData: {
part: selectedPart,
template: selectedTemplate
}
});
const editParameter = useEditApiFormModal({
url: ApiEndpoints.part_parameter_list,
title: t`Edit Part Parameter`,
pk: selectedParameter,
fields: partParameterFields,
onFormSuccess: (parameter: any) => {
updateParameterRecord(selectedPart, parameter);
}
});
// Update a single parameter record in the table
const updateParameterRecord = useCallback(
(part: number, parameter: any) => {
let records = table.records;
let partIndex = records.findIndex((record: any) => record.pk == part);
if (partIndex < 0) {
// No matching part: reload the entire table
table.refreshTable();
return;
}
let parameterIndex = records[partIndex].parameters.findIndex(
(p: any) => p.pk == parameter.pk
);
if (parameterIndex < 0) {
// No matching parameter - append new parameter
records[partIndex].parameters.push(parameter);
} else {
records[partIndex].parameters[parameterIndex] = parameter;
}
table.setRecords(records);
},
[table.records]
);
const parameterColumns: TableColumn[] = useMemo(() => {
let data = categoryParmeters.data ?? [];
@ -53,37 +193,33 @@ export default function ParametricPartTable({
accessor: `parameter_${template.pk}`,
title: title,
sortable: true,
render: (record: any) => {
// Find matching template parameter
let parameter = record.parameters?.find(
(p: any) => p.template == template.pk
);
extra: {
template: template.pk
},
render: (record: any) => (
<ParameterCell
record={record}
template={template}
canEdit={user.hasChangeRole(UserRoles.part)}
onEdit={() => {
setSelectedTemplate(template.pk);
setSelectedPart(record.pk);
let parameter = record.parameters?.find(
(p: any) => p.template == template.pk
);
if (!parameter) {
return '-';
}
let extra: any[] = [];
if (
template.units &&
parameter.data_numeric &&
parameter.data_numeric != parameter.data
) {
extra.push(`${parameter.data_numeric} [${template.units}]`);
}
return (
<TableHoverCard
value={parameter.data}
extra={extra}
title={t`Internal Units`}
/>
);
}
if (parameter) {
setSelectedParameter(parameter.pk);
editParameter.open();
} else {
addParameter.open();
}
}}
/>
)
};
});
}, [categoryParmeters.data]);
}, [user, categoryParmeters.data]);
const tableColumns: TableColumn[] = useMemo(() => {
const partColumns: TableColumn[] = [
@ -104,25 +240,54 @@ export default function ParametricPartTable({
return [...partColumns, ...parameterColumns];
}, [parameterColumns]);
// Callback when a parameter cell is clicked - either edit or add a new parameter
const handleCellClick = useCallback(
(record: any, column: any) => {
let template_id = column?.extra?.template;
if (!template_id) {
return;
}
setSelectedPart(record.pk);
setSelectedTemplate(template_id);
// Find the associated parameter
let parameter = record?.parameters?.find(
(p: any) => p.template == template_id
);
if (parameter) {
// Parameter exists - open edit dialog
setSelectedParameter(parameter.pk);
editParameter.open();
} else {
// Parameter does not exist - create it!
addParameter.open();
}
},
[user]
);
return (
<InvenTreeTable
url={apiUrl(ApiEndpoints.part_list)}
tableState={table}
columns={tableColumns}
props={{
enableDownload: false,
params: {
category: categoryId,
cascade: true,
category_detail: true,
parameters: true
},
onRowClick: (record) => {
if (record.pk) {
navigate(getDetailUrl(ModelType.part, record.pk));
}
}
}}
/>
<>
{addParameter.modal}
{editParameter.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.part_list)}
tableState={table}
columns={tableColumns}
props={{
enableDownload: false,
params: {
category: categoryId,
cascade: true,
category_detail: true,
parameters: true
},
modelType: ModelType.part
}}
/>
</>
);
}

View File

@ -95,7 +95,7 @@ export function PartCategoryTable({ parentId }: { parentId?: any }) {
pk: selectedCategory,
title: t`Edit Part Category`,
fields: partCategoryFields({}),
onFormSuccess: table.refreshTable
onFormSuccess: (record: any) => table.updateRecord(record)
});
const tableActions = useMemo(() => {
@ -143,8 +143,7 @@ export function PartCategoryTable({ parentId }: { parentId?: any }) {
tableFilters: tableFilters,
tableActions: tableActions,
rowActions: rowActions,
onRowClick: (record) =>
navigate(getDetailUrl(ModelType.partcategory, record.pk))
modelType: ModelType.partcategory
}}
/>
</>

View File

@ -14,7 +14,7 @@ import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import { DescriptionColumn } from '../ColumnRenderers';
import { BooleanColumn, DescriptionColumn } from '../ColumnRenderers';
import { TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowDeleteAction, RowEditAction } from '../RowActions';
@ -61,9 +61,9 @@ export default function PartParameterTemplateTable() {
sortable: true
},
DescriptionColumn({}),
{
BooleanColumn({
accessor: 'checkbox'
},
}),
{
accessor: 'choices'
}
@ -96,7 +96,7 @@ export default function PartParameterTemplateTable() {
pk: selectedTemplate,
title: t`Edit Parameter Template`,
fields: partParameterTemplateFields,
onFormSuccess: table.refreshTable
onFormSuccess: (record: any) => table.updateRecord(record)
});
const deleteTemplate = useDeleteApiFormModal({

View File

@ -4,7 +4,6 @@ import { ReactNode, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { Thumbnail } from '../../components/images/Thumbnail';
import { formatPriceRange } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
@ -163,7 +162,7 @@ function partTableColumns(): TableColumn[] {
render: (record: any) =>
formatPriceRange(record.pricing_min, record.pricing_max)
},
LinkColumn()
LinkColumn({})
];
}
@ -268,8 +267,8 @@ export function PartListTable({ props }: { props: InvenTreeTableProps }) {
const tableFilters = useMemo(() => partTableFilters(), []);
const table = useTable('part-list');
const navigate = useNavigate();
const user = useUserState();
const navigate = useNavigate();
const newPart = useCreateApiFormModal({
url: ApiEndpoints.part_list,
@ -305,14 +304,13 @@ export function PartListTable({ props }: { props: InvenTreeTableProps }) {
props={{
...props,
enableDownload: true,
modelType: ModelType.part,
tableFilters: tableFilters,
tableActions: tableActions,
params: {
...props.params,
category_detail: true
},
onRowClick: (record) =>
navigate(getDetailUrl(ModelType.part, record.pk))
}
}}
/>
</>

View File

@ -134,7 +134,7 @@ export default function PartTestTemplateTable({ partId }: { partId: number }) {
pk: selectedTest,
title: t`Edit Test Template`,
fields: partTestTemplateFields,
onFormSuccess: table.refreshTable
onFormSuccess: (record: any) => table.updateRecord(record)
});
const deleteTestTemplate = useDeleteApiFormModal({

View File

@ -1,6 +1,5 @@
import { t } from '@lingui/macro';
import { ReactNode, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { Thumbnail } from '../../components/images/Thumbnail';
@ -9,8 +8,6 @@ import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useManufacturerPartFields } from '../../forms/CompanyForms';
import { openDeleteApiForm, openEditApiForm } from '../../functions/forms';
import { notYetImplemented } from '../../functions/notifications';
import { getDetailUrl } from '../../functions/urls';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
@ -27,7 +24,6 @@ export function ManufacturerPartTable({ params }: { params: any }): ReactNode {
const table = useTable('manufacturerparts');
const user = useUserState();
const navigate = useNavigate();
// Construct table columns for this table
const tableColumns: TableColumn[] = useMemo(() => {
@ -58,7 +54,7 @@ export function ManufacturerPartTable({ params }: { params: any }): ReactNode {
sortable: true
},
DescriptionColumn({}),
LinkColumn()
LinkColumn({})
];
}, [params]);
@ -139,11 +135,7 @@ export function ManufacturerPartTable({ params }: { params: any }): ReactNode {
},
rowActions: rowActions,
tableActions: tableActions,
onRowClick: (record: any) => {
if (record?.pk) {
navigate(getDetailUrl(ModelType.manufacturerpart, record.pk));
}
}
modelType: ModelType.manufacturerpart
}}
/>
</>

View File

@ -2,7 +2,6 @@ import { t } from '@lingui/macro';
import { Text } from '@mantine/core';
import { IconSquareArrowRight } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ActionButton } from '../../components/buttons/ActionButton';
import { AddItemButton } from '../../components/buttons/AddItemButton';
@ -16,7 +15,6 @@ import {
usePurchaseOrderLineItemFields,
useReceiveLineItems
} from '../../forms/PurchaseOrderForms';
import { getDetailUrl } from '../../functions/urls';
import {
useCreateApiFormModal,
useDeleteApiFormModal,
@ -28,6 +26,7 @@ import { useUserState } from '../../states/UserState';
import {
CurrencyColumn,
LinkColumn,
NoteColumn,
ReferenceColumn,
TargetDateColumn,
TotalPriceColumn
@ -52,7 +51,6 @@ export function PurchaseOrderLineItemTable({
}) {
const table = useTable('purchase-order-line-item');
const navigate = useNavigate();
const user = useUserState();
const [singleRecord, setSingeRecord] = useState(null);
@ -180,11 +178,8 @@ export function PurchaseOrderLineItemTable({
? RenderStockLocation({ instance: record.destination_detail })
: '-'
},
{
accessor: 'notes',
title: t`Notes`
},
LinkColumn()
NoteColumn(),
LinkColumn({})
];
}, [orderId, user]);
@ -291,11 +286,7 @@ export function PurchaseOrderLineItemTable({
},
rowActions: rowActions,
tableActions: tableActions,
onRowClick: (row: any) => {
if (row.part) {
navigate(getDetailUrl(ModelType.supplierpart, row.part));
}
}
modelType: ModelType.supplierpart
}}
/>
</>

View File

@ -138,11 +138,7 @@ export function PurchaseOrderTable({
},
tableFilters: tableFilters,
tableActions: tableActions,
onRowClick: (row: any) => {
if (row.pk) {
navigate(getDetailUrl(ModelType.purchaseorder, row.pk));
}
}
modelType: ModelType.purchaseorder
}}
/>
</>

View File

@ -1,7 +1,6 @@
import { t } from '@lingui/macro';
import { Text } from '@mantine/core';
import { ReactNode, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { Thumbnail } from '../../components/images/Thumbnail';
@ -10,7 +9,6 @@ import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { useSupplierPartFields } from '../../forms/CompanyForms';
import { openDeleteApiForm, openEditApiForm } from '../../functions/forms';
import { getDetailUrl } from '../../functions/urls';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
@ -34,7 +32,6 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode {
const table = useTable('supplierparts');
const user = useUserState();
const navigate = useNavigate();
// Construct table columns for this table
const tableColumns: TableColumn[] = useMemo(() => {
@ -125,7 +122,7 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode {
);
}
},
LinkColumn(),
LinkColumn({}),
NoteColumn(),
{
accessor: 'available',
@ -232,11 +229,7 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode {
},
rowActions: rowActions,
tableActions: tableActions,
onRowClick: (record: any) => {
if (record?.pk) {
navigate(getDetailUrl(ModelType.supplierpart, record.pk));
}
}
modelType: ModelType.supplierpart
}}
/>
</>

View File

@ -1,6 +1,5 @@
import { t } from '@lingui/macro';
import { useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { Thumbnail } from '../../components/images/Thumbnail';
@ -8,7 +7,6 @@ import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
import { notYetImplemented } from '../../functions/notifications';
import { getDetailUrl } from '../../functions/urls';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
@ -36,8 +34,6 @@ export function ReturnOrderTable({ params }: { params?: any }) {
const table = useTable('return-orders');
const user = useUserState();
const navigate = useNavigate();
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
@ -115,11 +111,7 @@ export function ReturnOrderTable({ params }: { params?: any }) {
},
tableFilters: tableFilters,
tableActions: tableActions,
onRowClick: (row: any) => {
if (row.pk) {
navigate(getDetailUrl(ModelType.returnorder, row.pk));
}
}
modelType: ModelType.returnorder
}}
/>
);

View File

@ -136,11 +136,7 @@ export function SalesOrderTable({
},
tableFilters: tableFilters,
tableActions: tableActions,
onRowClick: (row: any) => {
if (row.pk) {
navigate(getDetailUrl(ModelType.salesorder, row.pk));
}
}
modelType: ModelType.salesorder
}}
/>
</>

View File

@ -59,7 +59,7 @@ export default function CustomUnitsTable() {
pk: selectedUnit,
title: t`Edit Custom Unit`,
fields: customUnitsFields(),
onFormSuccess: table.refreshTable
onFormSuccess: (record: any) => table.updateRecord(record)
});
const deleteUnit = useDeleteApiFormModal({

View File

@ -53,7 +53,7 @@ export default function ProjectCodeTable() {
pk: selectedProjectCode,
title: t`Edit Project Code`,
fields: projectCodeFields(),
onFormSuccess: table.refreshTable
onFormSuccess: (record: any) => table.updateRecord(record)
});
const deleteProjectCode = useDeleteApiFormModal({

View File

@ -1,9 +1,16 @@
import { Trans, t } from '@lingui/macro';
import { Alert, List, LoadingOverlay, Stack, Text, Title } from '@mantine/core';
import {
Alert,
List,
LoadingOverlay,
Spoiler,
Stack,
Text,
Title
} from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Link } from 'react-router-dom';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { EditApiForm } from '../../components/forms/ApiForm';
@ -12,7 +19,10 @@ import {
DetailDrawerLink
} from '../../components/nav/DetailDrawer';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { openCreateApiForm, openDeleteApiForm } from '../../functions/forms';
import {
useCreateApiFormModal,
useDeleteApiFormModal
} from '../../hooks/UseForm';
import { useInstance } from '../../hooks/UseInstance';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
@ -120,25 +130,29 @@ export function UserDrawer({
id={`user-detail-drawer-${id}`}
/>
<Title order={5}>
<Trans>Groups</Trans>
</Title>
<Text ml={'md'}>
{userDetail?.groups && userDetail?.groups?.length > 0 ? (
<List>
{userDetail?.groups?.map((group) => (
<List.Item key={group.pk}>
<DetailDrawerLink
to={`../group-${group.pk}`}
text={group.name}
/>
</List.Item>
))}
</List>
) : (
<Trans>No groups</Trans>
)}
</Text>
<Stack>
<Title order={5}>
<Trans>Groups</Trans>
</Title>
<Spoiler maxHeight={125} showLabel="Show More" hideLabel="Show Less">
<Text ml={'md'}>
{userDetail?.groups && userDetail?.groups?.length > 0 ? (
<List>
{userDetail?.groups?.map((group) => (
<List.Item key={group.pk}>
<DetailDrawerLink
to={`../group-${group.pk}`}
text={group.name}
/>
</List.Item>
))}
</List>
) : (
<Trans>No groups</Trans>
)}
</Text>
</Spoiler>
</Stack>
</Stack>
);
}
@ -194,6 +208,9 @@ export function UserTable() {
];
}, []);
// Row Actions
const [selectedUser, setSelectedUser] = useState<number>(-1);
const rowActions = useCallback((record: UserDetailI): RowAction[] => {
return [
RowEditAction({
@ -201,39 +218,45 @@ export function UserTable() {
}),
RowDeleteAction({
onClick: () => {
openDeleteApiForm({
url: ApiEndpoints.user_list,
pk: record.pk,
title: t`Delete user`,
successMessage: t`User deleted`,
onFormSuccess: table.refreshTable,
preFormWarning: t`Are you sure you want to delete this user?`
});
setSelectedUser(record.pk);
deleteUser.open();
}
})
];
}, []);
const addUser = useCallback(() => {
openCreateApiForm({
url: ApiEndpoints.user_list,
title: t`Add user`,
fields: {
username: {},
email: {},
first_name: {},
last_name: {}
},
onFormSuccess: table.refreshTable,
successMessage: t`Added user`
});
}, []);
const deleteUser = useDeleteApiFormModal({
url: ApiEndpoints.user_list,
pk: selectedUser,
title: t`Delete user`,
successMessage: t`User deleted`,
onFormSuccess: table.refreshTable,
preFormWarning: t`Are you sure you want to delete this user?`
});
// Table Actions - Add New User
const newUser = useCreateApiFormModal({
url: ApiEndpoints.user_list,
title: t`Add user`,
fields: {
username: {},
email: {},
first_name: {},
last_name: {}
},
onFormSuccess: table.refreshTable,
successMessage: t`Added user`
});
const tableActions = useMemo(() => {
let actions = [];
actions.push(
<AddItemButton key="add-user" onClick={addUser} tooltip={t`Add user`} />
<AddItemButton
key="add-user"
onClick={newUser.open}
tooltip={t`Add user`}
/>
);
return actions;
@ -241,6 +264,8 @@ export function UserTable() {
return (
<>
{newUser.modal}
{deleteUser.modal}
<DetailDrawer
title={t`Edit user`}
renderContent={(id) => {

View File

@ -0,0 +1,69 @@
import { useMemo } from 'react';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import { PartColumn, StatusColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable';
export default function InstalledItemsTable({
parentId
}: {
parentId?: number | string;
}) {
const table = useTable('stock_item_install');
const user = useUserState();
const tableColumns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'part',
switchable: false,
render: (record: any) => PartColumn(record?.part_detail)
},
{
accessor: 'quantity',
switchable: false,
render: (record: any) => {
let text = record.quantity;
if (record.serial && record.quantity == 1) {
text = `# ${record.serial}`;
}
return text;
}
},
{
accessor: 'batch',
switchable: false
},
StatusColumn(ModelType.stockitem)
];
}, []);
const tableActions = useMemo(() => {
return [];
}, [user]);
return (
<>
<InvenTreeTable
url={apiUrl(ApiEndpoints.stock_item_list)}
tableState={table}
columns={tableColumns}
props={{
tableActions: tableActions,
modelType: ModelType.stockitem,
params: {
belongs_to: parentId,
part_detail: true
}
}}
/>
</>
);
}

View File

@ -509,8 +509,7 @@ export function StockItemTable({ params = {} }: { params?: any }) {
enableSelection: true,
tableFilters: tableFilters,
tableActions: tableActions,
onRowClick: (record) =>
navigate(getDetailUrl(ModelType.stockitem, record.pk)),
modelType: ModelType.stockitem,
params: {
...params,
part_detail: true,

View File

@ -277,6 +277,13 @@ export default function StockItemTestResultTable({
message: t`Test result has been recorded`,
color: 'green'
});
})
.catch(() => {
showNotification({
title: t`Error`,
message: t`Failed to record test result`,
color: 'red'
});
});
},
[itemId]

View File

@ -105,7 +105,7 @@ export function StockLocationTable({ parentId }: { parentId?: any }) {
pk: selectedLocation,
title: t`Edit Stock Location`,
fields: stockLocationFields({}),
onFormSuccess: table.refreshTable
onFormSuccess: (record: any) => table.updateRecord(record)
});
const tableActions = useMemo(() => {
@ -153,9 +153,7 @@ export function StockLocationTable({ parentId }: { parentId?: any }) {
tableFilters: tableFilters,
tableActions: tableActions,
rowActions: rowActions,
onRowClick: (record) => {
navigate(getDetailUrl(ModelType.stocklocation, record.pk));
}
modelType: ModelType.stocklocation
}}
/>
</>

View File

@ -2155,9 +2155,9 @@ find-up@^3.0.0:
locate-path "^3.0.0"
follow-redirects@^1.15.0:
version "1.15.4"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf"
integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==
version "1.15.6"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b"
integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==
form-data@^4.0.0:
version "4.0.0"