diff --git a/.docker/symfony.conf b/.docker/symfony.conf
index f5847434..33287e32 100644
--- a/.docker/symfony.conf
+++ b/.docker/symfony.conf
@@ -35,7 +35,7 @@
PassEnv DEMO_MODE NO_URL_REWRITE_AVAILABLE FIXER_API_KEY BANNER
# In old version the SAML sp private key env, was wrongly named SAMLP_SP_PRIVATE_KEY, keep it for backward compatibility
PassEnv SAML_ENABLED SAML_ROLE_MAPPING SAML_UPDATE_GROUP_ON_LOGIN SAML_IDP_ENTITY_ID SAML_IDP_SINGLE_SIGN_ON_SERVICE SAML_IDP_SINGLE_LOGOUT_SERVICE SAML_IDP_X509_CERT SAML_SP_ENTITY_ID SAML_SP_X509_CERT SAML_SP_PRIVATE_KEY SAMLP_SP_PRIVATE_KEY
- PassEnv TABLE_DEFAULT_PAGE_SIZE
+ PassEnv TABLE_DEFAULT_PAGE_SIZE TABLE_PARTS_DEFAULT_COLUMNS
PassEnv PROVIDER_DIGIKEY_CLIENT_ID PROVIDER_DIGIKEY_SECRET PROVIDER_DIGIKEY_CURRENCY PROVIDER_DIGIKEY_LANGUAGE PROVIDER_DIGIKEY_COUNTRY
PassEnv PROVIDER_ELEMENT14_KEY PROVIDER_ELEMENT14_STORE_ID
diff --git a/.env b/.env
index 0b834c2e..afbe9385 100644
--- a/.env
+++ b/.env
@@ -93,6 +93,9 @@ ERROR_PAGE_SHOW_HELP=1
# The default page size for the part table (set to -1 to show all parts on one page)
TABLE_DEFAULT_PAGE_SIZE=50
+# Configure which columns will be visible by default in the parts table (and in which order).
+# This is a comma separated list of column names. See documentation for available values.
+TABLE_PARTS_DEFAULT_COLUMNS=name,description,category,footprint,manufacturer,storage_location,amount
##################################################################################
# Info provider settings
diff --git a/config/parameters.yaml b/config/parameters.yaml
index 486cc961..9839bcf5 100644
--- a/config/parameters.yaml
+++ b/config/parameters.yaml
@@ -53,7 +53,8 @@ parameters:
######################################################################################################################
# Table settings
######################################################################################################################
- partdb.table.default_page_size: '%env(int:TABLE_DEFAULT_PAGE_SIZE)%' # The default number of entries shown per page in tables
+ partdb.table.default_page_size: '%env(int:TABLE_DEFAULT_PAGE_SIZE)%' # The default number of entries shown per page in tables
+ partdb.table.parts.default_columns: '%env(trim:string:TABLE_PARTS_DEFAULT_COLUMNS)%' # The default columns in part tables and their order
######################################################################################################################
# Sidebar
diff --git a/config/services.yaml b/config/services.yaml
index b99bb72a..5b8dd51f 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -211,6 +211,15 @@ services:
arguments:
$saml_enabled: '%partdb.saml.enabled%'
+ ####################################################################################################################
+ # Table settings
+ ####################################################################################################################
+ App\DataTables\PartsDataTable:
+ arguments:
+ $visible_columns: '%partdb.table.parts.default_columns%'
+
+ App\DataTables\Helpers\ColumnSortHelper:
+ shared: false # Service has a state so not share it between different tables
####################################################################################################################
# Label system
diff --git a/docs/configuration.md b/docs/configuration.md
index 7dae3923..e5c75cce 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -46,6 +46,8 @@ The following configuration options can only be changed by the server administra
### Table related settings
* `TABLE_DEFAULT_PAGE_SIZE`: The default page size for tables. This is the number of rows which are shown per page. Set to `-1` to disable pagination and show all rows at once.
+* `TABLE_PARTS_DEFAULT_COLUMNS`: The columns in parts tables, which are visible by default (when loading table for first time).
+Also specify the default order of the columns. This is a comma separated list of column names. Available columns are: `name`, `id`, `ipn`, `description`, `category`, `footprint`, `manufacturer`, `storage_location`, `amount`, `minamount`, `partUnit`, `addedDate`, `lastModified`, `needs_review`, `favorite`, `manufacturing_status`, `manufacturer_product_number`, `mass`, `tags`, `attachments`, `edit`.
### History/Eventlog related settings
The following options are used to configure, which (and how much) data is written to the system log:
diff --git a/src/DataTables/Helpers/ColumnSortHelper.php b/src/DataTables/Helpers/ColumnSortHelper.php
new file mode 100644
index 00000000..a16a64d8
--- /dev/null
+++ b/src/DataTables/Helpers/ColumnSortHelper.php
@@ -0,0 +1,131 @@
+.
+ */
+
+declare(strict_types=1);
+
+
+namespace App\DataTables\Helpers;
+
+use Omines\DataTablesBundle\DataTable;
+use Psr\Log\LoggerInterface;
+
+class ColumnSortHelper
+{
+ private array $columns = [];
+
+ public function __construct(LoggerInterface $logger)
+ {
+ $this->logger = $logger;
+ }
+
+ /**
+ * Add a new column which can be sorted and visibility controlled by the user. The basic syntax is similar to
+ * the DataTable add method, but with additional options.
+ * @param string $name
+ * @param string $type
+ * @param array $options
+ * @param string|null $alias If an alias is set here, the column will be available under this alias in the config
+ * string instead of the name.
+ * @param bool $visibility_configurable If set to false, this column can not be visibility controlled by the user
+ * @return $this
+ */
+ public function add(string $name, string $type, array $options = [], ?string $alias = null,
+ bool $visibility_configurable = true): self
+ {
+ //Alias allows us to override the name of the column in the env variable
+ $this->columns[$alias ?? $name] = [
+ 'name' => $name,
+ 'type' => $type,
+ 'options' => $options,
+ 'visibility_configurable' => $visibility_configurable
+ ];
+
+ return $this;
+ }
+
+ /**
+ * Remove all columns saved inside this helper
+ * @return void
+ */
+ public function reset(): void
+ {
+ $this->columns = [];
+ }
+
+ /**
+ * Apply the visibility configuration to the given DataTable and configure the columns.
+ * @param DataTable $dataTable
+ * @param string|array $visible_columns Either a list or a comma separated string of column names, which should
+ * be visible by default. If a column is not listed here, it will be hidden by default.
+ * @return void
+ */
+ public function applyVisibilityAndConfigureColumns(DataTable $dataTable, string|array $visible_columns,
+ string $config_var_name): void
+ {
+ //If the config is given as a string, convert it to an array first
+ if (!is_array($visible_columns)) {
+ $visible_columns = array_map(trim(...), explode(",", $visible_columns));
+ }
+
+ $processed_columns = [];
+
+ //First add all columns which visibility is not configurable
+ foreach ($this->columns as $col_id => $col_data) {
+ if (!$col_data['visibility_configurable']) {
+ $this->addColumnEntry($dataTable, $this->columns[$col_id], null);
+ $processed_columns[] = $col_id;
+ }
+ }
+
+ //Afterwards the columns, which should be visible by default
+ foreach ($visible_columns as $col_id) {
+ if (!isset($this->columns[$col_id]) || !$this->columns[$col_id]['visibility_configurable']) {
+ $this->logger->warning("Configuration option $config_var_name specify invalid column '$col_id'. Column is skipped.");
+ continue;
+ }
+
+ if (in_array($col_id, $processed_columns, true)) {
+ $this->logger->warning("Configuration option $config_var_name specify column '$col_id' multiple time. Only first occurrence is used.");
+ continue;
+ }
+ $this->addColumnEntry($dataTable, $this->columns[$col_id], true);
+ $processed_columns[] = $col_id;
+ }
+
+ //and the remaining non-visible columns
+ foreach ($this->columns as $col_id => $col_data) {
+ if (in_array($col_id, $processed_columns)) {
+ // column already processed
+ continue;
+ }
+ $this->addColumnEntry($dataTable, $this->columns[$col_id], false);
+ $processed_columns[] = $col_id;
+ }
+ }
+
+ private function addColumnEntry(DataTable $dataTable, array $entry, ?bool $visible): void
+ {
+ $options = $entry['options'] ?? [];
+ if (!is_null($visible)) {
+ $options["visible"] = $visible;
+ }
+ $dataTable->add($entry['name'], $entry['type'], $options);
+ }
+}
\ No newline at end of file
diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php
index aa87438d..3042a3c9 100644
--- a/src/DataTables/PartsDataTable.php
+++ b/src/DataTables/PartsDataTable.php
@@ -25,6 +25,7 @@ namespace App\DataTables;
use App\DataTables\Adapters\FetchResultsAtOnceORMAdapter;
use App\DataTables\Adapters\TwoStepORMAdapter;
use App\DataTables\Column\EnumColumn;
+use App\DataTables\Helpers\ColumnSortHelper;
use App\Doctrine\Helpers\FieldHelper;
use App\Entity\Parts\ManufacturingStatus;
use Doctrine\ORM\Mapping\ClassMetadata;
@@ -62,8 +63,15 @@ use Symfony\Contracts\Translation\TranslatorInterface;
final class PartsDataTable implements DataTableTypeInterface
{
- public function __construct(private readonly EntityURLGenerator $urlGenerator, private readonly TranslatorInterface $translator, private readonly AmountFormatter $amountFormatter, private readonly PartDataTableHelper $partDataTableHelper, private readonly Security $security)
- {
+ public function __construct(
+ private readonly EntityURLGenerator $urlGenerator,
+ private readonly TranslatorInterface $translator,
+ private readonly AmountFormatter $amountFormatter,
+ private readonly PartDataTableHelper $partDataTableHelper,
+ private readonly Security $security,
+ private readonly string $visible_columns,
+ private readonly ColumnSortHelper $csh,
+ ) {
}
public function configureOptions(OptionsResolver $optionsResolver): void
@@ -83,9 +91,9 @@ final class PartsDataTable implements DataTableTypeInterface
$this->configureOptions($resolver);
$options = $resolver->resolve($options);
- $dataTable
+ $this->csh
//Color the table rows depending on the review and favorite status
- ->add('dont_matter', RowClassColumn::class, [
+ ->add('row_color', RowClassColumn::class, [
'render' => function ($value, Part $context): string {
if ($context->isNeedsReview()) {
return 'table-secondary';
@@ -96,51 +104,39 @@ final class PartsDataTable implements DataTableTypeInterface
return ''; //Default coloring otherwise
},
- ])
-
- ->add('select', SelectColumn::class)
+ ], visibility_configurable: false)
+ ->add('select', SelectColumn::class, visibility_configurable: false)
->add('picture', TextColumn::class, [
'label' => '',
'className' => 'no-colvis',
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderPicture($context),
- ])
+ ], visibility_configurable: false)
->add('name', TextColumn::class, [
'label' => $this->translator->trans('part.table.name'),
'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context),
])
->add('id', TextColumn::class, [
'label' => $this->translator->trans('part.table.id'),
- 'visible' => false,
])
->add('ipn', TextColumn::class, [
'label' => $this->translator->trans('part.table.ipn'),
- 'visible' => false,
])
->add('description', MarkdownColumn::class, [
'label' => $this->translator->trans('part.table.description'),
- ]);
-
- if ($this->security->isGranted('@categories.read')) {
- $dataTable->add('category', EntityColumn::class, [
+ ])
+ ->add('category', EntityColumn::class, [
'label' => $this->translator->trans('part.table.category'),
'property' => 'category',
- ]);
- }
-
- if ($this->security->isGranted('@footprints.read')) {
- $dataTable->add('footprint', EntityColumn::class, [
+ ])
+ ->add('footprint', EntityColumn::class, [
'property' => 'footprint',
'label' => $this->translator->trans('part.table.footprint'),
- ]);
- }
- if ($this->security->isGranted('@manufacturers.read')) {
- $dataTable->add('manufacturer', EntityColumn::class, [
+ ])
+ ->add('manufacturer', EntityColumn::class, [
'property' => 'manufacturer',
'label' => $this->translator->trans('part.table.manufacturer'),
- ]);
- }
- if ($this->security->isGranted('@storelocations.read')) {
- $dataTable->add('storelocation', TextColumn::class, [
+ ])
+ ->add('storelocation', TextColumn::class, [
'label' => $this->translator->trans('part.table.storeLocations'),
'orderField' => 'storelocations.name',
'render' => function ($value, Part $context): string {
@@ -160,119 +156,106 @@ final class PartsDataTable implements DataTableTypeInterface
return implode('
', $tmp);
},
- ]);
- }
+ ], alias: 'storage_location')
+ ->add('amount', TextColumn::class, [
+ 'label' => $this->translator->trans('part.table.amount'),
+ 'render' => function ($value, Part $context) {
+ $amount = $context->getAmountSum();
+ $expiredAmount = $context->getExpiredAmountSum();
- $dataTable->add('amount', TextColumn::class, [
- 'label' => $this->translator->trans('part.table.amount'),
- 'render' => function ($value, Part $context) {
- $amount = $context->getAmountSum();
- $expiredAmount = $context->getExpiredAmountSum();
+ $ret = '';
- $ret = '';
-
- if ($context->isAmountUnknown()) {
- //When all amounts are unknown, we show a question mark
- if ($amount === 0.0) {
- $ret .= sprintf('?',
- $this->translator->trans('part_lots.instock_unknown'));
- } else { //Otherwise mark it with greater equal and the (known) amount
- $ret .= sprintf('≥',
- $this->translator->trans('part_lots.instock_unknown')
- );
+ if ($context->isAmountUnknown()) {
+ //When all amounts are unknown, we show a question mark
+ if ($amount === 0.0) {
+ $ret .= sprintf('?',
+ $this->translator->trans('part_lots.instock_unknown'));
+ } else { //Otherwise mark it with greater equal and the (known) amount
+ $ret .= sprintf('≥',
+ $this->translator->trans('part_lots.instock_unknown')
+ );
+ $ret .= htmlspecialchars($this->amountFormatter->format($amount, $context->getPartUnit()));
+ }
+ } else {
$ret .= htmlspecialchars($this->amountFormatter->format($amount, $context->getPartUnit()));
}
- } else {
- $ret .= htmlspecialchars($this->amountFormatter->format($amount, $context->getPartUnit()));
- }
- //If we have expired lots, we show them in parentheses behind
- if ($expiredAmount > 0) {
- $ret .= sprintf(' (+%s)',
- $this->translator->trans('part_lots.is_expired'),
- htmlspecialchars($this->amountFormatter->format($expiredAmount, $context->getPartUnit())));
- }
+ //If we have expired lots, we show them in parentheses behind
+ if ($expiredAmount > 0) {
+ $ret .= sprintf(' (+%s)',
+ $this->translator->trans('part_lots.is_expired'),
+ htmlspecialchars($this->amountFormatter->format($expiredAmount, $context->getPartUnit())));
+ }
- //When the amount is below the minimum amount, we highlight the number red
- if ($context->isNotEnoughInstock()) {
- $ret = sprintf('%s',
- $this->translator->trans('part.info.amount.less_than_desired'),
- $ret);
- }
+ //When the amount is below the minimum amount, we highlight the number red
+ if ($context->isNotEnoughInstock()) {
+ $ret = sprintf('%s',
+ $this->translator->trans('part.info.amount.less_than_desired'),
+ $ret);
+ }
- return $ret;
- },
- 'orderField' => 'amountSum'
- ])
+ return $ret;
+ },
+ 'orderField' => 'amountSum'
+ ])
->add('minamount', TextColumn::class, [
'label' => $this->translator->trans('part.table.minamount'),
- 'visible' => false,
- 'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format($value, $context->getPartUnit())),
- ]);
-
- if ($this->security->isGranted('@footprints.read')) {
- $dataTable->add('partUnit', TextColumn::class, [
+ 'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format($value,
+ $context->getPartUnit())),
+ ])
+ ->add('partUnit', TextColumn::class, [
'field' => 'partUnit.name',
'label' => $this->translator->trans('part.table.partUnit'),
- 'visible' => false,
- ]);
- }
-
- $dataTable->add('addedDate', LocaleDateTimeColumn::class, [
- 'label' => $this->translator->trans('part.table.addedDate'),
- 'visible' => false,
- ])
+ ])
+ ->add('addedDate', LocaleDateTimeColumn::class, [
+ 'label' => $this->translator->trans('part.table.addedDate'),
+ ])
->add('lastModified', LocaleDateTimeColumn::class, [
'label' => $this->translator->trans('part.table.lastModified'),
- 'visible' => false,
])
->add('needs_review', PrettyBoolColumn::class, [
'label' => $this->translator->trans('part.table.needsReview'),
- 'visible' => false,
])
->add('favorite', PrettyBoolColumn::class, [
'label' => $this->translator->trans('part.table.favorite'),
- 'visible' => false,
])
->add('manufacturing_status', EnumColumn::class, [
'label' => $this->translator->trans('part.table.manufacturingStatus'),
- 'visible' => false,
'class' => ManufacturingStatus::class,
- 'render' => function(?ManufacturingStatus $status, Part $context): string {
+ 'render' => function (?ManufacturingStatus $status, Part $context): string {
if (!$status) {
return '';
}
return $this->translator->trans($status->toTranslationKey());
- } ,
+ },
])
->add('manufacturer_product_number', TextColumn::class, [
'label' => $this->translator->trans('part.table.mpn'),
- 'visible' => false,
])
->add('mass', SIUnitNumberColumn::class, [
'label' => $this->translator->trans('part.table.mass'),
- 'visible' => false,
'unit' => 'g'
])
->add('tags', TagsColumn::class, [
'label' => $this->translator->trans('part.table.tags'),
- 'visible' => false,
])
->add('attachments', PartAttachmentsColumn::class, [
'label' => $this->translator->trans('part.table.attachments'),
- 'visible' => false,
])
->add('edit', IconLinkColumn::class, [
'label' => $this->translator->trans('part.table.edit'),
- 'visible' => false,
'href' => fn($value, Part $context) => $this->urlGenerator->editURL($context),
'disabled' => fn($value, Part $context) => !$this->security->isGranted('edit', $context),
'title' => $this->translator->trans('part.table.edit.title'),
- ])
+ ]);
- ->addOrderBy('name')
- ->createAdapter(TwoStepORMAdapter::class, [
+ //Apply the user configured order and visibility and add the columns to the table
+ $this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->visible_columns,
+ "TABLE_PARTS_DEFAULT_COLUMNS");
+
+ $dataTable->addOrderBy('name')
+ ->createAdapter(TwoStepORMAdapater::class, [
'filter_query' => $this->getFilterQuery(...),
'detail_query' => $this->getDetailQuery(...),
'entity' => Part::class,
@@ -300,7 +283,7 @@ final class PartsDataTable implements DataTableTypeInterface
->addSelect(
'(
SELECT IFNULL(SUM(partLot.amount), 0.0)
- FROM '. PartLot::class. ' partLot
+ FROM '.PartLot::class.' partLot
WHERE partLot.part = part.id
AND partLot.instock_unknown = false
AND (partLot.expiration_date IS NULL OR partLot.expiration_date > CURRENT_DATE())
@@ -321,8 +304,7 @@ final class PartsDataTable implements DataTableTypeInterface
->leftJoin('part.parameters', 'parameters')
//This must be the only group by, or the paginator will not work correctly
- ->addGroupBy('part.id')
- ;
+ ->addGroupBy('part.id');
}
private function getDetailQuery(QueryBuilder $builder, array $filter_results): void
@@ -352,7 +334,7 @@ final class PartsDataTable implements DataTableTypeInterface
->addSelect(
'(
SELECT IFNULL(SUM(partLot.amount), 0.0)
- FROM '. PartLot::class. ' partLot
+ FROM '.PartLot::class.' partLot
WHERE partLot.part = part.id
AND partLot.instock_unknown = false
AND (partLot.expiration_date IS NULL OR partLot.expiration_date > CURRENT_DATE())
@@ -371,7 +353,6 @@ final class PartsDataTable implements DataTableTypeInterface
->leftJoin('part.attachments', 'attachments')
->leftJoin('part.partUnit', 'partUnit')
->leftJoin('part.parameters', 'parameters')
-
->where('part.id IN (:ids)')
->setParameter('ids', $ids)
@@ -388,8 +369,7 @@ final class PartsDataTable implements DataTableTypeInterface
->addGroupBy('suppliers')
->addGroupBy('attachments')
->addGroupBy('partUnit')
- ->addGroupBy('parameters')
- ;
+ ->addGroupBy('parameters');
//Get the results in the same order as the IDs were passed
FieldHelper::addOrderByFieldParam($builder, 'part.id', 'ids');
@@ -408,6 +388,5 @@ final class PartsDataTable implements DataTableTypeInterface
$filter = $options['filter'];
$filter->apply($builder);
}
-
}
}