2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-09-13 14:11:37 +00:00

Import update (#10188)

* Add field to "update" existing records

* Ensure the ID is first

* Prevent editing of "ID" field

* Extract db instance

* Bump API version

* Prevent edit of "id" field

* Refactoring

* Enhanced playwright tests for data importing

* Update docs

* Update src/backend/InvenTree/importer/models.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/frontend/src/forms/ImporterForms.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix silly AI mistake

* Fix for table pagination

- Ensure page does not exceed available records

* Bug fix for playwright test

* Add end-to-end API testing

* Fix unit tests

* Adjust table page logic

* Ensure sensible page size

* Simplify playwright test

* Simplify test again

* Tweak unit test

- Importing has invalidated the BOM?

* Adjust playwright tests

* Further playwright fixes

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Oliver
2025-08-20 15:34:49 +10:00
committed by GitHub
parent e44008f528
commit 885ec81a08
21 changed files with 351 additions and 51 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -76,3 +76,37 @@ Each individual row can be imported, or removed (deleted) by the user. Once all
### Import Completed
Once all records have been processed, the import session is considered complete. The import session can be closed, and the imported records are now stored in the database.
## Updating Existing Records
The data import process can also be used to update existing records in the database. This requires that the imported data file contains a unique identifier for each record, which can be used to match the records in the database.
The basic outline of this process is:
1. Export the existing records to a CSV file.
2. Modify the CSV file to update the records as required.
3. Upload the modified CSV file to the import session.
!!! note "Mixing Creation and Update"
It is not possible to mix the creation of new records with the updating of existing records in a single import session. If you wish to create new records, you must create a separate import session for that purpose.
### Create Import Session
!!! note "Admin Center"
Updating existing records can only be performed when creating a new import session from the [Admin Center](./admin.md#admin-center).
Create a new import session, and ensure that the *Update Existing Records* option is selected. This will allow the import session to update existing records in the database.
{{ image("admin/import_session_create_update.png", "Update existing records") }}
### Map Data Fields
When mapping the data fields, ensure that the `ID` field is correctly mapped to the corresponding column in the file:
{{ image("admin/import_select_id.png", "Update existing records") }}
### Process Data
When processing the data, each row will be matched against an existing record in the database. If a match is found, the existing record will be updated with the new data from the imported file.
{{ image("admin/import_update_process.png", "Update existing records") }}

View File

@@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 386
INVENTREE_API_VERSION = 387
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v387 -> 2025-08-19 : https://github.com/inventree/InvenTree/pull/10188
- Adds "update_records" field to the DataImportSession API
v386 -> 2025-08-11 : https://github.com/inventree/InvenTree/pull/8191
- Adds "consumed" field to the BuildItem API
- Adds API endpoint to consume stock against a BuildOrder

View File

@@ -0,0 +1,22 @@
# Generated by Django 4.2.23 on 2025-08-18 13:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("importer", "0004_alter_dataimportsession_model_type"),
]
operations = [
migrations.AddField(
model_name="dataimportsession",
name="update_records",
field=models.BooleanField(
default=False,
help_text="If enabled, existing records will be updated with new data",
verbose_name="Update Existing Records",
),
),
]

View File

@@ -1,6 +1,7 @@
"""Model definitions for the 'importer' app."""
import json
from collections import OrderedDict
from typing import Optional
from django.contrib.auth.models import User
@@ -39,6 +40,8 @@ class DataImportSession(models.Model):
field_filters: JSONField for field filter values - optional field API filters
"""
ID_FIELD_LABEL = 'id'
class ModelChoices(RenderChoices):
"""Model choices for data import sessions."""
@@ -118,6 +121,12 @@ class DataImportSession(models.Model):
validators=[importer.validators.validate_field_defaults],
)
update_records = models.BooleanField(
default=False,
verbose_name=_('Update Existing Records'),
help_text=_('If enabled, existing records will be updated with new data'),
)
@property
def field_mapping(self) -> dict:
"""Construct a dict of field mappings for this import session.
@@ -351,13 +360,25 @@ class DataImportSession(models.Model):
metadata = InvenTreeMetadata()
fields = OrderedDict()
if self.update_records:
# If we are updating records, ensure the ID field is included
fields[self.ID_FIELD_LABEL] = {
'label': _('ID'),
'help_text': _('Existing database identifier for the record'),
'type': 'integer',
'required': True,
'read_only': False,
}
if serializer_class := self.serializer_class:
serializer = serializer_class(data={}, importing=True)
fields = metadata.get_serializer_info(serializer)
else:
fields = {}
fields.update(metadata.get_serializer_info(serializer))
# Cache the available fields against this instance
self._available_fields = fields
return fields
def required_fields(self) -> dict:
@@ -370,6 +391,10 @@ class DataImportSession(models.Model):
if info.get('required', False):
required[field] = info
elif self.update_records and field == self.ID_FIELD_LABEL:
# If we are updating records, the ID field is required
required[field] = info
return required
@@ -630,11 +655,13 @@ class DataImportRow(models.Model):
return data
def construct_serializer(self, request=None):
def construct_serializer(self, instance=None, request=None):
"""Construct a serializer object for this row."""
if serializer_class := self.session.serializer_class:
return serializer_class(
data=self.serializer_data(), context={'request': request}
instance=instance,
data=self.serializer_data(),
context={'request': request},
)
def validate(self, commit=False, request=None) -> bool:
@@ -654,6 +681,25 @@ class DataImportRow(models.Model):
# Row has already been completed
return True
if self.session.update_records:
# Extract the ID field from the data
instance_id = self.data.get(self.session.ID_FIELD_LABEL, None)
if not instance_id:
raise DjangoValidationError(
_('ID is required for updating existing records.')
)
try:
instance = self.session.model_class.objects.get(pk=instance_id)
except self.session.model_class.DoesNotExist:
raise DjangoValidationError(_('No record found with the provided ID.'))
except ValueError:
raise DjangoValidationError(_('Invalid ID format provided.'))
serializer = self.construct_serializer(instance=instance, request=request)
else:
serializer = self.construct_serializer(request=request)
if not serializer:

View File

@@ -41,6 +41,7 @@ class DataImportSessionSerializer(InvenTreeModelSerializer):
'pk',
'timestamp',
'data_file',
'update_records',
'model_type',
'available_fields',
'status',

View File

@@ -0,0 +1,6 @@
ID,Name,Description,Default Location,Default keywords,Level,Parent Category,Parts,Subcategories,Path,Starred,Structural,Icon,Parent default location
23,Category 0,"Part category, level 1",,,0,,0,5,Category 0,False,False,,
1,Electronics,Electronic components and systems,,,0,,135,12,Electronics,False,False,,
17,Furniture,Furniture and associated things,,,0,,22,2,Furniture,False,False,,
2,Mechanical,Mechanical components,,,0,,263,3,Mechanical,False,False,,
20,Paint,"Paints, inks, etc",,,0,,5,0,Paint,False,False,,
1 ID Name Description Default Location Default keywords Level Parent Category Parts Subcategories Path Starred Structural Icon Parent default location
2 23 Category 0 Part category, level 1 0 0 5 Category 0 False False
3 1 Electronics Electronic components and systems 0 135 12 Electronics False False
4 17 Furniture Furniture and associated things 0 22 2 Furniture False False
5 2 Mechanical Mechanical components 0 263 3 Mechanical False False
6 20 Paint Paints, inks, etc 0 5 0 Paint False False

View File

@@ -3,25 +3,23 @@
import os
from django.core.files.base import ContentFile
from django.urls import reverse
from importer.models import DataImportRow, DataImportSession
from InvenTree.unit_test import AdminTestCase, InvenTreeTestCase
from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase, InvenTreeTestCase
class ImporterMixin:
"""Helpers for import tests."""
def helper_file(self):
def helper_file(self, fn: str) -> ContentFile:
"""Return test data."""
fn = os.path.join(os.path.dirname(__file__), 'test_data', 'companies.csv')
file_path = os.path.join(os.path.dirname(__file__), 'test_data', fn)
with open(fn, encoding='utf-8') as input_file:
with open(file_path, encoding='utf-8') as input_file:
data = input_file.read()
return data
def helper_content(self):
"""Return content file."""
return ContentFile(self.helper_file(), 'companies.csv')
return ContentFile(data, fn)
class ImporterTest(ImporterMixin, InvenTreeTestCase):
@@ -33,8 +31,10 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase):
n = Company.objects.count()
data_file = self.helper_file('companies.csv')
session = DataImportSession.objects.create(
data_file=self.helper_content(), model_type='company'
data_file=data_file, model_type='company'
)
session.extract_columns()
@@ -74,13 +74,116 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase):
"""Test default field values."""
class ImportAPITest(ImporterMixin, InvenTreeAPITestCase):
"""End-to-end tests for the importer API."""
def test_import(self):
"""Test full import process via the API."""
from part.models import PartCategory
N = PartCategory.objects.count()
url = reverse('api-importer-session-list')
# Load data file
data_file = self.helper_file('part_categories.csv')
data = self.post(
url,
{'model_type': 'partcategory', 'data_file': data_file},
format='multipart',
).data
self.assertFalse(data['update_records'])
self.assertEqual(data['model_type'], 'partcategory')
# No data has been imported yet
self.assertEqual(data['row_count'], 0)
self.assertEqual(data['completed_row_count'], 0)
field_names = data['available_fields'].keys()
for fn in ['name', 'default_location', 'description']:
self.assertIn(fn, field_names)
self.assertEqual(len(data['columns']), 14)
for col in ['Name', 'Parent Category', 'Path']:
self.assertIn(col, data['columns'])
session_id = data['pk']
# Accept the field mappings
url = reverse('api-import-session-accept-fields', kwargs={'pk': session_id})
# Initially the user does not have the right permissions
self.post(url, expected_code=403)
# Assign correct permission to user
self.assignRole('part_category.add')
self.post(url, expected_code=200)
session = self.get(
reverse('api-import-session-detail', kwargs={'pk': session_id})
).data
self.assertEqual(session['row_count'], 5)
self.assertEqual(session['completed_row_count'], 0)
# Fetch each row, and validate it
rows = self.get(
reverse('api-importer-row-list'), data={'session': session_id}
).data
self.assertEqual(len(rows), 5)
row_ids = []
for row in rows:
row_ids.append(row['pk'])
self.assertEqual(row['session'], session_id)
self.assertTrue(row['valid'])
self.assertFalse(row['complete'])
# Validate the rows
url = reverse('api-import-session-accept-rows', kwargs={'pk': session_id})
self.post(
url,
{
'rows': row_ids[1:] # Validate all but the first row
},
)
# Update session information
session = self.get(
reverse('api-import-session-detail', kwargs={'pk': session_id})
).data
self.assertEqual(session['row_count'], 5)
self.assertEqual(session['completed_row_count'], 4)
for idx, row in enumerate(row_ids):
detail = self.get(
reverse('api-importer-row-detail', kwargs={'pk': row})
).data
self.assertEqual(detail['session'], session_id)
self.assertEqual(detail['complete'], idx > 0)
# Check that there are new database records
self.assertEqual(PartCategory.objects.count(), N + 4)
class AdminTest(ImporterMixin, AdminTestCase):
"""Tests for the admin interface integration."""
def test_admin(self):
"""Test the admin URL."""
data_file = self.helper_file('companies.csv')
session = self.helper(
model=DataImportSession,
model_kwargs={'data_file': self.helper_content(), 'model_type': 'company'},
model_kwargs={'data_file': data_file, 'model_type': 'company'},
)
self.helper(model=DataImportRow, model_kwargs={'session_id': session.id})

View File

@@ -31,7 +31,6 @@ const BASE_URL: string = IS_CI
: 'http://localhost:5173';
console.log('Running Playwright Tests:');
console.log(`- CI Mode: ${IS_CI}`);
console.log('- Base URL:', BASE_URL);
export default defineConfig({

View File

@@ -153,6 +153,10 @@ export default function ImporterDataSelector({
};
}
if (field == 'id') {
continue; // Skip the ID field
}
fields[field] = {
...fieldDef,
field_type: fieldDef.type,
@@ -225,6 +229,10 @@ export default function ImporterDataSelector({
const editCell = useCallback(
(row: any, col: any) => {
if (col.field == 'id') {
return; // Cannot edit the ID field
}
setSelectedRow(row);
setSelectedFieldNames([col.field]);
editRow.open();

View File

@@ -61,6 +61,7 @@ function ImporterColumn({
return (
<Select
aria-label={`import-column-map-${column.field}`}
error={errorMessage}
clearable
searchable

View File

@@ -1,9 +1,23 @@
import type { ModelType } from '@lib/enums/ModelType';
import type { ApiFormFieldSet } from '@lib/types/Forms';
export function dataImporterSessionFields(): ApiFormFieldSet {
export function dataImporterSessionFields({
modelType,
allowUpdate = false
}: {
modelType?: ModelType | string;
allowUpdate?: boolean;
}): ApiFormFieldSet {
return {
data_file: {},
model_type: {},
model_type: {
value: modelType,
hidden: modelType != undefined
},
update_records: {
hidden: allowUpdate !== true,
value: allowUpdate ? undefined : false
},
field_defaults: {
hidden: true,
value: {}

View File

@@ -340,7 +340,28 @@ export function InvenTreeTable<T extends Record<string, any>>({
// Reset the pagination state when the search term changes
useEffect(() => {
tableState.setPage(1);
}, [tableState.searchTerm]);
}, [
tableState.searchTerm,
tableState.filterSet.activeFilters,
tableState.queryFilters
]);
// Account for invalid page offsets
useEffect(() => {
if (
tableState.page > 1 &&
pageSize * tableState.page > tableState.recordCount
) {
tableState.setPage(1);
} else if (tableState.page < 1) {
tableState.setPage(1);
}
if (pageSize < 10) {
// Default page size
setPageSize(25);
}
}, [tableState.records, tableState.page, pageSize]);
// Data Sorting
const [sortStatus, setSortStatus] = useState<DataTableSortStatus<T>>({
@@ -705,7 +726,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
..._params,
totalRecords: tableState.recordCount,
recordsPerPage: tablePageSize,
page: tableState.page,
page: Math.max(1, tableState.page),
onPageChange: tableState.setPage,
recordsPerPageOptions: PAGE_SIZES,
onRecordsPerPageChange: updatePageSize

View File

@@ -451,10 +451,9 @@ export function BomTable({
const [selectedBomItem, setSelectedBomItem] = useState<any>({});
const importSessionFields = useMemo(() => {
const fields = dataImporterSessionFields();
fields.model_type.hidden = true;
fields.model_type.value = 'bomitem';
const fields = dataImporterSessionFields({
modelType: 'bomitem'
});
fields.field_overrides.value = {
part: partId

View File

@@ -79,10 +79,9 @@ export function PurchaseOrderLineItemTable({
);
const importSessionFields = useMemo(() => {
const fields = dataImporterSessionFields();
fields.model_type.hidden = true;
fields.model_type.value = ModelType.purchaseorderlineitem;
const fields = dataImporterSessionFields({
modelType: ModelType.purchaseorderlineitem
});
// Specify override values for import
fields.field_overrides.value = {

View File

@@ -42,7 +42,9 @@ export default function ImportSessionTable() {
const newImportSession = useCreateApiFormModal({
url: ApiEndpoints.import_session_list,
title: t`Create Import Session`,
fields: dataImporterSessionFields(),
fields: dataImporterSessionFields({
allowUpdate: true
}),
onFormSuccess: (response: any) => {
setSelectedSession(response.pk);
setOpened(true);

View File

@@ -1,5 +1,4 @@
Assembly,Component,Reference,Quantity,Overage,Allow Variants,Gets inherited,Optional,Consumable,Note,ID,Pricing min,Pricing max,Pricing min total,Pricing max total,Pricing updated,Component.Ipn,Component.Name,Component.Description,Validated,Available Stock,Available substitute stock,Available variant stock,External stock,On Order,In Production,Can Build
87,66,Screws,4.0,,False,False,False,False,,16,0.28,0.648622,1.12,2.594488,2024-08-08 06:55,,M3x8 Torx,"Torx head screw, M3 thread, 8.0mm",True,485.0,0.0,0.0,0.0,0.0,0.0,121.25
87,67,Large screw,1.0,,False,False,False,False,,17,0.574802,0.574802,0.574802,0.574802,2024-07-27 05:13,,M3x10 Torx,"Torx head screw, M3 thread, 10.0mm",True,1450.0,0.0,0.0,0.0,0.0,0.0,1450.0
87,82,Enclosure,1.0,,False,False,False,False,,15,,,,,2024-07-27 05:08,,1551ABK,"Small plastic enclosure, black",True,165.0,223.0,0.0,0.0,0.0,0.0,388.0
87,88,PCBA,1.0,,True,False,False,False,Assembled board,23,80.431083,129.328176,80.431083,129.328176,2024-12-27 23:14,002.01-PCBA,Widget Board (assembled),Assembled PCB for converting electricity into magic smoke,True,55.0,0.0,0.0,0.0,0.0,0.0,55.0
Assembly,Component,Reference,Quantity,Allow Variants,Gets inherited,Optional,Consumable,Setup quantity,Attrition,Rounding multiple,Note,ID,Pricing min,Pricing max,Pricing min total,Pricing max total,Pricing updated,Component.Ipn,Component.Name,Component.Description,Validated,Available Stock,Available substitute stock,Available variant stock,External stock,On Order,In Production,Can Build
106,98,screws,5,FALSE,TRUE,FALSE,TRUE,0,0,0,,39,0.075,0.1,0.375,0.5,23/07/2025 9:12,,Wood Screw,Screw for fixing wood to other wood,TRUE,1604,0,0,0,0,0,320.8
106,95,legs,4,FALSE,TRUE,FALSE,FALSE,0,0,0,,40,10.6,12.75,42.4,51,23/07/2025 9:12,,Leg,Leg for a chair or a table,TRUE,317,0,0,0,0,0,79.25
109,92,paint,0.125,FALSE,FALSE,FALSE,FALSE,0,0,0,,43,1.403886,14.389836,0.175486,1.79873,23/07/2025 9:12,,Green Paint,Green Paint,TRUE,110.125,0,0,0,0,0,881
1 Assembly Component Reference Quantity Overage Allow Variants Gets inherited Optional Consumable Setup quantity Attrition Rounding multiple Note ID Pricing min Pricing max Pricing min total Pricing max total Pricing updated Component.Ipn Component.Name Component.Description Validated Available Stock Available substitute stock Available variant stock External stock On Order In Production Can Build
2 87 106 66 98 Screws screws 4.0 5 False FALSE False TRUE False FALSE False TRUE 0 0 0 16 39 0.28 0.075 0.648622 0.1 1.12 0.375 2.594488 0.5 2024-08-08 06:55 23/07/2025 9:12 M3x8 Torx Wood Screw Torx head screw, M3 thread, 8.0mm Screw for fixing wood to other wood True TRUE 485.0 1604 0.0 0 0.0 0 0.0 0 0.0 0 0.0 0 121.25 320.8
3 87 106 67 95 Large screw legs 1.0 4 False FALSE False TRUE False FALSE False FALSE 0 0 0 17 40 0.574802 10.6 0.574802 12.75 0.574802 42.4 0.574802 51 2024-07-27 05:13 23/07/2025 9:12 M3x10 Torx Leg Torx head screw, M3 thread, 10.0mm Leg for a chair or a table True TRUE 1450.0 317 0.0 0 0.0 0 0.0 0 0.0 0 0.0 0 1450.0 79.25
4 87 109 82 92 Enclosure paint 1.0 0.125 False FALSE False FALSE False FALSE False FALSE 0 0 0 15 43 1.403886 14.389836 0.175486 1.79873 2024-07-27 05:08 23/07/2025 9:12 1551ABK Green Paint Small plastic enclosure, black Green Paint True TRUE 165.0 110.125 223.0 0 0.0 0 0.0 0 0.0 0 0.0 0 388.0 881
87 88 PCBA 1.0 True False False False Assembled board 23 80.431083 129.328176 80.431083 129.328176 2024-12-27 23:14 002.01-PCBA Widget Board (assembled) Assembled PCB for converting electricity into magic smoke True 55.0 0.0 0.0 0.0 0.0 0.0 55.0

View File

@@ -92,8 +92,6 @@ test('Parts - BOM', async ({ browser }) => {
await setTableChoiceFilter(page, 'active', 'Yes');
await setTableChoiceFilter(page, 'BOM Valid', 'Yes');
await page.getByText('1 - 12 / 12').waitFor();
// Navigate to BOM for a particular assembly
await navigate(page, 'part/87/bom');
await loadTab(page, 'Bill of Materials');
@@ -620,8 +618,11 @@ test('Parts - Bulk Edit', async ({ browser }) => {
await page.getByLabel('Select record 2', { exact: true }).click();
await page.getByLabel('action-menu-part-actions').click();
await page.getByLabel('action-menu-part-actions-set-category').click();
await page.getByLabel('related-field-category').fill('rnitu');
await page.getByRole('option', { name: '- Furniture/Chairs' }).click;
await page.waitForTimeout(250);
await page.getByRole('option', { name: '- Furniture/Chairs' }).click();
await page.getByRole('button', { name: 'Update' }).click();
await page.getByText('Items Updated').waitFor();
});

View File

@@ -14,6 +14,14 @@ test('Importing - Admin Center', async ({ browser }) => {
const fileInput = await page.locator('input[type="file"]');
await fileInput.setInputFiles('./tests/fixtures/bom_data.csv');
await page
.locator('label')
.filter({ hasText: 'Update Existing RecordsIf' })
.locator('div')
.first()
.click();
await page.getByRole('button', { name: 'Submit' }).click();
// Submitting without selecting model type, should show error
@@ -22,21 +30,54 @@ test('Importing - Admin Center', async ({ browser }) => {
await page
.getByRole('textbox', { name: 'choice-field-model_type' })
.fill('Cat');
await page
.getByRole('option', { name: 'Part Category', exact: true })
.click();
.fill('bom');
await page.getByRole('option', { name: 'BOM Item', exact: true }).click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Description (optional)').waitFor();
await page.getByText('Parent Category').waitFor();
await page.getByText('Select the parent assembly').waitFor();
await page.getByText('Select the component part').waitFor();
await page.getByText('Existing database identifier for the record').waitFor();
await page
.getByRole('textbox', { name: 'import-column-map-reference' })
.click();
await page.getByRole('option', { name: 'Ignore this field' }).click();
await page.getByRole('button', { name: 'Accept Column Mapping' }).click();
// Check for expected ID values
for (const itemId of ['16', '17', '15', '23']) {
await page.getByRole('cell', { name: itemId, exact: true });
}
// Import all the records
await page
.getByRole('row', { name: 'Select all records Row Not' })
.getByLabel('Select all records')
.click();
await page
.getByRole('button', { name: 'action-button-import-selected' })
.click();
await page.getByText('Data has been imported successfully').waitFor();
await page.getByRole('button', { name: 'Close' }).click();
// Confirmation of full import success
await page.getByRole('cell', { name: '3 / 3' }).first().waitFor();
// Manually delete records
await page.getByRole('checkbox', { name: 'Select all records' }).click();
await page
.getByRole('button', { name: 'action-button-delete-selected' })
.click();
await page.getByRole('button', { name: 'Delete', exact: true }).click();
});
test('Importing - BOM', async ({ browser }) => {
const page = await doCachedLogin(browser, {
username: 'steven',
password: 'wizardstaff',
url: 'part/87/bom'
url: 'part/109/bom'
});
await page
@@ -53,10 +94,10 @@ test('Importing - BOM', async ({ browser }) => {
await page.waitForTimeout(500);
await page.getByText('Importing Data').waitFor();
await page.getByText('0 / 4').waitFor();
await page.getByText('0 / 3').waitFor();
await page.getByText('Torx head screw, M3 thread, 10.0mm').first().waitFor();
await page.getByText('Small plastic enclosure, black').first().waitFor();
await page.getByText('Screw for fixing wood').first().waitFor();
await page.getByText('Leg for a chair or a table').first().waitFor();
// Select some rows
await page
@@ -90,15 +131,16 @@ test('Importing - BOM', async ({ browser }) => {
await page.getByRole('button', { name: 'Submit' }).click();
await page.waitForTimeout(250);
await page.getByText('0 / 2', { exact: true }).waitFor();
await page.getByText('0 / 1', { exact: true }).waitFor();
// Submit a row
await page
.getByRole('row', { name: 'Select record 1 2 Thumbnail' })
.getByLabel('row-action-menu-')
.click();
await page.getByRole('menuitem', { name: 'Accept' }).click();
await page.getByText('1 / 2', { exact: true }).waitFor();
await page.getByText('0 / 1', { exact: true }).waitFor();
});
test('Importing - Purchase Order', async ({ browser }) => {