From 9d54001f89e407b22ceb4cb0c999f09ed5233ed2 Mon Sep 17 00:00:00 2001 From: misaz Date: Wed, 4 Oct 2023 21:14:57 +0200 Subject: [PATCH 1/6] added support for configuring columns in part table which are enabled by default as well as their order. --- .docker/symfony.conf | 2 +- config/parameters.yaml | 3 +- config/services.yaml | 6 + docs/configuration.md | 1 + src/DataTables/PartsDataTable.php | 703 +++++++++++++++++------------- 5 files changed, 419 insertions(+), 296 deletions(-) diff --git a/.docker/symfony.conf b/.docker/symfony.conf index 1527286c..589761ba 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_PART_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/config/parameters.yaml b/config/parameters.yaml index 486cc961..b24c7f57 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.default_part_columns: '%env(trim:string:TABLE_PART_DEFAULT_COLUMNS)%' # The default columns in part tables and their order ###################################################################################################################### # Sidebar diff --git a/config/services.yaml b/config/services.yaml index 24e6a6ac..c30e54c4 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -211,6 +211,12 @@ services: arguments: $saml_enabled: '%partdb.saml.enabled%' + #################################################################################################################### + # Table settings + #################################################################################################################### + App\DataTables\PartsDataTable: + arguments: + $default_part_columns: '%partdb.table.default_part_columns%' #################################################################################################################### # Label system diff --git a/docs/configuration.md b/docs/configuration.md index 7dae3923..b878bd49 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -46,6 +46,7 @@ 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_PART_DEFAULT_COLUMNS`: The default columns in part tables loading table for first time. Also specify default order of the columns. Specify as comma separated string. Available columns are: `name`, `id`, `ipn`, `description`, `category`, `footprint`, `manufacturer`, `storelocation`, `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/PartsDataTable.php b/src/DataTables/PartsDataTable.php index eac739f8..c10a52b7 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -33,6 +33,7 @@ use Doctrine\ORM\Query; use Doctrine\ORM\Tools\Pagination\Paginator; use Omines\DataTablesBundle\Adapter\Doctrine\Event\ORMAdapterQueryEvent; use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapterEvents; +use Psr\Log\LoggerInterface; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Parts\Storelocation; use App\DataTables\Column\EntityColumn; @@ -62,129 +63,161 @@ 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 $default_part_columns, protected LoggerInterface $logger) + { + } - public function configureOptions(OptionsResolver $optionsResolver): void - { - $optionsResolver->setDefaults([ - 'filter' => null, - 'search' => null - ]); + public function configureOptions(OptionsResolver $optionsResolver): void + { + $optionsResolver->setDefaults([ + 'filter' => null, + 'search' => null + ]); - $optionsResolver->setAllowedTypes('filter', [PartFilter::class, 'null']); - $optionsResolver->setAllowedTypes('search', [PartSearchFilter::class, 'null']); - } + $optionsResolver->setAllowedTypes('filter', [PartFilter::class, 'null']); + $optionsResolver->setAllowedTypes('search', [PartSearchFilter::class, 'null']); + } - public function configure(DataTable $dataTable, array $options): void - { - $resolver = new OptionsResolver(); - $this->configureOptions($resolver); - $options = $resolver->resolve($options); + public function configure(DataTable $dataTable, array $options): void + { + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + $options = $resolver->resolve($options); - $dataTable - //Color the table rows depending on the review and favorite status - ->add('dont_matter', RowClassColumn::class, [ - 'render' => function ($value, Part $context): string { - if ($context->isNeedsReview()) { - return 'table-secondary'; - } - if ($context->isFavorite()) { - return 'table-info'; - } + $dataTable + //Color the table rows depending on the review and favorite status + ->add('dont_matter', RowClassColumn::class, [ + 'render' => function ($value, Part $context): string { + if ($context->isNeedsReview()) { + return 'table-secondary'; + } + if ($context->isFavorite()) { + return 'table-info'; + } - return ''; //Default coloring otherwise - }, - ]) + return ''; //Default coloring otherwise + }, + ]) - ->add('select', SelectColumn::class) - ->add('picture', TextColumn::class, [ - 'label' => '', - 'className' => 'no-colvis', - 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderPicture($context), - ]) - ->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'), - ]); + ->add('select', SelectColumn::class) + ->add('picture', TextColumn::class, [ + 'label' => '', + 'className' => 'no-colvis', + 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderPicture($context), + ]); - if ($this->security->isGranted('@categories.read')) { - $dataTable->add('category', EntityColumn::class, [ - 'label' => $this->translator->trans('part.table.category'), - 'property' => 'category', - ]); - } - if ($this->security->isGranted('@footprints.read')) { - $dataTable->add('footprint', EntityColumn::class, [ - 'property' => 'footprint', - 'label' => $this->translator->trans('part.table.footprint'), - ]); - } - if ($this->security->isGranted('@manufacturers.read')) { - $dataTable->add('manufacturer', EntityColumn::class, [ - 'property' => 'manufacturer', - 'label' => $this->translator->trans('part.table.manufacturer'), - ]); - } - if ($this->security->isGranted('@storelocations.read')) { - $dataTable->add('storelocation', TextColumn::class, [ - 'label' => $this->translator->trans('part.table.storeLocations'), - 'orderField' => 'storelocations.name', - 'render' => function ($value, Part $context): string { - $tmp = []; - foreach ($context->getPartLots() as $lot) { - //Ignore lots without storelocation - if (!$lot->getStorageLocation() instanceof Storelocation) { - continue; - } - $tmp[] = sprintf( - '%s', - $this->urlGenerator->listPartsURL($lot->getStorageLocation()), - htmlspecialchars($lot->getStorageLocation()->getFullPath()), - htmlspecialchars($lot->getStorageLocation()->getName()) - ); - } + // internal array of $dataTable->add(...) parameters. Parameters will be later passed to the method + // after sorting columns according to TABLE_PART_DEFAULT_COLUMNS option. + $columns = []; - return implode('
', $tmp); - }, - ]); - } + $columns['name'] = [ + 'name', TextColumn::class, + [ + 'label' => $this->translator->trans('part.table.name'), + 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context), + ] + ]; + $columns['id'] = [ + 'id', TextColumn::class, + [ + 'label' => $this->translator->trans('part.table.id'), + 'visible' => false, + ] + ]; + $columns['ipn'] = [ + 'ipn', TextColumn::class, + [ + 'label' => $this->translator->trans('part.table.ipn'), + 'visible' => false, + ] + ]; + $columns['description'] = [ + 'description', MarkdownColumn::class, + [ + 'label' => $this->translator->trans('part.table.description'), + ] + ]; - $dataTable->add('amount', TextColumn::class, [ - 'label' => $this->translator->trans('part.table.amount'), - 'render' => function ($value, Part $context) { - $amount = $context->getAmountSum(); - $expiredAmount = $context->getExpiredAmountSum(); + if ($this->security->isGranted('@categories.read')) { + $columns['category'] = [ + 'category', EntityColumn::class, + [ + 'label' => $this->translator->trans('part.table.category'), + 'property' => 'category', + ] + ]; + } - $ret = ''; + if ($this->security->isGranted('@footprints.read')) { + $columns['footprint'] = [ + 'footprint', EntityColumn::class, + [ + 'property' => 'footprint', + 'label' => $this->translator->trans('part.table.footprint'), + ] + ]; + } + if ($this->security->isGranted('@manufacturers.read')) { + $columns['manufacturer'] = [ + 'manufacturer', EntityColumn::class, + [ + 'property' => 'manufacturer', + 'label' => $this->translator->trans('part.table.manufacturer'), + ] + ]; + } + if ($this->security->isGranted('@storelocations.read')) { + $columns['storelocation'] = [ + 'storelocation', TextColumn::class, + [ + 'label' => $this->translator->trans('part.table.storeLocations'), + 'orderField' => 'storelocations.name', + 'render' => function ($value, Part $context): string { + $tmp = []; + foreach ($context->getPartLots() as $lot) { + //Ignore lots without storelocation + if (!$lot->getStorageLocation() instanceof Storelocation) { + continue; + } + $tmp[] = sprintf( + '%s', + $this->urlGenerator->listPartsURL($lot->getStorageLocation()), + htmlspecialchars($lot->getStorageLocation()->getFullPath()), + htmlspecialchars($lot->getStorageLocation()->getName()) + ); + } - 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())); - } + return implode('
', $tmp); + }, + ] + ]; + } + + $columns['amount'] = [ + 'amount', TextColumn::class, + [ + 'label' => $this->translator->trans('part.table.amount'), + 'render' => function ($value, Part $context) { + $amount = $context->getAmountSum(); + $expiredAmount = $context->getExpiredAmountSum(); + + $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') + ); + $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) { @@ -200,214 +233,296 @@ final class PartsDataTable implements DataTableTypeInterface $ret); } - 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())), - ]); + return $ret; + }, + 'orderField' => 'amountSum' + ] + ]; + $columns['minamount'] = [ + '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, [ - 'field' => 'partUnit.name', - 'label' => $this->translator->trans('part.table.partUnit'), - 'visible' => false, - ]); - } + if ($this->security->isGranted('@footprints.read')) { + $columns['partUnit'] = [ + '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('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 { - if (!$status) { - return ''; - } + $columns['addedDate'] = [ + 'addedDate', LocaleDateTimeColumn::class, + [ + 'label' => $this->translator->trans('part.table.addedDate'), + 'visible' => false, + ] + ]; + $columns['lastModified'] = [ + 'lastModified', LocaleDateTimeColumn::class, + [ + 'label' => $this->translator->trans('part.table.lastModified'), + 'visible' => false, + ] + ]; + $columns['needs_review'] = [ + 'needs_review', PrettyBoolColumn::class, + [ + 'label' => $this->translator->trans('part.table.needsReview'), + 'visible' => false, + ] + ]; + $columns['favorite'] = [ + 'favorite', PrettyBoolColumn::class, + [ + 'label' => $this->translator->trans('part.table.favorite'), + 'visible' => false, + ] + ]; + $columns['manufacturing_status'] = [ + 'manufacturing_status', EnumColumn::class, + [ + 'label' => $this->translator->trans('part.table.manufacturingStatus'), + 'visible' => false, + 'class' => ManufacturingStatus::class, + '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'), - ]) + return $this->translator->trans($status->toTranslationKey()); + } , + ] + ]; + $columns['manufacturer_product_number'] = [ + 'manufacturer_product_number', TextColumn::class, + [ + 'label' => $this->translator->trans('part.table.mpn'), + 'visible' => false, + ] + ]; + $columns['mass'] = [ + 'mass', SIUnitNumberColumn::class, + [ + 'label' => $this->translator->trans('part.table.mass'), + 'visible' => false, + 'unit' => 'g' + ] + ]; + $columns['tags'] = [ + 'tags', TagsColumn::class, + [ + 'label' => $this->translator->trans('part.table.tags'), + 'visible' => false, + ] + ]; + $columns['attachments'] = [ + 'attachments', PartAttachmentsColumn::class, + [ + 'label' => $this->translator->trans('part.table.attachments'), + 'visible' => false, + ] + ]; + $columns['edit'] = [ + '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(TwoStepORMAdapater::class, [ - 'filter_query' => $this->getFilterQuery(...), - 'detail_query' => $this->getDetailQuery(...), - 'entity' => Part::class, - 'hydrate' => Query::HYDRATE_OBJECT, - 'criteria' => [ - function (QueryBuilder $builder) use ($options): void { - $this->buildCriteria($builder, $options); - }, - new SearchCriteriaProvider(), - ], - ]); - } + $visible_columns_ids = array_map("trim", explode(",", $this->default_part_columns)); + $allowed_configurable_columns_ids = ["name", "id", "ipn", "description", "category", "footprint", "manufacturer", + "storelocation", "amount", "minamount", "partUnit", "addedDate", "lastModified", "needs_review", "favorite", + "manufacturing_status", "manufacturer_product_number", "mass", "tags", "attachments", "edit" + ]; + $processed_columns = []; + + foreach ($visible_columns_ids as $col_id) { + if (!in_array($col_id, $allowed_configurable_columns_ids) || !isset($columns[$col_id])) { + $this->logger->warning("Configuration option TABLE_PART_DEFAULT_COLUMNS specify invalid column '$col_id'. Collumn is skipped."); + continue; + } + + if (in_array($col_id, $processed_columns)) { + $this->logger->warning("Configuration option TABLE_PART_DEFAULT_COLUMNS specify column '$col_id' multiple time. Only first occurence is used."); + continue; + } + + $options = []; + if (count($columns[$col_id]) >= 3) { + $options = $columns[$col_id][2]; + } + $options["visible"] = true; + $dataTable->add($col_id, $columns[$col_id][1], $options); + + $processed_columns[] = $col_id; + } + + // add remaining non-visible columns + foreach ($allowed_configurable_columns_ids as $col_id) { + if (in_array($col_id, $processed_columns)) { + // column already processed + continue; + } + + $options = []; + if (count($columns[$col_id]) >= 3) { + $options = $columns[$col_id][2]; + } + $options["visible"] = false; + $dataTable->add($col_id, $columns[$col_id][1], $options); + + $processed_columns[] = $col_id; + } + + $dataTable->addOrderBy('name') + ->createAdapter(TwoStepORMAdapater::class, [ + 'filter_query' => $this->getFilterQuery(...), + 'detail_query' => $this->getDetailQuery(...), + 'entity' => Part::class, + 'hydrate' => Query::HYDRATE_OBJECT, + 'criteria' => [ + function (QueryBuilder $builder) use ($options): void { + $this->buildCriteria($builder, $options); + }, + new SearchCriteriaProvider(), + ], + ]); + } - private function getFilterQuery(QueryBuilder $builder): void - { - /* In the filter query we only select the IDs. The fetching of the full entities is done in the detail query. - * We only need to join the entities here, so we can filter by them. - * The filter conditions are added to this QB in the buildCriteria method. - */ - $builder - ->select('part.id') - ->addSelect('part.minamount AS HIDDEN minamount') - //Calculate amount sum using a subquery, so we can filter and sort by it - ->addSelect( - '( + private function getFilterQuery(QueryBuilder $builder): void + { + /* In the filter query we only select the IDs. The fetching of the full entities is done in the detail query. + * We only need to join the entities here, so we can filter by them. + * The filter conditions are added to this QB in the buildCriteria method. + */ + $builder + ->select('part.id') + ->addSelect('part.minamount AS HIDDEN minamount') + //Calculate amount sum using a subquery, so we can filter and sort by it + ->addSelect( + '( SELECT IFNULL(SUM(partLot.amount), 0.0) 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()) ) AS HIDDEN amountSum' - ) - ->from(Part::class, 'part') - ->leftJoin('part.category', 'category') - ->leftJoin('part.master_picture_attachment', 'master_picture_attachment') - ->leftJoin('part.partLots', 'partLots') - ->leftJoin('partLots.storage_location', 'storelocations') - ->leftJoin('part.footprint', 'footprint') - ->leftJoin('footprint.master_picture_attachment', 'footprint_attachment') - ->leftJoin('part.manufacturer', 'manufacturer') - ->leftJoin('part.orderdetails', 'orderdetails') - ->leftJoin('orderdetails.supplier', 'suppliers') - ->leftJoin('part.attachments', 'attachments') - ->leftJoin('part.partUnit', 'partUnit') - ->leftJoin('part.parameters', 'parameters') + ) + ->from(Part::class, 'part') + ->leftJoin('part.category', 'category') + ->leftJoin('part.master_picture_attachment', 'master_picture_attachment') + ->leftJoin('part.partLots', 'partLots') + ->leftJoin('partLots.storage_location', 'storelocations') + ->leftJoin('part.footprint', 'footprint') + ->leftJoin('footprint.master_picture_attachment', 'footprint_attachment') + ->leftJoin('part.manufacturer', 'manufacturer') + ->leftJoin('part.orderdetails', 'orderdetails') + ->leftJoin('orderdetails.supplier', 'suppliers') + ->leftJoin('part.attachments', 'attachments') + ->leftJoin('part.partUnit', 'partUnit') + ->leftJoin('part.parameters', 'parameters') - //This must be the only group by, or the paginator will not work correctly - ->addGroupBy('part.id') - ; - } + //This must be the only group by, or the paginator will not work correctly + ->addGroupBy('part.id') + ; + } - private function getDetailQuery(QueryBuilder $builder, array $filter_results): void - { - $ids = array_map(fn($row) => $row['id'], $filter_results); + private function getDetailQuery(QueryBuilder $builder, array $filter_results): void + { + $ids = array_map(fn($row) => $row['id'], $filter_results); - /* - * In this query we take the IDs which were filtered, paginated and sorted in the filter query, and fetch the - * full entities. - * We can do complex fetch joins, as we do not need to filter or sort here (which would kill the performance). - * The only condition should be for the IDs. - * It is important that elements are ordered the same way, as the IDs are passed, or ordering will be wrong. - */ - $builder - ->select('part') - ->addSelect('category') - ->addSelect('footprint') - ->addSelect('manufacturer') - ->addSelect('partUnit') - ->addSelect('master_picture_attachment') - ->addSelect('footprint_attachment') - ->addSelect('partLots') - ->addSelect('orderdetails') - ->addSelect('attachments') - ->addSelect('storelocations') - //Calculate amount sum using a subquery, so we can filter and sort by it - ->addSelect( - '( + /* + * In this query we take the IDs which were filtered, paginated and sorted in the filter query, and fetch the + * full entities. + * We can do complex fetch joins, as we do not need to filter or sort here (which would kill the performance). + * The only condition should be for the IDs. + * It is important that elements are ordered the same way, as the IDs are passed, or ordering will be wrong. + */ + $builder + ->select('part') + ->addSelect('category') + ->addSelect('footprint') + ->addSelect('manufacturer') + ->addSelect('partUnit') + ->addSelect('master_picture_attachment') + ->addSelect('footprint_attachment') + ->addSelect('partLots') + ->addSelect('orderdetails') + ->addSelect('attachments') + ->addSelect('storelocations') + //Calculate amount sum using a subquery, so we can filter and sort by it + ->addSelect( + '( SELECT IFNULL(SUM(partLot.amount), 0.0) 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()) ) AS HIDDEN amountSum' - ) - ->from(Part::class, 'part') - ->leftJoin('part.category', 'category') - ->leftJoin('part.master_picture_attachment', 'master_picture_attachment') - ->leftJoin('part.partLots', 'partLots') - ->leftJoin('partLots.storage_location', 'storelocations') - ->leftJoin('part.footprint', 'footprint') - ->leftJoin('footprint.master_picture_attachment', 'footprint_attachment') - ->leftJoin('part.manufacturer', 'manufacturer') - ->leftJoin('part.orderdetails', 'orderdetails') - ->leftJoin('orderdetails.supplier', 'suppliers') - ->leftJoin('part.attachments', 'attachments') - ->leftJoin('part.partUnit', 'partUnit') - ->leftJoin('part.parameters', 'parameters') + ) + ->from(Part::class, 'part') + ->leftJoin('part.category', 'category') + ->leftJoin('part.master_picture_attachment', 'master_picture_attachment') + ->leftJoin('part.partLots', 'partLots') + ->leftJoin('partLots.storage_location', 'storelocations') + ->leftJoin('part.footprint', 'footprint') + ->leftJoin('footprint.master_picture_attachment', 'footprint_attachment') + ->leftJoin('part.manufacturer', 'manufacturer') + ->leftJoin('part.orderdetails', 'orderdetails') + ->leftJoin('orderdetails.supplier', 'suppliers') + ->leftJoin('part.attachments', 'attachments') + ->leftJoin('part.partUnit', 'partUnit') + ->leftJoin('part.parameters', 'parameters') - ->where('part.id IN (:ids)') - ->setParameter('ids', $ids) + ->where('part.id IN (:ids)') + ->setParameter('ids', $ids) - //We have to group by all elements, or only the first sub elements of an association is fetched! (caused issue #190) - ->addGroupBy('part') - ->addGroupBy('partLots') - ->addGroupBy('category') - ->addGroupBy('master_picture_attachment') - ->addGroupBy('storelocations') - ->addGroupBy('footprint') - ->addGroupBy('footprint_attachment') - ->addGroupBy('manufacturer') - ->addGroupBy('orderdetails') - ->addGroupBy('suppliers') - ->addGroupBy('attachments') - ->addGroupBy('partUnit') - ->addGroupBy('parameters') - ; + //We have to group by all elements, or only the first sub elements of an association is fetched! (caused issue #190) + ->addGroupBy('part') + ->addGroupBy('partLots') + ->addGroupBy('category') + ->addGroupBy('master_picture_attachment') + ->addGroupBy('storelocations') + ->addGroupBy('footprint') + ->addGroupBy('footprint_attachment') + ->addGroupBy('manufacturer') + ->addGroupBy('orderdetails') + ->addGroupBy('suppliers') + ->addGroupBy('attachments') + ->addGroupBy('partUnit') + ->addGroupBy('parameters') + ; - //Get the results in the same order as the IDs were passed - FieldHelper::addOrderByFieldParam($builder, 'part.id', 'ids'); - } + //Get the results in the same order as the IDs were passed + FieldHelper::addOrderByFieldParam($builder, 'part.id', 'ids'); + } - private function buildCriteria(QueryBuilder $builder, array $options): void - { - //Apply the search criterias first - if ($options['search'] instanceof PartSearchFilter) { - $search = $options['search']; - $search->apply($builder); - } + private function buildCriteria(QueryBuilder $builder, array $options): void + { + //Apply the search criterias first + if ($options['search'] instanceof PartSearchFilter) { + $search = $options['search']; + $search->apply($builder); + } - //We do the most stuff here in the filter class - if ($options['filter'] instanceof PartFilter) { - $filter = $options['filter']; - $filter->apply($builder); - } + //We do the most stuff here in the filter class + if ($options['filter'] instanceof PartFilter) { + $filter = $options['filter']; + $filter->apply($builder); + } - } + } } From 0753b7137f2804fb48186cb1ef0d2f2c6357110d Mon Sep 17 00:00:00 2001 From: misaz Date: Wed, 4 Oct 2023 21:30:04 +0200 Subject: [PATCH 2/6] fixed tab/spaces in PartsDataTable --- src/DataTables/PartsDataTable.php | 794 +++++++++++++++--------------- 1 file changed, 397 insertions(+), 397 deletions(-) diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index c10a52b7..b33205ef 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -63,161 +63,161 @@ 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, private readonly string $default_part_columns, protected LoggerInterface $logger) - { - } + 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 $default_part_columns, protected LoggerInterface $logger) + { + } - public function configureOptions(OptionsResolver $optionsResolver): void - { - $optionsResolver->setDefaults([ - 'filter' => null, - 'search' => null - ]); + public function configureOptions(OptionsResolver $optionsResolver): void + { + $optionsResolver->setDefaults([ + 'filter' => null, + 'search' => null + ]); - $optionsResolver->setAllowedTypes('filter', [PartFilter::class, 'null']); - $optionsResolver->setAllowedTypes('search', [PartSearchFilter::class, 'null']); - } + $optionsResolver->setAllowedTypes('filter', [PartFilter::class, 'null']); + $optionsResolver->setAllowedTypes('search', [PartSearchFilter::class, 'null']); + } - public function configure(DataTable $dataTable, array $options): void - { - $resolver = new OptionsResolver(); - $this->configureOptions($resolver); - $options = $resolver->resolve($options); + public function configure(DataTable $dataTable, array $options): void + { + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + $options = $resolver->resolve($options); - $dataTable - //Color the table rows depending on the review and favorite status - ->add('dont_matter', RowClassColumn::class, [ - 'render' => function ($value, Part $context): string { - if ($context->isNeedsReview()) { - return 'table-secondary'; - } - if ($context->isFavorite()) { - return 'table-info'; - } + $dataTable + //Color the table rows depending on the review and favorite status + ->add('dont_matter', RowClassColumn::class, [ + 'render' => function ($value, Part $context): string { + if ($context->isNeedsReview()) { + return 'table-secondary'; + } + if ($context->isFavorite()) { + return 'table-info'; + } - return ''; //Default coloring otherwise - }, - ]) + return ''; //Default coloring otherwise + }, + ]) - ->add('select', SelectColumn::class) - ->add('picture', TextColumn::class, [ - 'label' => '', - 'className' => 'no-colvis', - 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderPicture($context), - ]); + ->add('select', SelectColumn::class) + ->add('picture', TextColumn::class, [ + 'label' => '', + 'className' => 'no-colvis', + 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderPicture($context), + ]); - // internal array of $dataTable->add(...) parameters. Parameters will be later passed to the method - // after sorting columns according to TABLE_PART_DEFAULT_COLUMNS option. - $columns = []; + // internal array of $dataTable->add(...) parameters. Parameters will be later passed to the method + // after sorting columns according to TABLE_PART_DEFAULT_COLUMNS option. + $columns = []; - $columns['name'] = [ - 'name', TextColumn::class, - [ - 'label' => $this->translator->trans('part.table.name'), - 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context), - ] - ]; - $columns['id'] = [ - 'id', TextColumn::class, - [ - 'label' => $this->translator->trans('part.table.id'), - 'visible' => false, - ] - ]; - $columns['ipn'] = [ - 'ipn', TextColumn::class, - [ - 'label' => $this->translator->trans('part.table.ipn'), - 'visible' => false, - ] - ]; - $columns['description'] = [ - 'description', MarkdownColumn::class, - [ - 'label' => $this->translator->trans('part.table.description'), - ] - ]; + $columns['name'] = [ + 'name', TextColumn::class, + [ + 'label' => $this->translator->trans('part.table.name'), + 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context), + ] + ]; + $columns['id'] = [ + 'id', TextColumn::class, + [ + 'label' => $this->translator->trans('part.table.id'), + 'visible' => false, + ] + ]; + $columns['ipn'] = [ + 'ipn', TextColumn::class, + [ + 'label' => $this->translator->trans('part.table.ipn'), + 'visible' => false, + ] + ]; + $columns['description'] = [ + 'description', MarkdownColumn::class, + [ + 'label' => $this->translator->trans('part.table.description'), + ] + ]; - if ($this->security->isGranted('@categories.read')) { - $columns['category'] = [ - 'category', EntityColumn::class, - [ - 'label' => $this->translator->trans('part.table.category'), - 'property' => 'category', - ] - ]; - } + if ($this->security->isGranted('@categories.read')) { + $columns['category'] = [ + 'category', EntityColumn::class, + [ + 'label' => $this->translator->trans('part.table.category'), + 'property' => 'category', + ] + ]; + } - if ($this->security->isGranted('@footprints.read')) { - $columns['footprint'] = [ - 'footprint', EntityColumn::class, - [ - 'property' => 'footprint', - 'label' => $this->translator->trans('part.table.footprint'), - ] - ]; - } - if ($this->security->isGranted('@manufacturers.read')) { - $columns['manufacturer'] = [ - 'manufacturer', EntityColumn::class, - [ - 'property' => 'manufacturer', - 'label' => $this->translator->trans('part.table.manufacturer'), - ] - ]; - } - if ($this->security->isGranted('@storelocations.read')) { - $columns['storelocation'] = [ - 'storelocation', TextColumn::class, - [ - 'label' => $this->translator->trans('part.table.storeLocations'), - 'orderField' => 'storelocations.name', - 'render' => function ($value, Part $context): string { - $tmp = []; - foreach ($context->getPartLots() as $lot) { - //Ignore lots without storelocation - if (!$lot->getStorageLocation() instanceof Storelocation) { - continue; - } - $tmp[] = sprintf( - '%s', - $this->urlGenerator->listPartsURL($lot->getStorageLocation()), - htmlspecialchars($lot->getStorageLocation()->getFullPath()), - htmlspecialchars($lot->getStorageLocation()->getName()) - ); - } + if ($this->security->isGranted('@footprints.read')) { + $columns['footprint'] = [ + 'footprint', EntityColumn::class, + [ + 'property' => 'footprint', + 'label' => $this->translator->trans('part.table.footprint'), + ] + ]; + } + if ($this->security->isGranted('@manufacturers.read')) { + $columns['manufacturer'] = [ + 'manufacturer', EntityColumn::class, + [ + 'property' => 'manufacturer', + 'label' => $this->translator->trans('part.table.manufacturer'), + ] + ]; + } + if ($this->security->isGranted('@storelocations.read')) { + $columns['storelocation'] = [ + 'storelocation', TextColumn::class, + [ + 'label' => $this->translator->trans('part.table.storeLocations'), + 'orderField' => 'storelocations.name', + 'render' => function ($value, Part $context): string { + $tmp = []; + foreach ($context->getPartLots() as $lot) { + //Ignore lots without storelocation + if (!$lot->getStorageLocation() instanceof Storelocation) { + continue; + } + $tmp[] = sprintf( + '%s', + $this->urlGenerator->listPartsURL($lot->getStorageLocation()), + htmlspecialchars($lot->getStorageLocation()->getFullPath()), + htmlspecialchars($lot->getStorageLocation()->getName()) + ); + } - return implode('
', $tmp); - }, - ] - ]; - } + return implode('
', $tmp); + }, + ] + ]; + } - $columns['amount'] = [ - 'amount', TextColumn::class, - [ - 'label' => $this->translator->trans('part.table.amount'), - 'render' => function ($value, Part $context) { - $amount = $context->getAmountSum(); - $expiredAmount = $context->getExpiredAmountSum(); + $columns['amount'] = [ + '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') - ); - $ret .= htmlspecialchars($this->amountFormatter->format($amount, $context->getPartUnit())); - } - } else { - $ret .= htmlspecialchars($this->amountFormatter->format($amount, $context->getPartUnit())); - } + 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())); + } //If we have expired lots, we show them in parentheses behind if ($expiredAmount > 0) { @@ -233,296 +233,296 @@ final class PartsDataTable implements DataTableTypeInterface $ret); } - return $ret; - }, - 'orderField' => 'amountSum' - ] - ]; - $columns['minamount'] = [ - '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())), - ] - ]; + return $ret; + }, + 'orderField' => 'amountSum' + ] + ]; + $columns['minamount'] = [ + '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')) { - $columns['partUnit'] = [ - 'partUnit', TextColumn::class, - [ - 'field' => 'partUnit.name', - 'label' => $this->translator->trans('part.table.partUnit'), - 'visible' => false, - ] - ]; - } + if ($this->security->isGranted('@footprints.read')) { + $columns['partUnit'] = [ + 'partUnit', TextColumn::class, + [ + 'field' => 'partUnit.name', + 'label' => $this->translator->trans('part.table.partUnit'), + 'visible' => false, + ] + ]; + } - $columns['addedDate'] = [ - 'addedDate', LocaleDateTimeColumn::class, - [ - 'label' => $this->translator->trans('part.table.addedDate'), - 'visible' => false, - ] - ]; - $columns['lastModified'] = [ - 'lastModified', LocaleDateTimeColumn::class, - [ - 'label' => $this->translator->trans('part.table.lastModified'), - 'visible' => false, - ] - ]; - $columns['needs_review'] = [ - 'needs_review', PrettyBoolColumn::class, - [ - 'label' => $this->translator->trans('part.table.needsReview'), - 'visible' => false, - ] - ]; - $columns['favorite'] = [ - 'favorite', PrettyBoolColumn::class, - [ - 'label' => $this->translator->trans('part.table.favorite'), - 'visible' => false, - ] - ]; - $columns['manufacturing_status'] = [ - 'manufacturing_status', EnumColumn::class, - [ - 'label' => $this->translator->trans('part.table.manufacturingStatus'), - 'visible' => false, - 'class' => ManufacturingStatus::class, - 'render' => function(?ManufacturingStatus $status, Part $context): string { - if (!$status) { - return ''; - } + $columns['addedDate'] = [ + 'addedDate', LocaleDateTimeColumn::class, + [ + 'label' => $this->translator->trans('part.table.addedDate'), + 'visible' => false, + ] + ]; + $columns['lastModified'] = [ + 'lastModified', LocaleDateTimeColumn::class, + [ + 'label' => $this->translator->trans('part.table.lastModified'), + 'visible' => false, + ] + ]; + $columns['needs_review'] = [ + 'needs_review', PrettyBoolColumn::class, + [ + 'label' => $this->translator->trans('part.table.needsReview'), + 'visible' => false, + ] + ]; + $columns['favorite'] = [ + 'favorite', PrettyBoolColumn::class, + [ + 'label' => $this->translator->trans('part.table.favorite'), + 'visible' => false, + ] + ]; + $columns['manufacturing_status'] = [ + 'manufacturing_status', EnumColumn::class, + [ + 'label' => $this->translator->trans('part.table.manufacturingStatus'), + 'visible' => false, + 'class' => ManufacturingStatus::class, + 'render' => function(?ManufacturingStatus $status, Part $context): string { + if (!$status) { + return ''; + } - return $this->translator->trans($status->toTranslationKey()); - } , - ] - ]; - $columns['manufacturer_product_number'] = [ - 'manufacturer_product_number', TextColumn::class, - [ - 'label' => $this->translator->trans('part.table.mpn'), - 'visible' => false, - ] - ]; - $columns['mass'] = [ - 'mass', SIUnitNumberColumn::class, - [ - 'label' => $this->translator->trans('part.table.mass'), - 'visible' => false, - 'unit' => 'g' - ] - ]; - $columns['tags'] = [ - 'tags', TagsColumn::class, - [ - 'label' => $this->translator->trans('part.table.tags'), - 'visible' => false, - ] - ]; - $columns['attachments'] = [ - 'attachments', PartAttachmentsColumn::class, - [ - 'label' => $this->translator->trans('part.table.attachments'), - 'visible' => false, - ] - ]; - $columns['edit'] = [ - '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'), - ] - ]; + return $this->translator->trans($status->toTranslationKey()); + } , + ] + ]; + $columns['manufacturer_product_number'] = [ + 'manufacturer_product_number', TextColumn::class, + [ + 'label' => $this->translator->trans('part.table.mpn'), + 'visible' => false, + ] + ]; + $columns['mass'] = [ + 'mass', SIUnitNumberColumn::class, + [ + 'label' => $this->translator->trans('part.table.mass'), + 'visible' => false, + 'unit' => 'g' + ] + ]; + $columns['tags'] = [ + 'tags', TagsColumn::class, + [ + 'label' => $this->translator->trans('part.table.tags'), + 'visible' => false, + ] + ]; + $columns['attachments'] = [ + 'attachments', PartAttachmentsColumn::class, + [ + 'label' => $this->translator->trans('part.table.attachments'), + 'visible' => false, + ] + ]; + $columns['edit'] = [ + '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'), + ] + ]; - $visible_columns_ids = array_map("trim", explode(",", $this->default_part_columns)); - $allowed_configurable_columns_ids = ["name", "id", "ipn", "description", "category", "footprint", "manufacturer", - "storelocation", "amount", "minamount", "partUnit", "addedDate", "lastModified", "needs_review", "favorite", - "manufacturing_status", "manufacturer_product_number", "mass", "tags", "attachments", "edit" - ]; - $processed_columns = []; + $visible_columns_ids = array_map("trim", explode(",", $this->default_part_columns)); + $allowed_configurable_columns_ids = ["name", "id", "ipn", "description", "category", "footprint", "manufacturer", + "storelocation", "amount", "minamount", "partUnit", "addedDate", "lastModified", "needs_review", "favorite", + "manufacturing_status", "manufacturer_product_number", "mass", "tags", "attachments", "edit" + ]; + $processed_columns = []; - foreach ($visible_columns_ids as $col_id) { - if (!in_array($col_id, $allowed_configurable_columns_ids) || !isset($columns[$col_id])) { - $this->logger->warning("Configuration option TABLE_PART_DEFAULT_COLUMNS specify invalid column '$col_id'. Collumn is skipped."); - continue; - } + foreach ($visible_columns_ids as $col_id) { + if (!in_array($col_id, $allowed_configurable_columns_ids) || !isset($columns[$col_id])) { + $this->logger->warning("Configuration option TABLE_PART_DEFAULT_COLUMNS specify invalid column '$col_id'. Collumn is skipped."); + continue; + } - if (in_array($col_id, $processed_columns)) { - $this->logger->warning("Configuration option TABLE_PART_DEFAULT_COLUMNS specify column '$col_id' multiple time. Only first occurence is used."); - continue; - } + if (in_array($col_id, $processed_columns)) { + $this->logger->warning("Configuration option TABLE_PART_DEFAULT_COLUMNS specify column '$col_id' multiple time. Only first occurence is used."); + continue; + } - $options = []; - if (count($columns[$col_id]) >= 3) { - $options = $columns[$col_id][2]; - } - $options["visible"] = true; - $dataTable->add($col_id, $columns[$col_id][1], $options); + $options = []; + if (count($columns[$col_id]) >= 3) { + $options = $columns[$col_id][2]; + } + $options["visible"] = true; + $dataTable->add($col_id, $columns[$col_id][1], $options); - $processed_columns[] = $col_id; - } + $processed_columns[] = $col_id; + } - // add remaining non-visible columns - foreach ($allowed_configurable_columns_ids as $col_id) { - if (in_array($col_id, $processed_columns)) { - // column already processed - continue; - } + // add remaining non-visible columns + foreach ($allowed_configurable_columns_ids as $col_id) { + if (in_array($col_id, $processed_columns)) { + // column already processed + continue; + } - $options = []; - if (count($columns[$col_id]) >= 3) { - $options = $columns[$col_id][2]; - } - $options["visible"] = false; - $dataTable->add($col_id, $columns[$col_id][1], $options); + $options = []; + if (count($columns[$col_id]) >= 3) { + $options = $columns[$col_id][2]; + } + $options["visible"] = false; + $dataTable->add($col_id, $columns[$col_id][1], $options); - $processed_columns[] = $col_id; - } + $processed_columns[] = $col_id; + } - $dataTable->addOrderBy('name') - ->createAdapter(TwoStepORMAdapater::class, [ - 'filter_query' => $this->getFilterQuery(...), - 'detail_query' => $this->getDetailQuery(...), - 'entity' => Part::class, - 'hydrate' => Query::HYDRATE_OBJECT, - 'criteria' => [ - function (QueryBuilder $builder) use ($options): void { - $this->buildCriteria($builder, $options); - }, - new SearchCriteriaProvider(), - ], - ]); - } + $dataTable->addOrderBy('name') + ->createAdapter(TwoStepORMAdapater::class, [ + 'filter_query' => $this->getFilterQuery(...), + 'detail_query' => $this->getDetailQuery(...), + 'entity' => Part::class, + 'hydrate' => Query::HYDRATE_OBJECT, + 'criteria' => [ + function (QueryBuilder $builder) use ($options): void { + $this->buildCriteria($builder, $options); + }, + new SearchCriteriaProvider(), + ], + ]); + } - private function getFilterQuery(QueryBuilder $builder): void - { - /* In the filter query we only select the IDs. The fetching of the full entities is done in the detail query. - * We only need to join the entities here, so we can filter by them. - * The filter conditions are added to this QB in the buildCriteria method. - */ - $builder - ->select('part.id') - ->addSelect('part.minamount AS HIDDEN minamount') - //Calculate amount sum using a subquery, so we can filter and sort by it - ->addSelect( - '( + private function getFilterQuery(QueryBuilder $builder): void + { + /* In the filter query we only select the IDs. The fetching of the full entities is done in the detail query. + * We only need to join the entities here, so we can filter by them. + * The filter conditions are added to this QB in the buildCriteria method. + */ + $builder + ->select('part.id') + ->addSelect('part.minamount AS HIDDEN minamount') + //Calculate amount sum using a subquery, so we can filter and sort by it + ->addSelect( + '( SELECT IFNULL(SUM(partLot.amount), 0.0) 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()) ) AS HIDDEN amountSum' - ) - ->from(Part::class, 'part') - ->leftJoin('part.category', 'category') - ->leftJoin('part.master_picture_attachment', 'master_picture_attachment') - ->leftJoin('part.partLots', 'partLots') - ->leftJoin('partLots.storage_location', 'storelocations') - ->leftJoin('part.footprint', 'footprint') - ->leftJoin('footprint.master_picture_attachment', 'footprint_attachment') - ->leftJoin('part.manufacturer', 'manufacturer') - ->leftJoin('part.orderdetails', 'orderdetails') - ->leftJoin('orderdetails.supplier', 'suppliers') - ->leftJoin('part.attachments', 'attachments') - ->leftJoin('part.partUnit', 'partUnit') - ->leftJoin('part.parameters', 'parameters') + ) + ->from(Part::class, 'part') + ->leftJoin('part.category', 'category') + ->leftJoin('part.master_picture_attachment', 'master_picture_attachment') + ->leftJoin('part.partLots', 'partLots') + ->leftJoin('partLots.storage_location', 'storelocations') + ->leftJoin('part.footprint', 'footprint') + ->leftJoin('footprint.master_picture_attachment', 'footprint_attachment') + ->leftJoin('part.manufacturer', 'manufacturer') + ->leftJoin('part.orderdetails', 'orderdetails') + ->leftJoin('orderdetails.supplier', 'suppliers') + ->leftJoin('part.attachments', 'attachments') + ->leftJoin('part.partUnit', 'partUnit') + ->leftJoin('part.parameters', 'parameters') - //This must be the only group by, or the paginator will not work correctly - ->addGroupBy('part.id') - ; - } + //This must be the only group by, or the paginator will not work correctly + ->addGroupBy('part.id') + ; + } - private function getDetailQuery(QueryBuilder $builder, array $filter_results): void - { - $ids = array_map(fn($row) => $row['id'], $filter_results); + private function getDetailQuery(QueryBuilder $builder, array $filter_results): void + { + $ids = array_map(fn($row) => $row['id'], $filter_results); - /* - * In this query we take the IDs which were filtered, paginated and sorted in the filter query, and fetch the - * full entities. - * We can do complex fetch joins, as we do not need to filter or sort here (which would kill the performance). - * The only condition should be for the IDs. - * It is important that elements are ordered the same way, as the IDs are passed, or ordering will be wrong. - */ - $builder - ->select('part') - ->addSelect('category') - ->addSelect('footprint') - ->addSelect('manufacturer') - ->addSelect('partUnit') - ->addSelect('master_picture_attachment') - ->addSelect('footprint_attachment') - ->addSelect('partLots') - ->addSelect('orderdetails') - ->addSelect('attachments') - ->addSelect('storelocations') - //Calculate amount sum using a subquery, so we can filter and sort by it - ->addSelect( - '( + /* + * In this query we take the IDs which were filtered, paginated and sorted in the filter query, and fetch the + * full entities. + * We can do complex fetch joins, as we do not need to filter or sort here (which would kill the performance). + * The only condition should be for the IDs. + * It is important that elements are ordered the same way, as the IDs are passed, or ordering will be wrong. + */ + $builder + ->select('part') + ->addSelect('category') + ->addSelect('footprint') + ->addSelect('manufacturer') + ->addSelect('partUnit') + ->addSelect('master_picture_attachment') + ->addSelect('footprint_attachment') + ->addSelect('partLots') + ->addSelect('orderdetails') + ->addSelect('attachments') + ->addSelect('storelocations') + //Calculate amount sum using a subquery, so we can filter and sort by it + ->addSelect( + '( SELECT IFNULL(SUM(partLot.amount), 0.0) 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()) ) AS HIDDEN amountSum' - ) - ->from(Part::class, 'part') - ->leftJoin('part.category', 'category') - ->leftJoin('part.master_picture_attachment', 'master_picture_attachment') - ->leftJoin('part.partLots', 'partLots') - ->leftJoin('partLots.storage_location', 'storelocations') - ->leftJoin('part.footprint', 'footprint') - ->leftJoin('footprint.master_picture_attachment', 'footprint_attachment') - ->leftJoin('part.manufacturer', 'manufacturer') - ->leftJoin('part.orderdetails', 'orderdetails') - ->leftJoin('orderdetails.supplier', 'suppliers') - ->leftJoin('part.attachments', 'attachments') - ->leftJoin('part.partUnit', 'partUnit') - ->leftJoin('part.parameters', 'parameters') + ) + ->from(Part::class, 'part') + ->leftJoin('part.category', 'category') + ->leftJoin('part.master_picture_attachment', 'master_picture_attachment') + ->leftJoin('part.partLots', 'partLots') + ->leftJoin('partLots.storage_location', 'storelocations') + ->leftJoin('part.footprint', 'footprint') + ->leftJoin('footprint.master_picture_attachment', 'footprint_attachment') + ->leftJoin('part.manufacturer', 'manufacturer') + ->leftJoin('part.orderdetails', 'orderdetails') + ->leftJoin('orderdetails.supplier', 'suppliers') + ->leftJoin('part.attachments', 'attachments') + ->leftJoin('part.partUnit', 'partUnit') + ->leftJoin('part.parameters', 'parameters') - ->where('part.id IN (:ids)') - ->setParameter('ids', $ids) + ->where('part.id IN (:ids)') + ->setParameter('ids', $ids) - //We have to group by all elements, or only the first sub elements of an association is fetched! (caused issue #190) - ->addGroupBy('part') - ->addGroupBy('partLots') - ->addGroupBy('category') - ->addGroupBy('master_picture_attachment') - ->addGroupBy('storelocations') - ->addGroupBy('footprint') - ->addGroupBy('footprint_attachment') - ->addGroupBy('manufacturer') - ->addGroupBy('orderdetails') - ->addGroupBy('suppliers') - ->addGroupBy('attachments') - ->addGroupBy('partUnit') - ->addGroupBy('parameters') - ; + //We have to group by all elements, or only the first sub elements of an association is fetched! (caused issue #190) + ->addGroupBy('part') + ->addGroupBy('partLots') + ->addGroupBy('category') + ->addGroupBy('master_picture_attachment') + ->addGroupBy('storelocations') + ->addGroupBy('footprint') + ->addGroupBy('footprint_attachment') + ->addGroupBy('manufacturer') + ->addGroupBy('orderdetails') + ->addGroupBy('suppliers') + ->addGroupBy('attachments') + ->addGroupBy('partUnit') + ->addGroupBy('parameters') + ; - //Get the results in the same order as the IDs were passed - FieldHelper::addOrderByFieldParam($builder, 'part.id', 'ids'); - } + //Get the results in the same order as the IDs were passed + FieldHelper::addOrderByFieldParam($builder, 'part.id', 'ids'); + } - private function buildCriteria(QueryBuilder $builder, array $options): void - { - //Apply the search criterias first - if ($options['search'] instanceof PartSearchFilter) { - $search = $options['search']; - $search->apply($builder); - } + private function buildCriteria(QueryBuilder $builder, array $options): void + { + //Apply the search criterias first + if ($options['search'] instanceof PartSearchFilter) { + $search = $options['search']; + $search->apply($builder); + } - //We do the most stuff here in the filter class - if ($options['filter'] instanceof PartFilter) { - $filter = $options['filter']; - $filter->apply($builder); - } + //We do the most stuff here in the filter class + if ($options['filter'] instanceof PartFilter) { + $filter = $options['filter']; + $filter->apply($builder); + } - } + } } From 1369091b901cec03c0a5e172f0fdb9decce666bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Oct 2023 21:07:22 +0200 Subject: [PATCH 3/6] Moved column sorting and visibility logic to its own (non-shared) helper service --- config/services.yaml | 5 +- src/DataTables/Helpers/ColumnSortHelper.php | 130 ++++++++ src/DataTables/PartsDataTable.php | 338 +++++++------------- 3 files changed, 249 insertions(+), 224 deletions(-) create mode 100644 src/DataTables/Helpers/ColumnSortHelper.php diff --git a/config/services.yaml b/config/services.yaml index c30e54c4..a327466b 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -216,7 +216,10 @@ services: #################################################################################################################### App\DataTables\PartsDataTable: arguments: - $default_part_columns: '%partdb.table.default_part_columns%' + $visible_columns: '%partdb.table.default_part_columns%' + + App\DataTables\Helpers\ColumnSortHelper: + shared: false # Service has a state so not share it between different tables #################################################################################################################### # Label system diff --git a/src/DataTables/Helpers/ColumnSortHelper.php b/src/DataTables/Helpers/ColumnSortHelper.php new file mode 100644 index 00000000..2cb8e0da --- /dev/null +++ b/src/DataTables/Helpers/ColumnSortHelper.php @@ -0,0 +1,130 @@ +. + */ + +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): 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 TABLE_PART_DEFAULT_COLUMNS specify invalid column '$col_id'. Column is skipped."); + continue; + } + + if (in_array($col_id, $processed_columns, true)) { + $this->logger->warning("Configuration option TABLE_PART_DEFAULT_COLUMNS 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 b33205ef..99956ce7 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\TwoStepORMAdapater; 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; @@ -33,7 +34,6 @@ use Doctrine\ORM\Query; use Doctrine\ORM\Tools\Pagination\Paginator; use Omines\DataTablesBundle\Adapter\Doctrine\Event\ORMAdapterQueryEvent; use Omines\DataTablesBundle\Adapter\Doctrine\ORMAdapterEvents; -use Psr\Log\LoggerInterface; use Symfony\Bundle\SecurityBundle\Security; use App\Entity\Parts\Storelocation; use App\DataTables\Column\EntityColumn; @@ -63,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, private readonly string $default_part_columns, protected LoggerInterface $logger) - { + 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 @@ -84,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'; @@ -97,127 +104,94 @@ 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), - ]); - - - // internal array of $dataTable->add(...) parameters. Parameters will be later passed to the method - // after sorting columns according to TABLE_PART_DEFAULT_COLUMNS option. - $columns = []; - - $columns['name'] = [ - 'name', TextColumn::class, - [ + ], visibility_configurable: false) + ->add('name', TextColumn::class, [ 'label' => $this->translator->trans('part.table.name'), 'render' => fn($value, Part $context) => $this->partDataTableHelper->renderName($context), - ] - ]; - $columns['id'] = [ - 'id', TextColumn::class, - [ + ]) + ->add('id', TextColumn::class, [ 'label' => $this->translator->trans('part.table.id'), 'visible' => false, - ] - ]; - $columns['ipn'] = [ - 'ipn', TextColumn::class, - [ + ]) + ->add('ipn', TextColumn::class, [ 'label' => $this->translator->trans('part.table.ipn'), 'visible' => false, - ] - ]; - $columns['description'] = [ - 'description', MarkdownColumn::class, - [ + ]) + ->add('description', MarkdownColumn::class, [ 'label' => $this->translator->trans('part.table.description'), - ] - ]; + ]); if ($this->security->isGranted('@categories.read')) { - $columns['category'] = [ - 'category', EntityColumn::class, - [ - 'label' => $this->translator->trans('part.table.category'), - 'property' => 'category', - ] - ]; + $this->csh->add('category', EntityColumn::class, [ + 'label' => $this->translator->trans('part.table.category'), + 'property' => 'category', + ]); } if ($this->security->isGranted('@footprints.read')) { - $columns['footprint'] = [ - 'footprint', EntityColumn::class, - [ - 'property' => 'footprint', - 'label' => $this->translator->trans('part.table.footprint'), - ] - ]; + $this->csh->add('footprint', EntityColumn::class, [ + 'property' => 'footprint', + 'label' => $this->translator->trans('part.table.footprint'), + ]); } if ($this->security->isGranted('@manufacturers.read')) { - $columns['manufacturer'] = [ - 'manufacturer', EntityColumn::class, - [ - 'property' => 'manufacturer', - 'label' => $this->translator->trans('part.table.manufacturer'), - ] - ]; + $this->csh->add('manufacturer', EntityColumn::class, [ + 'property' => 'manufacturer', + 'label' => $this->translator->trans('part.table.manufacturer'), + ]); } if ($this->security->isGranted('@storelocations.read')) { - $columns['storelocation'] = [ - 'storelocation', TextColumn::class, - [ - 'label' => $this->translator->trans('part.table.storeLocations'), - 'orderField' => 'storelocations.name', - 'render' => function ($value, Part $context): string { - $tmp = []; - foreach ($context->getPartLots() as $lot) { - //Ignore lots without storelocation - if (!$lot->getStorageLocation() instanceof Storelocation) { - continue; - } - $tmp[] = sprintf( - '%s', - $this->urlGenerator->listPartsURL($lot->getStorageLocation()), - htmlspecialchars($lot->getStorageLocation()->getFullPath()), - htmlspecialchars($lot->getStorageLocation()->getName()) - ); + $this->csh->add('storelocation', TextColumn::class, [ + 'label' => $this->translator->trans('part.table.storeLocations'), + 'orderField' => 'storelocations.name', + 'render' => function ($value, Part $context): string { + $tmp = []; + foreach ($context->getPartLots() as $lot) { + //Ignore lots without storelocation + if (!$lot->getStorageLocation() instanceof Storelocation) { + continue; } + $tmp[] = sprintf( + '%s', + $this->urlGenerator->listPartsURL($lot->getStorageLocation()), + htmlspecialchars($lot->getStorageLocation()->getFullPath()), + htmlspecialchars($lot->getStorageLocation()->getName()) + ); + } - return implode('
', $tmp); - }, - ] - ]; + return implode('
', $tmp); + }, + ]); } - $columns['amount'] = [ - 'amount', TextColumn::class, - [ - 'label' => $this->translator->trans('part.table.amount'), - 'render' => function ($value, Part $context) { - $amount = $context->getAmountSum(); - $expiredAmount = $context->getExpiredAmountSum(); + $this->csh->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') - ); - $ret .= htmlspecialchars($this->amountFormatter->format($amount, $context->getPartUnit())); - } - } else { + 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())); + } //If we have expired lots, we show them in parentheses behind if ($expiredAmount > 0) { @@ -233,158 +207,80 @@ final class PartsDataTable implements DataTableTypeInterface $ret); } - return $ret; - }, - 'orderField' => 'amountSum' - ] - ]; - $columns['minamount'] = [ - 'minamount', TextColumn::class, - [ + 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())), - ] - ]; + 'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format($value, + $context->getPartUnit())), + ]); if ($this->security->isGranted('@footprints.read')) { - $columns['partUnit'] = [ - 'partUnit', TextColumn::class, - [ - 'field' => 'partUnit.name', - 'label' => $this->translator->trans('part.table.partUnit'), - 'visible' => false, - ] - ]; + $this->csh->add('partUnit', TextColumn::class, [ + 'field' => 'partUnit.name', + 'label' => $this->translator->trans('part.table.partUnit'), + 'visible' => false, + ]); } - $columns['addedDate'] = [ - 'addedDate', LocaleDateTimeColumn::class, - [ - 'label' => $this->translator->trans('part.table.addedDate'), - 'visible' => false, - ] - ]; - $columns['lastModified'] = [ - 'lastModified', LocaleDateTimeColumn::class, - [ + $this->csh->add('addedDate', LocaleDateTimeColumn::class, [ + 'label' => $this->translator->trans('part.table.addedDate'), + 'visible' => false, + ]) + ->add('lastModified', LocaleDateTimeColumn::class, [ 'label' => $this->translator->trans('part.table.lastModified'), 'visible' => false, - ] - ]; - $columns['needs_review'] = [ - 'needs_review', PrettyBoolColumn::class, - [ + ]) + ->add('needs_review', PrettyBoolColumn::class, [ 'label' => $this->translator->trans('part.table.needsReview'), 'visible' => false, - ] - ]; - $columns['favorite'] = [ - 'favorite', PrettyBoolColumn::class, - [ + ]) + ->add('favorite', PrettyBoolColumn::class, [ 'label' => $this->translator->trans('part.table.favorite'), 'visible' => false, - ] - ]; - $columns['manufacturing_status'] = [ - 'manufacturing_status', EnumColumn::class, - [ + ]) + ->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()); - } , - ] - ]; - $columns['manufacturer_product_number'] = [ - 'manufacturer_product_number', TextColumn::class, - [ + }, + ]) + ->add('manufacturer_product_number', TextColumn::class, [ 'label' => $this->translator->trans('part.table.mpn'), 'visible' => false, - ] - ]; - $columns['mass'] = [ - 'mass', SIUnitNumberColumn::class, - [ + ]) + ->add('mass', SIUnitNumberColumn::class, [ 'label' => $this->translator->trans('part.table.mass'), 'visible' => false, 'unit' => 'g' - ] - ]; - $columns['tags'] = [ - 'tags', TagsColumn::class, - [ + ]) + ->add('tags', TagsColumn::class, [ 'label' => $this->translator->trans('part.table.tags'), 'visible' => false, - ] - ]; - $columns['attachments'] = [ - 'attachments', PartAttachmentsColumn::class, - [ + ]) + ->add('attachments', PartAttachmentsColumn::class, [ 'label' => $this->translator->trans('part.table.attachments'), 'visible' => false, - ] - ]; - $columns['edit'] = [ - 'edit', IconLinkColumn::class, - [ + ]) + ->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'), - ] - ]; + ]); - $visible_columns_ids = array_map("trim", explode(",", $this->default_part_columns)); - $allowed_configurable_columns_ids = ["name", "id", "ipn", "description", "category", "footprint", "manufacturer", - "storelocation", "amount", "minamount", "partUnit", "addedDate", "lastModified", "needs_review", "favorite", - "manufacturing_status", "manufacturer_product_number", "mass", "tags", "attachments", "edit" - ]; - $processed_columns = []; - - foreach ($visible_columns_ids as $col_id) { - if (!in_array($col_id, $allowed_configurable_columns_ids) || !isset($columns[$col_id])) { - $this->logger->warning("Configuration option TABLE_PART_DEFAULT_COLUMNS specify invalid column '$col_id'. Collumn is skipped."); - continue; - } - - if (in_array($col_id, $processed_columns)) { - $this->logger->warning("Configuration option TABLE_PART_DEFAULT_COLUMNS specify column '$col_id' multiple time. Only first occurence is used."); - continue; - } - - $options = []; - if (count($columns[$col_id]) >= 3) { - $options = $columns[$col_id][2]; - } - $options["visible"] = true; - $dataTable->add($col_id, $columns[$col_id][1], $options); - - $processed_columns[] = $col_id; - } - - // add remaining non-visible columns - foreach ($allowed_configurable_columns_ids as $col_id) { - if (in_array($col_id, $processed_columns)) { - // column already processed - continue; - } - - $options = []; - if (count($columns[$col_id]) >= 3) { - $options = $columns[$col_id][2]; - } - $options["visible"] = false; - $dataTable->add($col_id, $columns[$col_id][1], $options); - - $processed_columns[] = $col_id; - } + //Apply the user configured order and visibility and add the columns to the table + $this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->visible_columns); $dataTable->addOrderBy('name') ->createAdapter(TwoStepORMAdapater::class, [ @@ -415,7 +311,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()) @@ -436,8 +332,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 @@ -467,7 +362,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()) @@ -486,7 +381,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) @@ -503,8 +397,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'); @@ -523,6 +416,5 @@ final class PartsDataTable implements DataTableTypeInterface $filter = $options['filter']; $filter->apply($builder); } - } } From 79262972aab02b5f7d9bcaf72752d56764a92a00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Oct 2023 21:28:37 +0200 Subject: [PATCH 4/6] Renamed config env to TABLE_PARTS_DEFAULT_COLUMNS and updated documentation --- .docker/symfony.conf | 2 +- .env | 3 +++ config/parameters.yaml | 2 +- config/services.yaml | 2 +- docs/configuration.md | 3 ++- src/DataTables/Helpers/ColumnSortHelper.php | 7 ++++--- src/DataTables/PartsDataTable.php | 4 ++-- 7 files changed, 14 insertions(+), 9 deletions(-) diff --git a/.docker/symfony.conf b/.docker/symfony.conf index 589761ba..e00445c5 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 TABLE_PART_DEFAULT_COLUMNS + 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 5b1a73e5..b2c31a53 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 b24c7f57..9839bcf5 100644 --- a/config/parameters.yaml +++ b/config/parameters.yaml @@ -54,7 +54,7 @@ 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_part_columns: '%env(trim:string:TABLE_PART_DEFAULT_COLUMNS)%' # The default columns in part tables and their order + 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 a327466b..b161ec35 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -216,7 +216,7 @@ services: #################################################################################################################### App\DataTables\PartsDataTable: arguments: - $visible_columns: '%partdb.table.default_part_columns%' + $visible_columns: '%partdb.table.parts.default_columns%' App\DataTables\Helpers\ColumnSortHelper: shared: false # Service has a state so not share it between different tables diff --git a/docs/configuration.md b/docs/configuration.md index b878bd49..e5c75cce 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -46,7 +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_PART_DEFAULT_COLUMNS`: The default columns in part tables loading table for first time. Also specify default order of the columns. Specify as comma separated string. Available columns are: `name`, `id`, `ipn`, `description`, `category`, `footprint`, `manufacturer`, `storelocation`, `amount`, `minamount`, `partUnit`, `addedDate`, `lastModified`, `needs_review`, `favorite`, `manufacturing_status`, `manufacturer_product_number`, `mass`, `tags`, `attachments`, `edit`. +* `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 index 2cb8e0da..a16a64d8 100644 --- a/src/DataTables/Helpers/ColumnSortHelper.php +++ b/src/DataTables/Helpers/ColumnSortHelper.php @@ -76,7 +76,8 @@ class ColumnSortHelper * 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): 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)) { @@ -96,12 +97,12 @@ class ColumnSortHelper //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 TABLE_PART_DEFAULT_COLUMNS specify invalid column '$col_id'. Column is skipped."); + $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 TABLE_PART_DEFAULT_COLUMNS specify column '$col_id' multiple time. Only first occurrence is used."); + $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); diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index 99956ce7..42d622eb 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -167,7 +167,7 @@ final class PartsDataTable implements DataTableTypeInterface return implode('
', $tmp); }, - ]); + ], alias: 'storage_location'); } $this->csh->add('amount', TextColumn::class, [ @@ -280,7 +280,7 @@ final class PartsDataTable implements DataTableTypeInterface ]); //Apply the user configured order and visibility and add the columns to the table - $this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->visible_columns); + $this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->visible_columns, "TABLE_PARTS_DEFAULT_COLUMNS"); $dataTable->addOrderBy('name') ->createAdapter(TwoStepORMAdapater::class, [ From 185c88fa3e97665052a7687036610e9498d37fa6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Oct 2023 21:32:57 +0200 Subject: [PATCH 5/6] Removed now useless visibility options from PartsDataTable The visibility is now configured by the env variable, so this is useless. --- src/DataTables/PartsDataTable.php | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index 42d622eb..2a7d0973 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -117,11 +117,9 @@ final class PartsDataTable implements DataTableTypeInterface ]) ->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'), @@ -213,7 +211,6 @@ final class PartsDataTable implements DataTableTypeInterface ]) ->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())), ]); @@ -222,29 +219,23 @@ final class PartsDataTable implements DataTableTypeInterface $this->csh->add('partUnit', TextColumn::class, [ 'field' => 'partUnit.name', 'label' => $this->translator->trans('part.table.partUnit'), - 'visible' => false, ]); } $this->csh->add('addedDate', LocaleDateTimeColumn::class, [ 'label' => $this->translator->trans('part.table.addedDate'), - 'visible' => false, ]) ->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 { if (!$status) { @@ -256,31 +247,27 @@ final class PartsDataTable implements DataTableTypeInterface ]) ->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'), ]); //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"); + $this->csh->applyVisibilityAndConfigureColumns($dataTable, $this->visible_columns, + "TABLE_PARTS_DEFAULT_COLUMNS"); $dataTable->addOrderBy('name') ->createAdapter(TwoStepORMAdapater::class, [ From 470df57f58abac151653387c43df20e93d43cc51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Sun, 8 Oct 2023 21:36:05 +0200 Subject: [PATCH 6/6] Removed useless permissions checks, as the permissions are now always granted automatically, if the user has read access to parts --- src/DataTables/PartsDataTable.php | 113 +++++++++++++----------------- 1 file changed, 49 insertions(+), 64 deletions(-) diff --git a/src/DataTables/PartsDataTable.php b/src/DataTables/PartsDataTable.php index 2a7d0973..991d6aca 100644 --- a/src/DataTables/PartsDataTable.php +++ b/src/DataTables/PartsDataTable.php @@ -123,29 +123,20 @@ final class PartsDataTable implements DataTableTypeInterface ]) ->add('description', MarkdownColumn::class, [ 'label' => $this->translator->trans('part.table.description'), - ]); - - if ($this->security->isGranted('@categories.read')) { - $this->csh->add('category', EntityColumn::class, [ + ]) + ->add('category', EntityColumn::class, [ 'label' => $this->translator->trans('part.table.category'), 'property' => 'category', - ]); - } - - if ($this->security->isGranted('@footprints.read')) { - $this->csh->add('footprint', EntityColumn::class, [ + ]) + ->add('footprint', EntityColumn::class, [ 'property' => 'footprint', 'label' => $this->translator->trans('part.table.footprint'), - ]); - } - if ($this->security->isGranted('@manufacturers.read')) { - $this->csh->add('manufacturer', EntityColumn::class, [ + ]) + ->add('manufacturer', EntityColumn::class, [ 'property' => 'manufacturer', 'label' => $this->translator->trans('part.table.manufacturer'), - ]); - } - if ($this->security->isGranted('@storelocations.read')) { - $this->csh->add('storelocation', TextColumn::class, [ + ]) + ->add('storelocation', TextColumn::class, [ 'label' => $this->translator->trans('part.table.storeLocations'), 'orderField' => 'storelocations.name', 'render' => function ($value, Part $context): string { @@ -165,66 +156,60 @@ final class PartsDataTable implements DataTableTypeInterface return implode('
', $tmp); }, - ], alias: 'storage_location'); - } + ], 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(); - $this->csh->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'), 'render' => fn($value, Part $context): string => htmlspecialchars($this->amountFormatter->format($value, $context->getPartUnit())), - ]); - - if ($this->security->isGranted('@footprints.read')) { - $this->csh->add('partUnit', TextColumn::class, [ + ]) + ->add('partUnit', TextColumn::class, [ 'field' => 'partUnit.name', 'label' => $this->translator->trans('part.table.partUnit'), - ]); - } - - $this->csh->add('addedDate', LocaleDateTimeColumn::class, [ - 'label' => $this->translator->trans('part.table.addedDate'), - ]) + ]) + ->add('addedDate', LocaleDateTimeColumn::class, [ + 'label' => $this->translator->trans('part.table.addedDate'), + ]) ->add('lastModified', LocaleDateTimeColumn::class, [ 'label' => $this->translator->trans('part.table.lastModified'), ])