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_USER: inventree_user
INVENTREE_DB_PASSWORD: inventree_password INVENTREE_DB_PASSWORD: inventree_password
INVENTREE_PLUGINS_ENABLED: True INVENTREE_PLUGINS_ENABLED: True
INVENTREE_SITE_URL: http://localhost:8000
INVENTREE_CORS_ORIGIN_ALLOW_ALL: True
INVENTREE_PY_ENV: /home/inventree/dev/venv INVENTREE_PY_ENV: /home/inventree/dev/venv
depends_on: depends_on:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,14 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # 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.""" """Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """ 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 v183 - 2024-03-14 : https://github.com/inventree/InvenTree/pull/5972
- Adds "category_default_location" annotated field to part serializer - Adds "category_default_location" annotated field to part serializer
- Adds "part_detail.category_default_location" annotated field to stock item 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 If a new language translation is supported, it must be added here
After adding a new language, run the following command: After adding a new language, run the following command:
python manage.py makemessages -l <language_code> -e html,js,py --no-wrap 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: Additionally, update the following files with the new locale code:
- /src/frontend/.linguirc file - /src/frontend/.linguirc file
- /src/frontend/src/context/LanguageContext.tsx - /src/frontend/src/contexts/LanguageContext.tsx
""" """
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -30,6 +32,7 @@ LOCALES = [
('it', _('Italian')), ('it', _('Italian')),
('ja', _('Japanese')), ('ja', _('Japanese')),
('ko', _('Korean')), ('ko', _('Korean')),
('lv', _('Latvian')),
('nl', _('Dutch')), ('nl', _('Dutch')),
('no', _('Norwegian')), ('no', _('Norwegian')),
('pl', _('Polish')), ('pl', _('Polish')),

View File

@ -352,7 +352,12 @@ class InvenTreeModelSerializer(serializers.ModelSerializer):
try: try:
instance.full_clean() instance.full_clean()
except (ValidationError, DjangoValidationError) as exc: 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) # Change '__all__' key (django style) to 'non_field_errors' (DRF style)
if '__all__' in data: if '__all__' in data:

View File

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

View File

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

View File

@ -1750,6 +1750,14 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False, 'default': False,
'validator': bool, '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': { 'BUILDORDER_REFERENCE_PATTERN': {
'name': _('Build Order Reference Pattern'), 'name': _('Build Order Reference Pattern'),
'description': _( 'description': _(
@ -2004,6 +2012,12 @@ class InvenTreeSetting(BaseInvenTreeSetting):
'default': False, 'default': False,
'validator': bool, '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' typ = 'inventree'

View File

@ -12,6 +12,7 @@ from django.core.cache import cache
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import Client, TestCase from django.test import Client, TestCase
from django.test.utils import override_settings
from django.urls import reverse from django.urls import reverse
import PIL import PIL
@ -271,6 +272,7 @@ class SettingsTest(InvenTreeTestCase):
print(f"run_settings_check failed for user setting '{key}'") print(f"run_settings_check failed for user setting '{key}'")
raise exc raise exc
@override_settings(SITE_URL=None)
def test_defaults(self): def test_defaults(self):
"""Populate the settings with default values.""" """Populate the settings with default values."""
for key in InvenTreeSetting.SETTINGS.keys(): 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.cache import cache
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.test import TestCase from django.test import TestCase
from django.test.utils import override_settings
from allauth.account.models import EmailAddress from allauth.account.models import EmailAddress
@ -63,6 +64,7 @@ class TemplateTagTest(InvenTreeTestCase):
"""Test the 'instance name' setting.""" """Test the 'instance name' setting."""
self.assertEqual(inventree_extras.inventree_instance_name(), 'InvenTree') self.assertEqual(inventree_extras.inventree_instance_name(), 'InvenTree')
@override_settings(SITE_URL=None)
def test_inventree_base_url(self): def test_inventree_base_url(self):
"""Test that the base URL tag returns correctly.""" """Test that the base URL tag returns correctly."""
self.assertEqual(inventree_extras.inventree_base_url(), '') 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 maintenance_mode.core import set_maintenance_mode
from InvenTree.ready import canAppAccessDatabase, isInMainThread from InvenTree.ready import canAppAccessDatabase, isInMainThread, isInWorkerThread
from plugin import registry from plugin import registry
logger = logging.getLogger('inventree') logger = logging.getLogger('inventree')
@ -24,7 +24,8 @@ class PluginAppConfig(AppConfig):
def ready(self): def ready(self):
"""The ready method is extended to initialize plugins.""" """The ready method is extended to initialize plugins."""
# skip loading if we run in a background thread # skip loading if we run in a background thread
if not isInMainThread():
if not isInMainThread() and not isInWorkerThread():
return return
if not canAppAccessDatabase( if not canAppAccessDatabase(

View File

@ -117,7 +117,7 @@ def allow_table_event(table_name):
return False # pragma: no cover return False # pragma: no cover
# Prevent table events when in testing mode (saves a lot of time) # 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 return False
table_name = table_name.lower().strip() 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) value: Recorded test output value (optional)
attachment: Link to StockItem attachment (optional) attachment: Link to StockItem attachment (optional)
notes: Extra user notes related to the test (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 user: User who uploaded the test result
date: Date the test result was recorded 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) 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) date = models.DateTimeField(auto_now_add=True, editable=False)

View File

@ -64,6 +64,9 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
'value', 'value',
'attachment', 'attachment',
'notes', 'notes',
'test_station',
'started_datetime',
'finished_datetime',
'user', 'user',
'user_detail', 'user_detail',
'date', 'date',
@ -137,7 +140,18 @@ class StockItemTestResultSerializer(InvenTree.serializers.InvenTreeModelSerializ
part=stock_item.part, test_name=test_name 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): class StockItemSerializerBrief(InvenTree.serializers.InvenTreeModelSerializer):
@ -584,9 +598,14 @@ class InstallStockItemSerializer(serializers.Serializer):
parent_item = self.context['item'] parent_item = self.context['item']
parent_part = parent_item.part parent_part = parent_item.part
# Check if the selected part is in the Bill of Materials of the parent item if common.models.InvenTreeSetting.get_setting(
if not parent_part.check_if_part_in_bom(stock_item.part): 'STOCK_ENFORCE_BOM_INSTALLATION', backup_value=True, cache=False
raise ValidationError(_('Selected part is not in the Bill of Materials')) ):
# 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 return stock_item

View File

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

View File

@ -2590,6 +2590,9 @@ function constructInput(name, parameters, options={}) {
case 'date': case 'date':
func = constructDateInput; func = constructDateInput;
break; break;
case 'datetime':
func = constructDateTimeInput;
break;
case 'candy': case 'candy':
func = constructCandyInput; func = constructCandyInput;
break; 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 * Construct a "candy" field input
* No actual field data! * 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>`; 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 // 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>`; html += `<span class='badge badge-right rounded-pill bg-secondary'>${row.user_detail.username}</span>`;
} }
@ -1392,6 +1392,13 @@ function stockItemTestResultFields(options={}) {
notes: { notes: {
icon: 'fa-sticky-note', icon: 'fa-sticky-note',
}, },
test_station: {},
started_datetime: {
icon: 'fa-calendar-alt',
},
finished_datetime: {
icon: 'fa-calendar-alt',
},
stock_item: { stock_item: {
hidden: true, hidden: true,
}, },
@ -1530,7 +1537,30 @@ function loadStockTestResultsTable(table, options) {
title: '{% trans "Test Date" %}', title: '{% trans "Test Date" %}',
sortable: true, sortable: true,
formatter: function(value, row) { 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']['value'] = templateId;
fields['template']['filters']['part'] = options.part; 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" %}', { constructForm('{% url "api-stock-test-result-list" %}', {
method: 'POST', method: 'POST',
fields: fields, fields: fields,
@ -3204,7 +3240,7 @@ function installStockItem(stock_item_id, part_id, options={}) {
auto_fill: true, auto_fill: true,
filters: { filters: {
trackable: true, trackable: true,
in_bom_for: part_id, in_bom_for: options.enforce_bom ? part_id : undefined,
} }
}, },
stock_item: { 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_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_ADMIN_URL | admin_url | URL for accessing [admin interface](../settings/admin.md) | admin |
| INVENTREE_LANGUAGE | language | Default language | en-us | | 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 | | 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 ## 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" !!! 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. 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_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 | | 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 ## 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. 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 ### 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 | | Filename | Description |
| --- | --- | | --- | --- |
@ -109,6 +109,9 @@ This command launches the following containers:
!!! success "Up and Running!" !!! 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). 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 ## Updating InvenTree
To update your InvenTree installation to the latest version, follow these steps: 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 # via pdfminer-six
distlib==0.3.8 distlib==0.3.8
# via virtualenv # via virtualenv
django==4.2.10 django==4.2.11
# via django-slowtests # via django-slowtests
django-slowtests==1.1.1 django-slowtests==1.1.1
django-test-migrations==1.3.0 django-test-migrations==1.3.0
@ -57,7 +57,7 @@ pyyaml==6.0.1
# via pre-commit # via pre-commit
requests==2.31.0 requests==2.31.0
# via coveralls # via coveralls
setuptools==69.1.0 setuptools==69.2.0
# via # via
# nodeenv # nodeenv
# pip-tools # pip-tools

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -80,7 +80,7 @@ export function editAttachment({
model: string; model: string;
pk: number; pk: number;
attachmentType: 'file' | 'link'; attachmentType: 'file' | 'link';
callback?: () => void; callback?: (record: any) => void;
}) { }) {
let formFields: ApiFormFieldSet = { let formFields: ApiFormFieldSet = {
link: {}, 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; setHiddenColumns: (columns: string[]) => void;
searchTerm: string; searchTerm: string;
setSearchTerm: (term: string) => void; 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([]); 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 // A list of hidden columns, saved to local storage
const [hiddenColumns, setHiddenColumns] = useLocalStorage<string[]>({ const [hiddenColumns, setHiddenColumns] = useLocalStorage<string[]>({
key: `inventree-hidden-table-columns-${tableName}`, key: `inventree-hidden-table-columns-${tableName}`,
@ -80,6 +93,28 @@ export function useTable(tableName: string): TableState {
// Search term // Search term
const [searchTerm, setSearchTerm] = useState<string>(''); 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 { return {
tableKey, tableKey,
refreshTable, refreshTable,
@ -94,6 +129,13 @@ export function useTable(tableName: string): TableState {
hiddenColumns, hiddenColumns,
setHiddenColumns, setHiddenColumns,
searchTerm, 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 <GlobalSettingList
keys={['CURRENCY_UPDATE_PLUGIN', 'CURRENCY_UPDATE_INTERVAL']} 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_ALLOW_EXPIRED_BUILD',
'STOCK_OWNERSHIP_CONTROL', 'STOCK_OWNERSHIP_CONTROL',
'STOCK_LOCATION_DEFAULT_ICON', '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`, label: t`Category Details`,
icon: <IconInfoCircle />, icon: <IconInfoCircle />,
content: detailsPanel content: detailsPanel
// hidden: !category?.pk,
}, },
{ {
name: 'parts', name: 'parts',
@ -200,7 +199,7 @@ export default function CategoryDetail({}: {}) {
}, },
{ {
name: 'parameters', name: 'parameters',
label: t`Parameters`, label: t`Part Parameters`,
icon: <IconListDetails />, icon: <IconListDetails />,
content: <ParametricPartTable categoryId={id} /> content: <ParametricPartTable categoryId={id} />
} }

View File

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

View File

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

View File

@ -16,4 +16,5 @@ export type TableColumn<T = any> = {
ellipsis?: boolean; // Whether the column should be ellipsized ellipsis?: boolean; // Whether the column should be ellipsized
textAlignment?: 'left' | 'center' | 'right'; // The text alignment of the column textAlignment?: 'left' | 'center' | 'right'; // The text alignment of the column
cellsStyle?: any; // The style of the cells in 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. * Common rendering functions for table column data.
*/ */
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { Anchor } from '@mantine/core';
import { Thumbnail } from '../components/images/Thumbnail'; import { Thumbnail } from '../components/images/Thumbnail';
import { ProgressBar } from '../components/items/ProgressBar'; import { ProgressBar } from '../components/items/ProgressBar';
@ -10,6 +11,7 @@ import { TableStatusRenderer } from '../components/render/StatusRenderer';
import { RenderOwner } from '../components/render/User'; import { RenderOwner } from '../components/render/User';
import { formatCurrency, renderDate } from '../defaults/formatters'; import { formatCurrency, renderDate } from '../defaults/formatters';
import { ModelType } from '../enums/ModelType'; import { ModelType } from '../enums/ModelType';
import { cancelEvent } from '../functions/events';
import { TableColumn } from './Column'; import { TableColumn } from './Column';
import { ProjectCodeHoverCard } from './TableHoverCard'; import { ProjectCodeHoverCard } from './TableHoverCard';
@ -55,11 +57,36 @@ export function DescriptionColumn({
}; };
} }
export function LinkColumn(): TableColumn { export function LinkColumn({
accessor = 'link'
}: {
accessor?: string;
}): TableColumn {
return { return {
accessor: 'link', accessor: accessor,
sortable: false sortable: false,
// TODO: Custom URL hyperlink renderer? 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"> <Stack spacing="xs">
{tableState.activeFilters && {tableState.activeFilters &&
tableState.activeFilters.map((f) => ( tableState.activeFilters?.map((f) => (
<FilterItem key={f.name} flt={f} tableState={tableState} /> <FilterItem key={f.name} flt={f} tableState={tableState} />
))} ))}
{tableState.activeFilters && tableState.activeFilters.length > 0 && ( {tableState.activeFilters && tableState.activeFilters.length > 0 && (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -34,8 +34,7 @@ function attachmentTableColumns(): TableColumn[] {
if (record.attachment) { if (record.attachment) {
return <AttachmentLink attachment={record.attachment} />; return <AttachmentLink attachment={record.attachment} />;
} else if (record.link) { } else if (record.link) {
// TODO: Custom renderer for links return <AttachmentLink attachment={record.link} external />;
return record.link;
} else { } else {
return '-'; return '-';
} }
@ -121,7 +120,9 @@ export function AttachmentTable({
model: model, model: model,
pk: record.pk, pk: record.pk,
attachmentType: record.attachment ? 'file' : 'link', 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 { 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 { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../../App'; import { api } from '../../App';
import { ApiFormFieldSet } from '../../components/forms/fields/ApiFormField';
import { YesNoButton } from '../../components/items/YesNoButton';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType'; 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 { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState'; import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
@ -15,6 +24,71 @@ import { DescriptionColumn, PartColumn } from '../ColumnRenderers';
import { InvenTreeTable } from '../InvenTreeTable'; import { InvenTreeTable } from '../InvenTreeTable';
import { TableHoverCard } from '../TableHoverCard'; 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({ export default function ParametricPartTable({
categoryId categoryId
}: { }: {
@ -22,7 +96,6 @@ export default function ParametricPartTable({
}) { }) {
const table = useTable('parametric-parts'); const table = useTable('parametric-parts');
const user = useUserState(); const user = useUserState();
const navigate = useNavigate();
const categoryParmeters = useQuery({ const categoryParmeters = useQuery({
queryKey: ['category-parameters', categoryId], queryKey: ['category-parameters', categoryId],
@ -39,6 +112,73 @@ export default function ParametricPartTable({
refetchOnMount: true 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(() => { const parameterColumns: TableColumn[] = useMemo(() => {
let data = categoryParmeters.data ?? []; let data = categoryParmeters.data ?? [];
@ -53,37 +193,33 @@ export default function ParametricPartTable({
accessor: `parameter_${template.pk}`, accessor: `parameter_${template.pk}`,
title: title, title: title,
sortable: true, sortable: true,
render: (record: any) => { extra: {
// Find matching template parameter template: template.pk
let parameter = record.parameters?.find( },
(p: any) => p.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) { if (parameter) {
return '-'; setSelectedParameter(parameter.pk);
} editParameter.open();
} else {
let extra: any[] = []; addParameter.open();
}
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`}
/>
);
}
}; };
}); });
}, [categoryParmeters.data]); }, [user, categoryParmeters.data]);
const tableColumns: TableColumn[] = useMemo(() => { const tableColumns: TableColumn[] = useMemo(() => {
const partColumns: TableColumn[] = [ const partColumns: TableColumn[] = [
@ -104,25 +240,54 @@ export default function ParametricPartTable({
return [...partColumns, ...parameterColumns]; return [...partColumns, ...parameterColumns];
}, [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 ( return (
<InvenTreeTable <>
url={apiUrl(ApiEndpoints.part_list)} {addParameter.modal}
tableState={table} {editParameter.modal}
columns={tableColumns} <InvenTreeTable
props={{ url={apiUrl(ApiEndpoints.part_list)}
enableDownload: false, tableState={table}
params: { columns={tableColumns}
category: categoryId, props={{
cascade: true, enableDownload: false,
category_detail: true, params: {
parameters: true category: categoryId,
}, cascade: true,
onRowClick: (record) => { category_detail: true,
if (record.pk) { parameters: true
navigate(getDetailUrl(ModelType.part, record.pk)); },
} modelType: ModelType.part
} }}
}} />
/> </>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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