diff --git a/docs/project-default-cover-reset/README.md b/docs/project-default-cover-reset/README.md new file mode 100644 index 000000000..530afa3c1 --- /dev/null +++ b/docs/project-default-cover-reset/README.md @@ -0,0 +1,94 @@ + + +# Возврат стандартной обложки проекта + +## Уточнение UI: действие внутри рамки + +По замечанию к расположению элементов текстовая кнопка под обложкой заменена компактной кнопкой возврата в правом верхнем углу рамки. Название «Вернуть стандартную обложку» доступно через `title` и `aria-label`; при запросе на том же месте отображается индикатор. «Заменить файл» остаётся внутри рамки. Дополнительного текста снаружи больше нет. + +Нейтральный слот `fileAction` в UploadFile позволяет редактору передать собственное действие; бизнес-логика остаётся в редакторе. Кнопка позиционируется внутри рамки и не участвует в потоке. + +В реальном браузере при viewport 1280 × 720 сравнение с исходным Angular base подтвердило точное совпадение координат и размеров рамок обложки/презентации, ряда файлов, поля названия и следующего ряда. Рамка обложки: x=713,734375; y=244,1875; ширина=319,421875; высота=120 px. Эти значения одинаковы для custom, запроса reset с задержкой 4 секунды и default после ответа. Сброс выполнен клавишей Enter, POST завершился 200. [Измерения](ui-geometry.json), [custom](screenshots/UI-custom-in-frame.png), [default](screenshots/UI-default-in-frame.png). Остальные скриншоты ниже относятся к первоначальной версии PR. + +После этой правки: targeted `vitest run --pool=forks --maxWorkers=4 --minWorkers=1 project-main-step.component upload-file.component project-cover-reset` — 37/37; `npm run lint:ts`, scoped Prettier/Stylelint, `npm run build:pr`, локальная сборка и diff check успешны. Полный suite повторно не запускался для изменения HTML/SCSS; результаты первоначальной версии приведены ниже. + +## Базы и зависимость + +- Angular: `c0bbdd7a97f2575864e3d4d8c7d88e927b61bd47`, ветка `feature/dev-project-default-cover-reset-ui`. +- Backend: `8ed5670d2123dd1f1b53668567ad60bde8641bcc` → `d35f9edc5536067db178c3fd1d6a21e13ce4fd9f`, [Draft PR #743](https://github.com/PROCOLLAB-github/api/pull/743). +- Для будущего DEV-развёртывания сначала нужен backend-контракт, затем Angular. В рамках задачи подготовлены только Draft PR, без merge и deploy. + +## Причина и поведение + +Раньше корзина обложки вызывала глобальный `DELETE /files/`. Для стандартного файла другого владельца это закономерно даёт 403. После удаления собственного файла пустая обложка конфликтует с `Validators.required`. Удалять общий файл или делать обложку необязательной нельзя. + +Сообщение о 403 на DEV получено от пользователя. Дополнительно проблема воспроизведена в локальном браузере на исходном Angular: `DELETE /files/?link=http://localhost:8012/cleanup-files/default.png` → 403, общий файл сохранён. Код исходного локального стенда имеет то же дерево Git, что Angular base выше. Удалений на живом DEV автор изменений не выполнял. + +Теперь canonical Project detail передаёт `is_default_cover`, существующий camelcase interceptor преобразует его в `isDefaultCover`: + +- `true`: действие удаления/сброса отсутствует, замена загрузкой доступна; +- `false`: доступно «Вернуть стандартную обложку»; +- поле ещё неизвестно: удаление и сброс недоступны, URL не используется для угадывания типа; +- успешный upload делает обложку пользовательской; замена custom A → custom B не требует предварительного DELETE. + +Команда `POST /projects/{Project.id}/reset-cover/` возвращает `{cover_image_address, is_default_cover: true}`. Форма получает фактический URL из ответа, остаётся валидной; `Validators.required` сохранён. `Project.id` берётся из инициализированной формы, а не из ID связи с программой. + +Backend проверяет те же права, что обычный Project PUT: лидер, действующие права collaborator/invite на черновик, опубликованный проект и временные ограничения программы. Manager/expert не получают новых прав. Уже стандартная обложка возвращается идемпотентно. Если стандартной обложки нет, ответ 409 с `code=default_cover_unavailable` сохраняет прежний URL и показывает безопасный русский текст. + +Сначала backend фиксирует стандартную обложку, затем через `on_commit` допускает очистку собственного свободного UserFile. Чужой, системный, используемый другим проектом или отсутствующий файл не удаляется. Отказ CDN не отменяет reset и не превращает его в 500. Глобальные права DELETE не меняются. `is_default_cover` read-only; сериализатор кеширует набор стандартных URL на экземпляр, включая `many=True`, без N+1. + +## Состояние редактора + +`ProjectCoverResetService` живёт вместе со всем редактором. Смена шага не теряет ответ уже сохранённого сброса. Повторный клик блокируется; на время операции блокируются замена файла, обычное сохранение и сдача формы, чтобы они не записали прежний URL поверх reset. При смене проекта, повторной инициализации того же ID и уничтожении редактора подписка отменяется. Дополнительная проверка URL исключает применение ответа после параллельной замены файла. + +Кеш detail инвалидируется до команды и при её завершении, ошибке или отмене: отмена клиентского HTTP не доказывает, что сервер не успел сохранить результат. + +Общий `UploadFileComponent` получил только нейтральные `removable`, `replaceable`, `busy` с прежними значениями по умолчанию. Он не знает про Project или reset-cover. Presentation сохраняет DELETE, cleanup autosave, explicit empty value и offline queue из #349. Модели React, current_application, analytics-widget, зависимости, workflows, миграции и правила доступа не менялись. + +## Выполненная локальная интеграция + +Настоящие Angular, Django, JWT и отдельная синтетическая PostgreSQL БД `procollab_cover_reset_20260917`. Backend: localhost:8012; новый Angular: localhost:4317; исходный Angular: localhost:4316. Подменён только внешний транспорт Selectel/CDN локальным файловым хранилищем с управляемым отказом. Реальные пользовательские данные не использовались. + +Тестовый Project.id = 31, PartnerProgramProject.id = 73, программа = 21. Сетевые ответы и GET readback: [evidence.json](evidence.json). Поле `custom_exists` в readback относится к фиксированной фикстуре `custom.png`, а не к файлу с новым UUID после upload. + +| Сценарий | Фактический результат | Скриншот | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- | +| Исходная ошибка | Стандартный файл другого владельца: DELETE 403, понятная ошибка, файл сохранён | [Исходный Angular](screenshots/base-default-403.png) | +| A: default | Корзины и сброса нет, замена доступна, DELETE отсутствует | [A](screenshots/A-default.png) | +| B: default → upload → save → reopen | POST файла 201, PUT проекта 31 — 200; GET возвращает новый пользовательский URL и false | [B](screenshots/B-custom-reopened.png) | +| C: custom → reset → GET → reopen | POST 200, стандартный URL и true; собственный загруженный файл очищен после смены ссылки | [C](screenshots/C-reset-reopened.png) | +| D: чужой custom | POST 200, стандартный URL; UserFile другого владельца остаётся | Ответы в evidence.json | +| E: нет DefaultProjectCover | POST 409, русская ошибка, прежний custom URL и false сохраняются; повтор даёт тот же контролируемый результат | [E](screenshots/E-default-unavailable.png) | +| F: отказ CDN | POST 200, GET standard/true; старый файл остаётся, записано безопасное предупреждение | [F](screenshots/F-cleanup-failure.png) | +| Переход между шагами во время reset | При задержке CDN 4 секунды сохранение/отправка блокируются; после ответа доступны, возврат на основной шаг показывает standard без сброса | [Переход](screenshots/G-step-change.png) | + +В новой UI-цепочке A–F нет `DELETE /files/`, `/projects/NaN/`, `/projects/0/`, `/projects/null/` и 5xx. Кейс программной связи сохранён при обычном сохранении проекта. Пустая презентация остаётся пустой; отдельное удаление презентации покрыто регрессионными тестами, заново в браузере этой задачи не выполнялось. + +В локальной консоли были ожидаемые ответы 409 и 401 при входе/обновлении сессии. На исходном стенде также появлялось сообщение отключённого старого WebSocket. Утверждение о полностью пустой консоли не делается. Новых исключений JavaScript в reset flow не обнаружено. Во время проверки скрытой вкладки отложенный основной шаг отрисовался после показа браузера; ответ reset и состояние формы сохранились. + +## Проверки Angular + +Windows, Node 24.18.0, зависимости из существующего lockfile. Полные логи оставлены в локальном игнорируемом `tmp/`; ошибки не подавлялись. + +| Команда | Результат | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| `npx vitest run --pool=forks --maxWorkers=4 --minWorkers=1 project-cover-reset reset-project-cover project-cover-contract project-main-step project-form.service upload-file.component project-http.adapter project.repository projects-edit-info.service edit.component.spec` | 121/121, 11 файлов, exit 0 | +| `npx vitest run --pool=forks --maxWorkers=4 --minWorkers=1` | Финальный запуск: 1642/1642, 370 файлов, exit 0 | +| `npm run test:ci` | NG0401 при setupTestBed до выполнения тестов, exit 1; та же ошибка воспроизведена на неизменённом исходном дереве | +| `npm run lint:ts` | Exit 0, 6 существующих предупреждений unused eslint-disable | +| `npx prettier --check` для изменённых TS/HTML/SCSS и документации | Успешно | +| `npx stylelint projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.scss projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.scss` | Успешно | +| `npm run build:pr` | Exit 0; существующие предупреждения Sass | +| `git diff --check` | Успешно | + +В двух промежуточных полных запусках все тесты прошли, но процесс завершился с известным teardown-исключением ngx-autosize (`window is not defined`). Финальный полный запуск прошёл без исключения. Настройки тестов, зависимости и CI ради этих проблем не изменялись. Успешный forks suite не выдаётся за успешный `npm run test:ci`. + +Проверены реальный HTTP-контракт с camelcase interceptor, корректный ID, readonly-флаг, double click, ошибки, отмена и гонки контекста, уничтожение редактора, кеш при отмене, сохранение при pending, UI default/custom/upload, presentation и существующие регрессии всего suite. + +## Проверки backend и ограничения + +- Targeted: 18/18; с регрессиями прав — 38/38. +- [Штатный PostgreSQL CI](https://github.com/PROCOLLAB-github/api/actions/runs/35232907623): **867/867, OK**. Все три проверки PR #743 успешны, включая Lint и Tests. +- Локальный полный suite: 867 тестов, один нестабильный тест ленты `project != news`. На неизменённой базе полный suite 849/849; в 30 повторах того же теста на базе получены 2 идентичных failures. Это отдельно зафиксировано, не подавлено и не исправлялось посторонним изменением. +- Django check, check models, makemigrations --check --dry-run, Black, flake8 и diff check успешны; новых миграций нет. +- Настоящий Selectel, браузерная проверка на живом DEV и будущая DEV-сборка этим локальным стендом не проверены. Новые workflow не запускались вручную; deploy не выполнялся. diff --git a/docs/project-default-cover-reset/evidence.json b/docs/project-default-cover-reset/evidence.json new file mode 100644 index 000000000..21ae95b13 --- /dev/null +++ b/docs/project-default-cover-reset/evidence.json @@ -0,0 +1,442 @@ +{ + "database": "procollab_cover_reset_20260917", + "backend": "http://localhost:8012", + "frontend": "http://localhost:4317", + "readbacks": [ + { + "status": 200, + "project": 31, + "cover_image_address": "http://localhost:8012/cleanup-files/d1471177881a4cd08a47021bb902e2d7.png", + "is_default_cover": false, + "presentation_address": "", + "name": "Локальная проверка стандартной обложки", + "draft": true, + "default_count": 1, + "custom_exists": true, + "case": ["Изменённый кейс"] + }, + { + "status": 200, + "project": 31, + "cover_image_address": "http://localhost:8012/cleanup-files/default.png", + "is_default_cover": true, + "presentation_address": "", + "name": "Локальная проверка стандартной обложки", + "draft": true, + "default_count": 1, + "custom_exists": true, + "case": ["Изменённый кейс"] + }, + { + "status": 200, + "project": 31, + "cover_image_address": "http://localhost:8012/cleanup-files/default.png", + "is_default_cover": true, + "presentation_address": "", + "name": "Локальная проверка стандартной обложки", + "draft": true, + "default_count": 1, + "custom_exists": true, + "case": ["Изменённый кейс"] + }, + { + "status": 200, + "project": 31, + "cover_image_address": "http://localhost:8012/cleanup-files/custom.png", + "is_default_cover": false, + "presentation_address": "", + "name": "Локальная проверка стандартной обложки", + "draft": true, + "default_count": 0, + "custom_exists": true, + "case": ["Изменённый кейс"] + }, + { + "status": 200, + "project": 31, + "cover_image_address": "http://localhost:8012/cleanup-files/default.png", + "is_default_cover": true, + "presentation_address": "", + "name": "Локальная проверка стандартной обложки", + "draft": true, + "default_count": 1, + "custom_exists": true, + "case": ["Изменённый кейс"] + } + ], + "frontend_requests": [ + { + "at": "2026-09-17T14:06:24.481Z", + "method": "GET", + "route": "/programs/?limit=20&offset=0", + "status": 401 + }, + { + "at": "2026-09-17T14:06:30.876Z", + "method": "GET", + "route": "/programs/?limit=20&offset=0", + "status": 200 + }, + { + "at": "2026-09-17T14:06:50.474Z", + "method": "GET", + "route": "/projects/31/", + "status": 200 + }, + { + "at": "2026-09-17T14:06:50.577Z", + "method": "GET", + "route": "/projects/31/goals/", + "status": 200 + }, + { + "at": "2026-09-17T14:06:50.591Z", + "method": "GET", + "route": "/projects/31/companies/list/", + "status": 200 + }, + { + "at": "2026-09-17T14:06:50.595Z", + "method": "GET", + "route": "/projects/31/resources/", + "status": 200 + }, + { + "at": "2026-09-17T14:06:50.983Z", + "method": "GET", + "route": "/programs/?limit=20&offset=0", + "status": 200 + }, + { + "at": "2026-09-17T14:06:51.03Z", + "method": "GET", + "route": "/programs/partner-program-projects/73/fields/", + "status": 200 + }, + { + "at": "2026-09-17T14:07:08.447Z", + "method": "POST", + "route": "/files/", + "status": 201 + }, + { + "at": "2026-09-17T14:07:21.104Z", + "method": "PUT", + "route": "/programs/partner-program-projects/73/fields/", + "body": [ + { + "field_id": 1, + "value_text": "Изменённый кейс" + } + ], + "status": 200 + }, + { + "at": "2026-09-17T14:07:21.288Z", + "method": "PUT", + "route": "/projects/31/", + "body": { + "image_address": "http://localhost:8012/cleanup-files/default.png", + "name": "Локальная проверка стандартной обложки", + "region": "Москва", + "trl": 4, + "links": [], + "industry_id": 1, + "description": "Синтетические данные: сохранение обложки и кейса программы", + "cover_image_address": "http://localhost:8012/cleanup-files/d1471177881a4cd08a47021bb902e2d7.png", + "target_audience": "Локальные тестовые пользователи", + "problem": "Возврат стандартной обложки", + "achievements": [], + "draft": true + }, + "status": 200 + }, + { + "at": "2026-09-17T14:07:21.448Z", + "method": "GET", + "route": "/projects/31/", + "status": 200 + }, + { + "at": "2026-09-17T14:07:21.527Z", + "method": "GET", + "route": "/projects/31/subscribers/", + "status": 200 + }, + { + "at": "2026-09-17T14:07:21.713Z", + "method": "GET", + "route": "/projects/31/news/?limit=100", + "status": 200 + }, + { + "at": "2026-09-17T14:07:54.274Z", + "method": "GET", + "route": "/projects/31/goals/", + "status": 200 + }, + { + "at": "2026-09-17T14:07:54.285Z", + "method": "GET", + "route": "/projects/31/companies/list/", + "status": 200 + }, + { + "at": "2026-09-17T14:07:54.296Z", + "method": "GET", + "route": "/projects/31/resources/", + "status": 200 + }, + { + "at": "2026-09-17T14:07:54.525Z", + "method": "GET", + "route": "/programs/partner-program-projects/73/fields/", + "status": 200 + }, + { + "at": "2026-09-17T14:08:03.664Z", + "method": "POST", + "route": "/projects/31/reset-cover/", + "body": {}, + "status": 200 + }, + { + "at": "2026-09-17T14:08:49.792Z", + "method": "GET", + "route": "/projects/31/", + "status": 200 + }, + { + "at": "2026-09-17T14:08:49.954Z", + "method": "GET", + "route": "/projects/31/goals/", + "status": 200 + }, + { + "at": "2026-09-17T14:08:49.966Z", + "method": "GET", + "route": "/projects/31/resources/", + "status": 200 + }, + { + "at": "2026-09-17T14:08:50.013Z", + "method": "GET", + "route": "/projects/31/companies/list/", + "status": 200 + }, + { + "at": "2026-09-17T14:08:50.412Z", + "method": "GET", + "route": "/programs/?limit=20&offset=0", + "status": 200 + }, + { + "at": "2026-09-17T14:08:50.465Z", + "method": "GET", + "route": "/programs/partner-program-projects/73/fields/", + "status": 200 + }, + { + "at": "2026-09-17T14:09:20.951Z", + "method": "GET", + "route": "/projects/31/", + "status": 200 + }, + { + "at": "2026-09-17T14:09:21.099Z", + "method": "GET", + "route": "/projects/31/goals/", + "status": 200 + }, + { + "at": "2026-09-17T14:09:21.121Z", + "method": "GET", + "route": "/projects/31/resources/", + "status": 200 + }, + { + "at": "2026-09-17T14:09:21.157Z", + "method": "GET", + "route": "/projects/31/companies/list/", + "status": 200 + }, + { + "at": "2026-09-17T14:09:21.575Z", + "method": "GET", + "route": "/programs/?limit=20&offset=0", + "status": 200 + }, + { + "at": "2026-09-17T14:09:21.627Z", + "method": "GET", + "route": "/programs/partner-program-projects/73/fields/", + "status": 200 + }, + { + "at": "2026-09-17T14:09:21.813Z", + "method": "POST", + "route": "/projects/31/reset-cover/", + "body": {}, + "status": 200 + }, + { + "at": "2026-09-17T14:10:28.742Z", + "method": "GET", + "route": "/projects/31/", + "status": 200 + }, + { + "at": "2026-09-17T14:10:28.86Z", + "method": "GET", + "route": "/projects/31/goals/", + "status": 200 + }, + { + "at": "2026-09-17T14:10:28.881Z", + "method": "GET", + "route": "/projects/31/companies/list/", + "status": 200 + }, + { + "at": "2026-09-17T14:10:28.893Z", + "method": "GET", + "route": "/projects/31/resources/", + "status": 200 + }, + { + "at": "2026-09-17T14:10:29.23Z", + "method": "GET", + "route": "/programs/?limit=20&offset=0", + "status": 200 + }, + { + "at": "2026-09-17T14:10:29.428Z", + "method": "GET", + "route": "/programs/partner-program-projects/73/fields/", + "status": 200 + }, + { + "at": "2026-09-17T14:10:29.514Z", + "method": "POST", + "route": "/projects/31/reset-cover/", + "body": {}, + "status": 409 + }, + { + "at": "2026-09-17T14:11:25.678Z", + "method": "POST", + "route": "/projects/31/reset-cover/", + "body": {}, + "status": 409 + }, + { + "at": "2026-09-17T14:12:08.669Z", + "method": "GET", + "route": "/projects/31/", + "status": 401 + }, + { + "at": "2026-09-17T14:12:08.949Z", + "method": "GET", + "route": "/projects/31/", + "status": 200 + }, + { + "at": "2026-09-17T14:12:09.1Z", + "method": "GET", + "route": "/projects/31/goals/", + "status": 200 + }, + { + "at": "2026-09-17T14:12:09.123Z", + "method": "GET", + "route": "/projects/31/companies/list/", + "status": 200 + }, + { + "at": "2026-09-17T14:12:09.15Z", + "method": "GET", + "route": "/projects/31/resources/", + "status": 200 + }, + { + "at": "2026-09-17T14:12:09.506Z", + "method": "GET", + "route": "/programs/?limit=20&offset=0", + "status": 200 + }, + { + "at": "2026-09-17T14:12:09.677Z", + "method": "GET", + "route": "/programs/partner-program-projects/73/fields/", + "status": 200 + }, + { + "at": "2026-09-17T14:12:09.766Z", + "method": "POST", + "route": "/projects/31/reset-cover/", + "body": {}, + "status": 200 + }, + { + "at": "2026-09-17T14:22:02.727Z", + "method": "GET", + "route": "/projects/31/", + "status": 401 + }, + { + "at": "2026-09-17T14:22:02.979Z", + "method": "GET", + "route": "/projects/31/", + "status": 200 + }, + { + "at": "2026-09-17T14:22:03.122Z", + "method": "GET", + "route": "/projects/31/companies/list/", + "status": 200 + }, + { + "at": "2026-09-17T14:22:03.134Z", + "method": "GET", + "route": "/projects/31/resources/", + "status": 200 + }, + { + "at": "2026-09-17T14:22:03.195Z", + "method": "GET", + "route": "/projects/31/goals/", + "status": 200 + }, + { + "at": "2026-09-17T14:22:03.504Z", + "method": "GET", + "route": "/programs/?limit=20&offset=0", + "status": 200 + }, + { + "at": "2026-09-17T14:22:03.587Z", + "method": "GET", + "route": "/programs/partner-program-projects/73/fields/", + "status": 200 + }, + { + "at": "2026-09-17T14:22:12.882Z", + "method": "POST", + "route": "/projects/31/reset-cover/", + "body": {}, + "status": 200 + } + ], + "base_reproduction": { + "at": "2026-09-17T14:25:45.277Z", + "method": "DELETE", + "route": "/files/?link=http://localhost:8012/cleanup-files/default.png", + "status": 403 + }, + "final_cleanup_readback": { + "uploaded_old_file_exists": false, + "default_system_file_exists": true, + "project_cover": "http://localhost:8012/cleanup-files/default.png" + } +} diff --git a/docs/project-default-cover-reset/screenshots/A-default.png b/docs/project-default-cover-reset/screenshots/A-default.png new file mode 100644 index 000000000..c9a457475 Binary files /dev/null and b/docs/project-default-cover-reset/screenshots/A-default.png differ diff --git a/docs/project-default-cover-reset/screenshots/B-custom-reopened.png b/docs/project-default-cover-reset/screenshots/B-custom-reopened.png new file mode 100644 index 000000000..cadf63535 Binary files /dev/null and b/docs/project-default-cover-reset/screenshots/B-custom-reopened.png differ diff --git a/docs/project-default-cover-reset/screenshots/C-reset-reopened.png b/docs/project-default-cover-reset/screenshots/C-reset-reopened.png new file mode 100644 index 000000000..2f0cf51ed Binary files /dev/null and b/docs/project-default-cover-reset/screenshots/C-reset-reopened.png differ diff --git a/docs/project-default-cover-reset/screenshots/E-default-unavailable.png b/docs/project-default-cover-reset/screenshots/E-default-unavailable.png new file mode 100644 index 000000000..8a092a219 Binary files /dev/null and b/docs/project-default-cover-reset/screenshots/E-default-unavailable.png differ diff --git a/docs/project-default-cover-reset/screenshots/F-cleanup-failure.png b/docs/project-default-cover-reset/screenshots/F-cleanup-failure.png new file mode 100644 index 000000000..f7369ca5e Binary files /dev/null and b/docs/project-default-cover-reset/screenshots/F-cleanup-failure.png differ diff --git a/docs/project-default-cover-reset/screenshots/G-step-change.png b/docs/project-default-cover-reset/screenshots/G-step-change.png new file mode 100644 index 000000000..2f88d7bbc Binary files /dev/null and b/docs/project-default-cover-reset/screenshots/G-step-change.png differ diff --git a/docs/project-default-cover-reset/screenshots/UI-custom-in-frame.png b/docs/project-default-cover-reset/screenshots/UI-custom-in-frame.png new file mode 100644 index 000000000..7df421d16 Binary files /dev/null and b/docs/project-default-cover-reset/screenshots/UI-custom-in-frame.png differ diff --git a/docs/project-default-cover-reset/screenshots/UI-default-in-frame.png b/docs/project-default-cover-reset/screenshots/UI-default-in-frame.png new file mode 100644 index 000000000..b1ea45990 Binary files /dev/null and b/docs/project-default-cover-reset/screenshots/UI-default-in-frame.png differ diff --git a/docs/project-default-cover-reset/screenshots/base-default-403.png b/docs/project-default-cover-reset/screenshots/base-default-403.png new file mode 100644 index 000000000..ad95856ba Binary files /dev/null and b/docs/project-default-cover-reset/screenshots/base-default-403.png differ diff --git a/docs/project-default-cover-reset/ui-geometry.json b/docs/project-default-cover-reset/ui-geometry.json new file mode 100644 index 000000000..46366cd85 --- /dev/null +++ b/docs/project-default-cover-reset/ui-geometry.json @@ -0,0 +1,258 @@ +{ + "viewport": "1280x720", + "base_sha": "c0bbdd7a97f2575864e3d4d8c7d88e927b61bd47", + "base": { + "rects": { + "assets": { + "bottom": 364.1875, + "height": 141.59375, + "left": 278.328125, + "right": 1144.984375, + "top": 222.59375, + "width": 866.65625, + "x": 278.328125, + "y": 222.59375 + }, + "cover": { + "bottom": 364.1875, + "height": 120, + "left": 713.734375, + "right": 1033.15625, + "top": 244.1875, + "width": 319.421875, + "x": 713.734375, + "y": 244.1875 + }, + "main": { + "bottom": 443.78125, + "height": 63.59375, + "left": 278.328125, + "right": 1144.984375, + "top": 380.1875, + "width": 866.65625, + "x": 278.328125, + "y": 380.1875 + }, + "name": { + "bottom": 443.578125, + "height": 41.59375, + "left": 278.328125, + "right": 534.15625, + "top": 401.984375, + "width": 255.828125, + "x": 278.328125, + "y": 401.984375 + }, + "presentation": { + "bottom": 364.1875, + "height": 120, + "left": 378.328125, + "right": 697.734375, + "top": 244.1875, + "width": 319.40625, + "x": 378.328125, + "y": 244.1875 + }, + "reset": null + }, + "viewport": { + "height": 720, + "width": 1280 + } + }, + "custom": { + "rects": { + "assets": { + "bottom": 364.1875, + "height": 141.59375, + "left": 278.328125, + "right": 1144.984375, + "top": 222.59375, + "width": 866.65625, + "x": 278.328125, + "y": 222.59375 + }, + "cover": { + "bottom": 364.1875, + "height": 120, + "left": 713.734375, + "right": 1033.15625, + "top": 244.1875, + "width": 319.421875, + "x": 713.734375, + "y": 244.1875 + }, + "main": { + "bottom": 443.78125, + "height": 63.59375, + "left": 278.328125, + "right": 1144.984375, + "top": 380.1875, + "width": 866.65625, + "x": 278.328125, + "y": 380.1875 + }, + "name": { + "bottom": 443.578125, + "height": 41.59375, + "left": 278.328125, + "right": 534.15625, + "top": 401.984375, + "width": 255.828125, + "x": 278.328125, + "y": 401.984375 + }, + "presentation": { + "bottom": 364.1875, + "height": 120, + "left": 378.328125, + "right": 697.734375, + "top": 244.1875, + "width": 319.40625, + "x": 378.328125, + "y": 244.1875 + }, + "reset": { + "bottom": 281.1875, + "height": 28, + "left": 996.15625, + "right": 1024.15625, + "top": 253.1875, + "width": 28, + "x": 996.15625, + "y": 253.1875 + } + }, + "viewport": { + "height": 720, + "width": 1280 + } + }, + "pending": { + "rects": { + "assets": { + "bottom": 364.1875, + "height": 141.59375, + "left": 278.328125, + "right": 1144.984375, + "top": 222.59375, + "width": 866.65625, + "x": 278.328125, + "y": 222.59375 + }, + "cover": { + "bottom": 364.1875, + "height": 120, + "left": 713.734375, + "right": 1033.15625, + "top": 244.1875, + "width": 319.421875, + "x": 713.734375, + "y": 244.1875 + }, + "main": { + "bottom": 443.78125, + "height": 63.59375, + "left": 278.328125, + "right": 1144.984375, + "top": 380.1875, + "width": 866.65625, + "x": 278.328125, + "y": 380.1875 + }, + "name": { + "bottom": 443.578125, + "height": 41.59375, + "left": 278.328125, + "right": 534.15625, + "top": 401.984375, + "width": 255.828125, + "x": 278.328125, + "y": 401.984375 + }, + "presentation": { + "bottom": 364.1875, + "height": 120, + "left": 378.328125, + "right": 697.734375, + "top": 244.1875, + "width": 319.40625, + "x": 378.328125, + "y": 244.1875 + }, + "reset": { + "bottom": 281.1875, + "height": 28, + "left": 996.15625, + "right": 1024.15625, + "top": 253.1875, + "width": 28, + "x": 996.15625, + "y": 253.1875 + } + }, + "viewport": { + "height": 720, + "width": 1280 + } + }, + "default": { + "rects": { + "assets": { + "bottom": 364.1875, + "height": 141.59375, + "left": 278.328125, + "right": 1144.984375, + "top": 222.59375, + "width": 866.65625, + "x": 278.328125, + "y": 222.59375 + }, + "cover": { + "bottom": 364.1875, + "height": 120, + "left": 713.734375, + "right": 1033.15625, + "top": 244.1875, + "width": 319.421875, + "x": 713.734375, + "y": 244.1875 + }, + "main": { + "bottom": 443.78125, + "height": 63.59375, + "left": 278.328125, + "right": 1144.984375, + "top": 380.1875, + "width": 866.65625, + "x": 278.328125, + "y": 380.1875 + }, + "name": { + "bottom": 443.578125, + "height": 41.59375, + "left": 278.328125, + "right": 534.15625, + "top": 401.984375, + "width": 255.828125, + "x": 278.328125, + "y": 401.984375 + }, + "presentation": { + "bottom": 364.1875, + "height": 120, + "left": 378.328125, + "right": 697.734375, + "top": 244.1875, + "width": 319.40625, + "x": 378.328125, + "y": 244.1875 + }, + "reset": null + }, + "viewport": { + "height": 720, + "width": 1280 + } + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-cover-reset.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/project-cover-reset.service.spec.ts new file mode 100644 index 000000000..d8943d630 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-cover-reset.service.spec.ts @@ -0,0 +1,158 @@ +/** @format */ + +import { Component } from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { Subject, of } from "rxjs"; +import { Project } from "@domain/project/project.model"; +import { ProjectCoverReset } from "@domain/project/project-cover.model"; +import { Result, ok, fail } from "@domain/shared/result.type"; +import { SnackbarService } from "@domain/shared/snackbar.service"; +import { ProjectFormService } from "./project-form.service"; +import { ProjectCoverResetService } from "./project-cover-reset.service"; +import { UpdateFormUseCase } from "../../use-cases/update-form.use-case"; +import { + ResetProjectCoverUseCase, + ResetProjectCoverError, +} from "../../use-cases/reset-project-cover.use-case"; + +@Component({ template: "", providers: [ProjectCoverResetService] }) +class EditorHost {} + +describe("ProjectCoverResetService", () => { + let form: ProjectFormService; + let service: ProjectCoverResetService; + let response: Subject>; + let execute: ReturnType; + let update: ReturnType; + let error: ReturnType; + let fixture: ReturnType>; + const defaultCover: ProjectCoverReset = { + coverImageAddress: "https://files.test/default.png", + isDefaultCover: true, + }; + + function project(id = 31, isDefaultCover: boolean | undefined = false): Project { + return { + ...Project.default(), + id, + isDefaultCover, + coverImageAddress: `https://files.test/${id}.png`, + region: "Москва", + industry: 1, + name: "Проект", + description: "Описание", + problem: "Проблема", + targetAudience: "Студенты", + trl: "4", + implementationDeadline: "2027-01-01", + achievements: [], + links: [], + partnerProgram: { + id: 73, + programId: 21, + programLinkId: 73, + isSubmitted: false, + canSubmit: true, + programFields: [], + programFieldValues: [], + }, + }; + } + + beforeEach(() => { + response = new Subject(); + execute = vi.fn(() => response); + update = vi.fn(() => of(ok(Project.default()))); + error = vi.fn(); + TestBed.configureTestingModule({ + providers: [ + { provide: ResetProjectCoverUseCase, useValue: { execute } }, + { provide: UpdateFormUseCase, useValue: { execute: update } }, + { provide: SnackbarService, useValue: { error } }, + ], + }); + form = TestBed.inject(ProjectFormService); + form.initializeProjectData(project()); + fixture = TestBed.createComponent(EditorHost); + service = fixture.debugElement.injector.get(ProjectCoverResetService); + }); + + it("использует Project.id, игнорирует повторный клик и не очищает control перед ответом", () => { + service.reset(); + service.reset(); + expect(execute).toHaveBeenCalledExactlyOnceWith(31); + expect(form.coverImageAddress?.value).toContain("/31.png"); + expect(service.pending()).toBe(true); + response.next(ok(defaultCover)); + response.complete(); + expect(form.coverImageAddress?.value).toBe(defaultCover.coverImageAddress); + expect(form.isDefaultCover()).toBe(true); + expect(form.coverImageAddress?.valid).toBe(true); + expect(form.validateForm()).toBe(true); + expect(form.getFormValue().coverImageAddress).toBe(defaultCover.coverImageAddress); + expect(update).not.toHaveBeenCalled(); + expect(service.pending()).toBe(false); + }); + + it.each([true, undefined])("стандартность %s не разрешает сброс", flag => { + const data = project(); + data.isDefaultCover = flag; + form.initializeProjectData(data); + service.reset(); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each(["unavailable", "forbidden", "failed"] as const)( + "ошибка %s сохраняет прежний URL и показывает сообщение", + kind => { + service.reset(); + response.next(fail(kind)); + response.complete(); + expect(form.coverImageAddress?.value).toContain("/31.png"); + expect(form.isDefaultCover()).toBe(false); + expect(error).toHaveBeenCalledOnce(); + expect(service.pending()).toBe(false); + }, + ); + + it.each([31, 32])("поздний ответ не изменяет повторно инициализированный контекст %s", id => { + service.reset(); + form.initializeProjectData({ ...project(id), coverImageAddress: "https://files.test/new.png" }); + expect(response.observed).toBe(false); + response.next(ok(defaultCover)); + expect(form.coverImageAddress?.value).toBe("https://files.test/new.png"); + expect(form.isDefaultCover()).toBe(false); + expect(service.pending()).toBe(false); + }); + + it("уничтожение редактора отменяет подписку и не меняет форму", () => { + service.reset(); + fixture.destroy(); + response.next(ok(defaultCover)); + expect(response.observed).toBe(false); + expect(form.coverImageAddress?.value).toContain("/31.png"); + }); + + it("параллельная замена URL защищена от позднего результата", () => { + service.reset(); + form.coverImageAddress?.setValue("https://files.test/new.png"); + response.next(ok(defaultCover)); + response.complete(); + expect(form.coverImageAddress?.value).toBe("https://files.test/new.png"); + expect(form.isDefaultCover()).toBe(false); + }); + + it("сброс форм отменяет запрос, presentation cleanup сохраняет прежнюю семантику", () => { + form.presentationAddress?.setValue(""); + expect(update).toHaveBeenCalledExactlyOnceWith({ + id: 31, + data: { presentationAddress: "", draft: true }, + }); + service.reset(); + form.resetForms(); + response.next(ok(defaultCover)); + expect(form.isDefaultCover()).toBeNull(); + expect(form.currentProjectId()).toBeNull(); + expect(response.observed).toBe(false); + }); +}); diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-cover-reset.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-cover-reset.service.ts new file mode 100644 index 000000000..0be329100 --- /dev/null +++ b/projects/social_platform/src/app/api/project/facades/edit/project-cover-reset.service.ts @@ -0,0 +1,60 @@ +/** @format */ + +import { DestroyRef, inject, Injectable, signal } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { SnackbarService } from "@domain/shared/snackbar.service"; +import { finalize, takeUntil } from "rxjs"; +import { ResetProjectCoverUseCase } from "../../use-cases/reset-project-cover.use-case"; +import { ProjectFormService } from "./project-form.service"; + +/** + * Команда живёт вместе с редактором, а не отдельным шагом: переход к команде + * или кейсу не должен потерять ответ уже сохранённого сброса. Форма не очищается. + */ +@Injectable() +export class ProjectCoverResetService { + private readonly form = inject(ProjectFormService); + private readonly resetCover = inject(ResetProjectCoverUseCase); + private readonly snackbar = inject(SnackbarService); + private readonly destroyRef = inject(DestroyRef); + readonly pending = signal(false); + + /** + * Повторный клик игнорируется. Смена данных проекта (включая повторное открытие + * того же ID) и уничтожение редактора отменяют подписку до изменения новой формы. + */ + reset(): void { + const id = this.form.currentProjectId(); + if (this.pending() || this.form.isDefaultCover() !== false || id === null) return; + const previousUrl = this.form.coverImageAddress?.value; + this.pending.set(true); + this.form.coverResetPending = true; + this.resetCover + .execute(id) + .pipe( + takeUntil(this.form.contextChanged$), + takeUntilDestroyed(this.destroyRef), + finalize(() => { + this.pending.set(false); + this.form.coverResetPending = false; + }), + ) + .subscribe(result => { + // Защита от параллельной замены файла без смены контекста проекта. + if (this.form.coverImageAddress?.value !== previousUrl) return; + if (result.ok) { + this.form.coverImageAddress?.setValue(result.value.coverImageAddress); + this.form.coverImageAddress?.markAsDirty(); + this.form.isDefaultCover.set(true); + } else { + const message = + result.error === "unavailable" + ? "Стандартная обложка временно недоступна." + : result.error === "forbidden" + ? "Нет доступа к изменению обложки проекта." + : "Не удалось вернуть стандартную обложку. Попробуйте ещё раз."; + this.snackbar.error(message); + } + }); + } +} diff --git a/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts index 40bf2a124..58d1abc8d 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/project-form.service.ts @@ -20,6 +20,7 @@ import { import { ProjectFormAutosaveService } from "./project-form-autosave.service"; import { findCanonicalRussianRegion } from "@core/consts/lists/russian-regions-list.const"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { Subject } from "rxjs"; const FILE_FIELDS = ["presentationAddress", "coverImageAddress"] as const; /** Управляет основной формой проекта и формой дополнительных полей партнерской программы. */ @@ -28,6 +29,13 @@ export class ProjectFormService { private projectForm!: FormGroup; private additionalForm!: FormGroup; private readonly projectId = signal(null); + readonly currentProjectId = this.projectId.asReadonly(); + /** null означает, что detail ещё не подтвердил вид обложки; удаление запрещено. */ + readonly isDefaultCover = signal(null); + private readonly contextChanged = new Subject(); + readonly contextChanged$ = this.contextChanged.asObservable(); + /** Сохранение всей формы не должно вернуть прежний URL во время серверного сброса. */ + coverResetPending = false; private readonly fileHadValue = new Set<(typeof FILE_FIELDS)[number]>(); private readonly fb = inject(FormBuilder); @@ -64,7 +72,9 @@ export class ProjectFormService { * без событий. История очистки сбрасывается при каждом новом наборе данных. */ public initializeProjectData(project: Project): void { + this.contextChanged.next(); this.projectId.set(null); + this.isDefaultCover.set(project.isDefaultCover ?? null); this.fileHadValue.clear(); for (const field of FILE_FIELDS) { const value = project[field] ?? ""; @@ -258,7 +268,9 @@ export class ProjectFormService { /** Сбрасывает контекст до контролов, чтобы сброс формы не сохранялся в прежний проект. */ public resetForms(): void { + this.contextChanged.next(); this.projectId.set(null); + this.isDefaultCover.set(null); this.fileHadValue.clear(); this.projectForm.reset(); this.additionalForm?.reset(); diff --git a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts index 3279db6ca..08f91ca07 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.spec.ts @@ -56,6 +56,16 @@ describe("ProjectsEditInfoService canonical relation integration", () => { applyOpenSendDescisionLateModal: vi.fn(), applyOpenAssignProjectModal: vi.fn(), }; + + it("не сохраняет прежний URL обложки параллельно серверному сбросу", () => { + TestBed.inject(ProjectFormService).coverResetPending = true; + service.saveProjectAsDraft(); + service.saveProjectAsPublished(); + service.submitProjectForm(); + expect(update.execute).not.toHaveBeenCalled(); + expect(repo.updateProgramLinkFields).not.toHaveBeenCalled(); + expect(programRepo.submitCompettetiveProject).not.toHaveBeenCalled(); + }); function project(id = 55): Project { // The embedded legacy program is deliberately B. Explicit route link A is authoritative. return { diff --git a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts index 92082be30..eb10acc21 100644 --- a/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts +++ b/projects/social_platform/src/app/api/project/facades/edit/projects-edit-info.service.ts @@ -231,6 +231,7 @@ export class ProjectsEditInfoService { } saveProjectAsPublished(): void { + if (this.projectFormService.coverResetPending) return; if (isLoading(this.projFormIsSubmitting$()) || !this.additionalContextReady()) return; if (!this.submissionAvailable()) return; this.projectForm.get("draft")?.patchValue(false); @@ -275,6 +276,7 @@ export class ProjectsEditInfoService { } saveProjectAsDraft(): void { + if (this.projectFormService.coverResetPending) return; if (isLoading(this.projFormIsSubmitting$()) || !this.additionalContextReady()) return; this.clearAllValidationErrors(); this.projectForm.get("draft")?.patchValue(true); @@ -290,6 +292,7 @@ export class ProjectsEditInfoService { /** Сохраняет форму; перед публикацией подтверждает намеренную очистку файлов в режиме черновика. */ submitProjectForm(): void { + if (this.projectFormService.coverResetPending) return; if (!this.additionalContextReady()) return; const isDraft = this.projectForm.get("draft")?.value === true; diff --git a/projects/social_platform/src/app/api/project/use-cases/reset-project-cover.use-case.spec.ts b/projects/social_platform/src/app/api/project/use-cases/reset-project-cover.use-case.spec.ts new file mode 100644 index 000000000..194a09924 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-cases/reset-project-cover.use-case.spec.ts @@ -0,0 +1,57 @@ +/** @format */ + +import { HttpErrorResponse } from "@angular/common/http"; +import { TestBed } from "@angular/core/testing"; +import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port"; +import { firstValueFrom, of, throwError } from "rxjs"; +import { ResetProjectCoverUseCase } from "./reset-project-cover.use-case"; + +describe("ResetProjectCoverUseCase", () => { + let reset: ReturnType; + let useCase: ResetProjectCoverUseCase; + beforeEach(() => { + reset = vi.fn(); + TestBed.configureTestingModule({ + providers: [{ provide: ProjectRepositoryPort, useValue: { resetCover: reset } }], + }); + useCase = TestBed.inject(ResetProjectCoverUseCase); + }); + + it.each([NaN, 0, -1, Infinity, 1.5])("не отправляет неверный id %s", async id => { + expect(await firstValueFrom(useCase.execute(id))).toEqual({ ok: false, error: "failed" }); + expect(reset).not.toHaveBeenCalled(); + }); + + it("возвращает фактическую обложку из API", async () => { + const value = { coverImageAddress: "https://files.test/default.png", isDefaultCover: true }; + reset.mockReturnValue(of(value)); + expect(await firstValueFrom(useCase.execute(31))).toEqual({ ok: true, value }); + }); + + it.each([ + { coverImageAddress: "", isDefaultCover: true }, + { coverImageAddress: "custom", isDefaultCover: false }, + ])("не принимает некорректный успех %s", async value => { + reset.mockReturnValue(of(value)); + expect(await firstValueFrom(useCase.execute(31))).toEqual({ ok: false, error: "failed" }); + }); + + it.each([ + [409, "unavailable"], + [403, "forbidden"], + [401, "forbidden"], + [500, "failed"], + [0, "failed"], + ])("отображает безопасную ошибку HTTP %s", async (status, expected) => { + reset.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: Number(status), + error: { code: "default_cover_unavailable", detail: "private body" }, + }), + ), + ); + expect(await firstValueFrom(useCase.execute(31))).toEqual({ ok: false, error: expected }); + }); +}); diff --git a/projects/social_platform/src/app/api/project/use-cases/reset-project-cover.use-case.ts b/projects/social_platform/src/app/api/project/use-cases/reset-project-cover.use-case.ts new file mode 100644 index 000000000..323c1ac30 --- /dev/null +++ b/projects/social_platform/src/app/api/project/use-cases/reset-project-cover.use-case.ts @@ -0,0 +1,39 @@ +/** @format */ + +import { HttpErrorResponse } from "@angular/common/http"; +import { inject, Injectable } from "@angular/core"; +import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port"; +import { ProjectCoverReset } from "@domain/project/project-cover.model"; +import { fail, ok, Result } from "@domain/shared/result.type"; +import { catchError, map, Observable, of } from "rxjs"; + +export type ResetProjectCoverError = "unavailable" | "forbidden" | "failed"; + +/** Команда сброса с безопасными ошибками: серверный HTTP body не выводится пользователю. */ +@Injectable({ providedIn: "root" }) +export class ResetProjectCoverUseCase { + private readonly repository = inject(ProjectRepositoryPort); + + /** Принимает только реальный Project.id; успешный ответ обязан содержать стандартный URL. */ + execute(id: number): Observable> { + if (!Number.isSafeInteger(id) || id <= 0) return of(fail("failed")); + return this.repository.resetCover(id).pipe( + map(result => + result.isDefaultCover === true && !!result.coverImageAddress + ? ok(result) + : fail("failed"), + ), + catchError((error: unknown) => { + if (error instanceof HttpErrorResponse) { + if (error.status === 409 && error.error?.code === "default_cover_unavailable") { + return of(fail("unavailable")); + } + if (error.status === 401 || error.status === 403) { + return of(fail("forbidden")); + } + } + return of(fail("failed")); + }), + ); + } +} diff --git a/projects/social_platform/src/app/domain/project/ports/project.repository.port.ts b/projects/social_platform/src/app/domain/project/ports/project.repository.port.ts index f3fe5d2a2..24c9dbdd0 100644 --- a/projects/social_platform/src/app/domain/project/ports/project.repository.port.ts +++ b/projects/social_platform/src/app/domain/project/ports/project.repository.port.ts @@ -4,6 +4,7 @@ import { BehaviorSubject, Observable } from "rxjs"; import { Project, ProjectCount } from "../project.model"; import { ApiPagination } from "../../other/api-pagination.model"; import { HttpParams } from "@angular/common/http"; +import { ProjectCoverReset } from "../project-cover.model"; /** Порт репозитория проектов — контракт CRUD. Реализуется в infrastructure/repository/project. */ export abstract class ProjectRepositoryPort { @@ -16,4 +17,6 @@ export abstract class ProjectRepositoryPort { abstract update(id: number, data: Partial): Observable; abstract deleteOne(id: number): Observable; abstract refreshCount(): Observable; + /** Сбрасывает обложку с обычными серверными правами редактирования проекта. */ + abstract resetCover(id: number): Observable; } diff --git a/projects/social_platform/src/app/domain/project/project-cover.model.ts b/projects/social_platform/src/app/domain/project/project-cover.model.ts new file mode 100644 index 000000000..1abc97fd1 --- /dev/null +++ b/projects/social_platform/src/app/domain/project/project-cover.model.ts @@ -0,0 +1,10 @@ +/** + * Результат сохранённого сервером сброса; пустая обложка не является успехом. + * + * @format + */ + +export interface ProjectCoverReset { + coverImageAddress: string; + isDefaultCover: true; +} diff --git a/projects/social_platform/src/app/domain/project/project.model.ts b/projects/social_platform/src/app/domain/project/project.model.ts index f37c9d0f5..6b6e6d669 100644 --- a/projects/social_platform/src/app/domain/project/project.model.ts +++ b/projects/social_platform/src/app/domain/project/project.model.ts @@ -44,6 +44,8 @@ export class Project { viewsCount!: number; cover!: null | string; coverImageAddress!: string | null; + /** Канонический признак из detail API; сокращённые списки его не возвращают. */ + isDefaultCover?: boolean; collaborators!: Collaborator[]; collaborator?: Collaborator; links!: string[]; diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/dto/project.dto.ts b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project.dto.ts index 8568a024c..95e5146dc 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/project/dto/project.dto.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/project/dto/project.dto.ts @@ -30,6 +30,8 @@ export interface ProjectDto { presentationAddress: string; cover: string | null; coverImageAddress: string | null; + /** Поле detail API после общего преобразования snake_case в camelCase. */ + isDefaultCover?: boolean; links: string[]; numberOfCollaborators: number; viewsCount: number; diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-cover-contract.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-cover-contract.spec.ts new file mode 100644 index 000000000..2e6cc9f21 --- /dev/null +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-cover-contract.spec.ts @@ -0,0 +1,55 @@ +/** @format */ + +import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http"; +import { HttpTestingController, provideHttpClientTesting } from "@angular/common/http/testing"; +import { TestBed } from "@angular/core/testing"; +import { API_URL } from "@core/lib/providers"; +import { CamelcaseInterceptor } from "@core/lib/interceptors/camelcase.interceptor"; +import { LoggerService } from "@core/lib/services/logger/logger.service"; +import { ProjectHttpAdapter } from "./project-http.adapter"; + +describe("Контракт стандартной обложки через HTTP и общий interceptor", () => { + beforeEach(() => + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptorsFromDi()), + provideHttpClientTesting(), + { provide: API_URL, useValue: "https://local.test" }, + { provide: HTTP_INTERCEPTORS, useClass: CamelcaseInterceptor, multi: true }, + { provide: LoggerService, useValue: { warn: vi.fn() } }, + ], + }), + ); + afterEach(() => TestBed.inject(HttpTestingController).verify()); + + it.each([true, false])("detail преобразует is_default_cover=%s в модель Angular", flag => { + TestBed.inject(ProjectHttpAdapter) + .fetchOne(31) + .subscribe(value => { + expect(value.isDefaultCover).toBe(flag); + expect(value.coverImageAddress).toBe("https://files.test/cover.png"); + }); + TestBed.inject(HttpTestingController).expectOne("https://local.test/projects/31/").flush({ + id: 31, + is_default_cover: flag, + cover_image_address: "https://files.test/cover.png", + }); + }); + + it("POST reset-cover возвращает новый URL и серверный флаг", () => { + TestBed.inject(ProjectHttpAdapter) + .resetCover(31) + .subscribe(value => { + expect(value).toEqual({ + coverImageAddress: "https://files.test/default.png", + isDefaultCover: true, + }); + }); + const req = TestBed.inject(HttpTestingController).expectOne( + "https://local.test/projects/31/reset-cover/", + ); + expect(req.request.method).toBe("POST"); + expect(req.request.body).toEqual({}); + req.flush({ cover_image_address: "https://files.test/default.png", is_default_cover: true }); + }); +}); diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.spec.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.spec.ts index d4c9ed8f0..03685922f 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.spec.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.spec.ts @@ -84,4 +84,14 @@ describe("ProjectHttpAdapter", () => { expect(api.get).toHaveBeenCalledExactlyOnceWith("/auth/users/projects/", params); }); + + it("resetCover отправляет команду проекта без физического DELETE", () => { + setup(); + api.post.mockReturnValue( + of({ coverImageAddress: "https://files.test/default.png", isDefaultCover: true }), + ); + adapter.resetCover(31).subscribe(); + expect(api.post).toHaveBeenCalledExactlyOnceWith("/projects/31/reset-cover/", {}); + expect(api.delete).not.toHaveBeenCalled(); + }); }); diff --git a/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.ts b/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.ts index 105e4fed0..d6c8379a8 100644 --- a/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.ts +++ b/projects/social_platform/src/app/infrastructure/adapters/project/project-http.adapter.ts @@ -5,6 +5,7 @@ import { inject, Injectable } from "@angular/core"; import { ApiService } from "@corelib"; import { Observable } from "rxjs"; import { ProjectCountDto, ProjectDto, ProjectListDto } from "./dto/project.dto"; +import { ProjectCoverReset } from "@domain/project/project-cover.model"; /** HTTP-адаптер проектов: `/projects`, `/auth/users` (CRUD, счётчики, проекты лидера). */ @Injectable({ providedIn: "root" }) @@ -38,6 +39,11 @@ export class ProjectHttpAdapter { return this.apiService.delete(`${this.PROJECTS_URL}/${id}/`); } + /** Назначает стандартную обложку на сервере без DELETE общего файлового API. */ + resetCover(id: number): Observable { + return this.apiService.post(`${this.PROJECTS_URL}/${id}/reset-cover/`, {}); + } + fetchMy(params?: HttpParams): Observable { return this.apiService.get(`${this.AUTH_USERS_URL}/projects/`, params); } diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project.repository.spec.ts b/projects/social_platform/src/app/infrastructure/repository/project/project.repository.spec.ts index 8c71fa256..6edfd5671 100644 --- a/projects/social_platform/src/app/infrastructure/repository/project/project.repository.spec.ts +++ b/projects/social_platform/src/app/infrastructure/repository/project/project.repository.spec.ts @@ -1,7 +1,7 @@ /** @format */ import { TestBed } from "@angular/core/testing"; -import { of } from "rxjs"; +import { of, Subject } from "rxjs"; import { HttpParams } from "@angular/common/http"; import { ProjectRepository } from "./project.repository"; import { ProjectHttpAdapter } from "../../adapters/project/project-http.adapter"; @@ -32,6 +32,7 @@ describe("ProjectRepository", () => { fetchMy: vi.fn(), postCreate: vi.fn(), deleteOne: vi.fn(), + resetCover: vi.fn(), }; TestBed.configureTestingModule({ providers: [ProjectRepository, { provide: ProjectHttpAdapter, useValue: adapter }], @@ -70,6 +71,29 @@ describe("ProjectRepository", () => { expect(adapter.fetchOne).toHaveBeenCalledTimes(1); }); + it.each([true, false])("detail сохраняет серверный isDefaultCover=%s", flag => { + setup(); + adapter.fetchOne.mockReturnValue(of({ id: 31, isDefaultCover: flag })); + repository.getOne(31).subscribe(project => expect(project.isDefaultCover).toBe(flag)); + }); + + it.each(["success", "cancel"])("resetCover инвалидирует detail после %s", outcome => { + setup(); + const response = new Subject(); + adapter.resetCover.mockReturnValue(response); + adapter.fetchOne.mockReturnValue(of({ id: 31, isDefaultCover: false })); + repository.getOne(31).subscribe(); + const subscription = repository.resetCover(31).subscribe(); + repository.getOne(31).subscribe(); + if (outcome === "success") { + response.next({ coverImageAddress: "https://files.test/default.png", isDefaultCover: true }); + response.complete(); + } else subscription.unsubscribe(); + adapter.fetchOne.mockReturnValue(of({ id: 31, isDefaultCover: true })); + repository.getOne(31).subscribe(project => expect(project.isDefaultCover).toBe(true)); + expect(adapter.fetchOne).toHaveBeenCalledTimes(3); + }); + it("refreshCount мапит в ProjectCount и пушит в count$", () => new Promise(done => { setup(); diff --git a/projects/social_platform/src/app/infrastructure/repository/project/project.repository.ts b/projects/social_platform/src/app/infrastructure/repository/project/project.repository.ts index 66ebafa62..263188cd5 100644 --- a/projects/social_platform/src/app/infrastructure/repository/project/project.repository.ts +++ b/projects/social_platform/src/app/infrastructure/repository/project/project.repository.ts @@ -21,6 +21,8 @@ import { RejectVacancyResponse } from "@domain/vacancy/events/reject-vacancy-res import { EntityCache } from "@domain/shared/entity-cache"; import { AcceptInvite } from "@domain/invite/events/accept-invite.event"; import { LoggedOut } from "@domain/auth/events/logged-out.event"; +import { ProjectCoverReset } from "@domain/project/project-cover.model"; +import { defer, finalize } from "rxjs"; /** Репозиторий проектов с локальным кешем деталей, инвалидируемым по доменным событиям. */ @Injectable({ providedIn: "root" }) @@ -148,4 +150,17 @@ export class ProjectRepository implements ProjectRepositoryPort { deleteOne(id: number): Observable { return this.projectAdapter.deleteOne(id); } + + /** + * Инвалидирует detail при завершении и отмене: сервер мог сохранить сброс, + * даже если редактор закрылся до получения ответа или сеть оборвалась. + */ + resetCover(id: number): Observable { + return defer(() => { + this.entityCache.invalidate(id); + return this.projectAdapter + .resetCover(id) + .pipe(finalize(() => this.entityCache.invalidate(id))); + }); + } } diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html index 779c4bd76..690a27734 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.html @@ -55,8 +55,13 @@ >обложка @@ -69,6 +74,29 @@

Загрузите файл

}
+ + @if (isDefaultCover() === false && coverImageAddress.value) { + + } +
} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.scss b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.scss index d69a041d3..746a1f66d 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.scss +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.scss @@ -4,6 +4,35 @@ @use "styles/typography"; .project { + &__cover-reset { + // Действие внутри рамки не участвует в потоке и не сдвигает соседние поля. + position: absolute; + top: 8px; + right: 8px; + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 6px; + color: var(--accent); + cursor: pointer; + background: transparent; + border: 0; + border-radius: var(--rounded-md); + + &:hover, + &:focus-visible { + background: var(--light-gray); + outline: 1px solid var(--accent); + } + + &:disabled { + cursor: wait; + opacity: 0.5; + } + } + &__inner { width: 100%; } diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.spec.ts index c7741905e..5526462ec 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.spec.ts @@ -16,14 +16,31 @@ import { ProjectGoalService } from "@api/project/facades/edit/project-goals.serv import { ProjectTeamUIService } from "@api/project/facades/edit/ui/project-team-ui.service"; import { ProjectMainStepComponent } from "./project-main-step.component"; import { createProjectForm } from "@api/project/facades/edit/project-form.factory"; +import { ResetProjectCoverUseCase } from "@api/project/use-cases/reset-project-cover.use-case"; +import { UploadFileComponent } from "@ui/primitives/upload-file/upload-file.component"; +import { FileService } from "@core/lib/services/file/file.service"; +import { initial } from "@domain/shared/async-state"; +import { Subject } from "rxjs"; +import { ProjectCoverResetService } from "@api/project/facades/edit/project-cover-reset.service"; describe("ProjectMainStepComponent", () => { let fixture: ComponentFixture; let links: FormArray; + const resetCover = { execute: vi.fn() }; + const files = { deleteFile: vi.fn(), uploadFile: vi.fn() }; beforeEach(async () => { const fb = new FormBuilder(); const projectForm = createProjectForm(fb); + resetCover.execute.mockReset().mockReturnValue( + of({ + ok: true, + value: { coverImageAddress: "https://files.test/default.png", isDefaultCover: true }, + }), + ); + files.deleteFile.mockReset().mockReturnValue(of(undefined)); + files.uploadFile.mockReset().mockReturnValue(of({ url: "https://files.test/custom.png" })); + projectForm.get("coverImageAddress")?.setValue("https://files.test/default.png"); const emptyProjectControls = { name: projectForm.get("name"), region: projectForm.get("region"), @@ -36,7 +53,7 @@ describe("ProjectMainStepComponent", () => { trl: null, partnerProgramId: null, presentationAddress: null, - coverImageAddress: null, + coverImageAddress: projectForm.get("coverImageAddress"), imageAddress: null, }; links = projectForm.get("links") as FormArray; @@ -48,17 +65,28 @@ describe("ProjectMainStepComponent", () => { provideRouter([]), provideNgxMask(), ProjectContactsService, + ProjectCoverResetService, + { provide: ResetProjectCoverUseCase, useValue: resetCover }, + { provide: FileService, useValue: files }, { provide: ProjectFormService, useValue: { getForm: () => projectForm, editIndex: signal(null), + isDefaultCover: signal(true), + currentProjectId: signal(31), + contextChanged$: new Subject(), ...emptyProjectControls, }, }, { provide: ProjectsEditInfoService, - useValue: { projectForm, profileId: signal(1), industries$: of([]) }, + useValue: { + projectForm, + profileId: signal(1), + industries$: of([]), + projFormIsSubmitting$: signal(initial()), + }, }, { provide: ProjectsEditUIInfoService, @@ -95,6 +123,58 @@ describe("ProjectMainStepComponent", () => { fixture.detectChanges(); }); + it("стандартная обложка не имеет удаления или сброса, но допускает замену", () => { + const upload = fixture.debugElement.query(By.css('[formControlName="coverImageAddress"]')) + .componentInstance as UploadFileComponent; + expect( + fixture.nativeElement.querySelector('[formcontrolname="coverImageAddress"] .file__basket'), + ).toBeNull(); + expect(fixture.nativeElement.querySelector(".project__cover-reset")).toBeNull(); + expect( + fixture.nativeElement.querySelector('[formcontrolname="coverImageAddress"] .file__replace'), + ).not.toBeNull(); + upload.onRemove(); + expect(files.deleteFile).not.toHaveBeenCalled(); + expect(resetCover.execute).not.toHaveBeenCalled(); + }); + + it("пользовательская обложка сбрасывается кнопкой без DELETE, новый URL отображается сразу", async () => { + const form = TestBed.inject(ProjectFormService); + form.isDefaultCover.set(false); + form.coverImageAddress?.setValue("https://files.test/custom.png"); + await fixture.whenStable(); + const reset = fixture.nativeElement.querySelector(".project__cover-reset") as HTMLButtonElement; + expect(reset.title).toBe("Вернуть стандартную обложку"); + reset.focus(); + expect(document.activeElement).toBe(reset); + reset.click(); + await fixture.whenStable(); + expect(resetCover.execute).toHaveBeenCalledExactlyOnceWith(31); + expect(form.coverImageAddress?.value).toBe("https://files.test/default.png"); + expect(form.coverImageAddress?.valid).toBe(true); + expect(fixture.nativeElement.querySelector(".project__cover-reset")).toBeNull(); + const upload = fixture.debugElement.query(By.css('[formControlName="coverImageAddress"]')) + .componentInstance as UploadFileComponent; + expect(upload.value).toBe("https://files.test/default.png"); + expect(files.deleteFile).not.toHaveBeenCalled(); + }); + + it("замена стандартной и пользовательской обложки загрузкой не вызывает DELETE", async () => { + for (const url of ["https://files.test/custom-a.png", "https://files.test/custom-b.png"]) { + files.uploadFile.mockReturnValue(of({ url })); + const upload = fixture.debugElement.query(By.css('[formControlName="coverImageAddress"]')) + .componentInstance as UploadFileComponent; + upload.onUpdate({ + currentTarget: { files: [new File(["png"], "cover.png", { type: "image/png" })] }, + } as unknown as Event); + await fixture.whenStable(); + expect(TestBed.inject(ProjectFormService).coverImageAddress?.value).toBe(url); + expect(TestBed.inject(ProjectFormService).isDefaultCover()).toBe(false); + expect(fixture.nativeElement.querySelector(".project__cover-reset")).not.toBeNull(); + } + expect(files.deleteFile).not.toHaveBeenCalled(); + }); + it("untouched fields are quiet; submit exposes matching borders, warning icons and required messages", async () => { const required = ["name", "region", "industry", "problem", "description", "targetAudience"]; for (const id of required) { diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.ts index 625fc492e..a2805eb14 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/components/project-main-step/project-main-step.component.ts @@ -31,6 +31,8 @@ import { ProjectGoalService } from "@api/project/facades/edit/project-goals.serv import { ProjectContactsService } from "@api/project/facades/edit/project-contacts.service"; import { ProjectTeamUIService } from "@api/project/facades/edit/ui/project-team-ui.service"; import { RegionSelectComponent } from "@ui/widgets/region-select/region-select.component"; +import { ProjectCoverResetService } from "@api/project/facades/edit/project-cover-reset.service"; +import { isLoading } from "@domain/shared/async-state"; /** Шаг редактирования проекта: основная информация. */ @Component({ @@ -62,9 +64,13 @@ export class ProjectMainStepComponent implements OnInit { private readonly fb = inject(FormBuilder); private readonly projectFormService = inject(ProjectFormService); + protected readonly coverReset = inject(ProjectCoverResetService); + protected readonly isDefaultCover = this.projectFormService.isDefaultCover; private readonly projectTeamUIService = inject(ProjectTeamUIService); private readonly projectsEditInfoService = inject(ProjectsEditInfoService); + protected readonly projectSaveState = this.projectsEditInfoService.projFormIsSubmitting$; + protected readonly isLoading = isLoading; private readonly projectsEditUIInfoService = inject(ProjectsEditUIInfoService); private readonly projectGoalService = inject(ProjectGoalService); @@ -132,7 +138,7 @@ export class ProjectMainStepComponent implements OnInit { ngOnInit(): void {} - /** A submit attempt exposes actual errors; it must never make a valid control look required. */ + /** Показывает реальные ошибки после отправки, не превращая валидное поле в обязательное. */ protected showError(control: AbstractControl, errorName?: string): boolean { return ( control.enabled && diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.html b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.html index 015524a58..800ecc2d9 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.html +++ b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.html @@ -19,6 +19,7 @@

редактировать проект

customTypographyClass="text-body-12" appearance="outline" [loader]="projFormIsSubmittingAsDraft()" + [disabled]="coverReset.pending()" (click)="saveProjectAsDraft()" > сохранить черновик @@ -28,7 +29,7 @@

редактировать проект

customTypographyClass="text-body-12" [loader]="projFormIsSubmittingAsPublished()" (click)="saveProjectAsPublished()" - [disabled]="onEditClicked()" + [disabled]="onEditClicked() || coverReset.pending()" [style.opacity]="onEditClicked() ? '0.5' : '1'" > {{ isCompetitive() && !submitted() ? "отправить заявку" : "сохранить" }} diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.spec.ts b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.spec.ts index 3a987cdc3..63895a13e 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.spec.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.spec.ts @@ -21,6 +21,7 @@ import { ProjectsEditUIInfoService } from "@api/project/facades/edit/ui/projects import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; import { ToggleFieldsInfoService } from "@api/toggle-fields/toggle-fields-info.service"; import { ProjectStepService } from "@api/project/project-step.service"; +import { ProjectCoverResetService } from "@api/project/facades/edit/project-cover-reset.service"; describe("ProjectEditComponent", () => { let component: ProjectEditComponent; @@ -83,6 +84,7 @@ describe("ProjectEditComponent", () => { .overrideComponent(ProjectEditComponent, { remove: { providers: [ + ProjectCoverResetService, ProjectsEditInfoService, ProjectsEditUIInfoService, ProjectStepService, @@ -102,6 +104,7 @@ describe("ProjectEditComponent", () => { }, add: { providers: [ + { provide: ProjectCoverResetService, useValue: { pending: signal(false) } }, { provide: ProjectsEditInfoService, useValue: projectsEditInfoServiceSpy, @@ -148,6 +151,14 @@ describe("ProjectEditComponent", () => { expect(component).toBeTruthy(); }); + it("на любом шаге блокирует сохранение старой обложки во время reset", () => { + fixture.debugElement.injector.get(ProjectCoverResetService).pending.set(true); + fixture.detectChanges(); + const buttons = fixture.nativeElement.querySelectorAll(".project__save app-button button"); + expect(buttons[1].disabled).toBe(true); + expect(buttons[2].disabled).toBe(true); + }); + it("uses canonical competition/submitted state for the primary action label", () => { const facade = fixture.debugElement.injector.get(ProjectsEditInfoService); // Mutable test signals stand in for the facade's canonical readonly signals. diff --git a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.ts b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.ts index f55c10dad..416c89ab0 100644 --- a/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.ts +++ b/projects/social_platform/src/app/ui/pages/projects/edit/edit.component.ts @@ -39,6 +39,7 @@ import { TooltipInfoService } from "@api/tooltip/tooltip-info.service"; import { ToggleFieldsInfoService } from "@api/toggle-fields/toggle-fields-info.service"; import { AppRoutes } from "@api/paths/app-routes"; import { EditStep } from "@core/lib/models/edit-step"; +import { ProjectCoverResetService } from "@api/project/facades/edit/project-cover-reset.service"; /** Многошаговое редактирование проекта. */ @Component({ @@ -62,6 +63,7 @@ import { EditStep } from "@core/lib/models/edit-step"; ], providers: [ ProjectFormService, + ProjectCoverResetService, ProjectVacancyService, ProjectVacancyUIService, ProjectTeamService, @@ -79,6 +81,7 @@ import { EditStep } from "@core/lib/models/edit-step"; changeDetection: ChangeDetectionStrategy.OnPush, }) export class ProjectEditComponent implements OnInit, AfterViewInit, OnDestroy { + protected readonly coverReset = inject(ProjectCoverResetService); private readonly projectsEditInfoService = inject(ProjectsEditInfoService); protected readonly AppRoutes = AppRoutes; private readonly projectsEditUIInfoService = inject(ProjectsEditUIInfoService); diff --git a/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.html b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.html index cc5a2e05d..57a7d7ba9 100644 --- a/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.html +++ b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.html @@ -5,7 +5,14 @@ } @else if (!value) {
- +
} diff --git a/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.scss b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.scss index 11f8d4976..fc6faf1ea 100644 --- a/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.scss +++ b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.scss @@ -1,6 +1,7 @@ /** @format */ .control { + position: relative; display: flex; flex-direction: column; align-items: center; @@ -60,6 +61,19 @@ cursor: pointer; } + &__replace { + margin-bottom: 8px; + color: var(--accent); + cursor: pointer; + background: transparent; + border: 0; + + &:disabled { + cursor: wait; + opacity: 0.5; + } + } + &__basket { align-self: flex-end; color: var(--red); diff --git a/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.ts b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.ts index 276ae6b06..13428945e 100644 --- a/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.ts +++ b/projects/social_platform/src/app/ui/primitives/upload-file/upload-file.component.ts @@ -66,6 +66,13 @@ export class UploadFileComponent implements ControlValueAccessor { /** Режим: после загрузки сбросить в пустое состояние и не показывать "файл успешно загружен" */ resetAfterUpload = input(false); + /** Родитель может запретить физическое удаление, сохранив обычную загрузку. */ + removable = input(true); + /** Позволяет заменить заполненный файл без предварительного удаления прежнего. */ + replaceable = input(false); + /** Блокирует действия на время внешней операции родителя. */ + busy = input(false); + /** Событие с данными загруженного файла (url + метаданные оригинального файла) */ uploaded = output<{ url: string; @@ -105,6 +112,7 @@ export class UploadFileComponent implements ControlValueAccessor { /** Обработчик загрузки файла */ onUpdate(event: Event): void { + if (this.loading || this.busy()) return; const input = event.currentTarget as HTMLInputElement; const files = input.files; if (!files?.length) { @@ -153,7 +161,7 @@ export class UploadFileComponent implements ControlValueAccessor { * не записала ложную очистку и пользователь мог повторить действие. */ onRemove(): void { - if (this.loading || !this.value) return; + if (!this.removable() || this.busy() || this.loading || !this.value) return; const removedUrl = this.value; const removedRevision = this.valueRevision; this.loading = true;