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); } - } }