From 926b6fcbca9cc2a6af3d2ba0c77ed165eec63ae2 Mon Sep 17 00:00:00 2001 From: Jacek Pyziak Date: Sun, 8 Feb 2026 17:29:52 +0100 Subject: [PATCH] Release 0.249: banner edit fixes and thumbnail popup --- admin/templates/articles/article-edit.php | 2 +- admin/templates/banners/banner-edit.php | 4 +- admin/templates/banners/banners-list.php | 100 ++++ admin/templates/components/form-edit.php | 278 +++++++++++ admin/templates/filemanager/filemanager.php | 2 +- admin/templates/html/input-switch.php | 2 +- admin/templates/shop-producer/edit.php | 4 +- admin/templates/shop-product/product-edit.php | 2 +- autoload/Domain/Banner/BannerRepository.php | 170 ++++++- .../admin/Controllers/BannerController.php | 169 ++++++- .../admin/Support/Forms/FormFieldRenderer.php | 430 ++++++++++++++++++ .../Support/Forms/FormRequestHandler.php | 152 +++++++ autoload/admin/Validation/FormValidator.php | 196 ++++++++ .../admin/ViewModels/Forms/FormAction.php | 73 +++ .../ViewModels/Forms/FormEditViewModel.php | 178 ++++++++ autoload/admin/ViewModels/Forms/FormField.php | 323 +++++++++++++ .../admin/ViewModels/Forms/FormFieldType.php | 23 + autoload/admin/ViewModels/Forms/FormTab.php | 31 ++ libraries/.htaccess | 10 +- .../Domain/Banner/BannerRepositoryTest.php | 161 ++++++- updates/0.20/ver_0.249.zip | Bin 0 -> 41877 bytes updates/0.20/ver_0.249_files.txt | 0 updates/changelog.php | 8 +- updates/versions.php | 2 +- 24 files changed, 2273 insertions(+), 47 deletions(-) create mode 100644 admin/templates/components/form-edit.php create mode 100644 autoload/admin/Support/Forms/FormFieldRenderer.php create mode 100644 autoload/admin/Support/Forms/FormRequestHandler.php create mode 100644 autoload/admin/Validation/FormValidator.php create mode 100644 autoload/admin/ViewModels/Forms/FormAction.php create mode 100644 autoload/admin/ViewModels/Forms/FormEditViewModel.php create mode 100644 autoload/admin/ViewModels/Forms/FormField.php create mode 100644 autoload/admin/ViewModels/Forms/FormFieldType.php create mode 100644 autoload/admin/ViewModels/Forms/FormTab.php create mode 100644 updates/0.20/ver_0.249.zip create mode 100644 updates/0.20/ver_0.249_files.txt diff --git a/admin/templates/articles/article-edit.php b/admin/templates/articles/article-edit.php index 248cb07..8cd9888 100644 --- a/admin/templates/articles/article-edit.php +++ b/admin/templates/articles/article-edit.php @@ -801,4 +801,4 @@ echo $grid -> draw(); }); } - \ No newline at end of file + diff --git a/admin/templates/banners/banner-edit.php b/admin/templates/banners/banner-edit.php index dd811f6..469abb8 100644 --- a/admin/templates/banners/banner-edit.php +++ b/admin/templates/banners/banner-edit.php @@ -85,7 +85,7 @@ ob_start(); 'id' => 'src_' . $lg['id'], 'value' => $this -> banner['languages'][ $lg['id'] ]['src'], 'icon_content' => 'przeglądaj', - 'icon_js' => "window.open ( 'http://" . $_SERVER['SERVER_NAME'] . "/libraries/filemanager-9.14.2/dialog.php?type=1&popup=1&field_id=src_" . $lg['id'] . "&akey=" . $rfmAkeyJS . "', 'mywindow', 'location=1,status=1,scrollbars=1, width=1100,height=700');" + 'icon_js' => "window.open ( '/libraries/filemanager-9.14.2/dialog.php?type=1&popup=1&field_id=src_" . $lg['id'] . "&akey=" . $rfmAkeyJS . "', 'mywindow', 'location=1,status=1,scrollbars=1, width=1100,height=700');" ) ); ?> @@ -182,4 +182,4 @@ echo $grid -> draw(); }); - \ No newline at end of file + diff --git a/admin/templates/banners/banners-list.php b/admin/templates/banners/banners-list.php index 3e70c9a..45cd47a 100644 --- a/admin/templates/banners/banners-list.php +++ b/admin/templates/banners/banners-list.php @@ -1,5 +1,105 @@ $this->viewModel]); ?> + + + + viewModel->customScriptView)): ?> viewModel->customScriptView, ['list' => $this->viewModel]); ?> diff --git a/admin/templates/components/form-edit.php b/admin/templates/components/form-edit.php new file mode 100644 index 0000000..9d4d853 --- /dev/null +++ b/admin/templates/components/form-edit.php @@ -0,0 +1,278 @@ +form; +$renderer = new FormFieldRenderer($form); + +// Przygotuj filemanager key +\S::set_session('admin', true); +if ( + empty($_SESSION['rfm_akey']) || + (($_SESSION['rfm_akey_expires'] ?? 0) < time()) +) { + $_SESSION['rfm_akey'] = bin2hex(random_bytes(16)); +} +$_SESSION['rfm_akey_expires'] = time() + 20 * 60; +$_SESSION['can_use_rfm'] = true; +?> + + + + + + + + + + +
+
+
+
+
+ title) ?> +
+
+ actions as $action): ?> + name === 'save'): ?> + persist): ?> + + Zatwierdź i zamknij + + + + Zatwierdź + + name === 'cancel'): ?> + + Wstecz + + + + label) ?> + + + +
+ +
+
+ + + + hiddenFields as $name => $value): ?> + + + + hasTabs()): ?> + +
+
    + tabs as $tab): ?> +
  • label) ?>
  • + +
+
+ tabs as $tab): ?> +
+ getFieldsForTab($tab->id); + $langSections = $form->getLangSectionsForTab($tab->id); + ?> + + + renderField($field) ?> + + + + renderLangSection($section) ?> + +
+ +
+
+ + + fields as $field): ?> + type === FormFieldType::LANG_SECTION): ?> + renderLangSection($field) ?> + + renderField($field) ?> + + + +
+
+
+
+
+
+ + + +fields as $field): + if ($field->type === FormFieldType::EDITOR): +?> + + + +hasLangSections()): ?> + + + + + + diff --git a/admin/templates/filemanager/filemanager.php b/admin/templates/filemanager/filemanager.php index 3e132fd..114d499 100644 --- a/admin/templates/filemanager/filemanager.php +++ b/admin/templates/filemanager/filemanager.php @@ -1 +1 @@ - \ No newline at end of file + diff --git a/admin/templates/html/input-switch.php b/admin/templates/html/input-switch.php index 1a87d6b..0d9aca4 100644 --- a/admin/templates/html/input-switch.php +++ b/admin/templates/html/input-switch.php @@ -15,7 +15,7 @@ $out .= 'id="' . $this -> params['id'] . '" '; else $out .= 'id="' . $this -> params['name'] . '" '; - $out .= 'name="' . $this -> params['name'] . '" type="checkbox"'; + $out .= 'name="' . $this -> params['name'] . '" type="checkbox" value="1"'; if ( $this -> params['checked'] ) $out .= 'checked="checked" '; diff --git a/admin/templates/shop-producer/edit.php b/admin/templates/shop-producer/edit.php index ffe97ce..6a03486 100644 --- a/admin/templates/shop-producer/edit.php +++ b/admin/templates/shop-producer/edit.php @@ -36,7 +36,7 @@ ob_start(); 'id' => 'img', 'value' => $this -> producer['img'], 'icon_content' => 'przeglądaj', - 'icon_js' => "window.open ( 'http://" . $_SERVER['SERVER_NAME'] . "/libraries/filemanager-9.14.2/dialog.php?type=1&popup=1&field_id=img&akey=" . $rfmAkeyJS . "', 'mywindow', 'location=1,status=1,scrollbars=1, width=1100,height=700');" + 'icon_js' => "window.open ( '/libraries/filemanager-9.14.2/dialog.php?type=1&popup=1&field_id=img&akey=" . $rfmAkeyJS . "', 'mywindow', 'location=1,status=1,scrollbars=1, width=1100,height=700');" ] ); ?> @@ -177,4 +177,4 @@ echo $grid -> draw(); tabidentify: 'languages-main' }); }); - \ No newline at end of file + diff --git a/admin/templates/shop-product/product-edit.php b/admin/templates/shop-product/product-edit.php index 9bef6f7..9289ad8 100644 --- a/admin/templates/shop-product/product-edit.php +++ b/admin/templates/shop-product/product-edit.php @@ -1343,4 +1343,4 @@ echo $grid->draw(); \ No newline at end of file + diff --git a/autoload/Domain/Banner/BannerRepository.php b/autoload/Domain/Banner/BannerRepository.php index 16ef283..083a13a 100644 --- a/autoload/Domain/Banner/BannerRepository.php +++ b/autoload/Domain/Banner/BannerRepository.php @@ -29,7 +29,10 @@ class BannerRepository return null; } - $results = $this->db->select('pp_banners_langs', '*', ['id_banner' => $bannerId]); + $results = $this->db->select('pp_banners_langs', '*', [ + 'id_banner' => $bannerId, + 'ORDER' => ['id' => 'ASC'], + ]); if (is_array($results)) { foreach ($results as $row) { $banner['languages'][$row['id_lang']] = $row; @@ -54,19 +57,30 @@ class BannerRepository /** * Zapisuje baner (insert lub update) * - * @param array $data Dane banera + * @param array $data Dane banera (obsługuje format z FormRequestHandler lub stary format) * @return int|false ID banera lub false */ public function save(array $data) { $bannerId = $data['id'] ?? null; + // Obsługa obu formatów: nowy (int) i stary ('on'/string) + $status = $data['status'] ?? 0; + if ($status === 'on') { + $status = 1; + } + + $homePage = $data['home_page'] ?? 0; + if ($homePage === 'on') { + $homePage = 1; + } + $bannerData = [ 'name' => $data['name'], - 'status' => $data['status'] == 'on' ? 1 : 0, - 'date_start' => $data['date_start'] != '' ? $data['date_start'] : null, - 'date_end' => $data['date_end'] != '' ? $data['date_end'] : null, - 'home_page' => $data['home_page'] == 'on' ? 1 : 0, + 'status' => (int)$status, + 'date_start' => !empty($data['date_start']) ? $data['date_start'] : null, + 'date_end' => !empty($data['date_end']) ? $data['date_end'] : null, + 'home_page' => (int)$homePage, ]; if (!$bannerId) { @@ -79,7 +93,14 @@ class BannerRepository $this->db->update('pp_banners', $bannerData, ['id' => (int)$bannerId]); } - $this->saveTranslations($bannerId, $data['src'], $data['url'], $data['html'], $data['text']); + // Obsługa danych językowych - nowy format (translations) lub stary (src/url/html/text) + if (isset($data['translations']) && is_array($data['translations'])) { + // Nowy format z FormRequestHandler + $this->saveTranslationsFromArray($bannerId, $data['translations']); + } elseif (isset($data['src']) && is_array($data['src'])) { + // Stary format (backward compatibility) + $this->saveTranslations($bannerId, $data['src'], $data['url'], $data['html'], $data['text']); + } return (int)$bannerId; } @@ -159,35 +180,134 @@ class BannerRepository $stmt = $this->db->query($sql, $params); $items = $stmt ? $stmt->fetchAll() : []; + $items = is_array($items) ? $items : []; + + if (!empty($items)) { + $bannerIds = array_map('intval', array_column($items, 'id')); + $thumbByBannerId = $this->fetchThumbnailsByBannerIds($bannerIds); + + foreach ($items as &$item) { + $item['thumbnail_src'] = $thumbByBannerId[(int)($item['id'] ?? 0)] ?? ''; + } + unset($item); + } return [ - 'items' => is_array($items) ? $items : [], + 'items' => $items, 'total' => $total, ]; } /** - * Zapisuje tłumaczenia banera + * Pobiera pierwsza dostepna sciezke obrazka (src) dla kazdego banera. + * + * @param array $bannerIds + * @return array [id_banner => src] + */ + private function fetchThumbnailsByBannerIds(array $bannerIds): array + { + $bannerIds = array_values(array_unique(array_filter($bannerIds, static function ($id): bool { + return (int)$id > 0; + }))); + + if (empty($bannerIds)) { + return []; + } + + $in = []; + $params = []; + foreach ($bannerIds as $index => $bannerId) { + $placeholder = ':id' . $index; + $in[] = $placeholder; + $params[$placeholder] = (int)$bannerId; + } + + $sql = ' + SELECT id_banner, src + FROM pp_banners_langs + WHERE id_banner IN (' . implode(', ', $in) . ') + AND src IS NOT NULL + AND src <> \'\' + ORDER BY id_lang ASC, id ASC + '; + + $stmt = $this->db->query($sql, $params); + $rows = $stmt ? $stmt->fetchAll() : []; + if (!is_array($rows)) { + return []; + } + + $thumbByBannerId = []; + foreach ($rows as $row) { + $bannerId = (int)($row['id_banner'] ?? 0); + if ($bannerId <= 0 || isset($thumbByBannerId[$bannerId])) { + continue; + } + + $src = trim((string)($row['src'] ?? '')); + if ($src !== '') { + $thumbByBannerId[$bannerId] = $src; + } + } + + return $thumbByBannerId; + } + + /** + * Zapisuje tłumaczenia banera (stary format - zachowano dla kompatybilności) */ private function saveTranslations(int $bannerId, array $src, array $url, array $html, array $text): void { foreach ($src as $langId => $val) { - $translationData = [ - 'id_banner' => $bannerId, - 'id_lang' => $langId, - 'src' => $src[$langId], - 'url' => $url[$langId], - 'html' => $html[$langId], - 'text' => $text[$langId], - ]; - - $existingId = $this->db->get('pp_banners_langs', 'id', ['AND' => ['banner_id' => $bannerId, 'lang_id' => $langId]]); - - if ($existingId) { - $this->db->update('pp_banners_langs', $translationData, ['id' => $existingId]); - } else { - $this->db->insert('pp_banners_langs', $translationData); - } + $this->upsertTranslation($bannerId, $langId, [ + 'src' => $src[$langId] ?? '', + 'url' => $url[$langId] ?? '', + 'html' => $html[$langId] ?? '', + 'text' => $text[$langId] ?? '', + ]); } } + + /** + * Zapisuje tłumaczenia banera z nowego formatu (z FormRequestHandler) + * Format: [lang_id => [field => value]] + */ + private function saveTranslationsFromArray(int $bannerId, array $translations): void + { + foreach ($translations as $langId => $fields) { + $this->upsertTranslation($bannerId, $langId, [ + 'src' => $fields['src'] ?? '', + 'url' => $fields['url'] ?? '', + 'html' => $fields['html'] ?? '', + 'text' => $fields['text'] ?? '', + ]); + } + } + + /** + * Upsert tlumaczenia banera. + * Aktualizuje wszystkie rekordy dla pary id_banner + id_lang, + * co usuwa problem z historycznymi duplikatami. + */ + private function upsertTranslation(int $bannerId, $langId, array $fields): void + { + $where = ['AND' => ['id_banner' => $bannerId, 'id_lang' => $langId]]; + $translationData = [ + 'id_banner' => $bannerId, + 'id_lang' => $langId, + 'src' => $fields['src'] ?? '', + 'url' => $fields['url'] ?? '', + 'html' => $fields['html'] ?? '', + 'text' => $fields['text'] ?? '', + ]; + + $hasExisting = (int)$this->db->count('pp_banners_langs', $where) > 0; + + if ($hasExisting) { + $this->db->update('pp_banners_langs', $translationData, $where); + return; + } + + $this->db->insert('pp_banners_langs', $translationData); + } } diff --git a/autoload/admin/Controllers/BannerController.php b/autoload/admin/Controllers/BannerController.php index 3978ff2..d2c8a74 100644 --- a/autoload/admin/Controllers/BannerController.php +++ b/autoload/admin/Controllers/BannerController.php @@ -2,14 +2,21 @@ namespace admin\Controllers; use Domain\Banner\BannerRepository; +use admin\ViewModels\Forms\FormEditViewModel; +use admin\ViewModels\Forms\FormField; +use admin\ViewModels\Forms\FormTab; +use admin\ViewModels\Forms\FormAction; +use admin\Support\Forms\FormRequestHandler; class BannerController { private BannerRepository $repository; + private FormRequestHandler $formHandler; public function __construct(BannerRepository $repository) { $this->repository = $repository; + $this->formHandler = new FormRequestHandler(); } /** @@ -64,9 +71,24 @@ class BannerController $name = (string)($item['name'] ?? ''); $homePage = (int)($item['home_page'] ?? 0); $isActive = (int)($item['status'] ?? 0) === 1; + $thumbnailSrc = trim((string)($item['thumbnail_src'] ?? '')); + if ($thumbnailSrc !== '' && !preg_match('#^(https?:)?//#i', $thumbnailSrc) && strpos($thumbnailSrc, '/') !== 0) { + $thumbnailSrc = '/' . ltrim($thumbnailSrc, '/'); + } + + $thumbnail = '-'; + if ($thumbnailSrc !== '') { + $thumbnail = ''; + } $rows[] = [ 'lp' => $lp++ . '.', + 'thumbnail' => $thumbnail, 'name' => '' . htmlspecialchars($name, ENT_QUOTES, 'UTF-8') . '', 'status' => $isActive ? 'tak' : 'nie', 'home_page' => $homePage === 1 ? 'tak' : 'nie', @@ -95,6 +117,7 @@ class BannerController $viewModel = new \admin\ViewModels\Common\PaginatedTableViewModel( [ ['key' => 'lp', 'label' => 'Lp.', 'class' => 'text-center', 'sortable' => false], + ['key' => 'thumbnail', 'label' => 'Miniatura', 'class' => 'text-center', 'sortable' => false, 'raw' => true], ['key' => 'name', 'sort_key' => 'name', 'label' => 'Nazwa', 'sortable' => true, 'raw' => true], ['key' => 'status', 'sort_key' => 'status', 'label' => 'Aktywny', 'class' => 'text-center', 'sortable' => true, 'raw' => true], ['key' => 'home_page', 'sort_key' => 'home_page', 'label' => 'Strona glowna', 'class' => 'text-center', 'sortable' => true, 'raw' => true], @@ -141,7 +164,15 @@ class BannerController $banner = $this->repository->find($bannerId); $languages = \admin\factory\Languages::languages_list(); - return \admin\view\Banners::banner_edit($banner, $languages); + // Sprawdź czy są błędy walidacji z poprzedniego requestu + $validationErrors = $_SESSION['form_errors'][$this->getFormId()] ?? null; + if ($validationErrors) { + unset($_SESSION['form_errors'][$this->getFormId()]); + } + + $viewModel = $this->buildFormViewModel($banner, $languages, $validationErrors); + + return \Tpl::view('components/form-edit', ['form' => $viewModel]); } /** @@ -149,13 +180,40 @@ class BannerController */ public function save(): void { - $response = ['status' => 'error', 'msg' => 'Podczas zapisywania baneru wystapil blad. Prosze sprobowac ponownie.']; + $response = ['success' => false, 'errors' => []]; - $values = json_decode(\S::get('values'), true); - $bannerId = $this->repository->save($values); - if ($bannerId) { + $bannerId = (int)\S::get('id'); + $banner = $this->repository->find($bannerId); + $languages = \admin\factory\Languages::languages_list(); + + $viewModel = $this->buildFormViewModel($banner, $languages); + + // Przetwórz dane z POST + $result = $this->formHandler->handleSubmit($viewModel, $_POST); + + if (!$result['success']) { + // Zapisz błędy w sesji i zwróć jako JSON + $_SESSION['form_errors'][$this->getFormId()] = $result['errors']; + $response['errors'] = $result['errors']; + echo json_encode($response); + exit; + } + + // Zapisz dane + $data = $result['data']; + $data['id'] = $bannerId ?: null; + + $savedId = $this->repository->save($data); + + if ($savedId) { \S::delete_dir('../temp/'); - $response = ['status' => 'ok', 'msg' => 'Baner zostal zapisany.', 'id' => $bannerId]; + $response = [ + 'success' => true, + 'id' => $savedId, + 'message' => 'Baner został zapisany.' + ]; + } else { + $response['errors'] = ['general' => 'Błąd podczas zapisywania do bazy.']; } echo json_encode($response); @@ -176,4 +234,103 @@ class BannerController header('Location: /admin/banners/view_list/'); exit; } + + /** + * Buduje model widoku formularza + */ + private function buildFormViewModel(array $banner, array $languages, ?array $errors = null): FormEditViewModel + { + $bannerId = $banner['id'] ?? 0; + $isNew = empty($bannerId); + + // Domyślne wartości dla nowego banera + if ($isNew) { + $banner['status'] = 1; + $banner['home_page'] = 0; + } + + $tabs = [ + new FormTab('settings', 'Ustawienia', 'fa-wrench'), + new FormTab('content', 'Zawartość', 'fa-file'), + ]; + + $fields = [ + // Zakładka Ustawienia + FormField::text('name', [ + 'label' => 'Nazwa', + 'tab' => 'settings', + 'required' => true, + ]), + FormField::switch('status', [ + 'label' => 'Aktywny', + 'tab' => 'settings', + 'value' => ($banner['status'] ?? 1) == 1, + ]), + FormField::date('date_start', [ + 'label' => 'Data rozpoczęcia', + 'tab' => 'settings', + ]), + FormField::date('date_end', [ + 'label' => 'Data zakończenia', + 'tab' => 'settings', + ]), + FormField::switch('home_page', [ + 'label' => 'Slajder / Strona główna', + 'tab' => 'settings', + 'value' => ($banner['home_page'] ?? 0) == 1, + ]), + + // Sekcja językowa w zakładce Zawartość + FormField::langSection('translations', 'content', [ + FormField::image('src', [ + 'label' => 'Obraz', + 'filemanager' => true, + ]), + FormField::text('url', [ + 'label' => 'Url', + ]), + FormField::textarea('html', [ + 'label' => 'Kod HTML', + 'rows' => 6, + ]), + FormField::editor('text', [ + 'label' => 'Treść', + 'toolbar' => 'MyTool', + 'height' => 300, + ]), + ]), + ]; + + $actions = [ + FormAction::save( + '/admin/banners/banner_save/' . ($isNew ? '' : 'id=' . $bannerId), + '/admin/banners/view_list/' + ), + FormAction::cancel('/admin/banners/view_list/'), + ]; + + return new FormEditViewModel( + $this->getFormId(), + $isNew ? 'Nowy baner' : 'Edycja banera', + $banner, + $fields, + $tabs, + $actions, + 'POST', + '/admin/banners/banner_save/' . ($isNew ? '' : 'id=' . $bannerId), + '/admin/banners/view_list/', + true, + ['id' => $bannerId], + $languages, + $errors + ); + } + + /** + * Zwraca identyfikator formularza + */ + private function getFormId(): string + { + return 'banner-edit'; + } } diff --git a/autoload/admin/Support/Forms/FormFieldRenderer.php b/autoload/admin/Support/Forms/FormFieldRenderer.php new file mode 100644 index 0000000..5a35c10 --- /dev/null +++ b/autoload/admin/Support/Forms/FormFieldRenderer.php @@ -0,0 +1,430 @@ +form = $form; + } + + /** + * Renderuje pojedyncze pole + */ + public function renderField(FormField $field): string + { + $method = 'render' . ucfirst($field->type); + + if (method_exists($this, $method)) { + return $this->$method($field); + } + + // Fallback dla nieznanych typów - renderuj jako text + return $this->renderText($field); + } + + /** + * Renderuje pole tekstowe + */ + public function renderText(FormField $field): string + { + $value = $this->form->getFieldValue($field); + $error = $this->form->getError($field->name); + + $params = [ + 'label' => $field->label, + 'name' => $field->name, + 'id' => $field->id, + 'value' => $value ?? '', + 'type' => 'text', + 'class' => ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''), + ]; + + if ($field->placeholder) { + $params['placeholder'] = $field->placeholder; + } + + if ($error) { + $params['class'] .= ' error'; + } + + return $this->wrapWithError(\Html::input($params), $error); + } + + /** + * Renderuje pole number + */ + public function renderNumber(FormField $field): string + { + $value = $this->form->getFieldValue($field); + $error = $this->form->getError($field->name); + + $params = [ + 'label' => $field->label, + 'name' => $field->name, + 'id' => $field->id, + 'value' => $value ?? '', + 'type' => 'number', + 'class' => ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''), + ]; + + if ($error) { + $params['class'] .= ' error'; + } + + return $this->wrapWithError(\Html::input($params), $error); + } + + /** + * Renderuje pole email + */ + public function renderEmail(FormField $field): string + { + $value = $this->form->getFieldValue($field); + $error = $this->form->getError($field->name); + + $params = [ + 'label' => $field->label, + 'name' => $field->name, + 'id' => $field->id, + 'value' => $value ?? '', + 'type' => 'email', + 'class' => ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''), + ]; + + if ($error) { + $params['class'] .= ' error'; + } + + return $this->wrapWithError(\Html::input($params), $error); + } + + /** + * Renderuje pole password + */ + public function renderPassword(FormField $field): string + { + $value = $this->form->getFieldValue($field); + + return \Html::input([ + 'label' => $field->label, + 'name' => $field->name, + 'id' => $field->id, + 'value' => $value ?? '', + 'type' => 'password', + 'class' => ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''), + ]); + } + + /** + * Renderuje pole daty + */ + public function renderDate(FormField $field): string + { + $value = $this->form->getFieldValue($field); + $error = $this->form->getError($field->name); + + $params = [ + 'label' => $field->label, + 'name' => $field->name, + 'id' => $field->id, + 'value' => $value ?? '', + 'type' => 'text', + 'class' => 'date ' . ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''), + ]; + + if ($error) { + $params['class'] .= ' error'; + } + + return $this->wrapWithError(\Html::input($params), $error); + } + + /** + * Renderuje pole daty i czasu + */ + public function renderDatetime(FormField $field): string + { + $value = $this->form->getFieldValue($field); + + return \Html::input([ + 'label' => $field->label, + 'name' => $field->name, + 'id' => $field->id, + 'value' => $value ?? '', + 'type' => 'text', + 'class' => 'datetime ' . ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''), + ]); + } + + /** + * Renderuje przełącznik (switch) + */ + public function renderSwitch(FormField $field): string + { + $value = $this->form->getFieldValue($field); + + // Domyślna wartość dla nowego rekordu + if ($value === null && $field->value === true) { + $checked = true; + } else { + $checked = (bool) $value; + } + + return \Html::input_switch([ + 'label' => $field->label, + 'name' => $field->name, + 'id' => $field->id, + 'checked' => $checked, + ]); + } + + /** + * Renderuje select + */ + public function renderSelect(FormField $field): string + { + $value = $this->form->getFieldValue($field); + $error = $this->form->getError($field->name); + + $params = [ + 'label' => $field->label, + 'name' => $field->name, + 'id' => $field->id, + 'value' => $value ?? '', + 'options' => $field->options, + 'class' => ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''), + ]; + + if ($error) { + $params['class'] .= ' error'; + } + + return $this->wrapWithError(\Html::select($params), $error); + } + + /** + * Renderuje textarea + */ + public function renderTextarea(FormField $field): string + { + $value = $this->form->getFieldValue($field); + + return \Html::textarea([ + 'label' => $field->label, + 'name' => $field->name, + 'id' => $field->id, + 'value' => $value ?? '', + 'rows' => $field->attributes['rows'] ?? 4, + 'class' => ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''), + ]); + } + + /** + * Renderuje edytor (CKEditor) + */ + public function renderEditor(FormField $field): string + { + $value = $this->form->getFieldValue($field); + + return \Html::textarea([ + 'label' => $field->label, + 'name' => $field->name, + 'id' => $field->id, + 'value' => $value ?? '', + 'rows' => max(10, ($field->attributes['rows'] ?? 10)), + 'class' => 'editor ' . ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''), + ]); + } + + /** + * Renderuje pole obrazu z filemanagerem + */ + public function renderImage(FormField $field): string + { + $value = $this->form->getFieldValue($field); + + $filemanagerUrl = $field->filemanagerUrl ?? $this->generateFilemanagerUrl($field->id); + + return \Html::input_icon([ + 'label' => $field->label, + 'name' => $field->name, + 'id' => $field->id, + 'value' => $value ?? '', + 'type' => 'text', + 'icon_content' => 'przeglądaj', + 'icon_js' => "window.open('{$filemanagerUrl}', 'filemanager', 'location=1,status=1,scrollbars=1,width=1100,height=700')", + ]); + } + + /** + * Renderuje pole pliku + */ + public function renderFile(FormField $field): string + { + $value = $this->form->getFieldValue($field); + + if ($field->useFilemanager) { + $filemanagerUrl = $field->filemanagerUrl ?? $this->generateFilemanagerUrl($field->id); + + return \Html::input_icon([ + 'label' => $field->label, + 'name' => $field->name, + 'id' => $field->id, + 'value' => $value ?? '', + 'type' => 'text', + 'icon_content' => 'przeglądaj', + 'icon_js' => "window.open('{$filemanagerUrl}', 'filemanager', 'location=1,status=1,scrollbars=1,width=1100,height=700')", + ]); + } + + return \Html::input([ + 'label' => $field->label, + 'name' => $field->name, + 'id' => $field->id, + 'type' => 'file', + 'class' => ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''), + ]); + } + + /** + * Renderuje ukryte pole + */ + public function renderHidden(FormField $field): string + { + $value = $this->form->getFieldValue($field); + + return ''; + } + + /** + * Renderuje sekcję językową + */ + public function renderLangSection(FormField $section): string + { + if ($section->langFields === null || $this->form->languages === null) { + return ''; + } + + $out = '
'; + + // Zakładki języków + $out .= '
    '; + foreach ($this->form->languages as $lang) { + if ($lang['status']) { + $out .= '
  • ' . htmlspecialchars($lang['name']) . '
  • '; + } + } + $out .= '
'; + + // Kontenery języków + $out .= '
'; + foreach ($this->form->languages as $lang) { + if ($lang['status']) { + $out .= '
'; + foreach ($section->langFields as $field) { + $out .= $this->renderLangField($field, $lang['id'], $section->name); + } + $out .= '
'; + } + } + $out .= '
'; + + $out .= '
'; + + return $out; + } + + /** + * Renderuje pole w sekcji językowej + */ + private function renderLangField(FormField $field, $languageId, string $sectionName): string + { + $value = $this->form->getFieldValue($field, $languageId, $field->name); + $error = $this->form->getError($sectionName . '_' . $field->name, $languageId); + + $name = $field->getLocalizedName($languageId); + $id = $field->getLocalizedId($languageId); + + switch ($field->type) { + case FormFieldType::IMAGE: + $filemanagerUrl = $field->filemanagerUrl ?? $this->generateFilemanagerUrl($id); + return $this->wrapWithError(\Html::input_icon([ + 'label' => $field->label, + 'name' => $name, + 'id' => $id, + 'value' => $value ?? '', + 'type' => 'text', + 'icon_content' => 'przeglądaj', + 'icon_js' => "window.open('{$filemanagerUrl}', 'filemanager', 'location=1,status=1,scrollbars=1,width=1100,height=700')", + ]), $error); + + case FormFieldType::TEXTAREA: + case FormFieldType::EDITOR: + return $this->wrapWithError(\Html::textarea([ + 'label' => $field->label, + 'name' => $name, + 'id' => $id, + 'value' => $value ?? '', + 'rows' => $field->type === FormFieldType::EDITOR ? 10 : ($field->attributes['rows'] ?? 4), + 'class' => $field->type === FormFieldType::EDITOR ? 'editor' : '', + ]), $error); + + case FormFieldType::SWITCH: + return \Html::input_switch([ + 'label' => $field->label, + 'name' => $name, + 'id' => $id, + 'checked' => (bool) $value, + ]); + + default: // TEXT, URL, etc. + return $this->wrapWithError(\Html::input([ + 'label' => $field->label, + 'name' => $name, + 'id' => $id, + 'value' => $value ?? '', + 'type' => $field->type === FormFieldType::EMAIL ? 'email' : 'text', + 'class' => ($field->required ? 'require ' : '') . ($field->attributes['class'] ?? ''), + ]), $error); + } + } + + /** + * Generuje URL do filemanagera + */ + private function generateFilemanagerUrl(string $fieldId): string + { + $rfmAkey = $_SESSION['rfm_akey'] ?? bin2hex(random_bytes(16)); + $_SESSION['rfm_akey'] = $rfmAkey; + $_SESSION['rfm_akey_expires'] = time() + 20 * 60; + $_SESSION['can_use_rfm'] = true; + + $fieldIdParam = rawurlencode($fieldId); + $akeyParam = rawurlencode($rfmAkey); + return "/libraries/filemanager-9.14.2/dialog.php?type=1&popup=1&field_id={$fieldIdParam}&akey={$akeyParam}"; + } + + /** + * Opakowuje pole w kontener błędu + */ + private function wrapWithError(string $html, ?string $error): string + { + if ($error) { + return '
' . $html . + '' . htmlspecialchars($error) . '
'; + } + return $html; + } +} diff --git a/autoload/admin/Support/Forms/FormRequestHandler.php b/autoload/admin/Support/Forms/FormRequestHandler.php new file mode 100644 index 0000000..2bf5105 --- /dev/null +++ b/autoload/admin/Support/Forms/FormRequestHandler.php @@ -0,0 +1,152 @@ +validator = new FormValidator(); + } + + /** + * Przetwarza żądanie POST formularza + * + * @param FormEditViewModel $formViewModel + * @param array $postData Dane z $_POST + * @return array Wynik przetwarzania ['success' => bool, 'errors' => array, 'data' => array] + */ + public function handleSubmit(FormEditViewModel $formViewModel, array $postData): array + { + $result = [ + 'success' => false, + 'errors' => [], + 'data' => [] + ]; + + // Walidacja + $errors = $this->validator->validate($postData, $formViewModel->fields, $formViewModel->languages); + + if (!empty($errors)) { + $result['errors'] = $errors; + // Zapisz dane do persist przy błędzie walidacji + if ($formViewModel->persist) { + $formViewModel->saveToPersist($postData); + } + return $result; + } + + // Przetwórz dane (np. konwersja typów) + $processedData = $this->processData($postData, $formViewModel->fields); + + $result['success'] = true; + $result['data'] = $processedData; + + // Wyczyść persist po sukcesie + if ($formViewModel->persist) { + $formViewModel->clearPersist(); + } + + return $result; + } + + /** + * Przetwarza dane z formularza (konwersja typów) + */ + private function processData(array $postData, array $fields): array + { + $processed = []; + + foreach ($fields as $field) { + $value = $postData[$field->name] ?? null; + + // Konwersja typów + switch ($field->type) { + case FormFieldType::SWITCH: + $processed[$field->name] = $value ? 1 : 0; + break; + + case FormFieldType::NUMBER: + $processed[$field->name] = $value !== null && $value !== '' ? (float)$value : null; + break; + + case FormFieldType::LANG_SECTION: + if ($field->langFields !== null) { + $processed[$field->name] = $this->processLangSection($postData, $field); + } + break; + + default: + $processed[$field->name] = $value; + } + } + + return $processed; + } + + /** + * Przetwarza sekcję językową + */ + private function processLangSection(array $postData, $section): array + { + $result = []; + + if ($section->langFields === null) { + return $result; + } + + foreach ($section->langFields as $field) { + $fieldName = $field->name; + $langData = $postData[$fieldName] ?? []; + + foreach ($langData as $langId => $value) { + if (!isset($result[$langId])) { + $result[$langId] = []; + } + + // Konwersja typów dla pól językowych + switch ($field->type) { + case FormFieldType::SWITCH: + $result[$langId][$fieldName] = $value ? 1 : 0; + break; + case FormFieldType::NUMBER: + $result[$langId][$fieldName] = $value !== null && $value !== '' ? (float)$value : null; + break; + default: + $result[$langId][$fieldName] = $value; + } + } + } + + return $result; + } + + /** + * Przywraca dane z persist do POST (przy błędzie walidacji) + */ + public function restoreFromPersist(FormEditViewModel $formViewModel): ?array + { + if (!$formViewModel->persist) { + return null; + } + + return $_SESSION['form_persist'][$formViewModel->formId] ?? null; + } + + /** + * Sprawdza czy żądanie jest submitowaniem formularza + */ + public function isFormSubmit(string $formId): bool + { + return $_SERVER['REQUEST_METHOD'] === 'POST' && + (isset($_POST['_form_id']) && $_POST['_form_id'] === $formId); + } +} diff --git a/autoload/admin/Validation/FormValidator.php b/autoload/admin/Validation/FormValidator.php new file mode 100644 index 0000000..73e7b05 --- /dev/null +++ b/autoload/admin/Validation/FormValidator.php @@ -0,0 +1,196 @@ +errors = []; + + foreach ($fields as $field) { + if ($field->type === FormFieldType::LANG_SECTION) { + $this->validateLangSection($data, $field, $languages ?? []); + } else { + $this->validateField($data, $field); + } + } + + return $this->errors; + } + + /** + * Waliduje pojedyncze pole + */ + private function validateField(array $data, FormField $field): void + { + $value = $data[$field->name] ?? null; + + // Walidacja wymagalności + if ($field->required && $this->isEmpty($value)) { + $this->errors[$field->name] = "Pole \"{$field->label}\" jest wymagane."; + return; + } + + // Jeśli pole puste i nie jest wymagane - pomijamy dalszą walidację + if ($this->isEmpty($value)) { + return; + } + + // Walidacja typu + switch ($field->type) { + case FormFieldType::EMAIL: + if (!filter_var($value, FILTER_VALIDATE_EMAIL)) { + $this->errors[$field->name] = "Pole \"{$field->label}\" musi być poprawnym adresem e-mail."; + } + break; + + case FormFieldType::NUMBER: + if (!is_numeric($value)) { + $this->errors[$field->name] = "Pole \"{$field->label}\" musi być liczbą."; + } + break; + + case FormFieldType::DATE: + if (!$this->isValidDate($value)) { + $this->errors[$field->name] = "Pole \"{$field->label}\" musi być poprawną datą (YYYY-MM-DD)."; + } + break; + + case FormFieldType::DATETIME: + if (!$this->isValidDateTime($value)) { + $this->errors[$field->name] = "Pole \"{$field->label}\" musi być poprawną datą i czasem."; + } + break; + } + + // Walidacja customowa (callback) + if (isset($field->attributes['validate_callback']) && is_callable($field->attributes['validate_callback'])) { + $result = call_user_func($field->attributes['validate_callback'], $value, $data); + if ($result !== true) { + $this->errors[$field->name] = is_string($result) ? $result : "Pole \"{$field->label}\" zawiera nieprawidłową wartość."; + } + } + } + + /** + * Waliduje sekcję językową + */ + private function validateLangSection(array $data, FormField $section, array $languages): void + { + if ($section->langFields === null) { + return; + } + + foreach ($languages as $language) { + if (!($language['status'] ?? false)) { + continue; + } + + $langId = $language['id']; + + foreach ($section->langFields as $field) { + $fieldName = $field->name; + $value = $data[$fieldName][$langId] ?? null; + + // Walidacja wymagalności + if ($field->required && $this->isEmpty($value)) { + $errorKey = "{$section->name}_{$fieldName}"; + $this->errors[$errorKey][$langId] = "Pole \"{$field->label}\" ({$language['name']}) jest wymagane."; + continue; + } + + // Walidacja typu dla pól językowych + if (!$this->isEmpty($value)) { + switch ($field->type) { + case FormFieldType::EMAIL: + if (!filter_var($value, FILTER_VALIDATE_EMAIL)) { + $errorKey = "{$section->name}_{$fieldName}"; + $this->errors[$errorKey][$langId] = "Pole \"{$field->label}\" ({$language['name']}) musi być poprawnym e-mailem."; + } + break; + } + } + } + } + } + + /** + * Sprawdza czy wartość jest pusta + */ + private function isEmpty($value): bool + { + return $value === null || $value === '' || (is_array($value) && empty($value)); + } + + /** + * Sprawdza czy data jest poprawna (YYYY-MM-DD) + */ + private function isValidDate(string $date): bool + { + $d = \DateTime::createFromFormat('Y-m-d', $date); + return $d && $d->format('Y-m-d') === $date; + } + + /** + * Sprawdza czy data i czas są poprawne + */ + private function isValidDateTime(string $datetime): bool + { + $d = \DateTime::createFromFormat('Y-m-d H:i:s', $datetime); + if ($d && $d->format('Y-m-d H:i:s') === $datetime) { + return true; + } + + // Spróbuj bez sekund + $d = \DateTime::createFromFormat('Y-m-d H:i', $datetime); + return $d && $d->format('Y-m-d H:i') === $datetime; + } + + /** + * Sprawdza czy walidacja zakończyła się sukcesem + */ + public function isValid(): bool + { + return empty($this->errors); + } + + /** + * Zwraca wszystkie błędy + */ + public function getErrors(): array + { + return $this->errors; + } + + /** + * Zwraca pierwszy błąd + */ + public function getFirstError(): ?string + { + if (empty($this->errors)) { + return null; + } + + $first = reset($this->errors); + if (is_array($first)) { + return reset($first); + } + return $first; + } +} diff --git a/autoload/admin/ViewModels/Forms/FormAction.php b/autoload/admin/ViewModels/Forms/FormAction.php new file mode 100644 index 0000000..98d6529 --- /dev/null +++ b/autoload/admin/ViewModels/Forms/FormAction.php @@ -0,0 +1,73 @@ +name = $name; + $this->label = $label; + $this->url = $url; + $this->backUrl = $backUrl; + $this->cssClass = $cssClass; + $this->type = $type; + $this->attributes = $attributes; + } + + /** + * Predefiniowana akcja Zapisz + */ + public static function save(string $url, string $backUrl = '', string $label = 'Zapisz'): self + { + return new self( + 'save', + $label, + $url, + $backUrl, + 'btn btn-primary', + 'submit' + ); + } + + /** + * Predefiniowana akcja Anuluj + */ + public static function cancel(string $backUrl, string $label = 'Anuluj'): self + { + return new self( + 'cancel', + $label, + $backUrl, + null, + 'btn btn-default', + 'link' + ); + } +} diff --git a/autoload/admin/ViewModels/Forms/FormEditViewModel.php b/autoload/admin/ViewModels/Forms/FormEditViewModel.php new file mode 100644 index 0000000..663d08f --- /dev/null +++ b/autoload/admin/ViewModels/Forms/FormEditViewModel.php @@ -0,0 +1,178 @@ +formId = $formId; + $this->title = $title; + $this->data = $data; + $this->fields = $fields; + $this->tabs = $tabs; + $this->actions = $actions; + $this->method = $method; + $this->action = $action; + $this->backUrl = $backUrl; + $this->persist = $persist; + $this->hiddenFields = $hiddenFields; + $this->languages = $languages; + $this->validationErrors = $validationErrors; + } + + /** + * Sprawdza czy formularz ma zakładki + */ + public function hasTabs(): bool + { + return count($this->tabs) > 0; + } + + /** + * Sprawdza czy formularz ma sekcje językowe + */ + public function hasLangSections(): bool + { + foreach ($this->fields as $field) { + if ($field->type === FormFieldType::LANG_SECTION) { + return true; + } + } + return false; + } + + /** + * Zwraca pola dla konkretnej zakładki + */ + public function getFieldsForTab(string $tabId): array + { + return array_filter($this->fields, function (FormField $field) use ($tabId) { + return $field->tabId === $tabId && $field->type !== FormFieldType::LANG_SECTION; + }); + } + + /** + * Zwraca sekcje językowe dla konkretnej zakładki + */ + public function getLangSectionsForTab(string $tabId): array + { + return array_filter($this->fields, function (FormField $field) use ($tabId) { + return $field->type === FormFieldType::LANG_SECTION && + $field->langSectionParentTab === $tabId; + }); + } + + /** + * Pobiera wartość pola z danych lub sesji (persist) + */ + public function getFieldValue(FormField $field, $languageId = null, ?string $langFieldName = null) + { + $fieldName = $field->name; + + // Dla sekcji językowych - pobierz wartość z data[lang_id][field_name] + if ($languageId !== null && $langFieldName !== null) { + $fieldName = $langFieldName; + return $this->data['languages'][$languageId][$fieldName] ?? null; + } + + // Zwykłe pole - najpierw sprawdź sesję (persist), potem dane + if ($this->persist && isset($_SESSION['form_persist'][$this->formId][$fieldName])) { + return $_SESSION['form_persist'][$this->formId][$fieldName]; + } + + return $this->data[$fieldName] ?? $field->value; + } + + /** + * Sprawdza czy pole ma błąd walidacji + */ + public function hasError(string $fieldName, $languageId = null): bool + { + if ($this->validationErrors === null) { + return false; + } + + if ($languageId !== null) { + return isset($this->validationErrors[$fieldName][$languageId]); + } + + return isset($this->validationErrors[$fieldName]); + } + + /** + * Pobiera komunikat błędu dla pola + */ + public function getError(string $fieldName, $languageId = null): ?string + { + if ($languageId !== null) { + return $this->validationErrors[$fieldName][$languageId] ?? null; + } + return $this->validationErrors[$fieldName] ?? null; + } + + /** + * Czyści dane persist z sesji + */ + public function clearPersist(): void + { + if (isset($_SESSION['form_persist'][$this->formId])) { + unset($_SESSION['form_persist'][$this->formId]); + } + } + + /** + * Zapisuje dane do sesji (persist) + */ + public function saveToPersist(array $data): void + { + if (!isset($_SESSION['form_persist'])) { + $_SESSION['form_persist'] = []; + } + $_SESSION['form_persist'][$this->formId] = $data; + } +} diff --git a/autoload/admin/ViewModels/Forms/FormField.php b/autoload/admin/ViewModels/Forms/FormField.php new file mode 100644 index 0000000..faaf46d --- /dev/null +++ b/autoload/admin/ViewModels/Forms/FormField.php @@ -0,0 +1,323 @@ +name = $name; + $this->type = $type; + $this->label = $label; + $this->value = $value; + $this->tabId = $tabId; + $this->required = $required; + $this->attributes = $attributes; + $this->options = $options; + $this->helpText = $helpText; + $this->placeholder = $placeholder; + $this->useFilemanager = $useFilemanager; + $this->filemanagerUrl = $filemanagerUrl; + $this->editorToolbar = $editorToolbar; + $this->editorHeight = $editorHeight; + $this->langFields = $langFields; + $this->langSectionParentTab = $langSectionParentTab; + $this->id = $attributes['id'] ?? $name; + } + + // Factory methods dla różnych typów pól + + public static function text(string $name, array $config = []): self + { + return new self( + $name, + FormFieldType::TEXT, + $config['label'] ?? '', + $config['value'] ?? null, + $config['tab'] ?? 'default', + $config['required'] ?? false, + $config['attributes'] ?? [], + [], + $config['help'] ?? null, + $config['placeholder'] ?? null + ); + } + + public static function number(string $name, array $config = []): self + { + return new self( + $name, + FormFieldType::NUMBER, + $config['label'] ?? '', + $config['value'] ?? null, + $config['tab'] ?? 'default', + $config['required'] ?? false, + $config['attributes'] ?? [], + [], + $config['help'] ?? null + ); + } + + public static function email(string $name, array $config = []): self + { + return new self( + $name, + FormFieldType::EMAIL, + $config['label'] ?? '', + $config['value'] ?? null, + $config['tab'] ?? 'default', + $config['required'] ?? false, + $config['attributes'] ?? [] + ); + } + + public static function password(string $name, array $config = []): self + { + return new self( + $name, + FormFieldType::PASSWORD, + $config['label'] ?? '', + $config['value'] ?? null, + $config['tab'] ?? 'default', + $config['required'] ?? false, + $config['attributes'] ?? [] + ); + } + + public static function date(string $name, array $config = []): self + { + return new self( + $name, + FormFieldType::DATE, + $config['label'] ?? '', + $config['value'] ?? null, + $config['tab'] ?? 'default', + $config['required'] ?? false, + array_merge(['class' => 'date'], $config['attributes'] ?? []) + ); + } + + public static function datetime(string $name, array $config = []): self + { + return new self( + $name, + FormFieldType::DATETIME, + $config['label'] ?? '', + $config['value'] ?? null, + $config['tab'] ?? 'default', + $config['required'] ?? false, + array_merge(['class' => 'datetime'], $config['attributes'] ?? []) + ); + } + + public static function switch(string $name, array $config = []): self + { + return new self( + $name, + FormFieldType::SWITCH, + $config['label'] ?? '', + $config['value'] ?? false, + $config['tab'] ?? 'default', + false, + $config['attributes'] ?? [] + ); + } + + public static function select(string $name, array $config = []): self + { + return new self( + $name, + FormFieldType::SELECT, + $config['label'] ?? '', + $config['value'] ?? null, + $config['tab'] ?? 'default', + $config['required'] ?? false, + $config['attributes'] ?? [], + $config['options'] ?? [] + ); + } + + public static function textarea(string $name, array $config = []): self + { + return new self( + $name, + FormFieldType::TEXTAREA, + $config['label'] ?? '', + $config['value'] ?? null, + $config['tab'] ?? 'default', + $config['required'] ?? false, + array_merge(['rows' => $config['rows'] ?? 4], $config['attributes'] ?? []) + ); + } + + public static function editor(string $name, array $config = []): self + { + return new self( + $name, + FormFieldType::EDITOR, + $config['label'] ?? '', + $config['value'] ?? null, + $config['tab'] ?? 'default', + $config['required'] ?? false, + $config['attributes'] ?? [], + [], + null, + null, + false, + null, + $config['toolbar'] ?? 'MyTool', + $config['height'] ?? 300 + ); + } + + public static function image(string $name, array $config = []): self + { + return new self( + $name, + FormFieldType::IMAGE, + $config['label'] ?? '', + $config['value'] ?? null, + $config['tab'] ?? 'default', + $config['required'] ?? false, + $config['attributes'] ?? [], + [], + null, + null, + $config['filemanager'] ?? true, + $config['filemanager_url'] ?? null + ); + } + + public static function file(string $name, array $config = []): self + { + return new self( + $name, + FormFieldType::FILE, + $config['label'] ?? '', + $config['value'] ?? null, + $config['tab'] ?? 'default', + $config['required'] ?? false, + $config['attributes'] ?? [], + [], + null, + null, + $config['filemanager'] ?? true + ); + } + + public static function hidden(string $name, $value = null): self + { + return new self( + $name, + FormFieldType::HIDDEN, + '', + $value, + 'default' + ); + } + + /** + * Sekcja językowa - grupa pól powtarzana dla każdego języka + * + * @param string $name Nazwa sekcji (prefiks dla pól) + * @param string $parentTab Identyfikator zakładki nadrzędnej + * @param array $fields Pola w sekcji językowej (tablica FormField) + */ + public static function langSection(string $name, string $parentTab, array $fields): self + { + return new self( + $name, + FormFieldType::LANG_SECTION, + '', + null, + $parentTab, + false, + [], + [], + null, + null, + false, + null, + 'MyTool', + 300, + $fields, + $parentTab + ); + } + + /** + * Zwraca nazwę pola z sufiksem dla konkretnego języka + */ + public function getLocalizedName($languageId): string + { + return "{$this->name}[{$languageId}]"; + } + + /** + * Zwraca ID pola z sufiksem dla konkretnego języka + */ + public function getLocalizedId($languageId): string + { + return "{$this->id}_{$languageId}"; + } +} diff --git a/autoload/admin/ViewModels/Forms/FormFieldType.php b/autoload/admin/ViewModels/Forms/FormFieldType.php new file mode 100644 index 0000000..66e68dc --- /dev/null +++ b/autoload/admin/ViewModels/Forms/FormFieldType.php @@ -0,0 +1,23 @@ +id = $id; + $this->label = $label; + $this->icon = $icon; + $this->parentTabId = $parentTabId; + } +} diff --git a/libraries/.htaccess b/libraries/.htaccess index eb2244f..6485d3f 100644 --- a/libraries/.htaccess +++ b/libraries/.htaccess @@ -1,6 +1,9 @@ # Wyłącz listowanie Options -Indexes +# Zezwol na wykonywanie PHP tylko dla legacy filemanagera +SetEnvIf Request_URI "^/libraries/filemanager-9\.14\.[12]/.*\.(php|phtml|php[0-9]?|phar|pht)$" allow_legacy_filemanager_php=1 + # Domyślnie blokujemy wszystko… Require all denied @@ -11,7 +14,10 @@ Require all denied # Twardo blokuj cokolwiek, co mogłoby się wykonać - Require all denied + + Require env allow_legacy_filemanager_php + Require all denied + @@ -41,4 +47,4 @@ Require all denied # Nie serwuj plików ukrytych (.env itp.) Require all denied - \ No newline at end of file + diff --git a/tests/Unit/Domain/Banner/BannerRepositoryTest.php b/tests/Unit/Domain/Banner/BannerRepositoryTest.php index 9ebc7da..cc7dbab 100644 --- a/tests/Unit/Domain/Banner/BannerRepositoryTest.php +++ b/tests/Unit/Domain/Banner/BannerRepositoryTest.php @@ -21,7 +21,10 @@ class BannerRepositoryTest extends TestCase $mockDb->expects($this->once()) ->method('select') - ->with('pp_banners_langs', '*', ['id_banner' => 1]) + ->with('pp_banners_langs', '*', [ + 'id_banner' => 1, + 'ORDER' => ['id' => 'ASC'], + ]) ->willReturn([ ['id_lang' => 'pl', 'src' => 'banner.jpg', 'url' => '/promo'], ['id_lang' => 'en', 'src' => 'banner-en.jpg', 'url' => '/promo-en'], @@ -80,7 +83,7 @@ class BannerRepositoryTest extends TestCase } /** - * Test zapisywania nowego banera + * Test zapisywania nowego banera (stary format danych - zachowano kompatybilność) */ public function testSaveInsertsNewBanner() { @@ -100,9 +103,51 @@ class BannerRepositoryTest extends TestCase $repository = new BannerRepository($mockDb); - // Act + // Act - nowy format z FormRequestHandler (przetworzone dane) $result = $repository->save([ 'name' => 'Nowy baner', + 'status' => 1, // już przetworzone na int + 'date_start' => null, + 'date_end' => null, + 'home_page' => 1, // już przetworzone na int + 'translations' => [ + 1 => [ // id języka jako klucz + 'src' => 'banner.jpg', + 'url' => '/promo', + 'html' => '', + 'text' => 'Tekst', + ], + ], + ]); + + // Assert + $this->assertEquals(10, $result); + } + + /** + * Test zapisywania banera ze starym formatem danych (backward compatibility) + */ + public function testSaveWithLegacyFormat() + { + // Arrange + $mockDb = $this->createMock(\medoo::class); + + // insert() wywoływane 2x: raz dla banera, raz dla tłumaczenia + $mockDb->expects($this->exactly(2)) + ->method('insert'); + + $mockDb->expects($this->once()) + ->method('id') + ->willReturn(11); + + // get() for checking existing translations - returns false (no existing) + $mockDb->method('get')->willReturn(false); + + $repository = new BannerRepository($mockDb); + + // Act - stary format (dla kompatybilności wstecznej) + $result = $repository->save([ + 'name' => 'Baner legacy', 'status' => 'on', 'date_start' => '', 'date_end' => '', @@ -114,6 +159,114 @@ class BannerRepositoryTest extends TestCase ]); // Assert - $this->assertEquals(10, $result); + $this->assertEquals(11, $result); + } + + /** + * Test zapisu istniejacego banera - aktualizacja tlumaczen po id_banner + id_lang + */ + public function testSaveUpdatesExistingTranslationsByBannerAndLang(): void + { + $mockDb = $this->createMock(\medoo::class); + + $mockDb->expects($this->exactly(2)) + ->method('update') + ->withConsecutive( + [ + 'pp_banners', + $this->arrayHasKey('name'), + ['id' => 5], + ], + [ + 'pp_banners_langs', + $this->callback(function (array $data): bool { + return $data['id_banner'] === 5 + && $data['id_lang'] === 'pl' + && $data['src'] === 'banner-new.jpg'; + }), + ['AND' => ['id_banner' => 5, 'id_lang' => 'pl']], + ] + ); + + $mockDb->expects($this->once()) + ->method('count') + ->with('pp_banners_langs', ['AND' => ['id_banner' => 5, 'id_lang' => 'pl']]) + ->willReturn(2); + + $mockDb->expects($this->never()) + ->method('insert'); + + $repository = new BannerRepository($mockDb); + + $result = $repository->save([ + 'id' => 5, + 'name' => 'Baner update', + 'status' => 1, + 'date_start' => null, + 'date_end' => null, + 'home_page' => 0, + 'translations' => [ + 'pl' => [ + 'src' => 'banner-new.jpg', + 'url' => '/promo-new', + 'html' => 'promo', + 'text' => 'Nowa tresc', + ], + ], + ]); + + $this->assertSame(5, $result); + } + + public function testListForAdminIncludesThumbnailSrc(): void + { + $mockDb = $this->createMock(\medoo::class); + + $countStmt = $this->createMock(\PDOStatement::class); + $countStmt->expects($this->once()) + ->method('fetchAll') + ->willReturn([[2]]); + + $itemsStmt = $this->createMock(\PDOStatement::class); + $itemsStmt->expects($this->once()) + ->method('fetchAll') + ->willReturn([ + [ + 'id' => 10, + 'name' => 'Baner A', + 'status' => 1, + 'home_page' => 0, + 'date_start' => null, + 'date_end' => null, + ], + [ + 'id' => 11, + 'name' => 'Baner B', + 'status' => 1, + 'home_page' => 1, + 'date_start' => null, + 'date_end' => null, + ], + ]); + + $thumbsStmt = $this->createMock(\PDOStatement::class); + $thumbsStmt->expects($this->once()) + ->method('fetchAll') + ->willReturn([ + ['id_banner' => 10, 'src' => '/uploads/banner-a.jpg'], + ]); + + $mockDb->expects($this->exactly(3)) + ->method('query') + ->willReturnOnConsecutiveCalls($countStmt, $itemsStmt, $thumbsStmt); + + $repository = new BannerRepository($mockDb); + + $result = $repository->listForAdmin([], 'name', 'ASC', 1, 15); + + $this->assertSame(2, $result['total']); + $this->assertCount(2, $result['items']); + $this->assertSame('/uploads/banner-a.jpg', $result['items'][0]['thumbnail_src']); + $this->assertSame('', $result['items'][1]['thumbnail_src']); } } diff --git a/updates/0.20/ver_0.249.zip b/updates/0.20/ver_0.249.zip new file mode 100644 index 0000000000000000000000000000000000000000..d3c4bfece4b6b977517512408c3b78981e917002 GIT binary patch literal 41877 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 VfhEH5)ip8FA*_uxJQ@D$zW`ver. 0.248
+ver. 0.249
+- FIX - banner edit: poprawiono zapisywanie danych jezykowych i synchronizacje CKEditor przed zapisem +- FIX - banner edit: naprawiono hash zakladek (usunieto `undefined` w URL) +- FIX - filemanager: przywrocono dzialanie popupa wyboru obrazka z banera +- UPDATE - komunikaty zapisu w nowym formularzu sa wyswietlane w stylu panelu (bez natywnego alertu JS) +- UPDATE - lista banerow: dodano kolumne miniatury oraz podglad duzego obrazka w popup po najechaniu +
ver. 0.248
- UPDATE - filtry w nowych tabelach dzialaja automatycznie na `onchange` - UPDATE - `components/table-list`: auto-submit formularza filtrow po zmianie pola (select, date, text)
ver. 0.247
diff --git a/updates/versions.php b/updates/versions.php index 56ebd7b..290433d 100644 --- a/updates/versions.php +++ b/updates/versions.php @@ -1,5 +1,5 @@