Compare commits
21 Commits
cbd40791e4
...
6337b6e4ac
| Author | SHA1 | Date |
|---|---|---|
|
|
6337b6e4ac | |
|
|
f1f29185bf | |
|
|
570e42b212 | |
|
|
dd964f5d99 | |
|
|
0a85ea3cb2 | |
|
|
2c54a8f274 | |
|
|
a5bf967862 | |
|
|
e4a59e7a54 | |
|
|
bcd4d676db | |
|
|
91906e917e | |
|
|
c6645bb4fc | |
|
|
ba34162eeb | |
|
|
d3b4c0689c | |
|
|
6a3adcd245 | |
|
|
c880c0a319 | |
|
|
ab2a5ffb9a | |
|
|
8bf6f2a510 | |
|
|
0184ff9a32 | |
|
|
f12a3b7338 | |
|
|
969c218e99 | |
|
|
6cb0545957 |
|
|
@ -0,0 +1,148 @@
|
|||
# Шаг 11. Единый shell карточки внешнего контура
|
||||
|
||||
## Зачем нужен этот шаг
|
||||
|
||||
Сейчас карточка деталей во `Внешних контурах` решает прикладную задачу, но UX-паттерн у нее другой, чем во `Внутреннем контуре`.
|
||||
|
||||
Из-за этого:
|
||||
- у пользователя ломается ожидаемая логика открытия карточки
|
||||
- верхний action-bar живет по другим правилам
|
||||
- повторяется UI-логика, которая уже есть у стандартного peek-shell
|
||||
- дальнейшее развитие `Внешних контуров` начинает расходиться с остальным продуктом
|
||||
|
||||
Следующий шаг должен выровнять не данные, а каркас взаимодействия.
|
||||
|
||||
## Целевой результат
|
||||
|
||||
По клику на карточку во `Внешних контурах` пользователь получает тот же класс панели, что и во `Внутреннем контуре`:
|
||||
- закрытие просмотра
|
||||
- полноэкранный режим
|
||||
- переключение макета
|
||||
- подписка или отписка
|
||||
- копирование ссылки
|
||||
- меню дополнительных действий
|
||||
|
||||
При этом тело карточки остается отдельным и специфичным для `Внешних контуров`.
|
||||
|
||||
## Что обязательно сохраняем
|
||||
|
||||
Карточка внешнего контура не должна деградировать до обычной карточки `Issue`.
|
||||
|
||||
Нужно сохранить:
|
||||
- source-side режим без обязательного membership в target project
|
||||
- блок `Маршрутизация`
|
||||
- зеркалирование комментариев, вложений и activity
|
||||
- внешний lifecycle `Принять / Отклонить / Ответ во внешний контур`
|
||||
- правила доступа, отличающиеся от обычного внутреннего рабочего элемента
|
||||
|
||||
Иначе мы потеряем главный смысл модуля.
|
||||
|
||||
## Что меняем
|
||||
|
||||
Меняем только shell и верхний UX-паттерн:
|
||||
- каркас панели
|
||||
- поведение открытия и закрытия
|
||||
- поведение полноэкранного режима
|
||||
- верхний блок действий
|
||||
- раскладку заголовка и служебных controls
|
||||
|
||||
То есть цель шага — унификация оболочки, а не унификация доменной модели.
|
||||
|
||||
## Что не входит
|
||||
|
||||
В этот шаг не входят:
|
||||
- новые колонки `Исходящие / Входящие`
|
||||
- drag-and-drop
|
||||
- пользовательские кастомные представления
|
||||
- перенос всех операций внешнего контура на стандартные issue services
|
||||
- удаление запроса как обязательное действие из header action set
|
||||
|
||||
## Архитектурный принцип
|
||||
|
||||
Нельзя копировать целиком реализацию `peek overview` внутреннего контура и натягивать ее поверх внешней сущности.
|
||||
|
||||
Правильный путь:
|
||||
- переиспользовать shell-контракт
|
||||
- переиспользовать header action pattern
|
||||
- оставить внешний data-flow отдельным
|
||||
- оставить body карточки отдельным
|
||||
|
||||
То есть нужен не clone существующей карточки, а повторное использование общего слоя представления.
|
||||
|
||||
## Рекомендуемое разбиение реализации
|
||||
|
||||
### 1. Общий слой shell
|
||||
|
||||
Нужен общий контракт боковой карточки, который умеет:
|
||||
- sidebar-режим
|
||||
- full-screen режим
|
||||
- общий overlay и поведение закрытия
|
||||
- общий header slot
|
||||
- общий body slot
|
||||
|
||||
### 2. Отдельный adapter для `Внешних контуров`
|
||||
|
||||
Нужен внешний adapter, который подает в shell:
|
||||
- title
|
||||
- служебные действия
|
||||
- доступные действия меню
|
||||
- состояние source-only или target-access
|
||||
- body контент внешнего контура
|
||||
|
||||
### 3. Отдельный action set
|
||||
|
||||
Нельзя слепо использовать меню обычной задачи.
|
||||
|
||||
Нужно отдельное правило доступных действий для внешнего контура:
|
||||
- закрыть
|
||||
- fullscreen
|
||||
- layout toggle
|
||||
- subscribe/unsubscribe
|
||||
- copy link
|
||||
- `...`
|
||||
|
||||
При этом `...` для внешнего контура должен быть ограничен собственным набором действий и не обязан повторять delete-flow внутреннего рабочего элемента.
|
||||
|
||||
## Риски, которые надо избежать
|
||||
|
||||
### 1. Смешение доменных сущностей
|
||||
|
||||
Если внешний запрос начать вести как обычный `Issue`, то быстро сломаются:
|
||||
- source-side права
|
||||
- логика bridge
|
||||
- роутинг действий `Принять / Отклонить`
|
||||
- зеркальная история
|
||||
|
||||
### 2. Дублирование shell-кода
|
||||
|
||||
Если сделать второй независимый peek-shell только для `Внешних контуров`, то дальше появятся:
|
||||
- рассинхрон верхних action-bar
|
||||
- повторная поддержка полноэкранного режима
|
||||
- повторная поддержка keyboard и close behavior
|
||||
|
||||
### 3. Преждевременная универсальная платформа
|
||||
|
||||
Не нужно ради одного шага строить абстрактный UI-framework на все возможные будущие сущности.
|
||||
|
||||
Нужен только тот общий контракт, который уже доказан двумя режимами:
|
||||
- `Внутренний контур`
|
||||
- `Внешние контуры`
|
||||
|
||||
## Критерий приемки
|
||||
|
||||
- карточка `Внешних контуров` открывается по тому же UX-паттерну, что и во `Внутреннем контуре`
|
||||
- верхняя панель действий выглядит и ведет себя единообразно
|
||||
- body карточки остается внешнеконтурным
|
||||
- source-only сценарий не ломается
|
||||
- без прямого доступа в target project пользователь все равно видит рабочую карточку
|
||||
|
||||
## Зависимость следующего шага
|
||||
|
||||
Этот шаг желательно сделать до полной двусторонней доски.
|
||||
|
||||
Причина простая:
|
||||
- доска `Исходящие / Входящие` увеличит число точек входа в карточку
|
||||
- если shell не унифицирован заранее, новый board-layer закрепит текущую раздвоенную архитектуру
|
||||
|
||||
Следующий связанный шаг описан в:
|
||||
- [12_STEP_external-contours-bidirectional-board.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/12_STEP_external-contours-bidirectional-board.md)
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
# Шаг 12. Двусторонняя доска внешних контуров
|
||||
|
||||
## Главная продуктовая задача
|
||||
|
||||
Во `Внешних контурах` пользователь должен видеть не только прилетевшие или уже принятые задачи, но и те запросы, которые его проект сам отправил в другие контуры.
|
||||
|
||||
Без этого теряется управляемость:
|
||||
- пользователь отправляет запрос
|
||||
- но не может нормально наблюдать его как отдельную рабочую сущность
|
||||
- и не получает полноценный рабочий экран для исходящих запросов
|
||||
|
||||
Поэтому следующий обязательный этап — двусторонняя доска.
|
||||
|
||||
## Обязательный состав первой версии
|
||||
|
||||
В первой версии должны быть только две системные зоны:
|
||||
- `Исходящие`
|
||||
- `Входящие`
|
||||
|
||||
Это фиксированные рабочие представления, а не свободный конструктор колонок.
|
||||
|
||||
## Что означает каждая зона
|
||||
|
||||
### Исходящие
|
||||
|
||||
Запросы, которые текущий проект отправил в другие контуры.
|
||||
|
||||
Для них пользователь должен видеть:
|
||||
- текущий статус исполнения
|
||||
- целевой контур
|
||||
- назначенного
|
||||
- последнее изменение
|
||||
- признаки новых изменений
|
||||
|
||||
### Входящие
|
||||
|
||||
Запросы, которые пришли в текущий проект из других контуров.
|
||||
|
||||
Они должны оставаться видимыми во `Внешних контурах` как отдельный контекст межконтурной работы, даже если фактическая работа по исполнению идет во `Внутреннем контуре`.
|
||||
|
||||
Это не отменяет текущий lifecycle появления задачи во `Внутреннем контуре`.
|
||||
|
||||
Наоборот, это добавляет отдельный рабочий слой наблюдения и управления межконтурным потоком.
|
||||
|
||||
## Обязательное ограничение
|
||||
|
||||
Во `Внешних контурах` нельзя реализовывать перетаскивание карточек между блоками.
|
||||
|
||||
Причина:
|
||||
- блоки здесь не равны стадиям workflow
|
||||
- это не kanban статусов
|
||||
- это представления по направлению и фильтрам
|
||||
|
||||
Следовательно:
|
||||
- никаких drag-and-drop переходов между `Исходящими` и `Входящими`
|
||||
- никаких попыток “перетащить” запрос в другой блок как способ смены состояния
|
||||
|
||||
Смена состояния остается доменной операцией, а не визуальным переносом.
|
||||
|
||||
## Требования к фильтрации
|
||||
|
||||
Обе системные зоны должны поддерживать гибкую фильтрацию по аналогии с `Внутренним контуром`.
|
||||
|
||||
Минимально нужно уметь фильтровать:
|
||||
- по пользователям
|
||||
- по назначенным
|
||||
- по автору
|
||||
- по статусам
|
||||
- по связанным контурам или проектам
|
||||
- по приоритету
|
||||
- по срокам и датам
|
||||
|
||||
Если часть фильтров недоступна на первой итерации из-за backend-модели, это нужно закрывать адаптером или агрегированным endpoint, а не урезать целевой продуктовый контракт.
|
||||
|
||||
## Текущее архитектурное препятствие
|
||||
|
||||
На сегодня `Исходящие` и `Входящие` живут в разных подсистемах:
|
||||
- source-side отправленные запросы приходят из модуля `external-contours`
|
||||
- входящие рабочие объекты и bridge-история живут вокруг `intake` и связанной target issue
|
||||
|
||||
Поэтому полноценная двусторонняя доска не собирается простым склеиванием текущего UI.
|
||||
|
||||
Нужен единый слой представления данных.
|
||||
|
||||
## Рекомендуемое архитектурное решение
|
||||
|
||||
### 1. Не собирать эту доску фронтовыми хаками
|
||||
|
||||
Можно временно склеить две разные выдачи на frontend, но это быстро упрется в:
|
||||
- сортировки
|
||||
- пагинацию
|
||||
- счетчики
|
||||
- консистентность фильтров
|
||||
- unread-индикаторы
|
||||
|
||||
Поэтому базовый путь должен вести к агрегированному data contract.
|
||||
|
||||
### 2. Ввести единый board-level view model
|
||||
|
||||
Нужна единая проекция, условно:
|
||||
- `direction`
|
||||
- `source_project`
|
||||
- `target_project`
|
||||
- `status`
|
||||
- `assignee`
|
||||
- `created_by`
|
||||
- `updated_at`
|
||||
- `unread`
|
||||
- `target_issue_id`
|
||||
- `external_request_id`
|
||||
|
||||
Этот view model не обязан ломать существующие доменные сущности.
|
||||
|
||||
Он нужен как стабильный контракт уровня доски.
|
||||
|
||||
Детализация этого контракта вынесена отдельно в:
|
||||
- [13_STEP_external-contours-board-data-contract.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/13_STEP_external-contours-board-data-contract.md)
|
||||
|
||||
### 3. Сделать board как фиксированные системные представления
|
||||
|
||||
Первая версия не должна строиться как свободный пользовательский конструктор.
|
||||
|
||||
Нужны два системных блока:
|
||||
- блок `Исходящие`
|
||||
- блок `Входящие`
|
||||
|
||||
И только поверх этого позже можно добавлять дополнительные пользовательские представления.
|
||||
|
||||
## Как не уйти в крайности
|
||||
|
||||
### Плохой путь 1
|
||||
|
||||
Сделать поверх текущего экрана несколько разрозненных переоберток и локальных фильтров.
|
||||
|
||||
Результат:
|
||||
- быстрое накопление технического долга
|
||||
- отсутствие общего контракта
|
||||
- трудная интеграция следующих board-type модулей
|
||||
|
||||
### Плохой путь 2
|
||||
|
||||
Остановить проект и начать строить универсальную платформу на все будущие доски сразу.
|
||||
|
||||
Результат:
|
||||
- высокий срок поставки
|
||||
- риск поломать текущий runtime
|
||||
- потеря фокуса на реальной продуктовой потребности
|
||||
|
||||
### Рабочий путь
|
||||
|
||||
Сделать ограниченный, но расширяемый board contract именно для межконтурных задач:
|
||||
- фиксированные системные зоны
|
||||
- единый тип board item
|
||||
- единый фильтровый слой
|
||||
- переиспользуемый detail-shell из шага 11
|
||||
|
||||
## Связь с будущими пользовательскими колонками
|
||||
|
||||
Пользовательские представления нужны, но не должны быть частью первой двусторонней доски.
|
||||
|
||||
Их нужно проектировать как следующий слой поверх уже введенного board contract:
|
||||
- пользователь создает свой блок
|
||||
- задает имя
|
||||
- задает фильтр
|
||||
- получает еще одно представление рядом с системными блоками
|
||||
|
||||
Но это именно представление.
|
||||
|
||||
Это не новая стадия исполнения и не область для drag-and-drop.
|
||||
|
||||
## Связь с будущими типами досок
|
||||
|
||||
Архитектура этого шага должна допускать расширение на:
|
||||
- агентные доски
|
||||
- специализированные operational boards
|
||||
- гибридные наблюдательные представления по нескольким контурам
|
||||
|
||||
Из этого следуют два правила:
|
||||
- доска должна собираться вокруг контракта представления, а не вокруг одной конкретной сущности `Issue`
|
||||
- колонка должна означать выборку или представление, а не обязательно статус workflow
|
||||
|
||||
## Критерий приемки
|
||||
|
||||
- пользователь видит во `Внешних контурах` две системные зоны: `Исходящие` и `Входящие`
|
||||
- отправленные запросы не теряются после отправки и остаются видимыми как рабочие объекты
|
||||
- обе зоны фильтруются через единый механизм
|
||||
- открытие карточки из любой зоны ведет в единый shell шага 11
|
||||
- между зонами нет drag-and-drop
|
||||
|
||||
## Что идет следующим шагом после этого
|
||||
|
||||
После стабилизации двусторонней доски можно переходить к пользовательским представлениям поверх того же контракта.
|
||||
|
||||
Но только после того, как:
|
||||
- подтверждена стабильность системных зон
|
||||
- подтверждена консистентность фильтров
|
||||
- подтверждена работа detail-shell в обоих направлениях
|
||||
|
||||
Связанный документ:
|
||||
- [11_STEP_external-contours-detail-shell.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/11_STEP_external-contours-detail-shell.md)
|
||||
- [13_STEP_external-contours-board-data-contract.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/13_STEP_external-contours-board-data-contract.md)
|
||||
|
|
@ -0,0 +1,460 @@
|
|||
# Шаг 13. Технический контракт двусторонней доски внешних контуров
|
||||
|
||||
## Зачем нужен отдельный технический шаг
|
||||
|
||||
Продуктовое решение по двусторонней доске уже зафиксировано:
|
||||
- нужны `Исходящие`
|
||||
- нужны `Входящие`
|
||||
- обе зоны должны фильтроваться единообразно
|
||||
- открытие карточки должно вести в общий shell
|
||||
- drag-and-drop между зонами не нужен и не должен появиться
|
||||
|
||||
Но текущая реализация проекта не дает собрать это одной UI-переоберткой.
|
||||
|
||||
Нужен отдельный технический контракт уровня board-layer.
|
||||
|
||||
## Что есть в коде сейчас
|
||||
|
||||
### 1. `external-contours` сейчас source-side only
|
||||
|
||||
Текущий endpoint:
|
||||
- `GET /api/workspaces/:slug/projects/:project_id/external-contours/`
|
||||
|
||||
Сейчас возвращает только записи, где:
|
||||
- `extra.bridge = external-contours`
|
||||
- `extra.source_project_id = current project`
|
||||
|
||||
То есть это только исходящие запросы проекта-источника.
|
||||
|
||||
Следствие:
|
||||
- текущий список не умеет входящие
|
||||
- текущий detail endpoint тоже source-side only
|
||||
|
||||
### 2. Detail endpoint тоже завязан только на перспективу источника
|
||||
|
||||
Текущий detail:
|
||||
- `GET /api/workspaces/:slug/projects/:project_id/external-contours/:request_id/`
|
||||
|
||||
Он тоже фильтрует по `extra.source_project_id = current project`.
|
||||
|
||||
Следствие:
|
||||
- из проекта-цели нельзя открыть ту же сущность через тот же endpoint
|
||||
- двусторонняя доска не может использовать один и тот же detail flow без нового контракта доступа
|
||||
|
||||
### 3. Frontend store внешних контуров слишком узкий
|
||||
|
||||
Текущий `ProjectExternalContoursStore` умеет:
|
||||
- список запросов
|
||||
- вкладки `open / closed`
|
||||
- create
|
||||
- update
|
||||
- source-side actions `accept / decline / reply`
|
||||
|
||||
Но он не умеет:
|
||||
- фильтры по пользователям, статусам, датам и проектам
|
||||
- сортировки
|
||||
- две зоны в одной модели
|
||||
- отдельные cursors по зонам
|
||||
|
||||
### 4. Входящая сторона живет в другом модуле
|
||||
|
||||
Входящие рабочие объекты сейчас живут в `intake`:
|
||||
- у них свой store
|
||||
- свой фильтровый тип
|
||||
- своя пагинация
|
||||
- своя модель выдачи
|
||||
|
||||
То есть сегодня `Исходящие` и `Входящие` — это две разные подсистемы.
|
||||
|
||||
### 5. Существующий `views` слой нельзя просто взять как есть
|
||||
|
||||
Слой `ProjectView` уже умеет:
|
||||
- rich filters
|
||||
- display filters
|
||||
- display properties
|
||||
|
||||
Но он привязан к обычным `Issue` layouts проекта.
|
||||
|
||||
Он не является готовой моделью для межконтурной доски, потому что:
|
||||
- колонка там означает layout/grouping issues
|
||||
- а в `Внешних контурах` колонка должна означать направление или выборку
|
||||
- и сама сущность там не сводится к plain `Issue`
|
||||
|
||||
## Технический вывод
|
||||
|
||||
Новая доска должна строиться не вокруг существующего `Issue board`, а вокруг отдельного board contract для сущности `External Contour Request`.
|
||||
|
||||
При этом сам доменный источник правды можно по-прежнему держать на `IntakeIssue + bridge metadata`.
|
||||
|
||||
## Что считаем единицей доски
|
||||
|
||||
Единица двусторонней доски — это не обычный `Issue`.
|
||||
|
||||
Единица доски — это `External Contour Board Item`, то есть проекция bridge-сущности на UI-слой внешнего контура.
|
||||
|
||||
Базовый идентификатор:
|
||||
- `request_id = IntakeIssue.id`
|
||||
|
||||
Это важно, потому что:
|
||||
- исходящий и входящий сценарии должны указывать на одну и ту же межконтурную сущность
|
||||
- detail-shell должен открываться по одному стабильному id
|
||||
|
||||
## Целевой frontend type contract
|
||||
|
||||
```ts
|
||||
type TExternalContourBoardDirection = "outgoing" | "incoming";
|
||||
|
||||
type TExternalContourBoardStatus = "open" | "closed";
|
||||
|
||||
type TExternalContourBoardItem = {
|
||||
id: string;
|
||||
direction: TExternalContourBoardDirection;
|
||||
status: TExternalContourBoardStatus;
|
||||
has_unread_updates: boolean;
|
||||
requested_at: string | null;
|
||||
updated_at: string;
|
||||
source_decision?: "accepted" | null;
|
||||
issue: {
|
||||
id: string;
|
||||
sequence_id: number | null;
|
||||
name: string;
|
||||
description_html?: string | null;
|
||||
priority?: "low" | "medium" | "high" | "urgent" | "none";
|
||||
target_date?: string | null;
|
||||
state_id?: string | null;
|
||||
state_detail?: {
|
||||
id: string;
|
||||
name: string;
|
||||
group: string;
|
||||
color: string;
|
||||
} | null;
|
||||
assignee_details?: {
|
||||
id: string;
|
||||
display_name: string;
|
||||
avatar_url?: string | null;
|
||||
}[];
|
||||
created_by_detail?: {
|
||||
id: string;
|
||||
display_name: string;
|
||||
avatar_url?: string | null;
|
||||
} | null;
|
||||
label_details?: {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}[];
|
||||
};
|
||||
source_project: {
|
||||
id: string;
|
||||
identifier?: string | null;
|
||||
name: string;
|
||||
logo_props?: unknown;
|
||||
} | null;
|
||||
target_project: {
|
||||
id: string;
|
||||
identifier?: string | null;
|
||||
name: string;
|
||||
logo_props?: unknown;
|
||||
} | null;
|
||||
requested_by: {
|
||||
id: string | null;
|
||||
display_name: string | null;
|
||||
} | null;
|
||||
capabilities: {
|
||||
can_open_detail: boolean;
|
||||
can_open_target_issue: boolean;
|
||||
can_edit_request: boolean;
|
||||
can_reply: boolean;
|
||||
can_source_decide: boolean;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
## Целевой filter contract
|
||||
|
||||
Это отдельный filter layer для board-level представления, а не копия `inbox` и не копия `project views`.
|
||||
|
||||
```ts
|
||||
type TExternalContourBoardFilter = {
|
||||
direction?: TExternalContourBoardDirection[];
|
||||
status?: ("open" | "closed")[];
|
||||
state_ids?: string[];
|
||||
priority?: ("low" | "medium" | "high" | "urgent" | "none")[];
|
||||
assignee_ids?: string[];
|
||||
created_by_ids?: string[];
|
||||
requested_by_ids?: string[];
|
||||
source_project_ids?: string[];
|
||||
target_project_ids?: string[];
|
||||
label_ids?: string[];
|
||||
has_unread_updates?: boolean;
|
||||
created_at?: string[];
|
||||
updated_at?: string[];
|
||||
target_date?: string[];
|
||||
search?: string;
|
||||
};
|
||||
|
||||
type TExternalContourBoardSorting = {
|
||||
order_by?: "requested_at" | "updated_at" | "issue__sequence_id" | "target_date";
|
||||
sort_by?: "asc" | "desc";
|
||||
};
|
||||
```
|
||||
|
||||
### Почему фильтры именно такие
|
||||
|
||||
- `direction` нужен для будущих пользовательских представлений
|
||||
- `status` нужен для рабочего деления `open / closed`
|
||||
- `state_ids`, `assignee_ids`, `created_by_ids`, `label_ids`, `priority` повторяют рабочую логику внутренних контуров
|
||||
- `source_project_ids` и `target_project_ids` нужны именно для межконтурной аналитики и наблюдения
|
||||
- `requested_by_ids` нужен отдельно, потому что внешний инициатор и внутренний `created_by` обычной задачи не всегда совпадают по смыслу
|
||||
- `has_unread_updates` нужен для рабочего мониторинга
|
||||
|
||||
## Целевой backend API
|
||||
|
||||
### 1. Board list endpoint
|
||||
|
||||
Нужен отдельный endpoint уровня доски:
|
||||
|
||||
`GET /api/workspaces/:slug/projects/:project_id/external-contours/board/`
|
||||
|
||||
Он не должен заменять текущий source-side list сразу.
|
||||
|
||||
Он должен стать новым контрактом именно для двусторонней доски.
|
||||
|
||||
### Query params
|
||||
|
||||
```text
|
||||
direction=outgoing,incoming
|
||||
status=open,closed
|
||||
state_ids=<uuid>,<uuid>
|
||||
priority=urgent,high
|
||||
assignee_ids=<uuid>,<uuid>
|
||||
created_by_ids=<uuid>,<uuid>
|
||||
requested_by_ids=<uuid>,<uuid>
|
||||
source_project_ids=<uuid>,<uuid>
|
||||
target_project_ids=<uuid>,<uuid>
|
||||
label_ids=<uuid>,<uuid>
|
||||
has_unread_updates=true
|
||||
created_at=<range>,<range>
|
||||
updated_at=<range>,<range>
|
||||
target_date=<range>,<range>
|
||||
search=<string>
|
||||
order_by=updated_at
|
||||
sort_by=desc
|
||||
outgoing_cursor=<cursor>
|
||||
incoming_cursor=<cursor>
|
||||
per_page=20
|
||||
```
|
||||
|
||||
### Response shape
|
||||
|
||||
```json
|
||||
{
|
||||
"filters": {
|
||||
"direction": ["outgoing", "incoming"],
|
||||
"status": ["open"],
|
||||
"assignee_ids": [],
|
||||
"source_project_ids": []
|
||||
},
|
||||
"sorting": {
|
||||
"order_by": "updated_at",
|
||||
"sort_by": "desc"
|
||||
},
|
||||
"columns": [
|
||||
{
|
||||
"key": "outgoing",
|
||||
"title": "Исходящие",
|
||||
"total_count": 12,
|
||||
"next_cursor": "abc",
|
||||
"results": []
|
||||
},
|
||||
{
|
||||
"key": "incoming",
|
||||
"title": "Входящие",
|
||||
"total_count": 7,
|
||||
"next_cursor": "def",
|
||||
"results": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Почему один endpoint на обе зоны
|
||||
|
||||
Потому что это дает:
|
||||
- единый filter contract
|
||||
- единые count values
|
||||
- единый sorting contract
|
||||
- отсутствие расхождений между двумя независимыми загрузками
|
||||
|
||||
При этом пагинация должна быть раздельной:
|
||||
- отдельный cursor для `outgoing`
|
||||
- отдельный cursor для `incoming`
|
||||
|
||||
## Целевой detail contract
|
||||
|
||||
Нужен новый detail endpoint с перспективой доступа, а не только source-side read.
|
||||
|
||||
Рекомендуемый вариант:
|
||||
|
||||
`GET /api/workspaces/:slug/projects/:project_id/external-contours/board-items/:request_id/`
|
||||
|
||||
Контракт должен сам определить perspective относительно текущего `project_id`:
|
||||
- если проект совпадает с `source_project_id` — это `outgoing`
|
||||
- если проект совпадает с `target_project_id` или с проектом bridge issue — это `incoming`
|
||||
|
||||
Следствие:
|
||||
- detail-shell открывается по одному route-contract
|
||||
- входящая зона не должна притворяться source-side проектом только ради открытия карточки
|
||||
|
||||
### Что важно не ломать
|
||||
|
||||
Текущие source-side mutation endpoints можно оставить отдельными:
|
||||
- update
|
||||
- decision
|
||||
- reply
|
||||
|
||||
Но read-model detail для board должен быть общим.
|
||||
|
||||
## Рекомендуемая модель backend aggregation
|
||||
|
||||
### Базовый источник правды
|
||||
|
||||
Базовая сущность не меняется:
|
||||
- `IntakeIssue`
|
||||
- `Issue`
|
||||
- `extra.bridge = external-contours`
|
||||
|
||||
### Агрегация по направлениям
|
||||
|
||||
`outgoing`:
|
||||
- `extra.source_project_id = current project`
|
||||
|
||||
`incoming`:
|
||||
- `project_id = current project`
|
||||
- `extra.bridge = external-contours`
|
||||
|
||||
### Нормализованный serializer
|
||||
|
||||
Нужен отдельный serializer уровня board item.
|
||||
|
||||
Он должен:
|
||||
- выдавать `direction`
|
||||
- выдавать обе project references
|
||||
- отдавать capabilities
|
||||
- отдавать status и has_unread_updates
|
||||
- нормализовать issue-поля в один и тот же формат для обеих сторон
|
||||
|
||||
## Frontend store design
|
||||
|
||||
Рекомендуется не расширять бесконечно текущий `ProjectExternalContoursStore`.
|
||||
|
||||
Нужен отдельный store:
|
||||
|
||||
`ProjectExternalContoursBoardStore`
|
||||
|
||||
### Что он хранит
|
||||
|
||||
- board filters
|
||||
- board sorting
|
||||
- map items by id
|
||||
- columns:
|
||||
- `outgoing`
|
||||
- `incoming`
|
||||
- per-column cursor
|
||||
- per-column total_count
|
||||
- selected board item id
|
||||
- board loader states
|
||||
|
||||
### Что остается в текущем store
|
||||
|
||||
Текущий `ProjectExternalContoursStore` можно временно оставить для:
|
||||
- create request
|
||||
- source-side update
|
||||
- source-side decision
|
||||
- source-side reply
|
||||
- target project options
|
||||
|
||||
То есть board store отвечает за read-model, а существующий store пока отвечает за mutations.
|
||||
|
||||
Это позволит не ломать уже рабочий runtime одним большим переносом.
|
||||
|
||||
## UI contract
|
||||
|
||||
### 1. Доска состоит из фиксированных системных колонок
|
||||
|
||||
- колонка `Исходящие`
|
||||
- колонка `Входящие`
|
||||
- дальше уже поверх них могут появиться пользовательские представления
|
||||
|
||||
### 2. Колонка — это выборка, а не workflow stage
|
||||
|
||||
Следовательно:
|
||||
- нет drag handle
|
||||
- нет `onDrop`
|
||||
- нет optimistic move между колонками
|
||||
|
||||
### 3. Клик по карточке ведет в shell шага 11
|
||||
|
||||
Детали должны открываться через общий shell, а не через отдельный старый source-only right pane.
|
||||
|
||||
## Что сознательно не делаем на этом шаге
|
||||
|
||||
### 1. Не склеиваем две выдачи в браузере
|
||||
|
||||
Это проигрышно из-за:
|
||||
- разных сортировок
|
||||
- разных count values
|
||||
- разных filter models
|
||||
- разных правил пагинации
|
||||
|
||||
### 2. Не превращаем board в kanban по статусам
|
||||
|
||||
Это не тот продуктовый сценарий.
|
||||
|
||||
### 3. Не переносим пользовательские колонки в первый технический контракт
|
||||
|
||||
Для начала нужен стабильный системный board layer.
|
||||
|
||||
Пользовательские представления должны появиться только поверх уже работающего fixed contract.
|
||||
|
||||
## Порядок реализации
|
||||
|
||||
### Шаг A. Backend contract
|
||||
|
||||
- board list endpoint
|
||||
- common board item serializer
|
||||
- detail read endpoint для обеих перспектив
|
||||
- filter parsing
|
||||
- per-column pagination
|
||||
|
||||
### Шаг B. Frontend types and services
|
||||
|
||||
- новые types для board item, filters, sorting, response
|
||||
- новый service layer
|
||||
- новый board store
|
||||
|
||||
### Шаг C. UI board shell
|
||||
|
||||
- fixed `Исходящие / Входящие`
|
||||
- фильтровая пипка
|
||||
- counts
|
||||
- открытие detail-shell
|
||||
|
||||
### Шаг D. Cut-over
|
||||
|
||||
- перевод `Внешних контуров` со старого sidebar/list режима на board mode
|
||||
- сохранение работающих mutation flows
|
||||
- последующее упрощение legacy source-only списка
|
||||
|
||||
## Критерий приемки технического шага
|
||||
|
||||
- у команды есть конкретный API contract для двусторонней доски
|
||||
- у команды есть отдельный frontend type contract, не завязанный на plain `Issue`
|
||||
- у detail-shell есть единый read endpoint для `incoming` и `outgoing`
|
||||
- документ прямо фиксирует отказ от frontend-склейки и drag-and-drop
|
||||
|
||||
## Связанные документы
|
||||
|
||||
- [11_STEP_external-contours-detail-shell.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/11_STEP_external-contours-detail-shell.md)
|
||||
- [12_STEP_external-contours-bidirectional-board.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/12_STEP_external-contours-bidirectional-board.md)
|
||||
- [phase-roadmap.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/phase-roadmap.md)
|
||||
|
|
@ -116,6 +116,77 @@
|
|||
|
||||
На этом месте разработка следующего шага останавливается до фиксации продуктовых решений.
|
||||
|
||||
## Зафиксированные продуктовые решения на 2026-04-20
|
||||
|
||||
После freeze point приняты дополнительные продуктовые решения по следующему циклу развития `Внешних контуров`.
|
||||
|
||||
### 1. Нужны две обязательные системные зоны
|
||||
|
||||
Во `Внешних контурах` должны появиться:
|
||||
- `Исходящие`
|
||||
- `Входящие`
|
||||
|
||||
`Исходящие` обязательны, потому что пользователь должен видеть запросы, которые он сам отправил в другие контуры, до и после обработки.
|
||||
|
||||
`Входящие` обязательны, потому что проект должен видеть запросы, пришедшие к нему из других контуров, в отдельном специализированном режиме работы, а не только как обычные задачи `Внутреннего контура`.
|
||||
|
||||
### 2. Обе зоны должны фильтроваться так же гибко, как во `Внутреннем контуре`
|
||||
|
||||
Минимальный целевой принцип:
|
||||
- фильтрация по пользователям
|
||||
- фильтрация по статусам
|
||||
- фильтрация по назначенным
|
||||
- фильтрация по автору
|
||||
- фильтрация по контурам и связанным проектам
|
||||
|
||||
То есть `Внешние контуры` должны получить не просто статичный список, а управляемое рабочее представление.
|
||||
|
||||
### 3. Карточка деталей внешнего контура должна перейти на тот же UX-паттерн, что и во `Внутреннем контуре`
|
||||
|
||||
При клике по карточке должна открываться панель того же класса:
|
||||
- закрыть просмотр
|
||||
- открыть на весь экран
|
||||
- переключить макет
|
||||
- подписка или отписка
|
||||
- копирование ссылки
|
||||
- меню дополнительных действий
|
||||
|
||||
При этом:
|
||||
- действие удаления в этом shell не является обязательным
|
||||
- тело карточки остается внешнеконтурным, а не превращается в обычную карточку внутренней задачи
|
||||
|
||||
### 4. Внешний контур — это не workflow-kanban
|
||||
|
||||
Важно зафиксировать заранее:
|
||||
- пользователь не должен перетаскивать задачи между блоками
|
||||
- блоки во `Внешних контурах` — это представления и выборки, а не стадии исполнения
|
||||
- нельзя переносить сюда механику drag-and-drop из `Внутреннего контура`
|
||||
|
||||
### 5. Пользовательские колонки нужны, но не входят в ближайший обязательный срез
|
||||
|
||||
Собственные пользовательские блоки и сортировки нужны как следующий этап развития, но не должны размыть ближайшие обязательные поставки:
|
||||
1. единый shell карточки внешнего контура
|
||||
2. двусторонняя доска `Исходящие / Входящие`
|
||||
|
||||
### 6. Архитектура должна остаться расширяемой
|
||||
|
||||
Следующий слой развития не ограничивается только внешними контурами.
|
||||
|
||||
Дальше в проекте могут появиться:
|
||||
- новые типы досок
|
||||
- агентные доски
|
||||
- специализированные мониторинговые представления
|
||||
- пользовательские рабочие поверхности под разные роли
|
||||
|
||||
Поэтому нельзя решать следующий этап только переобертками и точечными хаками.
|
||||
|
||||
Но и полный демонтаж текущего проекта ради абстрактной платформы тоже недопустим.
|
||||
|
||||
Рабочий принцип:
|
||||
- не ломать текущий runtime
|
||||
- не дублировать уже существующие механики без причины
|
||||
- выносить только те контракты, которые реально переиспользуются дальше
|
||||
|
||||
## Что нужно решить перед продолжением
|
||||
|
||||
- Что именно делает действие `Принять`:
|
||||
|
|
@ -415,4 +486,7 @@
|
|||
- определение границ MVP
|
||||
|
||||
Подробная поэтапная разработка описана в:
|
||||
- [phase-roadmap.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/cross-project-task-routing/phase-roadmap.md)
|
||||
- [phase-roadmap.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/phase-roadmap.md)
|
||||
- [11_STEP_external-contours-detail-shell.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/11_STEP_external-contours-detail-shell.md)
|
||||
- [12_STEP_external-contours-bidirectional-board.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/12_STEP_external-contours-bidirectional-board.md)
|
||||
- [13_STEP_external-contours-board-data-contract.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/13_STEP_external-contours-board-data-contract.md)
|
||||
|
|
@ -27,6 +27,22 @@
|
|||
|
||||
Дальше по roadmap пока не идем, пока не приняты продуктовые решения по внутреннему жизненному циклу принятого запроса.
|
||||
|
||||
## Новая рамка после freeze point на 2026-04-20
|
||||
|
||||
После дополнительной продуктовой фиксации следующий цикл делится на три слоя:
|
||||
1. единый shell карточки внешнего контура
|
||||
2. двусторонняя доска `Исходящие / Входящие`
|
||||
3. пользовательские представления поверх той же доски
|
||||
|
||||
При этом обязательное архитектурное ограничение фиксируется заранее:
|
||||
- во `Внешних контурах` нет drag-and-drop между блоками
|
||||
- блоки здесь означают представления, а не стадии workflow
|
||||
|
||||
Подробные архитектурные шаги вынесены в:
|
||||
- [11_STEP_external-contours-detail-shell.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/11_STEP_external-contours-detail-shell.md)
|
||||
- [12_STEP_external-contours-bidirectional-board.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/12_STEP_external-contours-bidirectional-board.md)
|
||||
- [13_STEP_external-contours-board-data-contract.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/13_STEP_external-contours-board-data-contract.md)
|
||||
|
||||
## Этап 0. Термины и навигация
|
||||
|
||||
### Цель
|
||||
|
|
@ -236,6 +252,123 @@
|
|||
- тест-кейсы
|
||||
- регламент эксплуатации
|
||||
|
||||
## Этап 6. Единый shell карточки внешнего контура
|
||||
|
||||
### Цель
|
||||
|
||||
Привести карточку деталей `Внешних контуров` к тому же UX-паттерну, что и во `Внутреннем контуре`, не ломая при этом внешний data-flow.
|
||||
|
||||
### Что входит
|
||||
|
||||
- общий shell открытия карточки
|
||||
- тот же класс верхнего action-bar:
|
||||
- закрытие просмотра
|
||||
- полноэкранный режим
|
||||
- переключение макета
|
||||
- подписка или отписка
|
||||
- копирование ссылки
|
||||
- меню дополнительных действий
|
||||
- переиспользование общего представления shell без подмены доменной модели внешнего контура
|
||||
- сохранение внешнеконтурного body:
|
||||
- `Маршрутизация`
|
||||
- зеркальные комментарии
|
||||
- зеркальные вложения
|
||||
- external lifecycle
|
||||
|
||||
### Что не входит
|
||||
|
||||
- две системные зоны `Исходящие / Входящие`
|
||||
- пользовательские колонки
|
||||
- drag-and-drop
|
||||
- насильственный перевод внешнего запроса в обычный issue detail
|
||||
|
||||
### Критерий приемки
|
||||
|
||||
- карточка `Внешних контуров` открывается по тому же UX-паттерну, что и внутренний peek
|
||||
- source-only сценарий не ломается
|
||||
- body карточки остается внешнеконтурным
|
||||
|
||||
### Статус
|
||||
|
||||
Запланировано.
|
||||
|
||||
Подробно описано в:
|
||||
- [11_STEP_external-contours-detail-shell.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/11_STEP_external-contours-detail-shell.md)
|
||||
|
||||
## Этап 7. Двусторонняя доска `Исходящие / Входящие`
|
||||
|
||||
### Цель
|
||||
|
||||
Дать проекту единый рабочий экран межконтурной работы, где видны и отправленные, и полученные запросы.
|
||||
|
||||
### Что входит
|
||||
|
||||
- две фиксированные системные зоны:
|
||||
- `Исходящие`
|
||||
- `Входящие`
|
||||
- единый board-level data contract
|
||||
- фильтрация обеих зон по аналогии с `Внутренним контуром`
|
||||
- счетчики
|
||||
- открытие карточки в shell этапа 6
|
||||
|
||||
### Важное правило
|
||||
|
||||
Это не kanban статусов.
|
||||
|
||||
Во `Внешних контурах`:
|
||||
- нет drag-and-drop между блоками
|
||||
- блоки означают представления по направлению и фильтрам
|
||||
- смена состояния не должна кодироваться визуальным переносом карточки
|
||||
|
||||
### Что не входит
|
||||
|
||||
- пользовательские кастомные зоны
|
||||
- свободный конструктор колонок
|
||||
- превращение доски во второй workflow движок
|
||||
|
||||
### Критерий приемки
|
||||
|
||||
- пользователь видит и `Исходящие`, и `Входящие`
|
||||
- отправленные запросы не теряются после отправки
|
||||
- обе системные зоны фильтруются через единый механизм
|
||||
- карточка из любой зоны открывается единообразно
|
||||
|
||||
### Статус
|
||||
|
||||
Запланировано.
|
||||
|
||||
Подробно описано в:
|
||||
- [12_STEP_external-contours-bidirectional-board.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/12_STEP_external-contours-bidirectional-board.md)
|
||||
- [13_STEP_external-contours-board-data-contract.md](/Users/dcconstructions/Downloads/mnt/data/dc_taskmanager/NODEDC_TASKMANAGER/docs_prod/1_STEP_cross-project-task-routing/13_STEP_external-contours-board-data-contract.md)
|
||||
|
||||
## Этап 8. Пользовательские представления поверх двусторонней доски
|
||||
|
||||
### Цель
|
||||
|
||||
Разрешить пользователю собирать собственные рабочие выборки поверх уже стабилизированной двусторонней доски.
|
||||
|
||||
### Что входит
|
||||
|
||||
- пользовательские блоки или представления
|
||||
- пользовательское имя блока
|
||||
- сохранение набора фильтров
|
||||
- персональные рабочие выборки по контурам, статусам, пользователям и другим признакам
|
||||
|
||||
### Важное правило
|
||||
|
||||
Пользовательские блоки не меняют базовый принцип:
|
||||
- это не стадии workflow
|
||||
- это не drag-and-drop
|
||||
- это не замена `Внутреннего контура`
|
||||
|
||||
### Зависимость
|
||||
|
||||
Этап начинается только после стабилизации этапов 6 и 7.
|
||||
|
||||
### Статус
|
||||
|
||||
Запланировано как следующий слой развития, но не входит в ближайший обязательный срез.
|
||||
|
||||
## Технические решения, которые желательно держать с самого начала
|
||||
|
||||
### 1. Не ломать штатный intake
|
||||
|
|
@ -54,6 +54,7 @@ from .intake import (
|
|||
IntakeIssueUpdateSerializer,
|
||||
)
|
||||
from .external_contours import (
|
||||
ExternalContourBoardItemSerializer,
|
||||
ExternalContourRequestCreateSerializer,
|
||||
ExternalContourRequestDecisionSerializer,
|
||||
ExternalContourRequestReplySerializer,
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ class ExternalContourRequestReplySerializer(serializers.Serializer):
|
|||
class ExternalContourRequestUpdateSerializer(serializers.Serializer):
|
||||
name = serializers.CharField(max_length=255, required=False)
|
||||
description_html = serializers.CharField(required=False, allow_blank=True, allow_null=True)
|
||||
priority = serializers.ChoiceField(choices=Issue.PRIORITY_CHOICES, required=False)
|
||||
assignee_ids = serializers.ListField(child=serializers.UUIDField(), required=False)
|
||||
label_ids = serializers.ListField(child=serializers.UUIDField(), required=False)
|
||||
state_id = serializers.UUIDField(required=False)
|
||||
target_date = serializers.DateField(required=False, allow_null=True)
|
||||
|
||||
def validate(self, data):
|
||||
if not data:
|
||||
|
|
@ -69,6 +74,7 @@ class ExternalContourTargetProjectSerializer(BaseSerializer):
|
|||
class ExternalContourTargetOptionsSerializer(serializers.Serializer):
|
||||
project = ExternalContourTargetProjectSerializer(read_only=True)
|
||||
member_ids = serializers.ListField(child=serializers.UUIDField(), read_only=True)
|
||||
states = StateLiteSerializer(many=True, read_only=True)
|
||||
labels = LabelSerializer(many=True, read_only=True)
|
||||
|
||||
|
||||
|
|
@ -212,6 +218,10 @@ class ExternalContourRequestSerializer(BaseSerializer):
|
|||
return obj.extra.get("source_project_id")
|
||||
|
||||
def get_has_unread_updates(self, obj):
|
||||
annotated_value = getattr(obj, "has_unread_updates_annotated", None)
|
||||
if annotated_value is not None:
|
||||
return annotated_value
|
||||
|
||||
request = self.context.get("request")
|
||||
user = getattr(request, "user", None)
|
||||
user_id = getattr(user, "id", None)
|
||||
|
|
@ -329,3 +339,96 @@ class ExternalContourRequestSerializer(BaseSerializer):
|
|||
if issue and issue.state and issue.state.group in ["completed", "cancelled"]:
|
||||
return "closed"
|
||||
return "open"
|
||||
|
||||
|
||||
class ExternalContourBoardItemSerializer(ExternalContourRequestSerializer):
|
||||
direction = serializers.SerializerMethodField()
|
||||
source_project = serializers.SerializerMethodField()
|
||||
target_project = serializers.SerializerMethodField()
|
||||
requested_by = serializers.SerializerMethodField()
|
||||
capabilities = serializers.SerializerMethodField()
|
||||
|
||||
class Meta(ExternalContourRequestSerializer.Meta):
|
||||
fields = ExternalContourRequestSerializer.Meta.fields + [
|
||||
"direction",
|
||||
"source_project",
|
||||
"target_project",
|
||||
"requested_by",
|
||||
"capabilities",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def _resolve_direction(self, obj):
|
||||
current_project_id = str(self.context.get("current_project_id") or "")
|
||||
source_project_id = str(obj.extra.get("source_project_id") or "")
|
||||
target_project_id = str(obj.extra.get("target_project_id") or obj.issue.project_id or "")
|
||||
|
||||
if current_project_id and current_project_id == source_project_id:
|
||||
return "outgoing"
|
||||
if current_project_id and current_project_id == target_project_id:
|
||||
return "incoming"
|
||||
return self.context.get("direction") or "outgoing"
|
||||
|
||||
def get_has_unread_updates(self, obj):
|
||||
unread_request_ids = self.context.get("unread_request_ids")
|
||||
if unread_request_ids is not None:
|
||||
return str(obj.id) in unread_request_ids
|
||||
return super().get_has_unread_updates(obj)
|
||||
|
||||
def _get_project_payload(self, project_id, fallback_name=None):
|
||||
if not project_id:
|
||||
return None
|
||||
|
||||
project = (self.context.get("project_map") or {}).get(str(project_id))
|
||||
return {
|
||||
"id": str(project_id),
|
||||
"identifier": getattr(project, "identifier", None),
|
||||
"name": getattr(project, "name", None) or fallback_name,
|
||||
"logo_props": getattr(project, "logo_props", None),
|
||||
}
|
||||
|
||||
def get_direction(self, obj):
|
||||
return self._resolve_direction(obj)
|
||||
|
||||
def get_source_project(self, obj):
|
||||
source_project_id = obj.extra.get("source_project_id")
|
||||
fallback_name = obj.extra.get("source_project_name")
|
||||
return self._get_project_payload(source_project_id, fallback_name=fallback_name)
|
||||
|
||||
def get_target_project(self, obj):
|
||||
target_project_id = obj.extra.get("target_project_id") or (str(obj.issue.project_id) if obj.issue_id else None)
|
||||
fallback_name = obj.extra.get("target_project_name") or (obj.issue.project.name if obj.issue and obj.issue.project else None)
|
||||
return self._get_project_payload(target_project_id, fallback_name=fallback_name)
|
||||
|
||||
def get_requested_by(self, obj):
|
||||
requested_by_id = obj.extra.get("requested_by_id") or (str(obj.created_by_id) if obj.created_by_id else None)
|
||||
requested_by_name = obj.extra.get("requested_by_name") or self.get_requested_by_name(obj)
|
||||
return {
|
||||
"id": requested_by_id,
|
||||
"display_name": requested_by_name,
|
||||
}
|
||||
|
||||
def get_capabilities(self, obj):
|
||||
request = self.context.get("request")
|
||||
user_id = str(getattr(getattr(request, "user", None), "id", "") or "")
|
||||
requested_by_id = str(obj.extra.get("requested_by_id") or obj.created_by_id or "")
|
||||
direction = self._resolve_direction(obj)
|
||||
issue_project_id = str(obj.issue.project_id) if obj.issue_id and obj.issue and obj.issue.project_id else ""
|
||||
member_project_ids = self.context.get("member_project_ids") or set()
|
||||
is_open_request = self.get_status(obj) == "open"
|
||||
|
||||
return {
|
||||
"can_open_detail": True,
|
||||
"can_open_target_issue": issue_project_id in member_project_ids,
|
||||
"can_edit_request": direction == "outgoing" and user_id == requested_by_id and is_open_request,
|
||||
"can_reply": direction == "outgoing" and bool(obj.issue_id),
|
||||
"can_source_decide": direction == "outgoing" and not is_open_request,
|
||||
}
|
||||
|
||||
def get_source_project_name(self, obj):
|
||||
source_project = self.get_source_project(obj)
|
||||
return source_project["name"] if source_project else None
|
||||
|
||||
def get_target_project_name(self, obj):
|
||||
target_project = self.get_target_project(obj)
|
||||
return target_project["name"] if target_project else None
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ from django.urls import path
|
|||
|
||||
from plane.app.views import (
|
||||
ExternalContourAttachmentDownloadEndpoint,
|
||||
ExternalContourBoardEndpoint,
|
||||
ExternalContourBoardItemDetailEndpoint,
|
||||
ExternalContourDetailEndpoint,
|
||||
ExternalContourDecisionEndpoint,
|
||||
ExternalContourListCreateEndpoint,
|
||||
|
|
@ -16,6 +18,16 @@ from plane.app.views import (
|
|||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/board/",
|
||||
ExternalContourBoardEndpoint.as_view(http_method_names=["get"]),
|
||||
name="external-contour-board",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/board-items/<uuid:request_id>/",
|
||||
ExternalContourBoardItemDetailEndpoint.as_view(http_method_names=["get"]),
|
||||
name="external-contour-board-item-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/",
|
||||
ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
|
|
|
|||
|
|
@ -247,11 +247,15 @@ class ExternalContourTargetOptionsEndpoint(BaseAPIView):
|
|||
)
|
||||
|
||||
labels = Label.objects.filter(project=target_project).order_by("sort_order", "name")
|
||||
states = State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by(
|
||||
"sequence", "created_at"
|
||||
)
|
||||
|
||||
serializer = ExternalContourTargetOptionsSerializer(
|
||||
{
|
||||
"project": target_project,
|
||||
"member_ids": member_ids,
|
||||
"states": states,
|
||||
"labels": labels,
|
||||
}
|
||||
)
|
||||
|
|
@ -326,10 +330,17 @@ class ExternalContourDetailEndpoint(BaseAPIView):
|
|||
|
||||
serializer = ExternalContourRequestUpdateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
issue_update_data = serializer.validated_data.copy()
|
||||
assignee_ids = issue_update_data.pop("assignee_ids", None)
|
||||
label_ids = issue_update_data.pop("label_ids", None)
|
||||
if assignee_ids is not None:
|
||||
issue_update_data["assignees"] = assignee_ids
|
||||
if label_ids is not None:
|
||||
issue_update_data["labels"] = label_ids
|
||||
|
||||
issue_serializer = IssueCreateSerializer(
|
||||
issue,
|
||||
data=serializer.validated_data,
|
||||
data=issue_update_data,
|
||||
partial=True,
|
||||
context={
|
||||
"project_id": str(issue.project_id),
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ from django.urls import path
|
|||
|
||||
from plane.app.views import (
|
||||
ExternalContourAttachmentDownloadEndpoint,
|
||||
ExternalContourBoardEndpoint,
|
||||
ExternalContourBoardItemDetailEndpoint,
|
||||
ExternalContourDetailEndpoint,
|
||||
ExternalContourDecisionEndpoint,
|
||||
ExternalContourListCreateEndpoint,
|
||||
|
|
@ -16,6 +18,16 @@ from plane.app.views import (
|
|||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/board/",
|
||||
ExternalContourBoardEndpoint.as_view(http_method_names=["get"]),
|
||||
name="external-contour-board",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/board-items/<uuid:request_id>/",
|
||||
ExternalContourBoardItemDetailEndpoint.as_view(http_method_names=["get"]),
|
||||
name="external-contour-board-item-detail",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/",
|
||||
ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]),
|
||||
|
|
|
|||
|
|
@ -226,6 +226,8 @@ from .notification.base import (
|
|||
from .exporter.base import ExportIssuesEndpoint
|
||||
from .external_contours import (
|
||||
ExternalContourAttachmentDownloadEndpoint,
|
||||
ExternalContourBoardEndpoint,
|
||||
ExternalContourBoardItemDetailEndpoint,
|
||||
ExternalContourListCreateEndpoint,
|
||||
ExternalContourDetailEndpoint,
|
||||
ExternalContourDecisionEndpoint,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
# See the LICENSE file for details.
|
||||
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import ValidationError
|
||||
|
|
@ -11,6 +12,7 @@ from rest_framework.response import Response
|
|||
from plane.utils.host import base_host
|
||||
|
||||
from plane.api.serializers import (
|
||||
ExternalContourBoardItemSerializer,
|
||||
ExternalContourRequestCreateSerializer,
|
||||
ExternalContourRequestDecisionSerializer,
|
||||
ExternalContourRequestReplySerializer,
|
||||
|
|
@ -247,17 +249,338 @@ class ExternalContourTargetOptionsEndpoint(BaseAPIView):
|
|||
)
|
||||
|
||||
labels = Label.objects.filter(project=target_project).order_by("sort_order", "name")
|
||||
states = State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by(
|
||||
"sequence", "created_at"
|
||||
)
|
||||
|
||||
serializer = ExternalContourTargetOptionsSerializer(
|
||||
{
|
||||
"project": target_project,
|
||||
"member_ids": member_ids,
|
||||
"states": states,
|
||||
"labels": labels,
|
||||
}
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ExternalContourReadMixin:
|
||||
CLOSED_STATE_GROUPS = (StateGroup.COMPLETED.value, StateGroup.CANCELLED.value)
|
||||
BOARD_ORDERING_MAP = {
|
||||
"requested_at": "created_at",
|
||||
"updated_at": "updated_at",
|
||||
"issue__sequence_id": "issue__sequence_id",
|
||||
"target_date": "issue__target_date",
|
||||
}
|
||||
|
||||
def get_base_queryset(self):
|
||||
return (
|
||||
IntakeIssue.objects.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
extra__bridge="external-contours",
|
||||
)
|
||||
.select_related(
|
||||
"issue",
|
||||
"issue__state",
|
||||
"issue__project",
|
||||
"issue__created_by",
|
||||
)
|
||||
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
|
||||
)
|
||||
|
||||
def get_outgoing_queryset(self):
|
||||
return self.get_base_queryset().filter(extra__source_project_id=str(self.kwargs.get("project_id")))
|
||||
|
||||
def get_incoming_queryset(self):
|
||||
return self.get_base_queryset().filter(project_id=self.kwargs.get("project_id"))
|
||||
|
||||
def get_board_item_queryset(self):
|
||||
return self.get_base_queryset().filter(
|
||||
Q(extra__source_project_id=str(self.kwargs.get("project_id"))) | Q(project_id=self.kwargs.get("project_id"))
|
||||
)
|
||||
|
||||
def parse_csv_param(self, request, key):
|
||||
value = (request.query_params.get(key) or "").strip()
|
||||
if not value:
|
||||
return []
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
def parse_bool_param(self, request, key):
|
||||
value = (request.query_params.get(key) or "").strip().lower()
|
||||
if value in ["true", "1", "yes"]:
|
||||
return True
|
||||
if value in ["false", "0", "no"]:
|
||||
return False
|
||||
return None
|
||||
|
||||
def get_requested_directions(self, request):
|
||||
directions = set(self.parse_csv_param(request, "direction"))
|
||||
valid_directions = {"outgoing", "incoming"}
|
||||
return directions.intersection(valid_directions) or valid_directions
|
||||
|
||||
def get_applied_filters(self, request):
|
||||
filters = {}
|
||||
for key in [
|
||||
"direction",
|
||||
"status",
|
||||
"state_groups",
|
||||
"state_ids",
|
||||
"priority",
|
||||
"assignee_ids",
|
||||
"created_by_ids",
|
||||
"requested_by_ids",
|
||||
"counterparty_project_ids",
|
||||
"source_project_ids",
|
||||
"target_project_ids",
|
||||
"label_ids",
|
||||
]:
|
||||
filters[key] = self.parse_csv_param(request, key)
|
||||
|
||||
filters["has_unread_updates"] = self.parse_bool_param(request, "has_unread_updates")
|
||||
filters["search"] = (request.query_params.get("search") or "").strip()
|
||||
filters["target_date_exact"] = (request.query_params.get("target_date_exact") or "").strip()
|
||||
filters["target_date_from"] = (request.query_params.get("target_date_from") or "").strip()
|
||||
filters["target_date_to"] = (request.query_params.get("target_date_to") or "").strip()
|
||||
return filters
|
||||
|
||||
def get_sorting(self, request):
|
||||
order_by = (request.query_params.get("order_by") or "updated_at").strip()
|
||||
sort_by = (request.query_params.get("sort_by") or "desc").strip().lower()
|
||||
|
||||
if order_by not in self.BOARD_ORDERING_MAP:
|
||||
order_by = "updated_at"
|
||||
if sort_by not in ["asc", "desc"]:
|
||||
sort_by = "desc"
|
||||
|
||||
return {
|
||||
"order_by": order_by,
|
||||
"sort_by": sort_by,
|
||||
}
|
||||
|
||||
def get_unread_request_ids(self, user, request_ids=None):
|
||||
user_id = getattr(user, "id", None)
|
||||
if not user_id:
|
||||
return set()
|
||||
|
||||
queryset = Notification.objects.filter(
|
||||
receiver_id=user_id,
|
||||
sender__startswith="in_app:external_contours:",
|
||||
read_at__isnull=True,
|
||||
)
|
||||
if request_ids:
|
||||
queryset = queryset.filter(data__issue__id__in=[str(request_id) for request_id in request_ids])
|
||||
|
||||
return {
|
||||
str(request_id)
|
||||
for request_id in queryset.values_list("data__issue__id", flat=True)
|
||||
if request_id
|
||||
}
|
||||
|
||||
def apply_status_filter(self, queryset, statuses):
|
||||
normalized_statuses = {status_value for status_value in statuses if status_value in ["open", "closed"]}
|
||||
if not normalized_statuses or normalized_statuses == {"open", "closed"}:
|
||||
return queryset
|
||||
if normalized_statuses == {"open"}:
|
||||
return queryset.exclude(issue__state__group__in=self.CLOSED_STATE_GROUPS)
|
||||
if normalized_statuses == {"closed"}:
|
||||
return queryset.filter(issue__state__group__in=self.CLOSED_STATE_GROUPS)
|
||||
return queryset
|
||||
|
||||
def apply_board_filters(self, queryset, request, direction=None):
|
||||
filters = self.get_applied_filters(request)
|
||||
|
||||
if filters["search"]:
|
||||
queryset = queryset.filter(
|
||||
Q(issue__name__icontains=filters["search"]) | Q(issue__description_html__icontains=filters["search"])
|
||||
)
|
||||
|
||||
queryset = self.apply_status_filter(queryset, filters["status"])
|
||||
|
||||
if filters["state_groups"]:
|
||||
queryset = queryset.filter(issue__state__group__in=filters["state_groups"])
|
||||
if filters["state_ids"]:
|
||||
queryset = queryset.filter(issue__state_id__in=filters["state_ids"])
|
||||
if filters["priority"]:
|
||||
queryset = queryset.filter(issue__priority__in=filters["priority"])
|
||||
if filters["assignee_ids"]:
|
||||
queryset = queryset.filter(issue__issue_assignee__assignee_id__in=filters["assignee_ids"])
|
||||
if filters["created_by_ids"]:
|
||||
queryset = queryset.filter(issue__created_by_id__in=filters["created_by_ids"])
|
||||
if filters["requested_by_ids"]:
|
||||
queryset = queryset.filter(extra__requested_by_id__in=filters["requested_by_ids"])
|
||||
if filters["counterparty_project_ids"]:
|
||||
if direction == "outgoing":
|
||||
queryset = queryset.filter(
|
||||
Q(extra__target_project_id__in=filters["counterparty_project_ids"])
|
||||
| Q(issue__project_id__in=filters["counterparty_project_ids"])
|
||||
)
|
||||
elif direction == "incoming":
|
||||
queryset = queryset.filter(extra__source_project_id__in=filters["counterparty_project_ids"])
|
||||
if filters["source_project_ids"]:
|
||||
queryset = queryset.filter(extra__source_project_id__in=filters["source_project_ids"])
|
||||
if filters["target_project_ids"]:
|
||||
queryset = queryset.filter(
|
||||
Q(extra__target_project_id__in=filters["target_project_ids"])
|
||||
| Q(issue__project_id__in=filters["target_project_ids"])
|
||||
)
|
||||
if filters["label_ids"]:
|
||||
queryset = queryset.filter(issue__label_issue__label_id__in=filters["label_ids"])
|
||||
if filters["target_date_exact"]:
|
||||
queryset = queryset.filter(issue__target_date=filters["target_date_exact"])
|
||||
if filters["target_date_from"]:
|
||||
queryset = queryset.filter(issue__target_date__gte=filters["target_date_from"])
|
||||
if filters["target_date_to"]:
|
||||
queryset = queryset.filter(issue__target_date__lte=filters["target_date_to"])
|
||||
if filters["has_unread_updates"] is not None:
|
||||
unread_request_ids = self.get_unread_request_ids(request.user)
|
||||
if filters["has_unread_updates"]:
|
||||
queryset = queryset.filter(pk__in=unread_request_ids or ["00000000-0000-0000-0000-000000000000"])
|
||||
else:
|
||||
queryset = queryset.exclude(pk__in=unread_request_ids)
|
||||
|
||||
sorting = self.get_sorting(request)
|
||||
ordering = self.BOARD_ORDERING_MAP[sorting["order_by"]]
|
||||
if sorting["sort_by"] == "desc":
|
||||
ordering = f"-{ordering}"
|
||||
|
||||
return queryset.order_by(ordering).distinct()
|
||||
|
||||
def build_project_map(self, contour_requests):
|
||||
project_ids = {
|
||||
str(project_id)
|
||||
for contour_request in contour_requests
|
||||
for project_id in [
|
||||
contour_request.extra.get("source_project_id"),
|
||||
contour_request.extra.get("target_project_id") or (str(contour_request.issue.project_id) if contour_request.issue_id else None),
|
||||
]
|
||||
if project_id
|
||||
}
|
||||
|
||||
if not project_ids:
|
||||
return {}
|
||||
|
||||
return {
|
||||
str(project.id): project
|
||||
for project in Project.objects.filter(pk__in=project_ids)
|
||||
}
|
||||
|
||||
def build_member_project_ids(self, request, contour_requests):
|
||||
issue_project_ids = {
|
||||
str(contour_request.issue.project_id)
|
||||
for contour_request in contour_requests
|
||||
if contour_request.issue_id and contour_request.issue and contour_request.issue.project_id
|
||||
}
|
||||
|
||||
if not issue_project_ids:
|
||||
return set()
|
||||
|
||||
return {
|
||||
str(project_id)
|
||||
for project_id in ProjectMember.objects.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
member=request.user,
|
||||
project_id__in=issue_project_ids,
|
||||
is_active=True,
|
||||
).values_list("project_id", flat=True)
|
||||
}
|
||||
|
||||
def build_serializer_context(self, request, contour_requests, current_project_id, include_mirror_data=False):
|
||||
contour_requests = list(contour_requests)
|
||||
unread_request_ids = self.get_unread_request_ids(request.user, request_ids=[contour_request.id for contour_request in contour_requests])
|
||||
return {
|
||||
"include_mirror_data": include_mirror_data,
|
||||
"workspace_slug": self.kwargs.get("slug"),
|
||||
"source_project_id": str(current_project_id),
|
||||
"current_project_id": str(current_project_id),
|
||||
"project_map": self.build_project_map(contour_requests),
|
||||
"member_project_ids": self.build_member_project_ids(request, contour_requests),
|
||||
"unread_request_ids": unread_request_ids,
|
||||
"request": request,
|
||||
}
|
||||
|
||||
def mark_request_notifications_read(self, user, contour_request):
|
||||
user_id = getattr(user, "id", None)
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
Notification.objects.filter(
|
||||
receiver_id=user_id,
|
||||
sender__startswith="in_app:external_contours:",
|
||||
read_at__isnull=True,
|
||||
data__issue__id=str(contour_request.id),
|
||||
).update(read_at=timezone.now())
|
||||
|
||||
|
||||
class ExternalContourBoardEndpoint(ExternalContourReadMixin, BaseAPIView):
|
||||
permission_classes = [ProjectLitePermission]
|
||||
serializer_class = ExternalContourBoardItemSerializer
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
requested_directions = self.get_requested_directions(request)
|
||||
current_project_id = str(project_id)
|
||||
|
||||
outgoing_queryset = self.apply_board_filters(self.get_outgoing_queryset(), request, direction="outgoing")
|
||||
incoming_queryset = self.apply_board_filters(self.get_incoming_queryset(), request, direction="incoming")
|
||||
|
||||
outgoing_requests = list(outgoing_queryset) if "outgoing" in requested_directions else []
|
||||
incoming_requests = list(incoming_queryset) if "incoming" in requested_directions else []
|
||||
|
||||
outgoing_serializer = ExternalContourBoardItemSerializer(
|
||||
outgoing_requests,
|
||||
many=True,
|
||||
context=self.build_serializer_context(request, outgoing_requests, current_project_id=current_project_id),
|
||||
)
|
||||
incoming_serializer = ExternalContourBoardItemSerializer(
|
||||
incoming_requests,
|
||||
many=True,
|
||||
context=self.build_serializer_context(request, incoming_requests, current_project_id=current_project_id),
|
||||
)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"filters": self.get_applied_filters(request),
|
||||
"sorting": self.get_sorting(request),
|
||||
"columns": [
|
||||
{
|
||||
"key": "outgoing",
|
||||
"title": "Исходящие",
|
||||
"total_count": len(outgoing_requests),
|
||||
"next_cursor": "",
|
||||
"results": outgoing_serializer.data,
|
||||
},
|
||||
{
|
||||
"key": "incoming",
|
||||
"title": "Входящие",
|
||||
"total_count": len(incoming_requests),
|
||||
"next_cursor": "",
|
||||
"results": incoming_serializer.data,
|
||||
},
|
||||
],
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class ExternalContourBoardItemDetailEndpoint(ExternalContourReadMixin, BaseAPIView):
|
||||
permission_classes = [ProjectLitePermission]
|
||||
serializer_class = ExternalContourBoardItemSerializer
|
||||
|
||||
def get(self, request, slug, project_id, request_id):
|
||||
contour_request = get_object_or_404(self.get_board_item_queryset(), pk=request_id)
|
||||
self.mark_request_notifications_read(request.user, contour_request)
|
||||
|
||||
serializer = ExternalContourBoardItemSerializer(
|
||||
contour_request,
|
||||
context=self.build_serializer_context(
|
||||
request,
|
||||
[contour_request],
|
||||
current_project_id=str(project_id),
|
||||
include_mirror_data=True,
|
||||
),
|
||||
)
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ExternalContourDetailEndpoint(BaseAPIView):
|
||||
permission_classes = [ProjectLitePermission]
|
||||
serializer_class = ExternalContourRequestSerializer
|
||||
|
|
@ -326,10 +649,17 @@ class ExternalContourDetailEndpoint(BaseAPIView):
|
|||
|
||||
serializer = ExternalContourRequestUpdateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
issue_update_data = serializer.validated_data.copy()
|
||||
assignee_ids = issue_update_data.pop("assignee_ids", None)
|
||||
label_ids = issue_update_data.pop("label_ids", None)
|
||||
if assignee_ids is not None:
|
||||
issue_update_data["assignees"] = assignee_ids
|
||||
if label_ids is not None:
|
||||
issue_update_data["labels"] = label_ids
|
||||
|
||||
issue_serializer = IssueCreateSerializer(
|
||||
issue,
|
||||
data=serializer.validated_data,
|
||||
data=issue_update_data,
|
||||
partial=True,
|
||||
context={
|
||||
"project_id": str(issue.project_id),
|
||||
|
|
@ -519,16 +849,13 @@ class ExternalContourReplyEndpoint(BaseAPIView):
|
|||
return Response(response_serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class ExternalContourAttachmentDownloadEndpoint(BaseAPIView):
|
||||
class ExternalContourAttachmentDownloadEndpoint(ExternalContourReadMixin, BaseAPIView):
|
||||
permission_classes = [ProjectLitePermission]
|
||||
|
||||
def get_queryset(self):
|
||||
return IntakeIssue.objects.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
extra__bridge="external-contours",
|
||||
extra__source_project_id=str(self.kwargs.get("project_id")),
|
||||
pk=self.kwargs.get("request_id"),
|
||||
).select_related("issue", "issue__project", "workspace")
|
||||
return self.get_board_item_queryset().filter(pk=self.kwargs.get("request_id")).select_related(
|
||||
"issue", "issue__project", "workspace"
|
||||
)
|
||||
|
||||
def get(self, request, slug, project_id, request_id, attachment_id):
|
||||
contour_request = get_object_or_404(self.get_queryset())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
import pytest
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State, WorkspaceMember
|
||||
from plane.db.models.intake import IntakeIssueStatus
|
||||
from plane.db.models.project import ROLE
|
||||
from plane.db.models.state import StateGroup
|
||||
|
||||
|
||||
@pytest.mark.contract
|
||||
class TestExternalContoursBoardAPI:
|
||||
def get_board_url(self, workspace_slug: str, project_id) -> str:
|
||||
return f"/api/workspaces/{workspace_slug}/projects/{project_id}/external-contours/board/"
|
||||
|
||||
def get_board_item_url(self, workspace_slug: str, project_id, request_id) -> str:
|
||||
return f"/api/workspaces/{workspace_slug}/projects/{project_id}/external-contours/board-items/{request_id}/"
|
||||
|
||||
def create_project_with_member(self, workspace, user, name: str, identifier: str, intake_view: bool = False) -> Project:
|
||||
project = Project.objects.create(
|
||||
name=name,
|
||||
identifier=identifier,
|
||||
workspace=workspace,
|
||||
intake_view=intake_view,
|
||||
)
|
||||
ProjectMember.objects.create(project=project, member=user, role=ROLE.ADMIN.value, is_active=True)
|
||||
return project
|
||||
|
||||
def create_state(self, project: Project, name: str, group: str, color: str, default: bool = False) -> State:
|
||||
return State.objects.create(
|
||||
project=project,
|
||||
workspace=project.workspace,
|
||||
name=name,
|
||||
group=group,
|
||||
color=color,
|
||||
sequence=1000,
|
||||
default=default,
|
||||
)
|
||||
|
||||
def create_external_contour_request(self, source_project: Project, target_project: Project, requested_by, state: State):
|
||||
intake = Intake.objects.create(
|
||||
name="External Contours Bridge",
|
||||
project=target_project,
|
||||
is_default=False,
|
||||
)
|
||||
issue = Issue.objects.create(
|
||||
project=target_project,
|
||||
state=state,
|
||||
name="Cross-project request",
|
||||
description_html="<p>Need help</p>",
|
||||
priority="high",
|
||||
created_by=requested_by,
|
||||
)
|
||||
return IntakeIssue.objects.create(
|
||||
intake=intake,
|
||||
project=target_project,
|
||||
issue=issue,
|
||||
status=IntakeIssueStatus.ACCEPTED.value,
|
||||
extra={
|
||||
"bridge": "external-contours",
|
||||
"source_project_id": str(source_project.id),
|
||||
"source_project_name": source_project.name,
|
||||
"target_project_id": str(target_project.id),
|
||||
"target_project_name": target_project.name,
|
||||
"requested_by_id": str(requested_by.id),
|
||||
"requested_by_name": requested_by.display_name,
|
||||
"requested_at": issue.created_at.isoformat() if issue.created_at else None,
|
||||
},
|
||||
)
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_source_project_member_gets_outgoing_board_items(self, workspace, create_user):
|
||||
source_user = create_user
|
||||
target_user = type(source_user).objects.create_user(email="target-board@example.com", username="target-board")
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=target_user, role=ROLE.ADMIN.value, is_active=True)
|
||||
|
||||
source_project = self.create_project_with_member(workspace, source_user, "Source Board", "SRCBRD")
|
||||
target_project = self.create_project_with_member(workspace, target_user, "Target Board", "TGTBRD", intake_view=True)
|
||||
open_state = self.create_state(target_project, "Todo", StateGroup.UNSTARTED.value, "#cccccc", default=True)
|
||||
contour_request = self.create_external_contour_request(source_project, target_project, source_user, open_state)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=source_user)
|
||||
response = client.get(self.get_board_url(workspace.slug, source_project.id), format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
columns = {column["key"]: column for column in response.data["columns"]}
|
||||
|
||||
assert columns["outgoing"]["total_count"] == 1
|
||||
assert columns["incoming"]["total_count"] == 0
|
||||
assert columns["outgoing"]["results"][0]["id"] == str(contour_request.id)
|
||||
assert columns["outgoing"]["results"][0]["direction"] == "outgoing"
|
||||
assert columns["outgoing"]["results"][0]["capabilities"]["can_open_target_issue"] is False
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_target_project_member_gets_incoming_board_items(self, workspace, create_user):
|
||||
source_user = create_user
|
||||
target_user = type(source_user).objects.create_user(email="incoming-board@example.com", username="incoming-board")
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=target_user, role=ROLE.ADMIN.value, is_active=True)
|
||||
|
||||
source_project = self.create_project_with_member(workspace, source_user, "Source Incoming", "SRCINC")
|
||||
target_project = self.create_project_with_member(workspace, target_user, "Target Incoming", "TGTINC", intake_view=True)
|
||||
open_state = self.create_state(target_project, "Todo", StateGroup.UNSTARTED.value, "#cccccc", default=True)
|
||||
contour_request = self.create_external_contour_request(source_project, target_project, source_user, open_state)
|
||||
|
||||
client = APIClient()
|
||||
client.force_authenticate(user=target_user)
|
||||
response = client.get(self.get_board_url(workspace.slug, target_project.id), format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
columns = {column["key"]: column for column in response.data["columns"]}
|
||||
|
||||
assert columns["outgoing"]["total_count"] == 0
|
||||
assert columns["incoming"]["total_count"] == 1
|
||||
assert columns["incoming"]["results"][0]["id"] == str(contour_request.id)
|
||||
assert columns["incoming"]["results"][0]["direction"] == "incoming"
|
||||
assert columns["incoming"]["results"][0]["capabilities"]["can_open_target_issue"] is True
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_board_item_detail_resolves_perspective_and_capabilities(self, workspace, create_user):
|
||||
source_user = create_user
|
||||
target_user = type(source_user).objects.create_user(email="detail-board@example.com", username="detail-board")
|
||||
WorkspaceMember.objects.create(workspace=workspace, member=target_user, role=ROLE.ADMIN.value, is_active=True)
|
||||
|
||||
source_project = self.create_project_with_member(workspace, source_user, "Source Detail", "SRCDET")
|
||||
target_project = self.create_project_with_member(workspace, target_user, "Target Detail", "TGTDET", intake_view=True)
|
||||
closed_state = self.create_state(target_project, "Done", StateGroup.COMPLETED.value, "#00ff00", default=True)
|
||||
contour_request = self.create_external_contour_request(source_project, target_project, source_user, closed_state)
|
||||
|
||||
source_client = APIClient()
|
||||
source_client.force_authenticate(user=source_user)
|
||||
source_response = source_client.get(
|
||||
self.get_board_item_url(workspace.slug, source_project.id, contour_request.id),
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert source_response.status_code == status.HTTP_200_OK
|
||||
assert source_response.data["direction"] == "outgoing"
|
||||
assert source_response.data["capabilities"]["can_source_decide"] is True
|
||||
assert source_response.data["capabilities"]["can_open_target_issue"] is False
|
||||
|
||||
target_client = APIClient()
|
||||
target_client.force_authenticate(user=target_user)
|
||||
target_response = target_client.get(
|
||||
self.get_board_item_url(workspace.slug, target_project.id, contour_request.id),
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert target_response.status_code == status.HTTP_200_OK
|
||||
assert target_response.data["direction"] == "incoming"
|
||||
assert target_response.data["capabilities"]["can_source_decide"] is False
|
||||
assert target_response.data["capabilities"]["can_open_target_issue"] is True
|
||||
|
|
@ -7,9 +7,7 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { Outlet } from "react-router";
|
||||
import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider";
|
||||
// plane web components
|
||||
import { ProjectAppSidebar } from "./_sidebar";
|
||||
import { ExtendedProjectSidebar } from "./extended-project-sidebar";
|
||||
import { ProjectShellTopToolbar } from "./project-shell-top-toolbar";
|
||||
|
||||
function WorkspaceLayout() {
|
||||
return (
|
||||
|
|
@ -18,10 +16,11 @@ function WorkspaceLayout() {
|
|||
<div className="relative flex h-full w-full flex-col overflow-hidden rounded-lg border border-subtle">
|
||||
<div id="full-screen-portal" className="absolute inset-0 w-full" />
|
||||
<div className="relative flex size-full overflow-hidden">
|
||||
<ProjectAppSidebar />
|
||||
<ExtendedProjectSidebar />
|
||||
<main className="relative flex h-full w-full flex-col overflow-hidden bg-surface-1">
|
||||
<Outlet />
|
||||
<main className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-surface-1">
|
||||
<ProjectShellTopToolbar />
|
||||
<div className="relative flex min-h-0 w-full flex-1 overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,284 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams, usePathname } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
import {
|
||||
EUserPermissions,
|
||||
EUserPermissionsLevel,
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS,
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS,
|
||||
WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { InboxIcon, PlusIcon, ProjectIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { copyUrlToClipboard, joinUrlPath } from "@plane/utils";
|
||||
import { TopNavPowerK } from "@/components/navigation";
|
||||
import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useWorkspaceNotifications } from "@/hooks/store/notifications";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import {
|
||||
usePersonalNavigationPreferences,
|
||||
useWorkspaceNavigationPreferences,
|
||||
} from "@/hooks/use-navigation-preferences";
|
||||
import { SidebarProjectsListItem } from "@/components/workspace/sidebar/projects-list-item";
|
||||
import { UserMenuRoot } from "@/components/workspace/sidebar/user-menu-root";
|
||||
import { WorkspaceMenuRoot } from "@/components/workspace/sidebar/workspace-menu-root";
|
||||
import { getSidebarNavigationItemIcon } from "@/plane-web/components/workspace/sidebar/helper";
|
||||
|
||||
type TToolbarItem = {
|
||||
key: string;
|
||||
href?: string;
|
||||
labelTranslationKey: string;
|
||||
active: boolean;
|
||||
icon: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const ToolbarIconLink = ({ item }: { item: TToolbarItem }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip tooltipContent={t(item.labelTranslationKey)} position="bottom">
|
||||
<Link
|
||||
href={item.href ?? "#"}
|
||||
className="nodedc-toolbar-icon-button flex h-8 w-8 items-center justify-center"
|
||||
data-active={item.active}
|
||||
aria-label={t(item.labelTranslationKey)}
|
||||
>
|
||||
<span className="nodedc-toolbar-icon-active-dot">{item.icon}</span>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const ToolbarIconButton = ({
|
||||
label,
|
||||
active = false,
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<Tooltip tooltipContent={label} position="bottom">
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-toolbar-icon-button flex h-8 w-8 items-center justify-center"
|
||||
data-active={active}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
<span className="nodedc-toolbar-icon-active-dot">{children}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const ProjectsToolbarMenu = observer(function ProjectsToolbarMenu() {
|
||||
const { t } = useTranslation();
|
||||
const pathname = usePathname();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { joinedProjectIds } = useProject();
|
||||
|
||||
const handleCopyText = (projectId: string) =>
|
||||
copyUrlToClipboard(`${workspaceSlug}/projects/${projectId}/issues`).then(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("link_copied"),
|
||||
message: t("project_link_copied_to_clipboard"),
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative">
|
||||
<Menu.Button
|
||||
type="button"
|
||||
title={t("workspace_sidebar.projects.main")}
|
||||
className="nodedc-toolbar-icon-button grid h-8 w-8 place-items-center"
|
||||
aria-label={t("workspace_sidebar.projects.main")}
|
||||
>
|
||||
<span
|
||||
className={`nodedc-toolbar-icon-active-dot ${
|
||||
pathname.includes("/projects/") ? "bg-[rgb(var(--nodedc-accent-rgb))] text-[#0b1117]" : ""
|
||||
}`}
|
||||
>
|
||||
<ProjectIcon className="size-4" />
|
||||
</span>
|
||||
</Menu.Button>
|
||||
|
||||
<Menu.Items className="absolute top-full -right-2 z-[170] mt-2 origin-top-right">
|
||||
<div className="nodedc-glass-modal nodedc-glass-popup-surface flex max-h-[70vh] min-w-[26rem] flex-col overflow-hidden rounded-[1.5rem] border-0 p-2 shadow-none outline-none">
|
||||
<div className="vertical-scrollbar scrollbar-sm flex max-h-[70vh] flex-col gap-0.5 overflow-y-auto pr-1">
|
||||
{joinedProjectIds.map((projectId, index) => (
|
||||
<SidebarProjectsListItem
|
||||
key={projectId}
|
||||
projectId={projectId}
|
||||
handleCopyText={() => handleCopyText(projectId)}
|
||||
projectListType="JOINED"
|
||||
disableDrag
|
||||
disableDrop
|
||||
isLastChild={index === joinedProjectIds.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
|
||||
export const ProjectShellTopToolbar = observer(function ProjectShellTopToolbar() {
|
||||
const { t } = useTranslation();
|
||||
const pathname = usePathname();
|
||||
const { workspaceSlug } = useParams();
|
||||
const { toggleCreateIssueModal } = useCommandPalette();
|
||||
const { joinedProjectIds } = useProject();
|
||||
const { data: currentUser } = useUser();
|
||||
const { allowPermissions } = useUserPermissions();
|
||||
const { unreadNotificationsCount, getUnreadNotificationsCount } = useWorkspaceNotifications();
|
||||
const { preferences: personalPreferences } = usePersonalNavigationPreferences();
|
||||
const { preferences: workspacePreferences } = useWorkspaceNavigationPreferences();
|
||||
|
||||
const canCreateIssue = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER],
|
||||
EUserPermissionsLevel.WORKSPACE
|
||||
);
|
||||
|
||||
useSWR(
|
||||
workspaceSlug ? "WORKSPACE_UNREAD_NOTIFICATION_COUNT" : null,
|
||||
workspaceSlug ? () => getUnreadNotificationsCount(workspaceSlug.toString()) : null
|
||||
);
|
||||
|
||||
const isMentionsEnabled = unreadNotificationsCount.mention_unread_notifications_count > 0;
|
||||
const totalNotifications = isMentionsEnabled
|
||||
? unreadNotificationsCount.mention_unread_notifications_count
|
||||
: unreadNotificationsCount.total_unread_notifications_count;
|
||||
|
||||
const primaryItems = useMemo(() => {
|
||||
const items = [...WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS_LINKS];
|
||||
const personalItems: Array<(typeof items)[0] & { sort_order: number }> = [];
|
||||
|
||||
const stickiesItem = WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["stickies"];
|
||||
if (personalPreferences.items.stickies?.enabled && stickiesItem) {
|
||||
personalItems.push({
|
||||
...stickiesItem,
|
||||
sort_order: personalPreferences.items.stickies.sort_order,
|
||||
});
|
||||
}
|
||||
if (personalPreferences.items.your_work?.enabled && WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["your-work"]) {
|
||||
personalItems.push({
|
||||
...WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["your-work"],
|
||||
sort_order: personalPreferences.items.your_work.sort_order,
|
||||
});
|
||||
}
|
||||
if (personalPreferences.items.drafts?.enabled && WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["drafts"]) {
|
||||
personalItems.push({
|
||||
...WORKSPACE_SIDEBAR_STATIC_NAVIGATION_ITEMS["drafts"],
|
||||
sort_order: personalPreferences.items.drafts.sort_order,
|
||||
});
|
||||
}
|
||||
|
||||
personalItems.sort((a, b) => a.sort_order - b.sort_order);
|
||||
|
||||
return [...items, ...personalItems].map((item) => {
|
||||
const href =
|
||||
item.key === "your_work" && currentUser?.id
|
||||
? joinUrlPath(workspaceSlug?.toString() ?? "", item.href, currentUser.id)
|
||||
: joinUrlPath(workspaceSlug?.toString() ?? "", item.href);
|
||||
|
||||
return {
|
||||
key: item.key,
|
||||
href,
|
||||
labelTranslationKey: item.labelTranslationKey,
|
||||
active: item.highlight(pathname, href),
|
||||
icon: getSidebarNavigationItemIcon(item.key),
|
||||
} satisfies TToolbarItem;
|
||||
});
|
||||
}, [currentUser?.id, pathname, personalPreferences, workspaceSlug]);
|
||||
|
||||
const secondaryItems = useMemo(
|
||||
() =>
|
||||
WORKSPACE_SIDEBAR_DYNAMIC_NAVIGATION_ITEMS_LINKS.map((item) => {
|
||||
const href = joinUrlPath(workspaceSlug?.toString() ?? "", item.href);
|
||||
const preference = workspacePreferences.items[item.key];
|
||||
|
||||
return {
|
||||
key: item.key,
|
||||
href,
|
||||
labelTranslationKey: item.labelTranslationKey,
|
||||
active: item.highlight(pathname, href),
|
||||
icon: getSidebarNavigationItemIcon(item.key),
|
||||
sort_order: preference ? preference.sort_order : 0,
|
||||
};
|
||||
}).sort((a, b) => a.sort_order - b.sort_order),
|
||||
[pathname, workspacePreferences, workspaceSlug]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="z-20 w-full flex-shrink-0 px-4 pt-4 pb-3">
|
||||
<div className="nodedc-glass-modal flex w-full flex-wrap items-center justify-between gap-4 rounded-[1.6rem] px-4 py-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="nodedc-toolbar-group relative flex items-center gap-1 overflow-visible">
|
||||
<ToolbarIconButton
|
||||
label={t("app_header.add_task")}
|
||||
onClick={() => toggleCreateIssueModal(true)}
|
||||
disabled={!canCreateIssue || joinedProjectIds.length === 0}
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
</ToolbarIconButton>
|
||||
<TopNavPowerK variant="sidebar" />
|
||||
<Tooltip tooltipContent={t("notification.label")} position="bottom">
|
||||
<Link
|
||||
href={`/${workspaceSlug?.toString()}/notifications/`}
|
||||
className="nodedc-toolbar-icon-button relative flex h-8 w-8 items-center justify-center"
|
||||
data-active={pathname.includes("/notifications/")}
|
||||
aria-label={t("notification.label")}
|
||||
>
|
||||
<span className="nodedc-toolbar-icon-active-dot">
|
||||
<InboxIcon className="size-4" />
|
||||
</span>
|
||||
{totalNotifications > 0 && (
|
||||
<span className="absolute top-1.5 right-1.5 size-2 rounded-full bg-danger-primary" />
|
||||
)}
|
||||
</Link>
|
||||
</Tooltip>
|
||||
<UserMenuRoot variant="toolbar" />
|
||||
<WorkspaceMenuRoot variant="toolbar" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 items-center justify-end gap-3">
|
||||
<div className="nodedc-toolbar-group flex items-center gap-1">
|
||||
{primaryItems.map((item) => (
|
||||
<ToolbarIconLink key={item.key} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="nodedc-toolbar-group relative flex items-center gap-1 overflow-visible">
|
||||
<ProjectsToolbarMenu />
|
||||
{secondaryItems.map((item) => (
|
||||
<ToolbarIconLink key={item.key} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -5,17 +5,23 @@
|
|||
*/
|
||||
|
||||
import { Outlet } from "react-router";
|
||||
import { useParams } from "react-router";
|
||||
import { AppHeader } from "@/components/core/app-header";
|
||||
import { ContentWrapper } from "@/components/core/content-wrapper";
|
||||
import { ProjectExternalContoursHeader } from "@/plane-web/components/projects/external-contours/header";
|
||||
import { ProjectExternalContoursFiltersProvider } from "@/plane-web/components/projects/external-contours/filters/provider";
|
||||
|
||||
export default function ProjectExternalContoursLayout() {
|
||||
const { projectId, workspaceSlug } = useParams();
|
||||
|
||||
if (!projectId || !workspaceSlug) return <Outlet />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectExternalContoursFiltersProvider projectId={projectId} workspaceSlug={workspaceSlug}>
|
||||
<AppHeader header={<ProjectExternalContoursHeader />} />
|
||||
<ContentWrapper>
|
||||
<Outlet />
|
||||
</ContentWrapper>
|
||||
</>
|
||||
</ProjectExternalContoursFiltersProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import {
|
|||
WORK_ITEM_TRACKER_ELEMENTS,
|
||||
} from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { NewTabIcon, WorkItemsIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { EIssuesStoreType } from "@plane/types";
|
||||
|
|
@ -25,6 +24,7 @@ import { Breadcrumbs, Header } from "@plane/ui";
|
|||
// components
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { CountChip } from "@/components/common/count-chip";
|
||||
import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button";
|
||||
// constants
|
||||
import { HeaderFilters } from "@/components/issues/filters";
|
||||
// helpers
|
||||
|
|
@ -117,18 +117,14 @@ export const IssuesHeader = observer(function IssuesHeader() {
|
|||
/>
|
||||
</div>
|
||||
{canUserCreateIssue && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="nodedc-toolbar-primary nodedc-toolbar-primary-wide"
|
||||
<AppHeaderPrimaryActionButton
|
||||
onClick={() => {
|
||||
toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
|
||||
}}
|
||||
data-ph-element={WORK_ITEM_TRACKER_ELEMENTS.HEADER_ADD_BUTTON.WORK_ITEMS}
|
||||
>
|
||||
<div className="block sm:hidden">{t("issue.label", { count: 1 })}</div>
|
||||
<div className="hidden sm:block">{t("issue.add.label")}</div>
|
||||
</Button>
|
||||
{t("app_header.add_task")}
|
||||
</AppHeaderPrimaryActionButton>
|
||||
)}
|
||||
</Header.RightItem>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { MoreHorizontal, Bell, BellOff } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { getIconButtonStyling } from "@plane/propel/icon-button";
|
||||
import { CheckCircleFilledIcon, CloseCircleFilledIcon, CopyLinkIcon, NewTabIcon } from "@plane/propel/icons";
|
||||
import { CustomMenu } from "@plane/ui";
|
||||
|
||||
type Props = {
|
||||
canOpenTargetWorkItem: boolean;
|
||||
canReviewClosedRequest: boolean;
|
||||
includeDecisionActions?: boolean;
|
||||
isSubscribed?: boolean;
|
||||
isSubscriptionLoading?: boolean;
|
||||
onAccept?: () => void;
|
||||
onCopy: () => void;
|
||||
onDecline?: () => void;
|
||||
onOpenTarget?: () => void;
|
||||
onToggleSubscription?: () => void;
|
||||
};
|
||||
|
||||
export const ExternalContourActionsMenu = (props: Props) => {
|
||||
const {
|
||||
canOpenTargetWorkItem,
|
||||
canReviewClosedRequest,
|
||||
includeDecisionActions = false,
|
||||
isSubscribed,
|
||||
isSubscriptionLoading = false,
|
||||
onAccept,
|
||||
onCopy,
|
||||
onDecline,
|
||||
onOpenTarget,
|
||||
onToggleSubscription,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<CustomMenu
|
||||
customButton={<MoreHorizontal className="size-4" />}
|
||||
customButtonClassName={getIconButtonStyling("secondary", "lg")}
|
||||
placement="bottom-start"
|
||||
>
|
||||
{includeDecisionActions && canReviewClosedRequest && onAccept && (
|
||||
<CustomMenu.MenuItem onClick={onAccept}>
|
||||
<div className="flex items-center gap-2 text-success-secondary">
|
||||
<CheckCircleFilledIcon width={14} height={14} />
|
||||
{t("external_contours_page.actions.accept")}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
{includeDecisionActions && canReviewClosedRequest && onDecline && (
|
||||
<CustomMenu.MenuItem onClick={onDecline}>
|
||||
<div className="flex items-center gap-2 text-danger-secondary">
|
||||
<CloseCircleFilledIcon width={14} height={14} />
|
||||
{t("external_contours_page.actions.decline")}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
<CustomMenu.MenuItem onClick={onCopy}>
|
||||
<div className="flex items-center gap-2">
|
||||
<CopyLinkIcon width={14} height={14} />
|
||||
{t("external_contours_page.actions.copy")}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
|
||||
{canOpenTargetWorkItem && onOpenTarget && (
|
||||
<CustomMenu.MenuItem onClick={onOpenTarget}>
|
||||
<div className="flex items-center gap-2">
|
||||
<NewTabIcon width={14} height={14} />
|
||||
{t("external_contours_page.actions.open")}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
|
||||
{canOpenTargetWorkItem && onToggleSubscription && (
|
||||
<CustomMenu.MenuItem onClick={onToggleSubscription} disabled={isSubscriptionLoading || isSubscribed === undefined}>
|
||||
<div className="flex items-center gap-2">
|
||||
{isSubscribed ? <BellOff width={14} height={14} /> : <Bell width={14} height={14} />}
|
||||
{isSubscribed ? t("common.actions.unsubscribe") : t("common.actions.subscribe")}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</CustomMenu>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { cn } from "@plane/utils";
|
||||
import type { TExternalContourBoardDirection } from "@plane/types";
|
||||
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
|
||||
import { ExternalContoursBoardItem } from "./board-item";
|
||||
import { ExternalContoursEmptyState } from "./empty-state";
|
||||
|
||||
type Props = {
|
||||
direction: TExternalContourBoardDirection;
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const ExternalContoursBoardColumn = observer(function ExternalContoursBoardColumn(props: Props) {
|
||||
const { direction, projectId, workspaceSlug } = props;
|
||||
const { t } = useTranslation();
|
||||
const { getColumnRequestIds, getColumnTotalCount, getRequestById } = useProjectExternalContoursBoard();
|
||||
const requestIds = getColumnRequestIds(direction);
|
||||
const totalCount = getColumnTotalCount(direction);
|
||||
|
||||
const title =
|
||||
direction === "outgoing"
|
||||
? t("external_contours_page.board.columns.outgoing")
|
||||
: t("external_contours_page.board.columns.incoming");
|
||||
|
||||
const emptyTitle =
|
||||
direction === "outgoing"
|
||||
? t("external_contours_page.board.empty.outgoing_title")
|
||||
: t("external_contours_page.board.empty.incoming_title");
|
||||
|
||||
const emptyDescription =
|
||||
direction === "outgoing"
|
||||
? t("external_contours_page.board.empty.outgoing_description")
|
||||
: t("external_contours_page.board.empty.incoming_description");
|
||||
|
||||
return (
|
||||
<section className="flex h-full min-h-0 w-[350px] flex-shrink-0 flex-col">
|
||||
<div className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="text-15 font-semibold text-primary">{title}</div>
|
||||
<div className="rounded-full bg-white/5 px-2 py-1 text-12 font-semibold text-secondary">{totalCount}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"vertical-scrollbar scrollbar-md h-full min-h-[120px] flex-1 overflow-y-auto pt-3",
|
||||
direction === "incoming" ? "-mr-4 pr-4" : "-mr-2 pr-2"
|
||||
)}
|
||||
>
|
||||
{requestIds.length > 0 ? (
|
||||
<>
|
||||
{requestIds.map((requestId) => {
|
||||
const request = getRequestById(requestId);
|
||||
if (!request) return null;
|
||||
|
||||
return (
|
||||
<ExternalContoursBoardItem
|
||||
key={requestId}
|
||||
direction={direction}
|
||||
projectId={projectId}
|
||||
request={request}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full min-h-[18rem] items-center justify-center">
|
||||
<ExternalContoursEmptyState title={emptyTitle} description={emptyDescription} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,380 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import { observer } from "mobx-react";
|
||||
import { EUserPermissions } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { PriorityIcon, StateGroupIcon } from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type {
|
||||
IState,
|
||||
TExternalContourBoardDirection,
|
||||
TExternalContourRequest,
|
||||
TIssue,
|
||||
} from "@plane/types";
|
||||
import { Avatar } from "@plane/ui";
|
||||
import { cn, renderFormattedDate, renderFormattedPayloadDate } from "@plane/utils";
|
||||
import { DateDropdown } from "@/components/dropdowns/date";
|
||||
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
|
||||
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
|
||||
import { MemberDropdownBase } from "@/components/dropdowns/member/base";
|
||||
import { PriorityDropdown } from "@/components/dropdowns/priority";
|
||||
import { WorkItemStateDropdownBase } from "@/components/dropdowns/state/base";
|
||||
import { StateDropdown } from "@/components/dropdowns/state/dropdown";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
|
||||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import { useProjectState } from "@/hooks/store/use-project-state";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { IssueService } from "@/services/issue/issue.service";
|
||||
|
||||
type Props = {
|
||||
direction: TExternalContourBoardDirection;
|
||||
projectId: string;
|
||||
request: TExternalContourRequest;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
const issueService = new IssueService();
|
||||
|
||||
const basePillClasses =
|
||||
"inline-flex min-h-9 items-center gap-1.5 rounded-full border-0 px-2.5 py-1 text-[11px] font-medium shadow-none outline-none transition-colors";
|
||||
|
||||
const buildSourceStateMap = (
|
||||
states: { id: string; name: string; color: string; group: IState["group"] }[] | undefined,
|
||||
projectId: string | null
|
||||
) =>
|
||||
Object.fromEntries(
|
||||
(states ?? []).map((state, index) => [
|
||||
state.id,
|
||||
{
|
||||
id: state.id,
|
||||
color: state.color,
|
||||
default: false,
|
||||
description: "",
|
||||
group: state.group,
|
||||
name: state.name,
|
||||
order: index + 1,
|
||||
project_id: projectId ?? "",
|
||||
sequence: index + 1,
|
||||
workspace_id: "",
|
||||
} satisfies IState,
|
||||
])
|
||||
);
|
||||
|
||||
const resolveRequestStatus = (issue: TExternalContourRequest["issue"], fallbackStatus: TExternalContourRequest["status"]) => {
|
||||
const stateGroup = issue.state_detail?.group;
|
||||
if (!stateGroup) return fallbackStatus;
|
||||
return stateGroup === "completed" || stateGroup === "cancelled" ? "closed" : "open";
|
||||
};
|
||||
|
||||
export const ExternalContoursBoardItem = observer(function ExternalContoursBoardItem(props: Props) {
|
||||
const { direction, projectId, request, workspaceSlug } = props;
|
||||
const router = useAppRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const { getUserDetails, workspace } = useMember();
|
||||
const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions();
|
||||
const { getStateById, getProjectStateIds } = useProjectState();
|
||||
const {
|
||||
fetchBoard,
|
||||
upsertBoardItems,
|
||||
} = useProjectExternalContoursBoard();
|
||||
const {
|
||||
fetchTargetOptions,
|
||||
getTargetOptionsByProjectId,
|
||||
updateRequest,
|
||||
updateRequestIssue,
|
||||
} = useProjectExternalContours();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [isSourceOptionsLoading, setIsSourceOptionsLoading] = useState(false);
|
||||
|
||||
const issue = request.issue;
|
||||
const selectedInboxIssueId = searchParams.get("inboxIssueId");
|
||||
const isActive = selectedInboxIssueId === request.id;
|
||||
const requester = request.requested_by?.display_name || request.requested_by_name || issue.created_by_detail?.display_name || "NODE.DC";
|
||||
const requesterAvatar = issue.created_by_detail?.avatar_url || "";
|
||||
const counterpartContourName =
|
||||
direction === "outgoing"
|
||||
? request.target_project?.name || request.target_project_name || issue.project_detail?.name
|
||||
: request.source_project?.name || request.source_project_name;
|
||||
const targetProjectId = issue.project_id || request.target_project?.id || request.target_project_id || null;
|
||||
const projectRole = targetProjectId
|
||||
? getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId)
|
||||
: undefined;
|
||||
const canEditTargetIssue =
|
||||
direction === "incoming" && !!targetProjectId && projectRole !== undefined && projectRole !== EUserPermissions.GUEST;
|
||||
const canEditSourceRequest = direction === "outgoing" && !!request.capabilities?.can_edit_request && !!targetProjectId;
|
||||
const canEditCard = canEditTargetIssue || canEditSourceRequest;
|
||||
const requestLink = `/${workspaceSlug}/projects/${projectId}/external-contours?inboxIssueId=${request.id}`;
|
||||
const targetOptions = getTargetOptionsByProjectId(targetProjectId);
|
||||
const sourceStateMap = useMemo(
|
||||
() => buildSourceStateMap(targetOptions?.states, targetProjectId),
|
||||
[targetOptions?.states, targetProjectId]
|
||||
);
|
||||
const sourceStateIds = useMemo(() => targetOptions?.states?.map((state) => state.id) ?? [], [targetOptions?.states]);
|
||||
const selectedState = canEditTargetIssue ? getStateById(issue.state_id) : sourceStateMap[issue.state_id ?? ""];
|
||||
const projectStateIds = issue.project_id ? getProjectStateIds(issue.project_id) : [];
|
||||
const foregroundClasses = isActive ? "text-[#111111]" : "text-white";
|
||||
const subtleTextClasses = isActive ? "text-[#2F4721]" : "text-[#B3B3B8]";
|
||||
const pillBackgroundClasses =
|
||||
isActive ? "bg-black/10 text-[#111111]" : "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white";
|
||||
const iconBubbleClasses = isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white";
|
||||
const statusIconColor = selectedState?.color ?? (isActive ? "#111111" : "var(--text-color-primary)");
|
||||
const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none");
|
||||
|
||||
if (!issue) return null;
|
||||
|
||||
const stopCardPropagation = (event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const openDetail = () => {
|
||||
if (isActive) return;
|
||||
router.push(requestLink);
|
||||
};
|
||||
|
||||
const syncBoardAfterMutation = async () => {
|
||||
await fetchBoard(workspaceSlug, projectId);
|
||||
};
|
||||
|
||||
const ensureSourceOptions = async () => {
|
||||
if (!canEditSourceRequest || !targetProjectId) return;
|
||||
|
||||
const tasks: Promise<unknown>[] = [];
|
||||
if (!targetOptions) {
|
||||
setIsSourceOptionsLoading(true);
|
||||
tasks.push(fetchTargetOptions(workspaceSlug, projectId, targetProjectId));
|
||||
}
|
||||
if (!workspace.workspaceMemberIds) {
|
||||
tasks.push(workspace.fetchWorkspaceMembers(workspaceSlug));
|
||||
}
|
||||
|
||||
if (tasks.length === 0) return;
|
||||
|
||||
try {
|
||||
await Promise.all(tasks);
|
||||
} finally {
|
||||
setIsSourceOptionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTargetIssueUpdate = async (data: Partial<TIssue>) => {
|
||||
if (!targetProjectId || !issue.id || isUpdating) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const updatedIssue = await issueService.patchIssue(workspaceSlug, targetProjectId, issue.id, data);
|
||||
const nextIssue = { ...issue, ...updatedIssue };
|
||||
const nextRequest = {
|
||||
...request,
|
||||
issue: nextIssue,
|
||||
status: resolveRequestStatus(nextIssue, request.status),
|
||||
};
|
||||
|
||||
updateRequestIssue(request.id, nextIssue);
|
||||
upsertBoardItems([nextRequest]);
|
||||
await syncBoardAfterMutation();
|
||||
} catch {
|
||||
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("issue_could_not_be_updated") });
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSourceRequestUpdate = async (data: Partial<TIssue>) => {
|
||||
if (!canEditSourceRequest || isUpdating) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const updatedRequest = await updateRequest(workspaceSlug, projectId, request.id, data);
|
||||
if (updatedRequest) {
|
||||
upsertBoardItems([updatedRequest]);
|
||||
}
|
||||
await syncBoardAfterMutation();
|
||||
} catch {
|
||||
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("issue_could_not_be_updated") });
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCardUpdate = async (data: Partial<TIssue>) => {
|
||||
if (canEditTargetIssue) {
|
||||
await handleTargetIssueUpdate(data);
|
||||
return;
|
||||
}
|
||||
|
||||
await handleSourceRequestUpdate(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group/kanban-block relative mb-2">
|
||||
<div
|
||||
data-active={isActive}
|
||||
className="nodedc-external-card relative flex min-h-[220px] w-full cursor-pointer flex-col p-4 transition-all hover:bg-white/5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={openDetail}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openDetail();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={cn("relative flex min-h-[220px] flex-col px-1", foregroundClasses)}>
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div className="shrink-0">
|
||||
<Avatar src={requesterAvatar} name={requester} size="md" />
|
||||
</div>
|
||||
<div className={cn("truncate text-body-sm-medium leading-5", foregroundClasses)}>{requester}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2" onClick={stopCardPropagation}>
|
||||
{request.has_unread_updates && (
|
||||
<span
|
||||
className={cn("size-2 rounded-full", isActive ? "bg-black/70" : "bg-accent-primary")}
|
||||
title={t("external_contours_page.list.unread_updates")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PriorityDropdown
|
||||
value={issue.priority}
|
||||
onChange={(priority) => void handleCardUpdate({ priority })}
|
||||
disabled={!canEditCard || isUpdating}
|
||||
buttonVariant="transparent-without-text"
|
||||
button={
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded-full border-0 shadow-none outline-none",
|
||||
iconBubbleClasses
|
||||
)}
|
||||
>
|
||||
<PriorityIcon priority={issue.priority} className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{canEditTargetIssue ? (
|
||||
<StateDropdown
|
||||
projectId={issue.project_id ?? undefined}
|
||||
stateIds={projectStateIds ?? []}
|
||||
value={issue.state_id}
|
||||
onChange={(stateId) => void handleCardUpdate({ state_id: stateId })}
|
||||
disabled={!canEditCard || isUpdating}
|
||||
buttonVariant="transparent-without-text"
|
||||
button={
|
||||
<div className={cn("flex h-8 w-8 items-center justify-center rounded-full", iconBubbleClasses)}>
|
||||
<StateGroupIcon
|
||||
stateGroup={selectedState?.group ?? "backlog"}
|
||||
color={statusIconColor}
|
||||
className="h-3.5 w-3.5"
|
||||
percentage={selectedState?.order}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<WorkItemStateDropdownBase
|
||||
projectId={targetProjectId ?? undefined}
|
||||
value={issue.state_id}
|
||||
stateIds={sourceStateIds}
|
||||
getStateById={(stateId) => (stateId ? sourceStateMap[stateId] : undefined)}
|
||||
onChange={(stateId) => void handleCardUpdate({ state_id: stateId })}
|
||||
disabled={!canEditCard || isUpdating || !targetProjectId}
|
||||
isInitializing={isSourceOptionsLoading}
|
||||
onDropdownOpen={() => {
|
||||
void ensureSourceOptions();
|
||||
}}
|
||||
buttonVariant="transparent-without-text"
|
||||
button={
|
||||
<div className={cn("flex h-8 w-8 items-center justify-center rounded-full", iconBubbleClasses)}>
|
||||
<StateGroupIcon
|
||||
stateGroup={selectedState?.group ?? "backlog"}
|
||||
color={statusIconColor}
|
||||
className="h-3.5 w-3.5"
|
||||
percentage={selectedState?.order}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cn("truncate -mt-0.5 pl-8 text-[11px] font-medium leading-4", subtleTextClasses)}>
|
||||
{counterpartContourName || t("common.none")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center px-5 py-4 text-center">
|
||||
<div className="line-clamp-4 max-w-full text-lg font-semibold leading-6">{issue.name}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3" onClick={stopCardPropagation}>
|
||||
{canEditTargetIssue ? (
|
||||
<MemberDropdown
|
||||
multiple
|
||||
projectId={issue.project_id ?? undefined}
|
||||
value={issue.assignee_ids ?? []}
|
||||
onChange={(assigneeIds) => void handleCardUpdate({ assignee_ids: assigneeIds })}
|
||||
disabled={!canEditCard || isUpdating}
|
||||
buttonVariant="transparent-without-text"
|
||||
button={
|
||||
<div className={cn(basePillClasses, pillBackgroundClasses, "pl-1 pr-2")}>
|
||||
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size="sm" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<MemberDropdownBase
|
||||
multiple
|
||||
getUserDetails={getUserDetails}
|
||||
memberIds={targetOptions?.member_ids ?? []}
|
||||
value={issue.assignee_ids ?? []}
|
||||
onChange={(assigneeIds) => void handleCardUpdate({ assignee_ids: assigneeIds })}
|
||||
disabled={!canEditCard || isUpdating || !targetProjectId}
|
||||
onDropdownOpen={() => {
|
||||
void ensureSourceOptions();
|
||||
}}
|
||||
buttonVariant="transparent-without-text"
|
||||
button={
|
||||
<div className={cn(basePillClasses, pillBackgroundClasses, "pl-1 pr-2")}>
|
||||
<ButtonAvatars showTooltip={false} userIds={issue.assignee_ids ?? []} size="sm" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DateDropdown
|
||||
value={issue.target_date}
|
||||
onChange={(targetDate) =>
|
||||
void handleCardUpdate({
|
||||
target_date: targetDate ? renderFormattedPayloadDate(targetDate) : null,
|
||||
})
|
||||
}
|
||||
disabled={!canEditCard || isUpdating}
|
||||
buttonVariant="transparent-without-text"
|
||||
button={
|
||||
<div className={cn(basePillClasses, pillBackgroundClasses)}>
|
||||
<CalendarDays className="h-3.5 w-3.5" />
|
||||
<span className="truncate">{dueDateLabel}</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { cn } from "@plane/utils";
|
||||
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
|
||||
import { ExternalContoursBoardColumn } from "./board-column";
|
||||
|
||||
type Props = {
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const ExternalContoursBoardRoot = observer(function ExternalContoursBoardRoot(props: Props) {
|
||||
const { projectId, workspaceSlug } = props;
|
||||
const { t } = useTranslation();
|
||||
const { hasAnyItems, isFiltering, loader } = useProjectExternalContoursBoard();
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col overflow-hidden px-8 pb-6">
|
||||
{loader === "init-loading" && !hasAnyItems ? (
|
||||
<div className="flex flex-1 items-center justify-center text-13 text-secondary">{t("loading")}...</div>
|
||||
) : (
|
||||
<div className="relative min-h-0 flex-1">
|
||||
{isFiltering && (
|
||||
<div className="pointer-events-none absolute top-0 right-0 z-10 rounded-full bg-black/35 px-3 py-1 text-11 font-medium text-secondary backdrop-blur-sm">
|
||||
{t("updating")}...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"horizontal-scrollbar flex h-full min-h-0 items-stretch gap-4 overflow-x-auto overflow-y-hidden pb-1 transition-opacity",
|
||||
{ "opacity-60": isFiltering }
|
||||
)}
|
||||
>
|
||||
<ExternalContoursBoardColumn
|
||||
direction="outgoing"
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
<ExternalContoursBoardColumn
|
||||
direction="incoming"
|
||||
projectId={projectId}
|
||||
workspaceSlug={workspaceSlug}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -9,27 +9,33 @@ import { observer } from "mobx-react";
|
|||
import useSWR from "swr";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import type { TNameDescriptionLoader } from "@plane/types";
|
||||
import { ContentWrapper } from "@plane/ui";
|
||||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import { useUser, useUserPermissions } from "@/hooks/store/user";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { ExternalContoursIssueActionsHeader } from "./issue-header";
|
||||
import { ExternalContoursPeekShell } from "./peek-shell";
|
||||
import { ExternalContoursIssueMainContent } from "./issue-root";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
inboxIssueId: string;
|
||||
isMobileSidebar: boolean;
|
||||
setIsMobileSidebar: (value: boolean) => void;
|
||||
embedIssue?: boolean;
|
||||
embedRemoveCurrentNotification?: () => void;
|
||||
};
|
||||
|
||||
export const ExternalContoursContentRoot = observer(function ExternalContoursContentRoot(props: Props) {
|
||||
const { workspaceSlug, projectId, inboxIssueId, isMobileSidebar, setIsMobileSidebar } = props;
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
inboxIssueId,
|
||||
embedIssue = false,
|
||||
embedRemoveCurrentNotification,
|
||||
} = props;
|
||||
const router = useAppRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved");
|
||||
const [isDetailResolved, setIsDetailResolved] = useState(false);
|
||||
const { data: currentUser } = useUser();
|
||||
const { currentTab, fetchRequestById, getRequestById, getIsRequestAvailable } = useProjectExternalContours();
|
||||
const { fetchRequestById, getRequestById } = useProjectExternalContours();
|
||||
const contourRequest = getRequestById(inboxIssueId);
|
||||
const issue = contourRequest?.issue;
|
||||
const targetProjectId = issue?.project_id || projectId;
|
||||
|
|
@ -38,20 +44,24 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
|
|||
targetProjectId && getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId) !== undefined
|
||||
);
|
||||
|
||||
const isIssueAvailable = getIsRequestAvailable(inboxIssueId?.toString() || "");
|
||||
|
||||
useEffect(() => {
|
||||
if (!isIssueAvailable && inboxIssueId) {
|
||||
router.replace(`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}`);
|
||||
if (isDetailResolved && !contourRequest && inboxIssueId) {
|
||||
router.replace(`/${workspaceSlug}/projects/${projectId}/external-contours`);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isIssueAvailable]);
|
||||
}, [contourRequest, inboxIssueId, isDetailResolved, projectId, router, workspaceSlug]);
|
||||
|
||||
useSWR(
|
||||
workspaceSlug && projectId && inboxIssueId
|
||||
? `PROJECT_EXTERNAL_CONTOUR_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}`
|
||||
: null,
|
||||
workspaceSlug && projectId && inboxIssueId ? () => fetchRequestById(workspaceSlug, projectId, inboxIssueId) : null,
|
||||
workspaceSlug && projectId && inboxIssueId
|
||||
? async () => {
|
||||
const request = await fetchRequestById(workspaceSlug, projectId, inboxIssueId);
|
||||
setIsDetailResolved(true);
|
||||
return request;
|
||||
}
|
||||
: null,
|
||||
{
|
||||
revalidateOnFocus: !hasDirectTargetAccess,
|
||||
revalidateIfStale: !hasDirectTargetAccess,
|
||||
|
|
@ -78,30 +88,26 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
|
|||
if (!contourRequest || !issue) return <></>;
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col overflow-hidden pt-6">
|
||||
<div className="z-[11] min-h-[52px] flex-shrink-0">
|
||||
<ExternalContoursIssueActionsHeader
|
||||
setIsMobileSidebar={setIsMobileSidebar}
|
||||
isMobileSidebar={isMobileSidebar}
|
||||
workspaceSlug={workspaceSlug}
|
||||
sourceProjectId={projectId}
|
||||
contourRequest={contourRequest}
|
||||
isSubmitting={isSubmitting}
|
||||
hasDirectTargetAccess={hasDirectTargetAccess}
|
||||
/>
|
||||
</div>
|
||||
<ContentWrapper className="space-y-4 px-4 pb-4 pt-4">
|
||||
<ExternalContoursIssueMainContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
sourceProjectId={projectId}
|
||||
contourRequest={contourRequest}
|
||||
hasDirectTargetAccess={hasDirectTargetAccess}
|
||||
isEditable={!!isEditable && !readOnly}
|
||||
isSourceEditable={isSourceEditable}
|
||||
isSubmitting={isSubmitting}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
</div>
|
||||
<ExternalContoursPeekShell
|
||||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
contourRequest={contourRequest}
|
||||
hasDirectTargetAccess={hasDirectTargetAccess}
|
||||
isSubmitting={isSubmitting}
|
||||
embedIssue={embedIssue}
|
||||
embedRemoveCurrentNotification={embedRemoveCurrentNotification}
|
||||
onClose={() => router.replace(`/${workspaceSlug}/projects/${projectId}/external-contours`)}
|
||||
>
|
||||
<ExternalContoursIssueMainContent
|
||||
workspaceSlug={workspaceSlug}
|
||||
sourceProjectId={projectId}
|
||||
contourRequest={contourRequest}
|
||||
hasDirectTargetAccess={hasDirectTargetAccess}
|
||||
isEditable={!!isEditable && !readOnly}
|
||||
isSourceEditable={isSourceEditable}
|
||||
isSubmitting={isSubmitting}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
/>
|
||||
</ExternalContoursPeekShell>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import { useTranslation } from "@plane/i18n";
|
|||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TIssue } from "@plane/types";
|
||||
import { EInboxIssueCurrentTab } from "@plane/types";
|
||||
import { ToggleSwitch } from "@plane/ui";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
|
||||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
|
|
@ -47,6 +47,7 @@ export const ExternalContoursCreateRoot = observer(function ExternalContoursCrea
|
|||
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id;
|
||||
const { currentProjectDetails } = useProject();
|
||||
const { createRequest, fetchTargetOptions, fetchTargetProjects } = useProjectExternalContours();
|
||||
const { fetchBoard } = useProjectExternalContoursBoard();
|
||||
const descriptionEditorRef = useRef<EditorRefApi>(null);
|
||||
const [createMore, setCreateMore] = useState(false);
|
||||
const [formSubmitting, setFormSubmitting] = useState(false);
|
||||
|
|
@ -97,6 +98,7 @@ export const ExternalContoursCreateRoot = observer(function ExternalContoursCrea
|
|||
|
||||
try {
|
||||
const createdRequest = await createRequest(workspaceSlug, projectId, formData);
|
||||
await fetchBoard(workspaceSlug, projectId);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("success"),
|
||||
|
|
@ -110,7 +112,7 @@ export const ExternalContoursCreateRoot = observer(function ExternalContoursCrea
|
|||
}
|
||||
|
||||
if (createdRequest?.id) {
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${EInboxIssueCurrentTab.OPEN}&inboxIssueId=${createdRequest.id}`);
|
||||
router.push(`/${workspaceSlug}/projects/${projectId}/external-contours?inboxIssueId=${createdRequest.id}`);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setToast({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { observer } from "mobx-react";
|
||||
import { FilterInstance, workItemFiltersAdapter } from "@plane/shared-state";
|
||||
import type { IFilterInstance } from "@plane/shared-state";
|
||||
import type { TWorkItemFilterExpression, TWorkItemFilterProperty } from "@plane/types";
|
||||
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
|
||||
import { useExternalContoursFiltersConfig } from "./use-external-contours-filters-config";
|
||||
import {
|
||||
buildExternalContourBoardFilters,
|
||||
buildExternalContourRichFilterExpression,
|
||||
} from "./utils";
|
||||
|
||||
const ExternalContoursFilterContext = createContext<
|
||||
IFilterInstance<TWorkItemFilterProperty, TWorkItemFilterExpression> | undefined
|
||||
>(undefined);
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
projectId: string;
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export const ProjectExternalContoursFiltersProvider = observer(function ProjectExternalContoursFiltersProvider(props: Props) {
|
||||
const { children, projectId, workspaceSlug } = props;
|
||||
const { filters, items, replaceFilters } = useProjectExternalContoursBoard();
|
||||
|
||||
const requests = useMemo(() => Object.values(items), [items]);
|
||||
const { areAllConfigsInitialized, configs } = useExternalContoursFiltersConfig({
|
||||
projectId,
|
||||
requests,
|
||||
workspaceSlug,
|
||||
});
|
||||
|
||||
const filter = useMemo(
|
||||
() =>
|
||||
new FilterInstance<TWorkItemFilterProperty, TWorkItemFilterExpression>({
|
||||
adapter: workItemFiltersAdapter,
|
||||
initialExpression: buildExternalContourRichFilterExpression(filters),
|
||||
onExpressionChange: (expression) => {
|
||||
void replaceFilters(workspaceSlug, projectId, buildExternalContourBoardFilters(expression));
|
||||
},
|
||||
}),
|
||||
[projectId, workspaceSlug]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
filter.onExpressionChange = (expression) => {
|
||||
void replaceFilters(workspaceSlug, projectId, buildExternalContourBoardFilters(expression));
|
||||
};
|
||||
}, [filter, projectId, replaceFilters, workspaceSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
filter.configManager.setAreConfigsReady(areAllConfigsInitialized);
|
||||
filter.configManager.registerAll(configs);
|
||||
}, [areAllConfigsInitialized, configs, filter.configManager]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextExpression = buildExternalContourRichFilterExpression(filters);
|
||||
const currentExpression = workItemFiltersAdapter.toExternal(filter.expression);
|
||||
|
||||
if (isEqual(currentExpression, nextExpression)) return;
|
||||
|
||||
const onExpressionChange = filter.onExpressionChange;
|
||||
filter.onExpressionChange = undefined;
|
||||
filter.resetExpression(nextExpression);
|
||||
filter.onExpressionChange = onExpressionChange;
|
||||
}, [filter, filters]);
|
||||
|
||||
return <ExternalContoursFilterContext.Provider value={filter}>{children}</ExternalContoursFilterContext.Provider>;
|
||||
});
|
||||
|
||||
export const useExternalContoursFilter = () => useContext(ExternalContoursFilterContext);
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Briefcase } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Logo } from "@plane/propel/emoji-icon-picker";
|
||||
import {
|
||||
DueDatePropertyIcon,
|
||||
LabelPropertyIcon,
|
||||
MembersPropertyIcon,
|
||||
PriorityIcon,
|
||||
PriorityPropertyIcon,
|
||||
StateGroupIcon,
|
||||
StatePropertyIcon,
|
||||
UserCirclePropertyIcon,
|
||||
} from "@plane/propel/icons";
|
||||
import type {
|
||||
IProject,
|
||||
IState,
|
||||
IIssueLabel,
|
||||
IUserLite,
|
||||
TExternalContourRequest,
|
||||
TFilterConfig,
|
||||
TWorkItemFilterProperty,
|
||||
} from "@plane/types";
|
||||
import { Avatar } from "@plane/ui";
|
||||
import {
|
||||
getAssigneeFilterConfig,
|
||||
getCreatedByFilterConfig,
|
||||
getFileURL,
|
||||
getLabelFilterConfig,
|
||||
getPriorityFilterConfig,
|
||||
getProjectFilterConfig,
|
||||
getStateFilterConfig,
|
||||
getStateGroupFilterConfig,
|
||||
getTargetDateFilterConfig,
|
||||
} from "@plane/utils";
|
||||
import { useFiltersOperatorConfigs } from "@/plane-web/hooks/rich-filters/use-filters-operator-configs";
|
||||
|
||||
const sortByName = <T extends { name?: string | null; display_name?: string | null }>(items: T[]) =>
|
||||
[...items].sort((left, right) =>
|
||||
(left.display_name || left.name || "").localeCompare(right.display_name || right.name || "", "ru")
|
||||
);
|
||||
|
||||
const buildCounterpartyProjects = (requests: TExternalContourRequest[], projectId: string): IProject[] => {
|
||||
const projectMap = new Map<string, IProject>();
|
||||
|
||||
requests.forEach((request) => {
|
||||
const project =
|
||||
request.direction === "incoming"
|
||||
? request.source_project
|
||||
: request.target_project || request.issue.project_detail || null;
|
||||
|
||||
if (!project?.id || project.id === projectId || projectMap.has(project.id)) return;
|
||||
|
||||
projectMap.set(
|
||||
project.id,
|
||||
{
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
logo_props: project.logo_props,
|
||||
} as IProject
|
||||
);
|
||||
});
|
||||
|
||||
return sortByName(Array.from(projectMap.values()));
|
||||
};
|
||||
|
||||
const buildStates = (requests: TExternalContourRequest[]): IState[] => {
|
||||
const stateMap = new Map<string, IState>();
|
||||
|
||||
requests.forEach((request, index) => {
|
||||
const state = request.issue.state_detail;
|
||||
if (!state?.id || stateMap.has(state.id)) return;
|
||||
|
||||
stateMap.set(
|
||||
state.id,
|
||||
{
|
||||
id: state.id,
|
||||
color: state.color,
|
||||
default: false,
|
||||
description: "",
|
||||
group: state.group,
|
||||
name: state.name,
|
||||
order: index + 1,
|
||||
project_id: request.issue.project_id || request.target_project?.id || request.source_project?.id || "",
|
||||
sequence: index + 1,
|
||||
workspace_id: "",
|
||||
} as IState
|
||||
);
|
||||
});
|
||||
|
||||
return sortByName(Array.from(stateMap.values()));
|
||||
};
|
||||
|
||||
const buildLabels = (requests: TExternalContourRequest[]): IIssueLabel[] => {
|
||||
const labelMap = new Map<string, IIssueLabel>();
|
||||
|
||||
requests.forEach((request) => {
|
||||
request.issue.label_details?.forEach((label) => {
|
||||
if (!label.id || labelMap.has(label.id)) return;
|
||||
labelMap.set(
|
||||
label.id,
|
||||
{
|
||||
id: label.id,
|
||||
color: label.color,
|
||||
name: label.name,
|
||||
parent: null,
|
||||
project_id: request.issue.project_id || "",
|
||||
sort_order: 0,
|
||||
workspace_id: "",
|
||||
} as IIssueLabel
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return sortByName(Array.from(labelMap.values()));
|
||||
};
|
||||
|
||||
const buildAssignees = (requests: TExternalContourRequest[]): IUserLite[] => {
|
||||
const memberMap = new Map<string, IUserLite>();
|
||||
|
||||
requests.forEach((request) => {
|
||||
request.issue.assignee_details?.forEach((assignee) => {
|
||||
if (!assignee.id || memberMap.has(assignee.id)) return;
|
||||
memberMap.set(
|
||||
assignee.id,
|
||||
{
|
||||
id: assignee.id,
|
||||
avatar_url: assignee.avatar_url,
|
||||
display_name: assignee.display_name,
|
||||
} as IUserLite
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return sortByName(Array.from(memberMap.values()));
|
||||
};
|
||||
|
||||
const buildRequesters = (requests: TExternalContourRequest[]): IUserLite[] => {
|
||||
const memberMap = new Map<string, IUserLite>();
|
||||
|
||||
requests.forEach((request) => {
|
||||
const requesterId = request.requested_by?.id || request.requested_by_id || request.issue.created_by_detail?.id;
|
||||
const requesterName =
|
||||
request.requested_by?.display_name || request.requested_by_name || request.issue.created_by_detail?.display_name;
|
||||
|
||||
if (!requesterId || !requesterName || memberMap.has(requesterId)) return;
|
||||
|
||||
memberMap.set(
|
||||
requesterId,
|
||||
{
|
||||
id: requesterId,
|
||||
avatar_url: request.issue.created_by_detail?.avatar_url,
|
||||
display_name: requesterName,
|
||||
} as IUserLite
|
||||
);
|
||||
});
|
||||
|
||||
return sortByName(Array.from(memberMap.values()));
|
||||
};
|
||||
|
||||
type TUseExternalContoursFiltersConfigProps = {
|
||||
projectId: string;
|
||||
requests: TExternalContourRequest[];
|
||||
workspaceSlug: string;
|
||||
};
|
||||
|
||||
export type TExternalContoursFiltersConfig = {
|
||||
areAllConfigsInitialized: boolean;
|
||||
configs: TFilterConfig<TWorkItemFilterProperty>[];
|
||||
};
|
||||
|
||||
export const useExternalContoursFiltersConfig = (
|
||||
props: TUseExternalContoursFiltersConfigProps
|
||||
): TExternalContoursFiltersConfig => {
|
||||
const { projectId, requests, workspaceSlug } = props;
|
||||
const { t } = useTranslation();
|
||||
const operatorConfigs = useFiltersOperatorConfigs({ workspaceSlug });
|
||||
|
||||
const counterpartyProjects = useMemo(() => buildCounterpartyProjects(requests, projectId), [projectId, requests]);
|
||||
const states = useMemo(() => buildStates(requests), [requests]);
|
||||
const labels = useMemo(() => buildLabels(requests), [requests]);
|
||||
const assignees = useMemo(() => buildAssignees(requests), [requests]);
|
||||
const requesters = useMemo(() => buildRequesters(requests), [requests]);
|
||||
|
||||
const configs = useMemo(
|
||||
() => [
|
||||
getProjectFilterConfig<TWorkItemFilterProperty>("project_id")({
|
||||
isEnabled: true,
|
||||
filterLabel: "Contour",
|
||||
filterIcon: Briefcase,
|
||||
projects: counterpartyProjects,
|
||||
getOptionIcon: (project) => <Logo logo={project.logo_props} size={12} />,
|
||||
...operatorConfigs,
|
||||
}),
|
||||
getStateGroupFilterConfig<TWorkItemFilterProperty>("state_group")({
|
||||
isEnabled: true,
|
||||
filterIcon: StatePropertyIcon,
|
||||
getItemLabel: (stateGroupKey) => t(`workspace_projects.state.${stateGroupKey}`),
|
||||
getOptionIcon: (stateGroupKey) => <StateGroupIcon stateGroup={stateGroupKey} />,
|
||||
...operatorConfigs,
|
||||
}),
|
||||
getStateFilterConfig<TWorkItemFilterProperty>("state_id")({
|
||||
isEnabled: true,
|
||||
filterIcon: StatePropertyIcon,
|
||||
getOptionIcon: (state) => <StateGroupIcon stateGroup={state.group} color={state.color} />,
|
||||
states,
|
||||
...operatorConfigs,
|
||||
}),
|
||||
getPriorityFilterConfig<TWorkItemFilterProperty>("priority")({
|
||||
isEnabled: true,
|
||||
filterLabel: t("common.priority"),
|
||||
filterIcon: PriorityPropertyIcon,
|
||||
getItemLabel: (priorityKey) => (priorityKey === "none" ? t("common.none") : t(priorityKey)),
|
||||
getOptionIcon: (priority) => <PriorityIcon priority={priority} />,
|
||||
...operatorConfigs,
|
||||
}),
|
||||
getAssigneeFilterConfig<TWorkItemFilterProperty>("assignee_id")({
|
||||
isEnabled: true,
|
||||
filterIcon: MembersPropertyIcon,
|
||||
members: assignees,
|
||||
getOptionIcon: (memberDetails) => (
|
||||
<Avatar
|
||||
name={memberDetails.display_name}
|
||||
src={getFileURL(memberDetails.avatar_url)}
|
||||
showTooltip={false}
|
||||
size="sm"
|
||||
/>
|
||||
),
|
||||
...operatorConfigs,
|
||||
}),
|
||||
getCreatedByFilterConfig<TWorkItemFilterProperty>("created_by_id")({
|
||||
isEnabled: true,
|
||||
filterIcon: UserCirclePropertyIcon,
|
||||
members: requesters,
|
||||
getOptionIcon: (memberDetails) => (
|
||||
<Avatar
|
||||
name={memberDetails.display_name}
|
||||
src={getFileURL(memberDetails.avatar_url)}
|
||||
showTooltip={false}
|
||||
size="sm"
|
||||
/>
|
||||
),
|
||||
...operatorConfigs,
|
||||
}),
|
||||
getLabelFilterConfig<TWorkItemFilterProperty>("label_id")({
|
||||
isEnabled: true,
|
||||
filterIcon: LabelPropertyIcon,
|
||||
labels,
|
||||
getOptionIcon: (color) => (
|
||||
<span className="flex size-2.5 flex-shrink-0 rounded-full" style={{ backgroundColor: color }} />
|
||||
),
|
||||
...operatorConfigs,
|
||||
}),
|
||||
getTargetDateFilterConfig<TWorkItemFilterProperty>("target_date")({
|
||||
isEnabled: true,
|
||||
filterIcon: DueDatePropertyIcon,
|
||||
...operatorConfigs,
|
||||
}),
|
||||
],
|
||||
[assignees, counterpartyProjects, labels, operatorConfigs, requesters, states, t]
|
||||
);
|
||||
|
||||
return {
|
||||
areAllConfigsInitialized: true,
|
||||
configs,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import {
|
||||
buildWorkItemFilterExpressionFromConditions,
|
||||
workItemFiltersAdapter,
|
||||
} from "@plane/shared-state";
|
||||
import type { TWorkItemFilterCondition } from "@plane/shared-state";
|
||||
import type {
|
||||
TExternalContourBoardFilter,
|
||||
TFilterValue,
|
||||
TWorkItemFilterExpression,
|
||||
TWorkItemFilterProperty,
|
||||
} from "@plane/types";
|
||||
import { COLLECTION_OPERATOR, COMPARISON_OPERATOR, EQUALITY_OPERATOR } from "@plane/types";
|
||||
import { extractConditionsWithDisplayOperators, renderFormattedPayloadDate } from "@plane/utils";
|
||||
|
||||
export const EXTERNAL_CONTOUR_FILTER_PROPERTIES = [
|
||||
"project_id",
|
||||
"state_group",
|
||||
"state_id",
|
||||
"priority",
|
||||
"assignee_id",
|
||||
"created_by_id",
|
||||
"label_id",
|
||||
"target_date",
|
||||
] as const satisfies TWorkItemFilterProperty[];
|
||||
|
||||
const toStringArray = (value: TFilterValue | TFilterValue[] | undefined): string[] => {
|
||||
if (value === undefined || value === null || value === "") return [];
|
||||
return (Array.isArray(value) ? value : [value]).map((item) => String(item)).filter(Boolean);
|
||||
};
|
||||
|
||||
const toPayloadDate = (value: TFilterValue | undefined): string | undefined => {
|
||||
if (value === undefined || value === null || value === "") return undefined;
|
||||
return renderFormattedPayloadDate(value instanceof Date ? value : String(value)) ?? undefined;
|
||||
};
|
||||
|
||||
export const buildExternalContourRichFilterExpression = (
|
||||
filters: Partial<TExternalContourBoardFilter>
|
||||
): TWorkItemFilterExpression => {
|
||||
const conditions: TWorkItemFilterCondition[] = [];
|
||||
|
||||
if (filters.counterparty_project_ids?.length) {
|
||||
conditions.push({
|
||||
property: "project_id",
|
||||
operator: COLLECTION_OPERATOR.IN,
|
||||
value: filters.counterparty_project_ids,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.state_groups?.length) {
|
||||
conditions.push({
|
||||
property: "state_group",
|
||||
operator: COLLECTION_OPERATOR.IN,
|
||||
value: filters.state_groups,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.state_ids?.length) {
|
||||
conditions.push({
|
||||
property: "state_id",
|
||||
operator: COLLECTION_OPERATOR.IN,
|
||||
value: filters.state_ids,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.priority?.length) {
|
||||
conditions.push({
|
||||
property: "priority",
|
||||
operator: COLLECTION_OPERATOR.IN,
|
||||
value: filters.priority,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.assignee_ids?.length) {
|
||||
conditions.push({
|
||||
property: "assignee_id",
|
||||
operator: COLLECTION_OPERATOR.IN,
|
||||
value: filters.assignee_ids,
|
||||
});
|
||||
}
|
||||
|
||||
const authorIds = filters.requested_by_ids?.length ? filters.requested_by_ids : filters.created_by_ids;
|
||||
if (authorIds?.length) {
|
||||
conditions.push({
|
||||
property: "created_by_id",
|
||||
operator: COLLECTION_OPERATOR.IN,
|
||||
value: authorIds,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.label_ids?.length) {
|
||||
conditions.push({
|
||||
property: "label_id",
|
||||
operator: COLLECTION_OPERATOR.IN,
|
||||
value: filters.label_ids,
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.target_date_exact) {
|
||||
conditions.push({
|
||||
property: "target_date",
|
||||
operator: EQUALITY_OPERATOR.EXACT,
|
||||
value: filters.target_date_exact,
|
||||
});
|
||||
} else if (filters.target_date_from && filters.target_date_to) {
|
||||
conditions.push({
|
||||
property: "target_date",
|
||||
operator: COMPARISON_OPERATOR.RANGE,
|
||||
value: [filters.target_date_from, filters.target_date_to],
|
||||
});
|
||||
}
|
||||
|
||||
return buildWorkItemFilterExpressionFromConditions({ conditions }) ?? {};
|
||||
};
|
||||
|
||||
export const buildExternalContourBoardFilters = (
|
||||
expression: TWorkItemFilterExpression
|
||||
): Partial<TExternalContourBoardFilter> => {
|
||||
const internalExpression = workItemFiltersAdapter.toInternal(expression);
|
||||
if (!internalExpression) return {};
|
||||
|
||||
const filters: Partial<TExternalContourBoardFilter> = {};
|
||||
const conditions = extractConditionsWithDisplayOperators(internalExpression);
|
||||
|
||||
conditions.forEach((condition) => {
|
||||
switch (condition.property) {
|
||||
case "project_id":
|
||||
filters.counterparty_project_ids = toStringArray(condition.value);
|
||||
break;
|
||||
case "state_group":
|
||||
filters.state_groups = toStringArray(condition.value);
|
||||
break;
|
||||
case "state_id":
|
||||
filters.state_ids = toStringArray(condition.value);
|
||||
break;
|
||||
case "priority":
|
||||
filters.priority = toStringArray(condition.value);
|
||||
break;
|
||||
case "assignee_id":
|
||||
filters.assignee_ids = toStringArray(condition.value);
|
||||
break;
|
||||
case "created_by_id":
|
||||
filters.requested_by_ids = toStringArray(condition.value);
|
||||
break;
|
||||
case "label_id":
|
||||
filters.label_ids = toStringArray(condition.value);
|
||||
break;
|
||||
case "target_date":
|
||||
if (condition.operator === COMPARISON_OPERATOR.RANGE) {
|
||||
const [from, to] = toStringArray(condition.value);
|
||||
filters.target_date_from = toPayloadDate(from);
|
||||
filters.target_date_to = toPayloadDate(to);
|
||||
} else {
|
||||
filters.target_date_exact = toPayloadDate(
|
||||
Array.isArray(condition.value) ? condition.value[0] : condition.value
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return filters;
|
||||
};
|
||||
|
|
@ -10,14 +10,16 @@ import { useParams } from "next/navigation";
|
|||
import { RefreshCcw } from "lucide-react";
|
||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TransferIcon } from "@plane/propel/icons";
|
||||
import { Breadcrumbs, Header } from "@plane/ui";
|
||||
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
|
||||
import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button";
|
||||
import { FiltersToggle } from "@/components/rich-filters/filters-toggle";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
|
||||
import { useExternalContoursFilter } from "./filters/provider";
|
||||
import { ExternalContourCreateModalRoot } from "./create-modal";
|
||||
|
||||
export const ProjectExternalContoursHeader = observer(function ProjectExternalContoursHeader() {
|
||||
|
|
@ -27,6 +29,7 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
|
|||
const { allowPermissions } = useUserPermissions();
|
||||
const { loader: currentProjectDetailsLoader } = useProject();
|
||||
const { loader } = useProjectExternalContours();
|
||||
const filter = useExternalContoursFilter();
|
||||
|
||||
const isAuthorized = allowPermissions(
|
||||
[EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
|
||||
|
|
@ -34,9 +37,9 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
|
|||
);
|
||||
|
||||
return (
|
||||
<Header className="pt-4">
|
||||
<Header.LeftItem className="items-center pt-2">
|
||||
<div className="flex flex-grow items-center gap-4">
|
||||
<Header>
|
||||
<Header.LeftItem>
|
||||
<div className="flex min-w-0 flex-grow items-center gap-4">
|
||||
<Breadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
|
||||
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
|
||||
<Breadcrumbs.Item
|
||||
|
|
@ -60,23 +63,21 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
|
|||
)}
|
||||
</div>
|
||||
</Header.LeftItem>
|
||||
<Header.RightItem className="pt-2">
|
||||
<Header.RightItem>
|
||||
{workspaceSlug && projectId && isAuthorized ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="nodedc-top-toolbar-cluster flex items-center gap-2">
|
||||
<FiltersToggle filter={filter} showAddFilterButtonWhenEmpty={false} />
|
||||
</div>
|
||||
<ExternalContourCreateModalRoot
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
modalState={createIssueModal}
|
||||
handleModalClose={() => setCreateIssueModal(false)}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={() => setCreateIssueModal(true)}
|
||||
className="nodedc-external-primary-button min-w-[14rem]"
|
||||
>
|
||||
{t("external_contours_page.header.add_request")}
|
||||
</Button>
|
||||
<AppHeaderPrimaryActionButton onClick={() => setCreateIssueModal(true)}>
|
||||
{t("app_header.add_task")}
|
||||
</AppHeaderPrimaryActionButton>
|
||||
</div>
|
||||
) : null}
|
||||
</Header.RightItem>
|
||||
|
|
|
|||
|
|
@ -6,21 +6,57 @@
|
|||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { MoveDiagonal, MoveRight } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { IconButton } from "@plane/propel/icon-button";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { CheckCircleFilledIcon, ChevronDownIcon, ChevronUpIcon, CloseCircleFilledIcon, LinkIcon, NewTabIcon } from "@plane/propel/icons";
|
||||
import {
|
||||
CenterPanelIcon,
|
||||
CheckCircleFilledIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
CloseCircleFilledIcon,
|
||||
CopyLinkIcon,
|
||||
FullScreenPanelIcon,
|
||||
SidePanelIcon,
|
||||
} from "@plane/propel/icons";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { TExternalContourRequest, TNameDescriptionLoader } from "@plane/types";
|
||||
import { EInboxIssueCurrentTab } from "@plane/types";
|
||||
import { ControlLink, Header, Row } from "@plane/ui";
|
||||
import { ControlLink, CustomSelect, Header, Row, Tooltip } from "@plane/ui";
|
||||
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
|
||||
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status";
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
|
||||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import { useAppRouter } from "@/hooks/use-app-router";
|
||||
import { ExternalContourActionsMenu } from "./actions-menu";
|
||||
import { ExternalContourStatePill } from "./state-pill";
|
||||
import { ExternalContourDeclineModal } from "./decline-modal";
|
||||
import {
|
||||
ExternalContourSubscriptionButton,
|
||||
useExternalContourSubscription,
|
||||
} from "./subscription";
|
||||
|
||||
export type TExternalContourPeekMode = "side-peek" | "modal" | "full-screen";
|
||||
|
||||
const PEEK_OPTIONS: { key: TExternalContourPeekMode; icon: any; i18n_title: string }[] = [
|
||||
{
|
||||
key: "side-peek",
|
||||
icon: SidePanelIcon,
|
||||
i18n_title: "common.side_peek",
|
||||
},
|
||||
{
|
||||
key: "modal",
|
||||
icon: CenterPanelIcon,
|
||||
i18n_title: "common.modal",
|
||||
},
|
||||
{
|
||||
key: "full-screen",
|
||||
icon: FullScreenPanelIcon,
|
||||
i18n_title: "common.full_screen",
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
|
|
@ -28,8 +64,10 @@ type Props = {
|
|||
contourRequest: TExternalContourRequest;
|
||||
hasDirectTargetAccess: boolean;
|
||||
isSubmitting: TNameDescriptionLoader;
|
||||
isMobileSidebar: boolean;
|
||||
setIsMobileSidebar: (value: boolean) => void;
|
||||
removeRoutePeekId: () => void;
|
||||
peekMode: TExternalContourPeekMode;
|
||||
setPeekMode: (value: TExternalContourPeekMode) => void;
|
||||
embedIssue?: boolean;
|
||||
};
|
||||
|
||||
export const ExternalContoursIssueActionsHeader = observer(function ExternalContoursIssueActionsHeader(props: Props) {
|
||||
|
|
@ -39,33 +77,43 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
|||
contourRequest,
|
||||
hasDirectTargetAccess,
|
||||
isSubmitting,
|
||||
isMobileSidebar,
|
||||
setIsMobileSidebar,
|
||||
removeRoutePeekId,
|
||||
peekMode,
|
||||
setPeekMode,
|
||||
embedIssue = false,
|
||||
} = props;
|
||||
const { t } = useTranslation();
|
||||
const router = useAppRouter();
|
||||
const [isDeclineModalOpen, setIsDeclineModalOpen] = useState(false);
|
||||
const { currentTab, decideRequest, filteredRequestIds, handleCurrentTab, loader } = useProjectExternalContours();
|
||||
const { decideRequest, filteredRequestIds, loader } = useProjectExternalContours();
|
||||
const { columnIdsMap } = useProjectExternalContoursBoard();
|
||||
const { getProjectById } = useProject();
|
||||
|
||||
const issue = contourRequest.issue;
|
||||
const currentRequestId = contourRequest.id;
|
||||
const canReviewClosedRequest = contourRequest.status === "closed" && contourRequest.source_decision !== "accepted";
|
||||
const boardRequestIds = [...(columnIdsMap.outgoing ?? []), ...(columnIdsMap.incoming ?? [])];
|
||||
const relativeRequestIds = boardRequestIds.includes(currentRequestId) ? boardRequestIds : filteredRequestIds;
|
||||
const currentMode = PEEK_OPTIONS.find((mode) => mode.key === peekMode);
|
||||
const hasRelativeNavigation = !!currentRequestId && relativeRequestIds.includes(currentRequestId);
|
||||
const canReviewClosedRequest =
|
||||
contourRequest.capabilities?.can_source_decide ??
|
||||
(contourRequest.status === "closed" && contourRequest.source_decision !== "accepted");
|
||||
const isSourceAccepted = contourRequest.source_decision === "accepted";
|
||||
|
||||
const redirectToRelativeIssue = useCallback(
|
||||
(direction: "next" | "prev") => {
|
||||
if (!filteredRequestIds || !currentRequestId) return;
|
||||
const currentIssueIndex = filteredRequestIds.findIndex((requestId) => requestId === currentRequestId);
|
||||
if (!relativeRequestIds || !currentRequestId || !hasRelativeNavigation || relativeRequestIds.length <= 1) return;
|
||||
const currentIssueIndex = relativeRequestIds.findIndex((requestId) => requestId === currentRequestId);
|
||||
if (currentIssueIndex === -1) return;
|
||||
const nextIssueIndex =
|
||||
direction === "next"
|
||||
? (currentIssueIndex + 1) % filteredRequestIds.length
|
||||
: (currentIssueIndex - 1 + filteredRequestIds.length) % filteredRequestIds.length;
|
||||
const nextIssueId = filteredRequestIds[nextIssueIndex];
|
||||
? (currentIssueIndex + 1) % relativeRequestIds.length
|
||||
: (currentIssueIndex - 1 + relativeRequestIds.length) % relativeRequestIds.length;
|
||||
const nextIssueId = relativeRequestIds[nextIssueIndex];
|
||||
if (!nextIssueId) return;
|
||||
router.push(`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?currentTab=${currentTab}&inboxIssueId=${nextIssueId}`);
|
||||
router.push(`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?inboxIssueId=${nextIssueId}`);
|
||||
},
|
||||
[currentRequestId, currentTab, filteredRequestIds, router, sourceProjectId, workspaceSlug]
|
||||
[currentRequestId, hasRelativeNavigation, relativeRequestIds, router, sourceProjectId, workspaceSlug]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -79,6 +127,7 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
|||
}, [redirectToRelativeIssue]);
|
||||
|
||||
const targetProjectIdentifier = issue.project_detail?.identifier || getProjectById(issue.project_id || "")?.identifier;
|
||||
const requestLink = `/${workspaceSlug}/projects/${sourceProjectId}/external-contours?inboxIssueId=${contourRequest.id}`;
|
||||
const workItemLink = generateWorkItemLink({
|
||||
workspaceSlug: workspaceSlug?.toString(),
|
||||
projectId: issue.project_id,
|
||||
|
|
@ -86,9 +135,15 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
|||
projectIdentifier: targetProjectIdentifier,
|
||||
sequenceId: issue.sequence_id,
|
||||
});
|
||||
const subscriptionProjectId = issue.project_id || sourceProjectId;
|
||||
const { isSubscribed, loading: isSubscriptionLoading, toggleSubscription } = useExternalContourSubscription({
|
||||
workspaceSlug,
|
||||
projectId: subscriptionProjectId,
|
||||
issueId: issue.id,
|
||||
});
|
||||
|
||||
const handleCopyLink = () =>
|
||||
copyUrlToClipboard(workItemLink).then(() =>
|
||||
copyUrlToClipboard(requestLink).then(() =>
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("common.link_copied"),
|
||||
|
|
@ -96,15 +151,34 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
|||
})
|
||||
);
|
||||
|
||||
const handleToggleSubscription = async () => {
|
||||
try {
|
||||
const nextValue = !isSubscribed;
|
||||
await toggleSubscription();
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("toast.success"),
|
||||
message: nextValue ? t("issue.subscription.actions.subscribed") : t("issue.subscription.actions.unsubscribed"),
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("common.error.message"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenTarget = () => {
|
||||
router.push(workItemLink);
|
||||
};
|
||||
|
||||
const handleDecision = async (action: "accept" | "decline", comment?: string) => {
|
||||
try {
|
||||
await decideRequest(workspaceSlug, sourceProjectId, contourRequest.id, action, comment);
|
||||
if (action === "decline") {
|
||||
setIsDeclineModalOpen(false);
|
||||
await handleCurrentTab(workspaceSlug, sourceProjectId, EInboxIssueCurrentTab.OPEN);
|
||||
router.push(
|
||||
`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?currentTab=${EInboxIssueCurrentTab.OPEN}&inboxIssueId=${contourRequest.id}`
|
||||
);
|
||||
router.push(`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?inboxIssueId=${contourRequest.id}`);
|
||||
}
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
|
|
@ -133,25 +207,71 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
|||
onSubmit={(comment) => handleDecision("decline", comment)}
|
||||
/>
|
||||
|
||||
<Row className="relative z-15 hidden h-full w-full items-center justify-between gap-2 px-4 lg:flex">
|
||||
<div className="flex items-center gap-4">
|
||||
<Row
|
||||
className={`relative z-15 hidden w-full items-center justify-between gap-3 px-5 py-3.5 lg:flex ${
|
||||
currentMode?.key === "full-screen" ? "border-b border-subtle/70" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Tooltip tooltipContent={t("common.close_peek_view")}>
|
||||
<button onClick={removeRoutePeekId}>
|
||||
<MoveRight className="h-4 w-4 text-tertiary hover:text-secondary" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
{hasDirectTargetAccess && (
|
||||
<Tooltip tooltipContent={t("issue.open_in_full_screen")}>
|
||||
<Link href={workItemLink} onClick={() => removeRoutePeekId()}>
|
||||
<MoveDiagonal className="h-4 w-4 text-tertiary hover:text-secondary" />
|
||||
</Link>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{currentMode && !embedIssue && (
|
||||
<CustomSelect
|
||||
value={currentMode}
|
||||
onChange={(value: TExternalContourPeekMode) => setPeekMode(value)}
|
||||
customButton={
|
||||
<Tooltip tooltipContent={t("common.toggle_peek_view_layout")}>
|
||||
<button type="button">
|
||||
<currentMode.icon className="h-4 w-4 text-tertiary hover:text-secondary" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{PEEK_OPTIONS.map((mode) => (
|
||||
<CustomSelect.Option key={mode.key} value={mode.key}>
|
||||
<div
|
||||
className={`flex items-center gap-1.5 ${
|
||||
currentMode.key === mode.key ? "text-secondary" : "text-placeholder hover:text-secondary"
|
||||
}`}
|
||||
>
|
||||
<mode.icon className="-my-1 h-4 w-4 flex-shrink-0" />
|
||||
{t(mode.i18n_title)}
|
||||
</div>
|
||||
</CustomSelect.Option>
|
||||
))}
|
||||
</CustomSelect>
|
||||
)}
|
||||
|
||||
{issue?.project_id && issue.sequence_id && (
|
||||
<h3 className="flex-shrink-0 text-14 font-medium text-tertiary">
|
||||
{targetProjectIdentifier}-{issue.sequence_id}
|
||||
</h3>
|
||||
)}
|
||||
<ExternalContourStatePill request={contourRequest} />
|
||||
<div className="flex w-full items-center justify-end">
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end">
|
||||
<NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="nodedc-external-detail-toolbar">
|
||||
<div className="nodedc-external-detail-toolbar min-w-0">
|
||||
<div className="nodedc-external-toolbar-cluster">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Previous request"
|
||||
onClick={() => redirectToRelativeIssue("prev")}
|
||||
disabled={!hasRelativeNavigation || relativeRequestIds.length <= 1}
|
||||
className="nodedc-external-icon-button"
|
||||
>
|
||||
<ChevronUpIcon className="size-3.5" />
|
||||
|
|
@ -160,6 +280,7 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
|||
type="button"
|
||||
aria-label="Next request"
|
||||
onClick={() => redirectToRelativeIssue("next")}
|
||||
disabled={!hasRelativeNavigation || relativeRequestIds.length <= 1}
|
||||
className="nodedc-external-icon-button"
|
||||
>
|
||||
<ChevronDownIcon className="size-3.5" />
|
||||
|
|
@ -186,56 +307,68 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
|
|||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
prependIcon={<LinkIcon className="h-2.5 w-2.5" />}
|
||||
onClick={handleCopyLink}
|
||||
className="nodedc-external-action-button"
|
||||
>
|
||||
{t("external_contours_page.actions.copy")}
|
||||
</Button>
|
||||
{hasDirectTargetAccess && (
|
||||
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self">
|
||||
<Button variant="secondary" size="lg" prependIcon={<NewTabIcon className="h-2.5 w-2.5" />} className="nodedc-external-action-button">
|
||||
{t("external_contours_page.actions.open")}
|
||||
</Button>
|
||||
</ControlLink>
|
||||
<ExternalContourSubscriptionButton
|
||||
isSubscribed={isSubscribed}
|
||||
loading={isSubscriptionLoading}
|
||||
onToggle={handleToggleSubscription}
|
||||
iconOnly
|
||||
buttonClassName="size-10 rounded-[18px] border-transparent bg-layer-2/80 px-0 shadow-none backdrop-blur-xl hover:!bg-layer-2-active focus-visible:outline-none"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tooltip tooltipContent={t("common.actions.copy_link")}>
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={handleCopyLink}
|
||||
icon={CopyLinkIcon}
|
||||
className="size-10 rounded-[18px] border-transparent bg-layer-2/80 shadow-none backdrop-blur-xl hover:bg-layer-2-active focus-visible:outline-none"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<ExternalContourActionsMenu
|
||||
canOpenTargetWorkItem={hasDirectTargetAccess}
|
||||
canReviewClosedRequest={canReviewClosedRequest}
|
||||
isSubscribed={isSubscribed}
|
||||
isSubscriptionLoading={isSubscriptionLoading}
|
||||
onCopy={handleCopyLink}
|
||||
onOpenTarget={handleOpenTarget}
|
||||
onToggleSubscription={handleToggleSubscription}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
<Header className="justify-start lg:hidden">
|
||||
<PanelLeft
|
||||
onClick={() => setIsMobileSidebar(!isMobileSidebar)}
|
||||
className={`my-auto mr-2 h-4 w-4 flex-shrink-0 ${isMobileSidebar ? "text-accent-primary" : "text-secondary"}`}
|
||||
/>
|
||||
<Header className="justify-start px-4 py-3 lg:hidden">
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<button onClick={removeRoutePeekId}>
|
||||
<MoveRight className="h-4 w-4 text-tertiary hover:text-secondary" />
|
||||
</button>
|
||||
{hasDirectTargetAccess && (
|
||||
<ControlLink href={workItemLink} onClick={() => removeRoutePeekId()} target="_self">
|
||||
<MoveDiagonal className="h-4 w-4 text-tertiary hover:text-secondary" />
|
||||
</ControlLink>
|
||||
)}
|
||||
<ExternalContourStatePill request={contourRequest} />
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{canReviewClosedRequest && (
|
||||
<>
|
||||
<Button variant="primary" size="sm" onClick={() => handleDecision("accept")} className="nodedc-external-primary-button">
|
||||
{t("external_contours_page.actions.accept")}
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setIsDeclineModalOpen(true)} className="nodedc-external-action-button">
|
||||
{t("external_contours_page.actions.decline")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isSourceAccepted && (
|
||||
<div className="nodedc-external-readonly-value min-h-10 w-auto px-4 text-13 font-medium">
|
||||
{t("external_contours_page.traceability.source_decision_accepted")}
|
||||
</div>
|
||||
)}
|
||||
{hasDirectTargetAccess && (
|
||||
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self">
|
||||
<Button variant="secondary" size="sm" className="nodedc-external-action-button">
|
||||
{t("external_contours_page.actions.open")}
|
||||
</Button>
|
||||
</ControlLink>
|
||||
)}
|
||||
<ExternalContourActionsMenu
|
||||
canOpenTargetWorkItem={hasDirectTargetAccess}
|
||||
canReviewClosedRequest={canReviewClosedRequest}
|
||||
includeDecisionActions
|
||||
isSubscribed={isSubscribed}
|
||||
isSubscriptionLoading={isSubscriptionLoading}
|
||||
onAccept={() => handleDecision("accept")}
|
||||
onCopy={handleCopyLink}
|
||||
onDecline={() => setIsDeclineModalOpen(true)}
|
||||
onOpenTarget={handleOpenTarget}
|
||||
onToggleSubscription={handleToggleSubscription}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export const ExternalContoursIssueContentProperties = observer(function External
|
|||
<div className="w-full overflow-visible">
|
||||
<h5 className="mb-2 text-body-sm-medium">{t("external_contours_page.properties.section_title")}</h5>
|
||||
<div className={`${!isEditable ? "opacity-60" : ""}`}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="nodedc-external-property-row">
|
||||
<div className="nodedc-external-property-label">
|
||||
<PriorityPropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
|
|
|
|||
|
|
@ -125,10 +125,7 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
|
|||
remove: async () => undefined,
|
||||
update: async (_workspaceSlug: string, _projectId: string, requestId: string, data: Partial<TIssue>) => {
|
||||
try {
|
||||
await updateRequest(workspaceSlug, sourceProjectId, requestId, {
|
||||
name: data.name,
|
||||
description_html: data.description_html,
|
||||
});
|
||||
await updateRequest(workspaceSlug, sourceProjectId, requestId, data);
|
||||
} catch {
|
||||
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("issue_could_not_be_updated") });
|
||||
}
|
||||
|
|
@ -196,7 +193,7 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
|
|||
|
||||
<div className="nodedc-external-section flex flex-col gap-3 px-4 py-4">
|
||||
<div className="text-body-sm-medium">{t("external_contours_page.properties.section_title")}</div>
|
||||
<div className="flex flex-col gap-3 text-13 text-secondary">
|
||||
<div className="grid grid-cols-2 gap-3 text-13 text-secondary">
|
||||
<div className="nodedc-external-property-row">
|
||||
<div className="nodedc-external-property-label">
|
||||
<PriorityPropertyIcon className="h-4 w-4 flex-shrink-0" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { createPortal } from "react-dom";
|
||||
import { useCallback, useEffect, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { cn } from "@plane/utils";
|
||||
import type { TExternalContourRequest, TNameDescriptionLoader } from "@plane/types";
|
||||
import useKeypress from "@/hooks/use-keypress";
|
||||
import usePeekOverviewOutsideClickDetector from "@/hooks/use-peek-overview-outside-click";
|
||||
import { ExternalContoursIssueActionsHeader, type TExternalContourPeekMode } from "./issue-header";
|
||||
|
||||
const SIDE_PEEK_WIDTH_STORAGE_KEY = "nodedc:external-contour-peek-width";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
contourRequest: TExternalContourRequest;
|
||||
hasDirectTargetAccess: boolean;
|
||||
isSubmitting: TNameDescriptionLoader;
|
||||
embedIssue?: boolean;
|
||||
embedRemoveCurrentNotification?: () => void;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const ExternalContoursPeekShell = observer(function ExternalContoursPeekShell(props: Props) {
|
||||
const {
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
contourRequest,
|
||||
hasDirectTargetAccess,
|
||||
isSubmitting,
|
||||
embedIssue = false,
|
||||
embedRemoveCurrentNotification,
|
||||
children,
|
||||
onClose,
|
||||
} = props;
|
||||
const [peekMode, setPeekMode] = useState<TExternalContourPeekMode>("side-peek");
|
||||
const [sidePeekWidth, setSidePeekWidth] = useState<number>(() => {
|
||||
if (typeof window === "undefined") return 720;
|
||||
|
||||
const fallbackWidth = Math.max(640, Math.floor(window.innerWidth * 0.5));
|
||||
const storedWidth = window.localStorage.getItem(SIDE_PEEK_WIDTH_STORAGE_KEY);
|
||||
const parsedWidth = storedWidth ? parseInt(storedWidth, 10) : NaN;
|
||||
|
||||
return Number.isFinite(parsedWidth) ? parsedWidth : fallbackWidth;
|
||||
});
|
||||
const [isResizingPeek, setIsResizingPeek] = useState(false);
|
||||
|
||||
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
|
||||
const initialPeekWidthRef = useRef<number>(0);
|
||||
const initialMouseXRef = useRef<number>(0);
|
||||
|
||||
const removeRoutePeekId = useCallback(() => {
|
||||
if (embedIssue) {
|
||||
embedRemoveCurrentNotification?.();
|
||||
return;
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [embedIssue, embedRemoveCurrentNotification, onClose]);
|
||||
|
||||
const stopPeekResizing = useCallback(() => {
|
||||
setIsResizingPeek(false);
|
||||
}, []);
|
||||
|
||||
const handlePeekResize = useCallback(
|
||||
(event: MouseEvent) => {
|
||||
if (!isResizingPeek) return;
|
||||
|
||||
const maxWidth = Math.max(720, window.innerWidth - 48);
|
||||
const minWidth = 640;
|
||||
const deltaX = event.clientX - initialMouseXRef.current;
|
||||
const nextWidth = Math.min(Math.max(initialPeekWidthRef.current - deltaX, minWidth), maxWidth);
|
||||
setSidePeekWidth(nextWidth);
|
||||
},
|
||||
[isResizingPeek]
|
||||
);
|
||||
|
||||
const startPeekResizing = useCallback(
|
||||
(event: ReactMouseEvent) => {
|
||||
if (peekMode !== "side-peek") return;
|
||||
event.preventDefault();
|
||||
setIsResizingPeek(true);
|
||||
initialPeekWidthRef.current = sidePeekWidth;
|
||||
initialMouseXRef.current = event.clientX;
|
||||
},
|
||||
[peekMode, sidePeekWidth]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleWindowResize = () => {
|
||||
const maxWidth = Math.max(720, window.innerWidth - 48);
|
||||
setSidePeekWidth((currentWidth) => Math.min(currentWidth, maxWidth));
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleWindowResize);
|
||||
return () => window.removeEventListener("resize", handleWindowResize);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const maxWidth = Math.max(720, window.innerWidth - 48);
|
||||
const clampedWidth = Math.min(Math.max(sidePeekWidth, 640), maxWidth);
|
||||
|
||||
window.localStorage.setItem(SIDE_PEEK_WIDTH_STORAGE_KEY, String(clampedWidth));
|
||||
|
||||
if (clampedWidth !== sidePeekWidth) {
|
||||
setSidePeekWidth(clampedWidth);
|
||||
}
|
||||
}, [sidePeekWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isResizingPeek) return;
|
||||
|
||||
document.addEventListener("mousemove", handlePeekResize);
|
||||
document.addEventListener("mouseup", stopPeekResizing);
|
||||
document.addEventListener("mouseleave", stopPeekResizing);
|
||||
window.addEventListener("blur", stopPeekResizing);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handlePeekResize);
|
||||
document.removeEventListener("mouseup", stopPeekResizing);
|
||||
document.removeEventListener("mouseleave", stopPeekResizing);
|
||||
window.removeEventListener("blur", stopPeekResizing);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
}, [handlePeekResize, isResizingPeek, stopPeekResizing]);
|
||||
|
||||
usePeekOverviewOutsideClickDetector(
|
||||
issuePeekOverviewRef,
|
||||
() => {
|
||||
if (!embedIssue) removeRoutePeekId();
|
||||
},
|
||||
contourRequest.id,
|
||||
["main-sidebar"]
|
||||
);
|
||||
|
||||
useKeypress("Escape", () => !embedIssue && removeRoutePeekId());
|
||||
|
||||
const peekOverviewClassName = cn(
|
||||
!embedIssue
|
||||
? "absolute z-[25] flex flex-col overflow-hidden border border-subtle/70 bg-surface-1/80 backdrop-blur-2xl transition-all duration-300"
|
||||
: "h-full w-full",
|
||||
!embedIssue && {
|
||||
"top-3 right-3 bottom-3 w-[calc(100%-1.5rem)] rounded-[28px] border md:min-w-[640px] md:max-w-[calc(100vw-1.5rem)]":
|
||||
peekMode === "side-peek",
|
||||
"top-[8.33%] left-[8.33%] size-5/6 rounded-[28px]": peekMode === "modal",
|
||||
"absolute inset-0 m-4 rounded-[28px]": peekMode === "full-screen",
|
||||
}
|
||||
);
|
||||
|
||||
const portalContainer = typeof document !== "undefined" ? document.getElementById("full-screen-portal") : null;
|
||||
|
||||
const content = (
|
||||
<div className="w-full text-body-sm-regular">
|
||||
<div
|
||||
ref={issuePeekOverviewRef}
|
||||
className={peekOverviewClassName}
|
||||
style={{
|
||||
width: !embedIssue && peekMode === "side-peek" ? `${sidePeekWidth}px` : undefined,
|
||||
boxShadow:
|
||||
"0px 4px 8px 0px rgba(0, 0, 0, 0.12), 0px 6px 12px 0px rgba(16, 24, 40, 0.12), 0px 1px 16px 0px rgba(16, 24, 40, 0.12)",
|
||||
}}
|
||||
>
|
||||
{!embedIssue && peekMode === "side-peek" && (
|
||||
<div
|
||||
className="absolute top-0 left-0 z-[26] h-full w-4 -translate-x-1/2 cursor-ew-resize rounded-l-[28px] bg-transparent"
|
||||
onMouseDown={startPeekResizing}
|
||||
role="separator"
|
||||
aria-label="Resize external contour panel"
|
||||
/>
|
||||
)}
|
||||
<ExternalContoursIssueActionsHeader
|
||||
workspaceSlug={workspaceSlug}
|
||||
sourceProjectId={projectId}
|
||||
contourRequest={contourRequest}
|
||||
hasDirectTargetAccess={hasDirectTargetAccess}
|
||||
isSubmitting={isSubmitting}
|
||||
removeRoutePeekId={removeRoutePeekId}
|
||||
peekMode={peekMode}
|
||||
setPeekMode={setPeekMode}
|
||||
embedIssue={embedIssue}
|
||||
/>
|
||||
<div className="vertical-scrollbar relative scrollbar-md h-full w-full overflow-hidden overflow-y-auto">
|
||||
{["side-peek", "modal"].includes(peekMode) ? (
|
||||
<div className="relative flex flex-col gap-4 px-6 pt-3 pb-5">{children}</div>
|
||||
) : (
|
||||
<div className="relative h-full w-full overflow-auto px-5 pt-3 pb-4">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return <>{!embedIssue && portalContainer ? createPortal(content, portalContainer) : content}</>;
|
||||
});
|
||||
|
|
@ -4,18 +4,17 @@
|
|||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TransferIcon } from "@plane/propel/icons";
|
||||
import type { TInboxIssueCurrentTab } from "@plane/types";
|
||||
import { EInboxIssueCurrentTab } from "@plane/types";
|
||||
import { cn } from "@plane/utils";
|
||||
import { FiltersRow } from "@/components/rich-filters/filters-row";
|
||||
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
|
||||
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
|
||||
import { ExternalContoursBoardRoot } from "./board-root";
|
||||
import { ExternalContoursContentRoot } from "./content-root";
|
||||
import { ExternalContoursEmptyState } from "./empty-state";
|
||||
import { ExternalContoursSidebar } from "./sidebar";
|
||||
import { useExternalContoursFilter } from "./filters/provider";
|
||||
|
||||
type TExternalContoursRoot = {
|
||||
workspaceSlug: string;
|
||||
|
|
@ -26,10 +25,15 @@ type TExternalContoursRoot = {
|
|||
|
||||
export const ExternalContoursRoot = observer(function ExternalContoursRoot(props: TExternalContoursRoot) {
|
||||
const { workspaceSlug, projectId, inboxIssueId, navigationTab } = props;
|
||||
const [isMobileSidebar, setIsMobileSidebar] = useState(true);
|
||||
const { t } = useTranslation();
|
||||
const { loader, error, currentTab, currentProjectId, requestIds, handleCurrentTab, fetchRequests } =
|
||||
useProjectExternalContours();
|
||||
const {
|
||||
error: boardError,
|
||||
currentProjectId: boardProjectId,
|
||||
fetchBoard,
|
||||
loader: boardLoader,
|
||||
} = useProjectExternalContoursBoard();
|
||||
const filter = useExternalContoursFilter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
|
|
@ -56,7 +60,15 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workspaceSlug, projectId, navigationTab]);
|
||||
|
||||
if (error && error?.status === "init-error") {
|
||||
useEffect(() => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
if (boardProjectId === projectId && boardLoader === "init-loading") return;
|
||||
|
||||
void fetchBoard(workspaceSlug.toString(), projectId.toString());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [workspaceSlug, projectId]);
|
||||
|
||||
if (error && error?.status === "init-error" && !!inboxIssueId) {
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col items-center justify-center gap-3">
|
||||
<TransferIcon className="size-[60px]" strokeWidth={1.5} />
|
||||
|
|
@ -65,52 +77,29 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
|
|||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!inboxIssueId && (
|
||||
<div className="flex h-12 w-full items-center border-b border-subtle px-4 lg:hidden">
|
||||
<PanelLeft
|
||||
onClick={() => setIsMobileSidebar(!isMobileSidebar)}
|
||||
className={cn("h-4 w-4", isMobileSidebar ? "text-accent-primary" : "text-secondary")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-full w-full overflow-hidden bg-surface-1 pt-2">
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-[50px] bottom-0 z-10 w-full flex-shrink-0 bg-surface-1 transition-all lg:!relative lg:!top-0 lg:w-2/6",
|
||||
isMobileSidebar ? "translate-x-0" : "-translate-x-full lg:!translate-x-0"
|
||||
)}
|
||||
>
|
||||
<ExternalContoursSidebar
|
||||
setIsMobileSidebar={setIsMobileSidebar}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
inboxIssueId={inboxIssueId}
|
||||
/>
|
||||
</div>
|
||||
if (boardError && boardError?.status === "init-error" && !inboxIssueId) {
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col items-center justify-center gap-3">
|
||||
<TransferIcon className="size-[60px]" strokeWidth={1.5} />
|
||||
<div className="text-secondary">{boardError?.message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{inboxIssueId ? (
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden bg-surface-1">
|
||||
{filter && <FiltersRow filter={filter} />}
|
||||
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden pt-2">
|
||||
<ExternalContoursBoardRoot workspaceSlug={workspaceSlug.toString()} projectId={projectId.toString()} />
|
||||
{inboxIssueId && (
|
||||
<ExternalContoursContentRoot
|
||||
setIsMobileSidebar={setIsMobileSidebar}
|
||||
isMobileSidebar={isMobileSidebar}
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={projectId.toString()}
|
||||
inboxIssueId={inboxIssueId.toString()}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden px-8">
|
||||
<div className="hidden h-20 shrink-0 lg:block" />
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center">
|
||||
<ExternalContoursEmptyState
|
||||
compact
|
||||
title={t("external_contours_page.empty_state.detail_title")}
|
||||
description={t("external_contours_page.empty_state.detail_description")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Bell, BellOff } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { Loader } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
import { IssueService } from "@/services/issue/issue.service";
|
||||
|
||||
const issueService = new IssueService();
|
||||
|
||||
type TSubscriptionIdentity = {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
};
|
||||
|
||||
type Props = TSubscriptionIdentity & {
|
||||
buttonClassName?: string;
|
||||
};
|
||||
|
||||
export const useExternalContourSubscription = ({ workspaceSlug, projectId, issueId }: TSubscriptionIdentity) => {
|
||||
const [isSubscribed, setIsSubscribed] = useState<boolean | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchSubscription = async () => {
|
||||
try {
|
||||
const response = await issueService.getIssueNotificationSubscriptionStatus(workspaceSlug, projectId, issueId);
|
||||
if (isMounted) setIsSubscribed(response?.subscribed ?? false);
|
||||
} catch {
|
||||
if (isMounted) setIsSubscribed(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (workspaceSlug && projectId && issueId) {
|
||||
void fetchSubscription();
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [workspaceSlug, projectId, issueId]);
|
||||
|
||||
const toggleSubscription = async () => {
|
||||
if (!workspaceSlug || !projectId || !issueId) return;
|
||||
|
||||
const nextValue = !isSubscribed;
|
||||
setLoading(true);
|
||||
setIsSubscribed(nextValue);
|
||||
|
||||
try {
|
||||
if (nextValue) {
|
||||
await issueService.subscribeToIssueNotifications(workspaceSlug, projectId, issueId);
|
||||
} else {
|
||||
await issueService.unsubscribeFromIssueNotifications(workspaceSlug, projectId, issueId);
|
||||
}
|
||||
} catch {
|
||||
setIsSubscribed(!nextValue);
|
||||
throw new Error("subscription-toggle-failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isSubscribed,
|
||||
loading,
|
||||
toggleSubscription,
|
||||
};
|
||||
};
|
||||
|
||||
type TExternalContourSubscriptionButtonProps = {
|
||||
isSubscribed: boolean | undefined;
|
||||
loading: boolean;
|
||||
onToggle: () => Promise<void>;
|
||||
buttonClassName?: string;
|
||||
iconOnly?: boolean;
|
||||
};
|
||||
|
||||
export const ExternalContourSubscriptionButton = observer(function ExternalContourSubscriptionButton(
|
||||
props: TExternalContourSubscriptionButtonProps
|
||||
) {
|
||||
const { isSubscribed, loading, onToggle, buttonClassName, iconOnly = false } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isSubscribed === undefined) {
|
||||
return (
|
||||
<Loader>
|
||||
<Loader.Item width={iconOnly ? "40px" : "106px"} height="40px" />
|
||||
</Loader>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
prependIcon={isSubscribed ? <BellOff /> : <Bell className="h-3 w-3" />}
|
||||
variant="secondary"
|
||||
className={cn("hover:!bg-accent-primary/20", buttonClassName)}
|
||||
onClick={() => void onToggle()}
|
||||
disabled={loading}
|
||||
size="lg"
|
||||
>
|
||||
{!iconOnly &&
|
||||
(loading ? (
|
||||
<span className="hidden sm:block">{t("common.loading")}</span>
|
||||
) : isSubscribed ? (
|
||||
<span className="hidden sm:block">{t("common.actions.unsubscribe")}</span>
|
||||
) : (
|
||||
<span className="hidden sm:block">{t("common.actions.subscribe")}</span>
|
||||
))}
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
|
||||
export const ExternalContourSubscription = observer(function ExternalContourSubscription(props: Props) {
|
||||
const { workspaceSlug, projectId, issueId, buttonClassName } = props;
|
||||
const { t } = useTranslation();
|
||||
const { isSubscribed, loading, toggleSubscription } = useExternalContourSubscription({
|
||||
workspaceSlug,
|
||||
projectId,
|
||||
issueId,
|
||||
});
|
||||
|
||||
const handleToggle = async () => {
|
||||
try {
|
||||
const nextValue = !isSubscribed;
|
||||
await toggleSubscription();
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: t("toast.success"),
|
||||
message: nextValue ? t("issue.subscription.actions.subscribed") : t("issue.subscription.actions.unsubscribed"),
|
||||
});
|
||||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: t("toast.error"),
|
||||
message: t("common.error.message"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ExternalContourSubscriptionButton
|
||||
isSubscribed={isSubscribed}
|
||||
loading={loading}
|
||||
onToggle={handleToggle}
|
||||
buttonClassName={buttonClassName}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import { AtSign, Briefcase } from "lucide-react";
|
||||
// plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Logo } from "@plane/propel/emoji-icon-picker";
|
||||
import {
|
||||
CalendarLayoutIcon,
|
||||
|
|
@ -91,6 +92,7 @@ export type TWorkItemFiltersConfig = {
|
|||
export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps): TWorkItemFiltersConfig => {
|
||||
const { allowedFilters, cycleIds, labelIds, memberIds, moduleIds, projectId, projectIds, stateIds, workspaceSlug } =
|
||||
props;
|
||||
const { t } = useTranslation();
|
||||
// store hooks
|
||||
const { loader: projectLoader, getProjectById } = useProject();
|
||||
const { getCycleById } = useCycle();
|
||||
|
|
@ -152,11 +154,13 @@ export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps):
|
|||
() =>
|
||||
getStateGroupFilterConfig<TWorkItemFilterProperty>("state_group")({
|
||||
isEnabled: isFilterEnabled("state_group"),
|
||||
filterLabel: t("common.state_group"),
|
||||
filterIcon: StatePropertyIcon,
|
||||
getItemLabel: (stateGroupKey) => t(`workspace_projects.state.${stateGroupKey}`),
|
||||
getOptionIcon: (stateGroupKey) => <StateGroupIcon stateGroup={stateGroupKey} />,
|
||||
...operatorConfigs,
|
||||
}),
|
||||
[isFilterEnabled, operatorConfigs]
|
||||
[isFilterEnabled, operatorConfigs, t]
|
||||
);
|
||||
|
||||
// state filter config
|
||||
|
|
@ -298,11 +302,13 @@ export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps):
|
|||
() =>
|
||||
getPriorityFilterConfig<TWorkItemFilterProperty>("priority")({
|
||||
isEnabled: isFilterEnabled("priority"),
|
||||
filterLabel: t("common.priority"),
|
||||
filterIcon: PriorityPropertyIcon,
|
||||
getItemLabel: (priorityKey) => (priorityKey === "none" ? t("common.none") : t(priorityKey)),
|
||||
getOptionIcon: (priority) => <PriorityIcon priority={priority} />,
|
||||
...operatorConfigs,
|
||||
}),
|
||||
[isFilterEnabled, operatorConfigs]
|
||||
[isFilterEnabled, operatorConfigs, t]
|
||||
);
|
||||
|
||||
// start date filter config
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// plane imports
|
||||
import { Row } from "@plane/ui";
|
||||
|
|
@ -21,10 +22,40 @@ export interface AppHeaderProps {
|
|||
|
||||
export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
|
||||
const { header, mobileHeader, className, rowClassName } = props;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [dockStyle, setDockStyle] = useState<CSSProperties | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const container = containerRef.current;
|
||||
const main = container?.closest("main");
|
||||
if (!(main instanceof HTMLElement)) return;
|
||||
|
||||
const updateDockBounds = () => {
|
||||
const { left, width } = main.getBoundingClientRect();
|
||||
|
||||
setDockStyle({
|
||||
left,
|
||||
width,
|
||||
});
|
||||
};
|
||||
|
||||
updateDockBounds();
|
||||
|
||||
const resizeObserver = new ResizeObserver(updateDockBounds);
|
||||
resizeObserver.observe(main);
|
||||
window.addEventListener("resize", updateDockBounds);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener("resize", updateDockBounds);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn("z-[18]", className)}>
|
||||
<Row className={cn("flex h-11 w-full items-center gap-2 border-b border-subtle bg-surface-1", rowClassName)}>
|
||||
<div ref={containerRef} className={cn("fixed right-0 bottom-0 z-[18]", className)} style={dockStyle}>
|
||||
<Row className={cn("nodedc-bottom-dock flex h-11 w-full items-center gap-2", rowClassName)}>
|
||||
<ExtendedAppHeader header={header} />
|
||||
</Row>
|
||||
{mobileHeader && mobileHeader}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { cn } from "@plane/utils";
|
||||
|
||||
type TPrimaryActionButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const AppHeaderPrimaryActionButton = (props: TPrimaryActionButtonProps) => {
|
||||
const { children, className, ...buttonProps } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className={cn("nodedc-toolbar-primary nodedc-toolbar-primary-wide", className)}
|
||||
{...buttonProps}
|
||||
>
|
||||
{children ?? t("app_header.add_task")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
|
@ -49,8 +49,8 @@ export const CycleDeleteModal = observer(function CycleDeleteModal(props: ICycle
|
|||
if (cycleId || peekCycle) router.push(`/${workspaceSlug}/projects/${projectId}/cycles`);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Cycle deleted successfully.",
|
||||
title: t("common.success"),
|
||||
message: t("entity.delete.success", { entity: t("common.cycle").toLowerCase() }),
|
||||
});
|
||||
})
|
||||
.catch((errors) => {
|
||||
|
|
@ -68,8 +68,8 @@ export const CycleDeleteModal = observer(function CycleDeleteModal(props: ICycle
|
|||
} catch {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Warning!",
|
||||
message: "Something went wrong please try again later.",
|
||||
title: t("common.warning"),
|
||||
message: t("common.something_went_wrong"),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -82,14 +82,11 @@ export const CycleDeleteModal = observer(function CycleDeleteModal(props: ICycle
|
|||
handleSubmit={formSubmit}
|
||||
isSubmitting={loader}
|
||||
isOpen={isOpen}
|
||||
title="Delete cycle"
|
||||
content={
|
||||
<>
|
||||
Are you sure you want to delete cycle{' "'}
|
||||
<span className="font-medium break-words text-primary">{cycle?.name}</span>
|
||||
{'"'}? All of the data related to the cycle will be permanently removed. This action cannot be undone.
|
||||
</>
|
||||
}
|
||||
title={t("entity.delete.label", { entity: t("common.cycle") })}
|
||||
content={t("entity.delete.confirmation", {
|
||||
entity: t("common.cycle").toLowerCase(),
|
||||
identifier: cycle?.name ? `"${cycle.name}"` : "",
|
||||
})}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePopper } from "react-popper";
|
||||
import { Combobox } from "@headlessui/react";
|
||||
// plane imports
|
||||
|
|
@ -211,60 +212,62 @@ export const WorkItemStateDropdownBase = observer(function WorkItemStateDropdown
|
|||
button={comboButton}
|
||||
renderByDefault={renderByDefault}
|
||||
>
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className="nodedc-glass-modal nodedc-glass-popup-surface my-1 w-52 rounded-[1.25rem] border-0 px-3 py-3 text-12 shadow-none outline-none"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 rounded-[0.95rem] border-0 bg-white/5 px-3 py-2 outline-none">
|
||||
<SearchIcon className="h-3.5 w-3.5 text-placeholder" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-0 text-12 text-secondary placeholder:text-placeholder outline-none focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("common.search.label")}
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
onKeyDown={searchInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 max-h-56 space-y-1 overflow-y-auto">
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
cn(
|
||||
`flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-[0.9rem] px-2 py-2 select-none outline-none ${
|
||||
active ? "bg-white/6" : ""
|
||||
} ${selected ? "text-primary" : "text-secondary"}`
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className="flex-grow truncate">{option.content}</span>
|
||||
{selected && <CheckIcon className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Combobox.Options data-prevent-outside-click className="fixed z-30" static>
|
||||
<div
|
||||
className="nodedc-glass-modal nodedc-glass-popup-surface my-1 w-52 rounded-[1.25rem] border-0 px-3 py-3 text-12 shadow-none outline-none"
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 rounded-[0.95rem] border-0 bg-white/5 px-3 py-2 outline-none">
|
||||
<SearchIcon className="h-3.5 w-3.5 text-placeholder" strokeWidth={1.5} />
|
||||
<Combobox.Input
|
||||
as="input"
|
||||
ref={inputRef}
|
||||
className="w-full bg-transparent py-0 text-12 text-secondary placeholder:text-placeholder outline-none focus:outline-none"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("common.search.label")}
|
||||
displayValue={(assigned: any) => assigned?.name}
|
||||
onKeyDown={searchInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 max-h-56 space-y-1 overflow-y-auto">
|
||||
{filteredOptions ? (
|
||||
filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ active, selected }) =>
|
||||
cn(
|
||||
`flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-[0.9rem] px-2 py-2 select-none outline-none ${
|
||||
active ? "bg-white/6" : ""
|
||||
} ${selected ? "text-primary" : "text-secondary"}`
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className="flex-grow truncate">{option.content}</span>
|
||||
{selected && <CheckIcon className="h-3.5 w-3.5 flex-shrink-0" />}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))
|
||||
) : (
|
||||
<p className="px-1.5 py-1 text-placeholder italic">{t("no_matching_results")}</p>
|
||||
)
|
||||
) : (
|
||||
<p className="px-1.5 py-1 text-placeholder italic">{t("no_matching_results")}</p>
|
||||
)
|
||||
) : (
|
||||
<p className="px-1.5 py-1 text-placeholder italic">{t("loading")}</p>
|
||||
)}
|
||||
<p className="px-1.5 py-1 text-placeholder italic">{t("loading")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
)}
|
||||
</Combobox.Options>,
|
||||
document.body
|
||||
)}
|
||||
</ComboDropDown>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@
|
|||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui";
|
||||
import { AlertModalCore } from "@plane/ui";
|
||||
// hooks
|
||||
import { useProjectEstimates } from "@/hooks/store/estimates";
|
||||
import { useEstimate } from "@/hooks/store/estimates/use-estimate";
|
||||
|
|
@ -26,6 +26,7 @@ type TDeleteEstimateModal = {
|
|||
export const DeleteEstimateModal = observer(function DeleteEstimateModal(props: TDeleteEstimateModal) {
|
||||
// props
|
||||
const { workspaceSlug, projectId, estimateId, isOpen, handleClose } = props;
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { areEstimateEnabledByProjectId, deleteEstimate } = useProjectEstimates();
|
||||
const { asJson: estimate } = useEstimate(estimateId);
|
||||
|
|
@ -44,46 +45,32 @@ export const DeleteEstimateModal = observer(function DeleteEstimateModal(props:
|
|||
setButtonLoader(false);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Estimate deleted",
|
||||
message: "Estimate has been removed from your project.",
|
||||
title: t("project_settings.estimates.delete_modal.success_title"),
|
||||
message: t("project_settings.estimates.delete_modal.success_message"),
|
||||
});
|
||||
handleClose();
|
||||
} catch (_error) {
|
||||
setButtonLoader(false);
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Estimate creation failed",
|
||||
message: "We were unable to delete the estimate, please try again.",
|
||||
title: t("project_settings.estimates.delete_modal.error_title"),
|
||||
message: t("project_settings.estimates.delete_modal.error_message"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXL}>
|
||||
<div className="relative space-y-6 py-5">
|
||||
{/* heading */}
|
||||
<div className="relative flex items-center justify-between gap-2 px-5">
|
||||
<div className="text-18 font-medium text-primary">Delete Estimate System</div>
|
||||
</div>
|
||||
|
||||
{/* estimate steps */}
|
||||
<div className="px-5">
|
||||
<div className="text-14 text-secondary">
|
||||
Deleting the estimate <span className="font-bold text-primary">{estimate?.name}</span>
|
||||
system will remove it from all work items permanently. This action cannot be undone. If you add
|
||||
estimates again, you will need to update all the work items.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center justify-end gap-3 border-t border-subtle px-5 pt-5">
|
||||
<Button variant="secondary" size="lg" onClick={handleClose} disabled={buttonLoader}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="error-fill" size="lg" onClick={handleDeleteEstimate} disabled={buttonLoader}>
|
||||
{buttonLoader ? "Deleting" : "Delete Estimate"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalCore>
|
||||
<AlertModalCore
|
||||
handleClose={handleClose}
|
||||
handleSubmit={handleDeleteEstimate}
|
||||
isSubmitting={buttonLoader}
|
||||
isOpen={isOpen}
|
||||
title={t("project_settings.estimates.delete_modal.title")}
|
||||
content={t("project_settings.estimates.delete_modal.description", { value: estimate?.name ?? "" })}
|
||||
primaryButtonText={{
|
||||
loading: t("project_settings.estimates.delete_modal.loading"),
|
||||
default: t("project_settings.estimates.delete_modal.submit"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,16 +47,9 @@ export function DeclineIssueModal(props: Props) {
|
|||
isSubmitting={isDeclining}
|
||||
isOpen={isOpen}
|
||||
title={t("inbox_issue.modals.decline.title")}
|
||||
// TODO: Need to translate the confirmation message
|
||||
content={
|
||||
<>
|
||||
Are you sure you want to decline work item{" "}
|
||||
<span className="font-medium break-words text-primary">
|
||||
{projectDetails?.identifier}-{data?.sequence_id}
|
||||
</span>
|
||||
{""}? This action cannot be undone.
|
||||
</>
|
||||
}
|
||||
content={t("inbox_issue.modals.decline.content", {
|
||||
value: `${projectDetails?.identifier}-${data?.sequence_id}`,
|
||||
})}
|
||||
primaryButtonText={{
|
||||
loading: t("declining"),
|
||||
default: t("decline"),
|
||||
|
|
|
|||
|
|
@ -63,14 +63,7 @@ export const IssueAttachmentDeleteModal = observer(function IssueAttachmentDelet
|
|||
isSubmitting={loader}
|
||||
isOpen={isOpen}
|
||||
title={t("attachment.delete")}
|
||||
content={
|
||||
<>
|
||||
{/* TODO: Translate here */}
|
||||
Are you sure you want to delete attachment-{" "}
|
||||
<span className="font-bold">{getFileName(attachment.attributes.name)}</span>? This attachment will be
|
||||
permanently removed. This action cannot be undone.
|
||||
</>
|
||||
}
|
||||
content={t("attachment.delete_confirmation", { value: getFileName(attachment.attributes.name) })}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -94,21 +94,23 @@ export const HeaderFilters = observer(function HeaderFilters(props: Props) {
|
|||
projectDetails={currentProjectDetails ?? undefined}
|
||||
isEpic={storeType === EIssuesStoreType.EPIC}
|
||||
/>
|
||||
<div className="nodedc-top-toolbar-cluster flex items-center gap-2">
|
||||
<div className="hidden @4xl:flex">
|
||||
<div className="pointer-events-none absolute top-1/2 left-1/2 z-[1] flex -translate-x-1/2 -translate-y-1/2 items-center">
|
||||
<div className="pointer-events-auto hidden @4xl:flex">
|
||||
<LayoutSelection
|
||||
layouts={LAYOUTS}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex @4xl:hidden">
|
||||
<div className="pointer-events-auto flex @4xl:hidden">
|
||||
<MobileLayoutSelection
|
||||
layouts={LAYOUTS}
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
activeLayout={activeLayout}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="nodedc-top-toolbar-cluster flex items-center gap-2">
|
||||
<WorkItemFiltersToggle entityType={storeType} entityId={projectId} />
|
||||
<FiltersDropdown
|
||||
miniIcon={<SlidersHorizontal className="size-3.5" />}
|
||||
|
|
|
|||
|
|
@ -27,10 +27,11 @@ export type TIssueSubscription = {
|
|||
issueId: string;
|
||||
serviceType?: EIssueServiceType;
|
||||
buttonClassName?: string;
|
||||
iconOnly?: boolean;
|
||||
};
|
||||
|
||||
export const IssueSubscription = observer(function IssueSubscription(props: TIssueSubscription) {
|
||||
const { workspaceSlug, projectId, issueId, serviceType = EIssueServiceType.ISSUES, buttonClassName } = props;
|
||||
const { workspaceSlug, projectId, issueId, serviceType = EIssueServiceType.ISSUES, buttonClassName, iconOnly = false } = props;
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const {
|
||||
|
|
@ -77,7 +78,7 @@ export const IssueSubscription = observer(function IssueSubscription(props: TIss
|
|||
if (isNil(isSubscribed))
|
||||
return (
|
||||
<Loader>
|
||||
<Loader.Item width="106px" height="28px" />
|
||||
<Loader.Item width={iconOnly ? "40px" : "106px"} height={iconOnly ? "40px" : "28px"} />
|
||||
</Loader>
|
||||
);
|
||||
|
||||
|
|
@ -91,15 +92,16 @@ export const IssueSubscription = observer(function IssueSubscription(props: TIss
|
|||
disabled={!isEditable || loading}
|
||||
size="lg"
|
||||
>
|
||||
{loading ? (
|
||||
<span>
|
||||
<span className="hidden sm:block">{t("common.loading")}</span>
|
||||
</span>
|
||||
) : isSubscribed ? (
|
||||
<div className="hidden sm:block">{t("common.actions.unsubscribe")}</div>
|
||||
) : (
|
||||
<div className="hidden sm:block">{t("common.actions.subscribe")}</div>
|
||||
)}
|
||||
{!iconOnly &&
|
||||
(loading ? (
|
||||
<span>
|
||||
<span className="hidden sm:block">{t("common.loading")}</span>
|
||||
</span>
|
||||
) : isSubscribed ? (
|
||||
<div className="hidden sm:block">{t("common.actions.unsubscribe")}</div>
|
||||
) : (
|
||||
<div className="hidden sm:block">{t("common.actions.subscribe")}</div>
|
||||
))}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -249,9 +249,9 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
|
|||
/>
|
||||
{/* drag and delete component */}
|
||||
<div
|
||||
className={`fixed left-1/2 -translate-x-1/2 ${
|
||||
isDragging ? "z-40" : ""
|
||||
} top-3 mx-3 flex w-[min(40rem,calc(100vw-2rem))] items-center justify-center`}
|
||||
className={`fixed left-1/2 top-3 mx-3 flex w-[min(40rem,calc(100vw-2rem))] -translate-x-1/2 items-center justify-center ${
|
||||
isDragging ? "pointer-events-auto z-40" : "pointer-events-none"
|
||||
}`}
|
||||
ref={deleteAreaRef}
|
||||
>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -84,9 +84,10 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
|
|||
|
||||
const maxDate = getDate(targetDate);
|
||||
maxDate?.setDate(maxDate.getDate());
|
||||
const propertyButtonClassName = "nodedc-work-item-property-button";
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="nodedc-work-item-properties-row">
|
||||
<Controller
|
||||
control={control}
|
||||
name="state_id"
|
||||
|
|
@ -100,6 +101,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
|
|||
}}
|
||||
projectId={projectId ?? undefined}
|
||||
buttonVariant="border-with-text"
|
||||
buttonClassName={propertyButtonClassName}
|
||||
tabIndex={getIndex("state_id")}
|
||||
isForWorkItemCreation={!id}
|
||||
/>
|
||||
|
|
@ -118,6 +120,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
|
|||
handleFormChange();
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
buttonClassName={propertyButtonClassName}
|
||||
tabIndex={getIndex("priority")}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -136,7 +139,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
|
|||
handleFormChange();
|
||||
}}
|
||||
buttonVariant={value?.length > 0 ? "transparent-without-text" : "border-with-text"}
|
||||
buttonClassName={value?.length > 0 ? "hover:bg-transparent" : ""}
|
||||
buttonClassName={propertyButtonClassName}
|
||||
placeholder={t("assignees")}
|
||||
multiple
|
||||
tabIndex={getIndex("assignee_ids")}
|
||||
|
|
@ -156,6 +159,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
|
|||
handleFormChange();
|
||||
}}
|
||||
projectId={projectId ?? undefined}
|
||||
buttonClassName={propertyButtonClassName}
|
||||
tabIndex={getIndex("label_ids")}
|
||||
createLabelEnabled={!!canCreateLabel}
|
||||
/>
|
||||
|
|
@ -174,6 +178,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
|
|||
handleFormChange();
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
buttonClassName={propertyButtonClassName}
|
||||
maxDate={maxDate ?? undefined}
|
||||
placeholder={t("start_date")}
|
||||
tabIndex={getIndex("start_date")}
|
||||
|
|
@ -193,6 +198,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
|
|||
handleFormChange();
|
||||
}}
|
||||
buttonVariant="border-with-text"
|
||||
buttonClassName={propertyButtonClassName}
|
||||
minDate={minDate ?? undefined}
|
||||
placeholder={t("due_date")}
|
||||
tabIndex={getIndex("target_date")}
|
||||
|
|
@ -215,6 +221,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
|
|||
placeholder={t("cycle.label", { count: 1 })}
|
||||
value={value}
|
||||
buttonVariant="border-with-text"
|
||||
buttonClassName={propertyButtonClassName}
|
||||
tabIndex={getIndex("cycle_id")}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -236,6 +243,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
|
|||
}}
|
||||
placeholder={t("modules")}
|
||||
buttonVariant="border-with-text"
|
||||
buttonClassName={propertyButtonClassName}
|
||||
tabIndex={getIndex("module_ids")}
|
||||
multiple
|
||||
showCount
|
||||
|
|
@ -258,6 +266,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
|
|||
}}
|
||||
projectId={projectId}
|
||||
buttonVariant="border-with-text"
|
||||
buttonClassName={propertyButtonClassName}
|
||||
tabIndex={getIndex("estimate_point")}
|
||||
placeholder={t("estimate")}
|
||||
/>
|
||||
|
|
@ -271,7 +280,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
|
|||
customButton={
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-full cursor-pointer items-center justify-between gap-1 rounded-sm border-[0.5px] border-strong px-2 py-0.5 text-caption-sm-regular hover:bg-layer-1"
|
||||
className={`${propertyButtonClassName} flex h-full cursor-pointer items-center justify-between gap-1 whitespace-nowrap`}
|
||||
>
|
||||
{selectedParentIssue?.project_id && (
|
||||
<IssueIdentifier
|
||||
|
|
@ -313,7 +322,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
|
|||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-full cursor-pointer items-center justify-between gap-1 rounded-sm border-[0.5px] border-strong px-2 py-0.5 text-caption-sm-regular hover:bg-layer-1"
|
||||
className={`${propertyButtonClassName} flex h-full cursor-pointer items-center justify-between gap-1 whitespace-nowrap`}
|
||||
onClick={() => setParentIssueListModalOpen(true)}
|
||||
>
|
||||
<ParentPropertyIcon className="h-3 w-3 flex-shrink-0" />
|
||||
|
|
|
|||
|
|
@ -486,7 +486,7 @@ export const IssueFormRoot = observer(function IssueFormRoot(props: IssueFormPro
|
|||
workspaceSlug={workspaceSlug?.toString()}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn("border-t border-subtle/70 bg-transparent px-6 py-4")}>
|
||||
<div className="bg-transparent px-6 pt-3 pb-4">
|
||||
<div className="pb-3">
|
||||
<IssueDefaultProperties
|
||||
control={control}
|
||||
|
|
@ -504,12 +504,12 @@ export const IssueFormRoot = observer(function IssueFormRoot(props: IssueFormPro
|
|||
</div>
|
||||
{showActionButtons && (
|
||||
<div
|
||||
className="flex items-center justify-end gap-4 border-t border-subtle/70 pt-6 pb-2"
|
||||
className="nodedc-work-item-actions-row"
|
||||
tabIndex={getIndex("create_more")}
|
||||
>
|
||||
{!data?.id && (
|
||||
<div
|
||||
className="inline-flex cursor-pointer items-center gap-1.5"
|
||||
className="nodedc-work-item-create-more"
|
||||
onClick={() => onCreateMoreToggleChange(!isCreateMoreToggleEnabled)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled);
|
||||
|
|
@ -517,7 +517,7 @@ export const IssueFormRoot = observer(function IssueFormRoot(props: IssueFormPro
|
|||
role="button"
|
||||
>
|
||||
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" />
|
||||
<span className="text-caption-sm-regular">{t("create_more")}</span>
|
||||
<span className="text-caption-sm-regular text-secondary">{t("create_more")}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -207,7 +207,8 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
|
|||
workspaceSlug={workspaceSlug}
|
||||
projectId={projectId}
|
||||
issueId={issueId}
|
||||
buttonClassName="!h-10 rounded-[18px] border-transparent bg-layer-2/80 px-4 shadow-none backdrop-blur-xl hover:!bg-layer-2-active focus-visible:outline-none"
|
||||
iconOnly
|
||||
buttonClassName="size-10 rounded-[18px] border-transparent bg-layer-2/80 px-0 shadow-none backdrop-blur-xl hover:!bg-layer-2-active focus-visible:outline-none"
|
||||
/>
|
||||
)}
|
||||
<Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useState } from "react";
|
|||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// types
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IIssueLabel } from "@plane/types";
|
||||
// ui
|
||||
|
|
@ -23,6 +24,7 @@ type Props = {
|
|||
|
||||
export const DeleteLabelModal = observer(function DeleteLabelModal(props: Props) {
|
||||
const { isOpen, onClose, data } = props;
|
||||
const { t } = useTranslation();
|
||||
// router
|
||||
const { workspaceSlug, projectId } = useParams();
|
||||
// store hooks
|
||||
|
|
@ -46,10 +48,10 @@ export const DeleteLabelModal = observer(function DeleteLabelModal(props: Props)
|
|||
})
|
||||
.catch((err) => {
|
||||
setIsDeleteLoading(false);
|
||||
const error = err?.error || "Label could not be deleted. Please try again.";
|
||||
const error = err?.error || t("label.delete_error");
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
title: t("common.error.label"),
|
||||
message: error,
|
||||
});
|
||||
});
|
||||
|
|
@ -61,13 +63,8 @@ export const DeleteLabelModal = observer(function DeleteLabelModal(props: Props)
|
|||
handleSubmit={handleDeletion}
|
||||
isSubmitting={isDeleteLoading}
|
||||
isOpen={isOpen}
|
||||
title="Delete Label"
|
||||
content={
|
||||
<>
|
||||
Are you sure you want to delete <span className="font-medium text-primary">{data?.name}</span>? This will
|
||||
remove the label from all the work item and from any views where the label is being filtered upon.
|
||||
</>
|
||||
}
|
||||
title={t("entity.delete.label", { entity: t("common.label") })}
|
||||
content={t("label.delete_confirmation", { value: data?.name ?? "" })}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,8 +52,8 @@ export const DeleteModuleModal = observer(function DeleteModuleModal(props: Prop
|
|||
handleClose();
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Module deleted successfully.",
|
||||
title: t("common.success"),
|
||||
message: t("entity.delete.success", { entity: t("common.module").toLowerCase() }),
|
||||
});
|
||||
})
|
||||
.catch((errors) => {
|
||||
|
|
@ -76,14 +76,11 @@ export const DeleteModuleModal = observer(function DeleteModuleModal(props: Prop
|
|||
handleSubmit={handleDeletion}
|
||||
isSubmitting={isDeleteLoading}
|
||||
isOpen={isOpen}
|
||||
title="Delete module"
|
||||
content={
|
||||
<>
|
||||
Are you sure you want to delete module-{" "}
|
||||
<span className="font-medium break-all text-primary">{data?.name}</span>? All of the data related to the
|
||||
module will be permanently removed. This action cannot be undone.
|
||||
</>
|
||||
}
|
||||
title={t("entity.delete.label", { entity: t("common.module") })}
|
||||
content={t("entity.delete.confirmation", {
|
||||
entity: t("common.module").toLowerCase(),
|
||||
identifier: data?.name ? `"${data.name}"` : "",
|
||||
})}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
|
|||
const width = 320;
|
||||
const viewportPadding = 16;
|
||||
const left = Math.min(rect.left, window.innerWidth - width - viewportPadding);
|
||||
const top = rect.top + rect.height / 2;
|
||||
const top = rect.bottom + 10;
|
||||
|
||||
setSidebarSearchPosition({
|
||||
left,
|
||||
|
|
@ -331,7 +331,7 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
|
|||
ref={sidebarSearchButtonRef}
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute left-0 top-0 z-[161] flex size-8 items-center justify-center rounded-full border border-white/8 bg-white/[0.04] text-placeholder backdrop-blur-[18px] outline-none transition-all hover:bg-white/[0.07]"
|
||||
"absolute left-0 top-0 z-[161] flex size-8 items-center justify-center rounded-full border-0 bg-white/[0.04] text-placeholder backdrop-blur-[18px] outline-none transition-all hover:bg-white/[0.07]"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isOpen) {
|
||||
|
|
@ -374,7 +374,6 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
|
|||
left: `${sidebarSearchPosition.left}px`,
|
||||
top: `${sidebarSearchPosition.top}px`,
|
||||
width: `${sidebarSearchPosition.width}px`,
|
||||
transform: "translateY(-50%)",
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
|
|
@ -400,7 +399,7 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
|
|||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="nodedc-glass-modal nodedc-glass-popup-surface absolute bottom-full left-0 mb-3 flex max-h-[70vh] w-full flex-col overflow-hidden rounded-[1.5rem] pt-3">
|
||||
<div className="nodedc-glass-modal nodedc-glass-popup-surface absolute top-full left-0 mt-3 flex max-h-[70vh] w-full flex-col overflow-hidden rounded-[1.5rem] pt-3">
|
||||
<div className="px-4 pb-2">
|
||||
<div className="text-[13px] font-medium text-secondary">
|
||||
{t("power_k.search_menu.quick_access_title")}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useState } from "react";
|
|||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { useParams } from "next/navigation";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { AlertModalCore } from "@plane/ui";
|
||||
import { getPageName } from "@plane/utils";
|
||||
|
|
@ -28,6 +29,7 @@ type TConfirmPageDeletionProps = {
|
|||
|
||||
export const DeletePageModal = observer(function DeletePageModal(props: TConfirmPageDeletionProps) {
|
||||
const { isOpen, onClose, page, storeType } = props;
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
// store hooks
|
||||
|
|
@ -52,8 +54,8 @@ export const DeletePageModal = observer(function DeletePageModal(props: TConfirm
|
|||
handleClose();
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Page deleted successfully.",
|
||||
title: t("project_page.delete_modal.success_title"),
|
||||
message: t("project_page.delete_modal.success_message"),
|
||||
});
|
||||
|
||||
if (routePageId) {
|
||||
|
|
@ -63,8 +65,8 @@ export const DeletePageModal = observer(function DeletePageModal(props: TConfirm
|
|||
.catch(() => {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Page could not be deleted. Please try again.",
|
||||
title: t("project_page.delete_modal.error_title"),
|
||||
message: t("project_page.delete_modal.error_message"),
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -79,14 +81,8 @@ export const DeletePageModal = observer(function DeletePageModal(props: TConfirm
|
|||
handleSubmit={handleDelete}
|
||||
isSubmitting={isDeleting}
|
||||
isOpen={isOpen}
|
||||
title="Delete page"
|
||||
content={
|
||||
<>
|
||||
Are you sure you want to delete page-{" "}
|
||||
<span className="font-medium break-words break-all text-primary">{getPageName(name)}</span> ? The Page will be
|
||||
deleted permanently. This action cannot be undone.
|
||||
</>
|
||||
}
|
||||
title={t("project_page.delete_modal.title")}
|
||||
content={t("project_page.delete_modal.content", { value: getPageName(name) })}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Loader } from "lucide-react";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { CloseIcon } from "@plane/propel/icons";
|
||||
// plane imports
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
|
|
@ -26,6 +27,7 @@ type TStateDelete = {
|
|||
|
||||
export const StateDelete = observer(function StateDelete(props: TStateDelete) {
|
||||
const { totalStates, state, deleteStateCallback } = props;
|
||||
const { t } = useTranslation();
|
||||
// hooks
|
||||
const { isMobile } = usePlatformOS();
|
||||
// states
|
||||
|
|
@ -47,15 +49,14 @@ export const StateDelete = observer(function StateDelete(props: TStateDelete) {
|
|||
if (errorStatus.status === 400) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message:
|
||||
"This state contains some work items within it, please move them to some other state to delete this state.",
|
||||
title: t("common.error.label"),
|
||||
message: t("project_settings.states.delete.blocked"),
|
||||
});
|
||||
} else {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "State could not be deleted. Please try again.",
|
||||
title: t("common.error.label"),
|
||||
message: t("project_settings.states.delete.error"),
|
||||
});
|
||||
}
|
||||
setIsDelete(false);
|
||||
|
|
@ -69,13 +70,11 @@ export const StateDelete = observer(function StateDelete(props: TStateDelete) {
|
|||
handleSubmit={handleDeleteState}
|
||||
isSubmitting={isDelete}
|
||||
isOpen={isDeleteModal}
|
||||
title="Delete State"
|
||||
content={
|
||||
<>
|
||||
Are you sure you want to delete state- <span className="font-medium text-primary">{state?.name}</span>? All
|
||||
of the data related to the state will be permanently removed. This action cannot be undone.
|
||||
</>
|
||||
}
|
||||
title={t("entity.delete.label", { entity: t("common.state") })}
|
||||
content={t("entity.delete.confirmation", {
|
||||
entity: t("common.state").toLowerCase(),
|
||||
identifier: state?.name ? `"${state.name}"` : "",
|
||||
})}
|
||||
/>
|
||||
|
||||
<button
|
||||
|
|
@ -89,7 +88,11 @@ export const StateDelete = observer(function StateDelete(props: TStateDelete) {
|
|||
>
|
||||
<Tooltip
|
||||
tooltipContent={
|
||||
state.default ? "Cannot delete the default state." : totalStates === 1 ? `Cannot have an empty group.` : ``
|
||||
state.default
|
||||
? t("project_settings.states.delete.disabled_default")
|
||||
: totalStates === 1
|
||||
? t("project_settings.states.delete.disabled_last")
|
||||
: ""
|
||||
}
|
||||
isMobile={isMobile}
|
||||
disabled={!isDeleteDisabled}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useState } from "react";
|
|||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// Plane imports
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import type { IState } from "@plane/types";
|
||||
// ui
|
||||
|
|
@ -23,6 +24,7 @@ type TStateDeleteModal = {
|
|||
|
||||
export const StateDeleteModal = observer(function StateDeleteModal(props: TStateDeleteModal) {
|
||||
const { isOpen, onClose, data } = props;
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
|
||||
// router
|
||||
|
|
@ -47,15 +49,14 @@ export const StateDeleteModal = observer(function StateDeleteModal(props: TState
|
|||
if (err.status === 400)
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message:
|
||||
"This state contains some work items within it, please move them to some other state to delete this state.",
|
||||
title: t("common.error.label"),
|
||||
message: t("project_settings.states.delete.blocked"),
|
||||
});
|
||||
else
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "State could not be deleted. Please try again.",
|
||||
title: t("common.error.label"),
|
||||
message: t("project_settings.states.delete.error"),
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
|
|
@ -69,13 +70,11 @@ export const StateDeleteModal = observer(function StateDeleteModal(props: TState
|
|||
handleSubmit={handleDeletion}
|
||||
isSubmitting={isDeleteLoading}
|
||||
isOpen={isOpen}
|
||||
title="Delete State"
|
||||
content={
|
||||
<>
|
||||
Are you sure you want to delete state- <span className="font-medium text-primary">{data?.name}</span>? All of
|
||||
the data related to the state will be permanently removed. This action cannot be undone.
|
||||
</>
|
||||
}
|
||||
title={t("entity.delete.label", { entity: t("common.state") })}
|
||||
content={t("entity.delete.confirmation", {
|
||||
entity: t("common.state").toLowerCase(),
|
||||
identifier: data?.name ? `"${data.name}"` : "",
|
||||
})}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ export const MultiSelectFilterValueInput = observer(function MultiSelectFilterVa
|
|||
return (
|
||||
<CustomSearchSelect
|
||||
{...getCommonCustomSearchSelectProps(isDisabled)}
|
||||
className="min-w-[4.5rem]"
|
||||
value={toFilterArray(condition.value)}
|
||||
onChange={handleSelectChange}
|
||||
options={formattedOptions}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ export const SingleSelectFilterValueInput = observer(function SingleSelectFilter
|
|||
return (
|
||||
<CustomSearchSelect
|
||||
{...getCommonCustomSearchSelectProps(isDisabled)}
|
||||
className="min-w-[4.5rem]"
|
||||
value={condition.value}
|
||||
onChange={handleSelectChange}
|
||||
options={formattedOptions}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { AddFilterButton } from "@/components/rich-filters/add-filters/button";
|
|||
|
||||
type TFiltersToggleProps<P extends TFilterProperty, E extends TExternalFilter> = {
|
||||
filter: IFilterInstance<P, E> | undefined;
|
||||
showAddFilterButtonWhenEmpty?: boolean;
|
||||
};
|
||||
|
||||
const COMMON_CLASSNAME =
|
||||
|
|
@ -24,13 +25,13 @@ const COMMON_CLASSNAME =
|
|||
export const FiltersToggle = observer(function FiltersToggle<P extends TFilterProperty, E extends TExternalFilter>(
|
||||
props: TFiltersToggleProps<P, E>
|
||||
) {
|
||||
const { filter } = props;
|
||||
const { filter, showAddFilterButtonWhenEmpty = true } = props;
|
||||
// derived values
|
||||
const hasAnyConditions = (filter?.allConditionsForDisplay.length ?? 0) > 0;
|
||||
const isFilterRowVisible = filter?.isVisible ?? false;
|
||||
const hasUpdates = filter?.canUpdateView === true && filter?.hasChanges === true;
|
||||
const showFilterRowChangesPill = hasUpdates || hasAnyConditions === true;
|
||||
const showAddFilterButton = !hasAnyConditions && !isFilterRowVisible && !hasUpdates;
|
||||
const showAddFilterButton = showAddFilterButtonWhenEmpty && !hasAnyConditions && !isFilterRowVisible && !hasUpdates;
|
||||
|
||||
const handleToggleFilter = () => {
|
||||
if (!filter) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ const FILTER_LABEL_MAP: Record<string, string> = {
|
|||
Label: "Метка",
|
||||
Labels: "Метки",
|
||||
Project: "Проект",
|
||||
Projects: "Проекты",
|
||||
Contour: "Контур",
|
||||
"Created by": "Автор",
|
||||
"Created at": "Дата создания",
|
||||
"Updated at": "Дата обновления",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
// ui
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { AlertModalCore } from "@plane/ui";
|
||||
// hooks
|
||||
|
|
@ -20,6 +21,7 @@ interface IDeleteWebhook {
|
|||
|
||||
export function DeleteWebhookModal(props: IDeleteWebhook) {
|
||||
const { isOpen, onClose } = props;
|
||||
const { t } = useTranslation();
|
||||
// states
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
// router
|
||||
|
|
@ -41,14 +43,14 @@ export function DeleteWebhookModal(props: IDeleteWebhook) {
|
|||
router.replace(`/${workspaceSlug}/settings/webhooks/`);
|
||||
setToast({
|
||||
type: TOAST_TYPE.SUCCESS,
|
||||
title: "Success!",
|
||||
message: "Webhook deleted successfully.",
|
||||
title: t("workspace_settings.settings.webhooks.toasts.removed.title"),
|
||||
message: t("workspace_settings.settings.webhooks.toasts.removed.message"),
|
||||
});
|
||||
} catch (_error) {
|
||||
setToast({
|
||||
type: TOAST_TYPE.ERROR,
|
||||
title: "Error!",
|
||||
message: "Webhook could not be deleted. Please try again.",
|
||||
title: t("workspace_settings.settings.webhooks.toasts.not_removed.title"),
|
||||
message: t("workspace_settings.settings.webhooks.toasts.not_removed.message"),
|
||||
});
|
||||
}
|
||||
setIsDeleting(false);
|
||||
|
|
@ -60,13 +62,8 @@ export function DeleteWebhookModal(props: IDeleteWebhook) {
|
|||
handleSubmit={handleDelete}
|
||||
isSubmitting={isDeleting}
|
||||
isOpen={isOpen}
|
||||
title="Delete webhook"
|
||||
content={
|
||||
<>
|
||||
Are you sure you want to delete this webhook? Future events will not be delivered to this webhook. This action
|
||||
cannot be undone.
|
||||
</>
|
||||
}
|
||||
title={t("workspace_settings.settings.webhooks.delete.title")}
|
||||
content={t("workspace_settings.settings.webhooks.delete.description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
import { Disclosure, Transition } from "@headlessui/react";
|
||||
import { WORKSPACE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "@plane/propel/icons";
|
||||
|
||||
|
|
@ -15,13 +16,14 @@ type Props = {
|
|||
|
||||
export function WebhookDeleteSection(props: Props) {
|
||||
const { openDeleteModal } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Disclosure as="div" className="border-t border-subtle">
|
||||
{({ open }) => (
|
||||
<div className="w-full">
|
||||
<Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between py-4">
|
||||
<span className="text-16 tracking-tight">Danger zone</span>
|
||||
<span className="text-16 tracking-tight">{t("workspace_settings.settings.webhooks.delete.title")}</span>
|
||||
{open ? <ChevronUpIcon className="h-5 w-5" /> : <ChevronDownIcon className="h-5 w-5" />}
|
||||
</Disclosure.Button>
|
||||
|
||||
|
|
@ -36,18 +38,16 @@ export function WebhookDeleteSection(props: Props) {
|
|||
>
|
||||
<Disclosure.Panel>
|
||||
<div className="flex flex-col gap-8">
|
||||
<span className="text-13 tracking-tight">
|
||||
Once a webhook is deleted, it cannot be restored. Future events will no longer be delivered to this
|
||||
webhook.
|
||||
</span>
|
||||
<span className="text-13 tracking-tight">{t("workspace_settings.settings.webhooks.delete.description")}</span>
|
||||
<div>
|
||||
<Button
|
||||
variant="error-fill"
|
||||
size="lg"
|
||||
onClick={openDeleteModal}
|
||||
className="nodedc-modal-danger-button"
|
||||
data-ph-element={WORKSPACE_SETTINGS_TRACKER_ELEMENTS.WEBHOOK_DELETE_BUTTON}
|
||||
>
|
||||
Delete webhook
|
||||
{t("workspace_settings.settings.webhooks.delete.title")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -24,5 +24,5 @@ export const WorkItemFiltersToggle = observer(function WorkItemFiltersToggle(pro
|
|||
// derived values
|
||||
const filter = getFilter(entityType, entityId);
|
||||
|
||||
return <FiltersToggle filter={filter} />;
|
||||
return <FiltersToggle filter={filter} showAddFilterButtonWhenEmpty={false} />;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -102,11 +102,11 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
|
|||
</div>
|
||||
) : (
|
||||
<ExternalContoursContentRoot
|
||||
setIsMobileSidebar={() => {}}
|
||||
isMobileSidebar={false}
|
||||
workspaceSlug={workspace_slug}
|
||||
projectId={project_id}
|
||||
inboxIssueId={issue_id}
|
||||
embedIssue
|
||||
embedRemoveCurrentNotification={embedRemoveCurrentNotification}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { LogOut, Settings, Settings2 } from "lucide-react";
|
||||
|
|
@ -23,7 +24,7 @@ import { useCommandPalette } from "@/hooks/store/use-command-palette";
|
|||
import { useUser } from "@/hooks/store/user";
|
||||
|
||||
type TUserMenuRootProps = {
|
||||
variant?: "default" | "sidebar-utility";
|
||||
variant?: "default" | "sidebar-utility" | "toolbar";
|
||||
};
|
||||
|
||||
export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootProps) {
|
||||
|
|
@ -42,6 +43,9 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
|
|||
// translation
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isSidebarUtilityVariant = variant === "sidebar-utility";
|
||||
const isToolbarVariant = variant === "toolbar";
|
||||
|
||||
const handleSignOut = () => {
|
||||
signOut().catch(() =>
|
||||
setToast({
|
||||
|
|
@ -58,46 +62,8 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
|
|||
else toggleAnySidebarDropdown(false);
|
||||
}, [isUserMenuOpen, toggleAnySidebarDropdown]);
|
||||
|
||||
return (
|
||||
<CustomMenu
|
||||
className="flex items-center"
|
||||
customButton={
|
||||
variant === "sidebar-utility" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 items-center justify-center rounded-full border border-white/8 bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07]"
|
||||
>
|
||||
<Avatar
|
||||
name={currentUser?.display_name}
|
||||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
size={18}
|
||||
shape="circle"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<AppSidebarItem
|
||||
variant="button"
|
||||
item={{
|
||||
icon: (
|
||||
<Avatar
|
||||
name={currentUser?.display_name}
|
||||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
size={20}
|
||||
shape="circle"
|
||||
/>
|
||||
),
|
||||
isActive: isUserMenuOpen,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
menuButtonOnClick={() => !isUserMenuOpen && setIsUserMenuOpen(true)}
|
||||
onMenuClose={() => setIsUserMenuOpen(false)}
|
||||
placement="bottom-end"
|
||||
maxHeight="2xl"
|
||||
optionsClassName="w-72 p-3 flex flex-col gap-y-3"
|
||||
closeOnSelect
|
||||
>
|
||||
const menuContent = (
|
||||
<>
|
||||
<div className="relative h-29 w-full rounded-lg">
|
||||
<CoverImage
|
||||
src={currentUser?.cover_image_url ?? undefined}
|
||||
|
|
@ -164,6 +130,77 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
|
|||
{t("enter_god_mode")}
|
||||
</CustomMenu.MenuItem>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isToolbarVariant) {
|
||||
return (
|
||||
<Menu as="div" className="relative">
|
||||
<Menu.Button
|
||||
type="button"
|
||||
aria-label={t("profile")}
|
||||
className="flex size-8 items-center justify-center rounded-full border-0 bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07]"
|
||||
>
|
||||
<Avatar
|
||||
name={currentUser?.display_name}
|
||||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
size={18}
|
||||
shape="circle"
|
||||
/>
|
||||
</Menu.Button>
|
||||
|
||||
<Menu.Items className="absolute top-full left-0 z-[170] mt-2 origin-top-left">
|
||||
<div className="nodedc-glass-modal nodedc-glass-popup-surface flex w-72 flex-col gap-y-3 rounded-[1.25rem] border-0 p-3 shadow-none outline-none">
|
||||
{menuContent}
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomMenu
|
||||
className="flex items-center"
|
||||
customButtonClassName={
|
||||
isSidebarUtilityVariant
|
||||
? "flex size-8 items-center justify-center rounded-full border-0 bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07]"
|
||||
: ""
|
||||
}
|
||||
customButton={
|
||||
isSidebarUtilityVariant ? (
|
||||
<span className="pointer-events-none flex size-8 items-center justify-center">
|
||||
<Avatar
|
||||
name={currentUser?.display_name}
|
||||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
size={18}
|
||||
shape="circle"
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<AppSidebarItem
|
||||
variant="button"
|
||||
item={{
|
||||
icon: (
|
||||
<Avatar
|
||||
name={currentUser?.display_name}
|
||||
src={getFileURL(currentUser?.avatar_url ?? "")}
|
||||
size={20}
|
||||
shape="circle"
|
||||
/>
|
||||
),
|
||||
isActive: isUserMenuOpen,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
menuButtonOnClick={() => !isUserMenuOpen && setIsUserMenuOpen(true)}
|
||||
onMenuClose={() => setIsUserMenuOpen(false)}
|
||||
placement="bottom-end"
|
||||
maxHeight="2xl"
|
||||
optionsClassName="w-72 p-3 flex flex-col gap-y-3"
|
||||
closeOnSelect
|
||||
>
|
||||
{menuContent}
|
||||
</CustomMenu>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import { WorkspaceLogo } from "../logo";
|
|||
import SidebarDropdownItem from "./dropdown-item";
|
||||
|
||||
type WorkspaceMenuRootProps = {
|
||||
variant: "sidebar" | "top-navigation" | "sidebar-panel";
|
||||
variant: "sidebar" | "top-navigation" | "sidebar-panel" | "toolbar";
|
||||
};
|
||||
|
||||
type WorkspaceMenuStateSyncProps = {
|
||||
|
|
@ -46,7 +46,7 @@ function WorkspaceMenuStateSync(props: WorkspaceMenuStateSyncProps) {
|
|||
const { open, variant, sidebarPanelButtonRef, onSidebarDropdownToggle, onSidebarPanelPositionChange } = props;
|
||||
|
||||
const updateSidebarPanelMenuPosition = useCallback(() => {
|
||||
if (variant !== "sidebar-panel" || !sidebarPanelButtonRef.current || typeof window === "undefined") return;
|
||||
if (!["sidebar-panel", "toolbar"].includes(variant) || !sidebarPanelButtonRef.current || typeof window === "undefined") return;
|
||||
|
||||
const rect = sidebarPanelButtonRef.current.getBoundingClientRect();
|
||||
const width = 480;
|
||||
|
|
@ -64,7 +64,7 @@ function WorkspaceMenuStateSync(props: WorkspaceMenuStateSyncProps) {
|
|||
}, [onSidebarDropdownToggle, open]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open || variant !== "sidebar-panel") {
|
||||
if (!open || !["sidebar-panel", "toolbar"].includes(variant)) {
|
||||
onSidebarPanelPositionChange(null);
|
||||
return;
|
||||
}
|
||||
|
|
@ -133,6 +133,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
"w-full justify-center text-center": variant === "sidebar",
|
||||
"flex-grow justify-stretch text-left": variant === "top-navigation",
|
||||
"w-full max-w-none justify-stretch text-left": variant === "sidebar-panel",
|
||||
"w-fit max-w-none justify-center text-center": variant === "toolbar",
|
||||
})}
|
||||
>
|
||||
{({ open, close }: { open: boolean; close: () => void }) => {
|
||||
|
|
@ -220,6 +221,24 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
/>
|
||||
</Menu.Button>
|
||||
)}
|
||||
{variant === "toolbar" && (
|
||||
<Menu.Button
|
||||
ref={sidebarPanelButtonRef}
|
||||
className={cn(
|
||||
"flex size-8 items-center justify-center rounded-full bg-white/[0.04] backdrop-blur-[18px] transition-all hover:bg-white/[0.07] focus:outline-none",
|
||||
{
|
||||
"bg-white/[0.08]": open,
|
||||
}
|
||||
)}
|
||||
aria-label={t("aria_labels.projects_sidebar.open_workspace_switcher")}
|
||||
>
|
||||
<WorkspaceLogo
|
||||
logo={activeWorkspace?.logo_url}
|
||||
name={activeWorkspace?.name}
|
||||
classNames="size-8 rounded-[0.9rem]"
|
||||
/>
|
||||
</Menu.Button>
|
||||
)}
|
||||
{(() => {
|
||||
const menuItems = (
|
||||
<Menu.Items
|
||||
|
|
@ -228,15 +247,15 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
"z-21 mt-1 flex min-w-[30rem] origin-top-left flex-col divide-y overflow-hidden outline-none",
|
||||
{
|
||||
"fixed divide-subtle rounded-md border-[0.5px] border-strong bg-surface-1 shadow-raised-200":
|
||||
variant !== "sidebar-panel",
|
||||
!["sidebar-panel", "toolbar"].includes(variant),
|
||||
"top-11 left-14": variant === "sidebar",
|
||||
"top-10 left-4": variant === "top-navigation",
|
||||
"nodedc-glass-modal nodedc-glass-popup-surface rounded-[1.5rem] divide-white/10":
|
||||
variant === "sidebar-panel",
|
||||
["sidebar-panel", "toolbar"].includes(variant),
|
||||
}
|
||||
)}
|
||||
style={
|
||||
variant === "sidebar-panel" && sidebarPanelMenuPosition
|
||||
["sidebar-panel", "toolbar"].includes(variant) && sidebarPanelMenuPosition
|
||||
? {
|
||||
position: "fixed",
|
||||
left: `${sidebarPanelMenuPosition.left}px`,
|
||||
|
|
@ -251,8 +270,8 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
className={cn(
|
||||
"sticky top-0 z-21 h-full w-full flex-shrink-0 truncate px-4 pt-3 pb-1 text-left text-13 font-medium text-placeholder",
|
||||
{
|
||||
"rounded-md bg-surface-1": variant !== "sidebar-panel",
|
||||
"bg-transparent": variant === "sidebar-panel",
|
||||
"rounded-md bg-surface-1": !["sidebar-panel", "toolbar"].includes(variant),
|
||||
"bg-transparent": ["sidebar-panel", "toolbar"].includes(variant),
|
||||
}
|
||||
)}
|
||||
>
|
||||
|
|
@ -324,7 +343,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
|
|||
</Menu.Items>
|
||||
);
|
||||
|
||||
if (variant === "sidebar-panel") {
|
||||
if (["sidebar-panel", "toolbar"].includes(variant)) {
|
||||
if (!open || !sidebarPanelMenuPosition || typeof document === "undefined") return null;
|
||||
return createPortal(menuItems, document.body);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { useContext } from "react";
|
||||
import { StoreContext } from "@/lib/store-context";
|
||||
import type { IProjectExternalContoursBoardStore } from "@/store/external-contours/project-external-contours-board.store";
|
||||
|
||||
export const useProjectExternalContoursBoard = (): IProjectExternalContoursBoardStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useProjectExternalContoursBoard must be used within StoreProvider");
|
||||
return context.projectExternalContoursBoard;
|
||||
};
|
||||
|
|
@ -6,6 +6,9 @@
|
|||
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import type {
|
||||
TExternalContourBoardFilter,
|
||||
TExternalContourBoardResponse,
|
||||
TExternalContourBoardSorting,
|
||||
TExternalContourRequest,
|
||||
TExternalContourRequestResponse,
|
||||
TExternalContourTargetOptions,
|
||||
|
|
@ -27,6 +30,27 @@ export class ExternalContourService extends APIService {
|
|||
});
|
||||
}
|
||||
|
||||
async listBoard(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
filters: Partial<TExternalContourBoardFilter> = {},
|
||||
sorting: TExternalContourBoardSorting = {}
|
||||
): Promise<TExternalContourBoardResponse> {
|
||||
const params = Object.fromEntries(
|
||||
Object.entries({ ...filters, ...sorting }).flatMap(([key, value]) => {
|
||||
if (value === undefined || value === null || value === "") return [];
|
||||
if (Array.isArray(value)) return [[key, value.join(",")]];
|
||||
return [[key, String(value)]];
|
||||
})
|
||||
);
|
||||
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/board/`, { params })
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async retrieve(workspaceSlug: string, projectId: string, requestId: string): Promise<TExternalContourRequest> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/`)
|
||||
.then((response) => response?.data)
|
||||
|
|
@ -35,11 +59,19 @@ export class ExternalContourService extends APIService {
|
|||
});
|
||||
}
|
||||
|
||||
async retrieveBoardItem(workspaceSlug: string, projectId: string, requestId: string): Promise<TExternalContourRequest> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/board-items/${requestId}/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async updateRequest(
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
requestId: string,
|
||||
data: Pick<Partial<TIssue>, "name" | "description_html">
|
||||
data: Pick<Partial<TIssue>, "name" | "description_html" | "priority" | "target_date" | "assignee_ids" | "label_ids" | "state_id">
|
||||
): Promise<TExternalContourRequest> {
|
||||
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/`, data)
|
||||
.then((response) => response?.data)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,251 @@
|
|||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import type {
|
||||
TExternalContourBoardDirection,
|
||||
TExternalContourBoardFilter,
|
||||
TExternalContourBoardSorting,
|
||||
TExternalContourRequest,
|
||||
TInboxIssueCurrentTab,
|
||||
} from "@plane/types";
|
||||
import { EInboxIssueCurrentTab } from "@plane/types";
|
||||
import { ExternalContourService } from "@/services/external-contours";
|
||||
import type { CoreRootStore } from "../root.store";
|
||||
|
||||
type TLoader = "init-loading" | "loading" | undefined;
|
||||
|
||||
const DEFAULT_SORTING: TExternalContourBoardSorting = { order_by: "updated_at", sort_by: "desc" };
|
||||
|
||||
const sanitizeBoardFilters = (filters: Partial<TExternalContourBoardFilter>): Partial<TExternalContourBoardFilter> =>
|
||||
Object.fromEntries(
|
||||
Object.entries(filters).flatMap(([key, value]) => {
|
||||
if (value === undefined || value === null || value === "") return [];
|
||||
if (Array.isArray(value) && value.length === 0) return [];
|
||||
return [[key, value]];
|
||||
})
|
||||
) as Partial<TExternalContourBoardFilter>;
|
||||
|
||||
export interface IProjectExternalContoursBoardStore {
|
||||
currentProjectId: string;
|
||||
currentTab: TInboxIssueCurrentTab;
|
||||
error: { message: string; status: "init-error" } | undefined;
|
||||
filters: Partial<TExternalContourBoardFilter>;
|
||||
items: Record<string, TExternalContourRequest>;
|
||||
loader: TLoader;
|
||||
sorting: TExternalContourBoardSorting;
|
||||
columnIdsMap: Record<TExternalContourBoardDirection, string[]>;
|
||||
columnCountMap: Record<TExternalContourBoardDirection, number>;
|
||||
tabCountMap: Record<TInboxIssueCurrentTab, number>;
|
||||
activeFiltersCount: number;
|
||||
fetchBoard: (workspaceSlug: string, projectId: string, tab?: TInboxIssueCurrentTab) => Promise<void>;
|
||||
getColumnRequestIds: (direction: TExternalContourBoardDirection) => string[];
|
||||
getColumnTotalCount: (direction: TExternalContourBoardDirection) => number;
|
||||
getRequestById: (requestId: string) => TExternalContourRequest | undefined;
|
||||
handleCurrentTab: (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => Promise<void>;
|
||||
hasAnyItems: boolean;
|
||||
isFiltering: boolean;
|
||||
isSortingDefault: boolean;
|
||||
clearFilters: (workspaceSlug: string, projectId: string) => Promise<void>;
|
||||
replaceFilters: (workspaceSlug: string, projectId: string, filters: Partial<TExternalContourBoardFilter>) => Promise<void>;
|
||||
updateFilters: (workspaceSlug: string, projectId: string, filters: Partial<TExternalContourBoardFilter>) => Promise<void>;
|
||||
updateSorting: (workspaceSlug: string, projectId: string, sorting: TExternalContourBoardSorting) => Promise<void>;
|
||||
upsertBoardItems: (items: TExternalContourRequest[]) => void;
|
||||
}
|
||||
|
||||
export class ProjectExternalContoursBoardStore implements IProjectExternalContoursBoardStore {
|
||||
currentProjectId = "";
|
||||
currentTab: TInboxIssueCurrentTab = EInboxIssueCurrentTab.OPEN;
|
||||
error: { message: string; status: "init-error" } | undefined = undefined;
|
||||
filters: Partial<TExternalContourBoardFilter> = {};
|
||||
items: Record<string, TExternalContourRequest> = {};
|
||||
loader: TLoader = "init-loading";
|
||||
sorting: TExternalContourBoardSorting = DEFAULT_SORTING;
|
||||
columnIdsMap: Record<TExternalContourBoardDirection, string[]> = {
|
||||
outgoing: [],
|
||||
incoming: [],
|
||||
};
|
||||
columnCountMap: Record<TExternalContourBoardDirection, number> = {
|
||||
outgoing: 0,
|
||||
incoming: 0,
|
||||
};
|
||||
tabCountMap: Record<TInboxIssueCurrentTab, number> = {
|
||||
[EInboxIssueCurrentTab.OPEN]: 0,
|
||||
[EInboxIssueCurrentTab.CLOSED]: 0,
|
||||
};
|
||||
hydratedProjectId = "";
|
||||
lastIssuedRequestId = 0;
|
||||
|
||||
externalContourService;
|
||||
|
||||
constructor(private store: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
currentProjectId: observable.ref,
|
||||
currentTab: observable.ref,
|
||||
error: observable.ref,
|
||||
filters: observable.ref,
|
||||
items: observable,
|
||||
loader: observable.ref,
|
||||
sorting: observable.ref,
|
||||
columnIdsMap: observable,
|
||||
columnCountMap: observable,
|
||||
tabCountMap: observable,
|
||||
activeFiltersCount: computed,
|
||||
hasAnyItems: computed,
|
||||
isFiltering: computed,
|
||||
isSortingDefault: computed,
|
||||
clearFilters: action,
|
||||
fetchBoard: action,
|
||||
handleCurrentTab: action,
|
||||
replaceFilters: action,
|
||||
updateFilters: action,
|
||||
updateSorting: action,
|
||||
upsertBoardItems: action,
|
||||
});
|
||||
|
||||
this.externalContourService = new ExternalContourService();
|
||||
}
|
||||
|
||||
get hasAnyItems() {
|
||||
return this.columnIdsMap.outgoing.length > 0 || this.columnIdsMap.incoming.length > 0;
|
||||
}
|
||||
|
||||
get isFiltering() {
|
||||
return this.loader === "loading";
|
||||
}
|
||||
|
||||
get isSortingDefault() {
|
||||
return this.sorting.order_by === DEFAULT_SORTING.order_by && this.sorting.sort_by === DEFAULT_SORTING.sort_by;
|
||||
}
|
||||
|
||||
get activeFiltersCount() {
|
||||
return Object.entries(this.filters).reduce((count, [key, value]) => {
|
||||
if (key === "status") return count;
|
||||
if (value === undefined || value === null || value === "") return count;
|
||||
if (Array.isArray(value)) return count + (value.length > 0 ? 1 : 0);
|
||||
if (typeof value === "boolean") return count + (value ? 1 : 0);
|
||||
return count + 1;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
getRequestById = (requestId: string) => this.items[requestId];
|
||||
|
||||
getColumnRequestIds = (direction: TExternalContourBoardDirection) => this.columnIdsMap[direction] ?? [];
|
||||
|
||||
getColumnTotalCount = (direction: TExternalContourBoardDirection) => this.columnCountMap[direction] ?? 0;
|
||||
|
||||
upsertBoardItems = (items: TExternalContourRequest[]) => {
|
||||
items.forEach((request) => {
|
||||
this.items[request.id] = request;
|
||||
});
|
||||
|
||||
this.store.projectExternalContours.upsertRequests(items);
|
||||
};
|
||||
|
||||
handleCurrentTab = async (workspaceSlug: string, projectId: string, tab: TInboxIssueCurrentTab) => {
|
||||
this.currentTab = tab;
|
||||
await this.fetchBoard(workspaceSlug, projectId, tab);
|
||||
};
|
||||
|
||||
updateFilters = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
filters: Partial<TExternalContourBoardFilter>
|
||||
) => {
|
||||
this.filters = sanitizeBoardFilters({
|
||||
...this.filters,
|
||||
...filters,
|
||||
});
|
||||
|
||||
await this.fetchBoard(workspaceSlug, projectId);
|
||||
};
|
||||
|
||||
replaceFilters = async (
|
||||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
filters: Partial<TExternalContourBoardFilter>
|
||||
) => {
|
||||
this.filters = sanitizeBoardFilters(filters);
|
||||
await this.fetchBoard(workspaceSlug, projectId);
|
||||
};
|
||||
|
||||
updateSorting = async (workspaceSlug: string, projectId: string, sorting: TExternalContourBoardSorting) => {
|
||||
this.sorting = sorting;
|
||||
await this.fetchBoard(workspaceSlug, projectId);
|
||||
};
|
||||
|
||||
clearFilters = async (workspaceSlug: string, projectId: string) => {
|
||||
this.filters = {};
|
||||
this.sorting = DEFAULT_SORTING;
|
||||
await this.fetchBoard(workspaceSlug, projectId);
|
||||
};
|
||||
|
||||
fetchBoard = async (workspaceSlug: string, projectId: string, tab = this.currentTab) => {
|
||||
const hasProjectChanged = !!this.currentProjectId && this.currentProjectId !== projectId;
|
||||
const isInitialLoad = this.hydratedProjectId !== projectId;
|
||||
const nextFilters = sanitizeBoardFilters(hasProjectChanged ? {} : this.filters);
|
||||
const nextSorting = hasProjectChanged ? DEFAULT_SORTING : this.sorting;
|
||||
const requestId = ++this.lastIssuedRequestId;
|
||||
|
||||
this.loader = isInitialLoad ? "init-loading" : "loading";
|
||||
this.error = undefined;
|
||||
if (hasProjectChanged) {
|
||||
this.items = {};
|
||||
this.columnIdsMap = { outgoing: [], incoming: [] };
|
||||
this.columnCountMap = { outgoing: 0, incoming: 0 };
|
||||
this.tabCountMap = {
|
||||
[EInboxIssueCurrentTab.OPEN]: 0,
|
||||
[EInboxIssueCurrentTab.CLOSED]: 0,
|
||||
};
|
||||
}
|
||||
this.currentProjectId = projectId;
|
||||
this.currentTab = tab;
|
||||
this.filters = nextFilters;
|
||||
this.sorting = nextSorting;
|
||||
|
||||
try {
|
||||
const response = await this.externalContourService.listBoard(workspaceSlug, projectId, nextFilters, nextSorting);
|
||||
if (requestId !== this.lastIssuedRequestId) return;
|
||||
|
||||
runInAction(() => {
|
||||
this.columnIdsMap = { outgoing: [], incoming: [] };
|
||||
this.columnCountMap = { outgoing: 0, incoming: 0 };
|
||||
this.filters = sanitizeBoardFilters(response.filters || nextFilters);
|
||||
this.sorting = response.sorting || nextSorting;
|
||||
this.hydratedProjectId = projectId;
|
||||
let openCount = 0;
|
||||
let closedCount = 0;
|
||||
|
||||
response.columns.forEach((column) => {
|
||||
this.columnIdsMap[column.key] = column.results.map((request) => request.id);
|
||||
this.columnCountMap[column.key] = column.total_count;
|
||||
column.results.forEach((request) => {
|
||||
if (request.status === EInboxIssueCurrentTab.CLOSED) closedCount += 1;
|
||||
else openCount += 1;
|
||||
});
|
||||
this.upsertBoardItems(column.results);
|
||||
});
|
||||
|
||||
this.tabCountMap = {
|
||||
[EInboxIssueCurrentTab.OPEN]: openCount,
|
||||
[EInboxIssueCurrentTab.CLOSED]: closedCount,
|
||||
};
|
||||
|
||||
this.loader = undefined;
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (requestId !== this.lastIssuedRequestId) return;
|
||||
|
||||
runInAction(() => {
|
||||
this.loader = undefined;
|
||||
this.error = {
|
||||
message: error?.error || "Не удалось загрузить доску внешних контуров",
|
||||
status: "init-error",
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ export interface IProjectExternalContoursStore {
|
|||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
requestId: string,
|
||||
data: Pick<Partial<TIssue>, "name" | "description_html">
|
||||
data: Pick<Partial<TIssue>, "name" | "description_html" | "priority" | "target_date" | "assignee_ids" | "label_ids" | "state_id">
|
||||
) => Promise<TExternalContourRequest | undefined>;
|
||||
decideRequest: (
|
||||
workspaceSlug: string,
|
||||
|
|
@ -215,7 +215,7 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
|
|||
fetchRequestById = async (workspaceSlug: string, projectId: string, requestId: string) => {
|
||||
this.loader = "issue-loading";
|
||||
try {
|
||||
const request = await this.externalContourService.retrieve(workspaceSlug, projectId, requestId);
|
||||
const request = await this.externalContourService.retrieveBoardItem(workspaceSlug, projectId, requestId);
|
||||
runInAction(() => {
|
||||
this.upsertRequests([request]);
|
||||
this.loader = undefined;
|
||||
|
|
@ -251,7 +251,7 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
|
|||
workspaceSlug: string,
|
||||
projectId: string,
|
||||
requestId: string,
|
||||
data: Pick<Partial<TIssue>, "name" | "description_html">
|
||||
data: Pick<Partial<TIssue>, "name" | "description_html" | "priority" | "target_date" | "assignee_ids" | "label_ids" | "state_id">
|
||||
) => {
|
||||
this.loader = "mutation-loading";
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ import { EditorAssetStore } from "./editor/asset.store";
|
|||
import type { IProjectEstimateStore } from "./estimates/project-estimate.store";
|
||||
import { ProjectEstimateStore } from "./estimates/project-estimate.store";
|
||||
import type { IProjectExternalContoursStore } from "./external-contours/project-external-contours.store";
|
||||
import type { IProjectExternalContoursBoardStore } from "./external-contours/project-external-contours-board.store";
|
||||
import { ProjectExternalContoursBoardStore } from "./external-contours/project-external-contours-board.store";
|
||||
import { ProjectExternalContoursStore } from "./external-contours/project-external-contours.store";
|
||||
import type { IFavoriteStore } from "./favorite.store";
|
||||
import { FavoriteStore } from "./favorite.store";
|
||||
|
|
@ -95,6 +97,7 @@ export class CoreRootStore {
|
|||
instance: IInstanceStore;
|
||||
user: IUserStore;
|
||||
projectInbox: IProjectInboxStore;
|
||||
projectExternalContoursBoard: IProjectExternalContoursBoardStore;
|
||||
projectExternalContours: IProjectExternalContoursStore;
|
||||
projectEstimate: IProjectEstimateStore;
|
||||
multipleSelect: IMultipleSelectStore;
|
||||
|
|
@ -127,6 +130,7 @@ export class CoreRootStore {
|
|||
this.multipleSelect = new MultipleSelectStore();
|
||||
this.projectInbox = new ProjectInboxStore(this);
|
||||
this.projectExternalContours = new ProjectExternalContoursStore(this);
|
||||
this.projectExternalContoursBoard = new ProjectExternalContoursBoardStore(this);
|
||||
this.projectPages = new ProjectPageStore(this as unknown as RootStore);
|
||||
this.projectEstimate = new ProjectEstimateStore(this);
|
||||
this.workspaceNotification = new WorkspaceNotificationStore(this);
|
||||
|
|
@ -161,6 +165,7 @@ export class CoreRootStore {
|
|||
this.dashboard = new DashboardStore(this);
|
||||
this.projectInbox = new ProjectInboxStore(this);
|
||||
this.projectExternalContours = new ProjectExternalContoursStore(this);
|
||||
this.projectExternalContoursBoard = new ProjectExternalContoursBoardStore(this);
|
||||
this.projectPages = new ProjectPageStore(this as unknown as RootStore);
|
||||
this.multipleSelect = new MultipleSelectStore();
|
||||
this.projectEstimate = new ProjectEstimateStore(this);
|
||||
|
|
|
|||
|
|
@ -218,9 +218,16 @@
|
|||
}
|
||||
|
||||
.nodedc-glass-modal {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.036) 0%, rgba(255, 255, 255, 0.012) 100%),
|
||||
rgba(6, 6, 8, 0.9) !important;
|
||||
border: 0 !important;
|
||||
outline: none !important;
|
||||
-webkit-backdrop-filter: blur(42px);
|
||||
backdrop-filter: blur(42px);
|
||||
box-shadow:
|
||||
0 20px 56px rgba(0, 0, 0, 0.34),
|
||||
0 4px 16px rgba(0, 0, 0, 0.18);
|
||||
0 24px 64px rgba(0, 0, 0, 0.42),
|
||||
0 8px 22px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.nodedc-glass-surface {
|
||||
|
|
@ -248,6 +255,30 @@
|
|||
0 6px 18px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.nodedc-bottom-dock {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.024) 0%, rgba(255, 255, 255, 0.008) 100%),
|
||||
rgba(7, 7, 10, 0.72) !important;
|
||||
border: 0 !important;
|
||||
outline: none !important;
|
||||
box-shadow:
|
||||
0 -18px 46px rgba(0, 0, 0, 0.3),
|
||||
0 -4px 18px rgba(0, 0, 0, 0.16);
|
||||
-webkit-backdrop-filter: blur(34px);
|
||||
backdrop-filter: blur(34px);
|
||||
}
|
||||
|
||||
.nodedc-bottom-dock [class~="bg-surface-1"] {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.nodedc-bottom-dock [class~="border-t"],
|
||||
.nodedc-bottom-dock [class~="border-b"],
|
||||
.nodedc-bottom-dock [class~="border-subtle"] {
|
||||
border-width: 0 !important;
|
||||
border-color: transparent !important;
|
||||
}
|
||||
|
||||
.nodedc-glass-modal [data-slot="button"],
|
||||
.nodedc-glass-modal [data-slot="icon-button"] {
|
||||
border: none !important;
|
||||
|
|
@ -268,11 +299,24 @@
|
|||
}
|
||||
|
||||
.nodedc-glass-modal button:focus-visible,
|
||||
.nodedc-glass-modal [role="button"]:focus-visible {
|
||||
.nodedc-glass-modal [role="button"]:focus-visible,
|
||||
.nodedc-glass-modal :focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.nodedc-modal-alert-icon {
|
||||
border: 0 !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.nodedc-modal-alert-icon-danger,
|
||||
.nodedc-modal-alert-icon-primary {
|
||||
background: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 18%, transparent) !important;
|
||||
color: rgb(var(--nodedc-accent-rgb)) !important;
|
||||
}
|
||||
|
||||
.nodedc-modal-field {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%),
|
||||
|
|
@ -295,6 +339,56 @@
|
|||
rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.nodedc-work-item-properties-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nodedc-work-item-property-button {
|
||||
min-height: 1.75rem !important;
|
||||
border: 0 !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: 1rem !important;
|
||||
padding-inline: 0.85rem !important;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.032) 0%, rgba(255, 255, 255, 0.014) 100%),
|
||||
rgba(255, 255, 255, 0.028) !important;
|
||||
color: var(--text-color-primary) !important;
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.nodedc-work-item-property-button:hover,
|
||||
.nodedc-work-item-property-button:focus-visible,
|
||||
.nodedc-work-item-property-button:focus-within {
|
||||
border: 0 !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.018) 100%),
|
||||
rgba(255, 255, 255, 0.04) !important;
|
||||
color: var(--text-color-primary) !important;
|
||||
}
|
||||
|
||||
.nodedc-work-item-actions-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.nodedc-work-item-create-more {
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nodedc-modal-input {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%),
|
||||
|
|
@ -485,17 +579,19 @@
|
|||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: 1.25rem !important;
|
||||
background: rgb(var(--nodedc-card-active-rgb)) !important;
|
||||
background: rgb(var(--nodedc-accent-rgb)) !important;
|
||||
color: #0b1117 !important;
|
||||
padding-inline: 1.25rem !important;
|
||||
}
|
||||
|
||||
.nodedc-modal-primary-button:hover {
|
||||
background: color-mix(in srgb, rgb(var(--nodedc-card-active-rgb)) 82%, white) !important;
|
||||
background: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 82%, white) !important;
|
||||
}
|
||||
|
||||
.nodedc-modal-primary-button,
|
||||
.nodedc-modal-primary-button *,
|
||||
.nodedc-modal-danger-button,
|
||||
.nodedc-modal-danger-button *,
|
||||
.nodedc-settings-primary-button,
|
||||
.nodedc-settings-primary-button *,
|
||||
.nodedc-settings-save-button,
|
||||
|
|
@ -509,9 +605,34 @@
|
|||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
border-radius: 1.25rem !important;
|
||||
background: rgb(var(--nodedc-accent-rgb)) !important;
|
||||
color: #0b1117 !important;
|
||||
padding-inline: 1.25rem !important;
|
||||
}
|
||||
|
||||
.nodedc-modal-danger-button:hover {
|
||||
background: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 82%, white) !important;
|
||||
}
|
||||
|
||||
.nodedc-glass-modal button.bg-danger-primary,
|
||||
.nodedc-glass-modal button.border-danger-strong {
|
||||
border: 0 !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
background: rgb(var(--nodedc-accent-rgb)) !important;
|
||||
color: #0b1117 !important;
|
||||
}
|
||||
|
||||
.nodedc-glass-modal button.bg-danger-primary:hover,
|
||||
.nodedc-glass-modal button.border-danger-strong:hover {
|
||||
background: color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 82%, white) !important;
|
||||
}
|
||||
|
||||
.nodedc-glass-modal button.bg-danger-primary *,
|
||||
.nodedc-glass-modal button.border-danger-strong * {
|
||||
color: #0b1117 !important;
|
||||
}
|
||||
|
||||
.nodedc-modal-chip {
|
||||
min-height: 2.5rem;
|
||||
border: 0 !important;
|
||||
|
|
|
|||
|
|
@ -295,6 +295,35 @@ export default {
|
|||
tabs: {
|
||||
open: "Open",
|
||||
closed: "Closed",
|
||||
},
|
||||
board: {
|
||||
columns: {
|
||||
outgoing: "Outgoing",
|
||||
incoming: "Incoming",
|
||||
},
|
||||
filters: {
|
||||
sort: "Sorting",
|
||||
contour: "Contour",
|
||||
requester: "Requester",
|
||||
unread_only: "Only with updates",
|
||||
search_placeholder: "Search by request title",
|
||||
search_contour: "Search by contour",
|
||||
search_state: "Search by state",
|
||||
search_priority: "Search by priority",
|
||||
search_assignee: "Search by assignee",
|
||||
search_requester: "Search by requester",
|
||||
sorting: {
|
||||
updated_at_desc: "Latest updates first",
|
||||
requested_at_desc: "Newest requests first",
|
||||
target_date_asc: "Closest due date first",
|
||||
},
|
||||
},
|
||||
empty: {
|
||||
outgoing_title: "No outgoing requests",
|
||||
outgoing_description: "Requests sent from this contour to other projects will appear here.",
|
||||
incoming_title: "No incoming requests",
|
||||
incoming_description: "Requests routed into this contour from other projects will appear here.",
|
||||
},
|
||||
},
|
||||
list: {
|
||||
last_updated: "Last updated",
|
||||
|
|
@ -479,6 +508,9 @@ export default {
|
|||
no_data_yet: "No Data yet",
|
||||
syncing: "Syncing",
|
||||
add_work_item: "Add work item",
|
||||
app_header: {
|
||||
add_task: "Add task",
|
||||
},
|
||||
advanced_description_placeholder: "Press '/' for commands",
|
||||
create_work_item: "Create work item",
|
||||
attachments: "Attachments",
|
||||
|
|
@ -1138,9 +1170,14 @@ export default {
|
|||
file_size_limit: "File must be of {size}MB or less in size.",
|
||||
drag_and_drop: "Drag and drop anywhere to upload",
|
||||
delete: "Delete attachment",
|
||||
delete_confirmation:
|
||||
"Are you sure you want to delete attachment {value}? This attachment will be permanently removed and this action cannot be undone.",
|
||||
},
|
||||
label: {
|
||||
select: "Add labels",
|
||||
delete_confirmation:
|
||||
'Are you sure you want to delete label "{value}"? It will be removed from all work items and from any views where this label is used.',
|
||||
delete_error: "Label could not be deleted. Please try again.",
|
||||
create: {
|
||||
success: "Label created successfully",
|
||||
failed: "Label creation failed",
|
||||
|
|
@ -1205,7 +1242,7 @@ export default {
|
|||
modals: {
|
||||
decline: {
|
||||
title: "Decline work item",
|
||||
content: "Are you sure you want to decline work item {value}?",
|
||||
content: "Are you sure you want to decline work item {value}? This action cannot be undone.",
|
||||
},
|
||||
delete: {
|
||||
title: "Delete work item",
|
||||
|
|
@ -1691,6 +1728,11 @@ export default {
|
|||
description: "Automate notifications to external services when project events occur.",
|
||||
title: "Webhooks",
|
||||
add_webhook: "Add webhook",
|
||||
delete: {
|
||||
title: "Delete webhook",
|
||||
description:
|
||||
"Are you sure you want to delete this webhook? Future events will no longer be delivered to it. This action cannot be undone.",
|
||||
},
|
||||
modal: {
|
||||
title: "Create webhook",
|
||||
details: "Webhook details",
|
||||
|
|
@ -1886,6 +1928,12 @@ export default {
|
|||
heading: "States",
|
||||
description: "Define and customize workflow states to track the progress of your work items.",
|
||||
describe_this_state_for_your_members: "Describe this state for your members.",
|
||||
delete: {
|
||||
blocked: "This state still contains work items. Move them to another state before deleting it.",
|
||||
error: "State could not be deleted. Please try again.",
|
||||
disabled_default: "Cannot delete the default state.",
|
||||
disabled_last: "Cannot leave the group without states.",
|
||||
},
|
||||
empty_state: {
|
||||
title: "No states available for the {groupKey} group",
|
||||
description: "Please create a new state",
|
||||
|
|
@ -1914,6 +1962,17 @@ export default {
|
|||
label: "Estimates",
|
||||
title: "Enable estimates for my project",
|
||||
enable_description: "They help you in communicating complexity and workload of the team.",
|
||||
delete_modal: {
|
||||
title: "Delete estimate system",
|
||||
description:
|
||||
'Deleting the estimate system "{value}" will permanently remove it from every work item in this project. If you enable estimates again later, you will need to configure work items again.',
|
||||
submit: "Delete estimate system",
|
||||
loading: "Deleting",
|
||||
success_title: "Estimate system deleted",
|
||||
success_message: "The estimate system has been removed from the project.",
|
||||
error_title: "Estimate system delete failed",
|
||||
error_message: "We were unable to delete the estimate system. Please try again.",
|
||||
},
|
||||
list_heading: "Estimate list",
|
||||
archived_heading: "Archived estimates",
|
||||
archived_description: "These are estimates from earlier project versions that are not currently in use. Read more",
|
||||
|
|
@ -2304,6 +2363,14 @@ export default {
|
|||
},
|
||||
},
|
||||
project_page: {
|
||||
delete_modal: {
|
||||
title: "Delete page",
|
||||
content: 'Are you sure you want to delete page "{value}"? The page will be permanently removed and this action cannot be undone.',
|
||||
success_title: "Page deleted",
|
||||
success_message: "Page deleted successfully.",
|
||||
error_title: "Page delete failed",
|
||||
error_message: "Page could not be deleted. Please try again.",
|
||||
},
|
||||
empty_state: {
|
||||
general: {
|
||||
title:
|
||||
|
|
|
|||
|
|
@ -452,6 +452,35 @@ export default {
|
|||
tabs: {
|
||||
open: "Открытые",
|
||||
closed: "Закрытые",
|
||||
},
|
||||
board: {
|
||||
columns: {
|
||||
outgoing: "Исходящие",
|
||||
incoming: "Входящие",
|
||||
},
|
||||
filters: {
|
||||
sort: "Сортировка",
|
||||
contour: "Контур",
|
||||
requester: "Отправитель",
|
||||
unread_only: "Только с изменениями",
|
||||
search_placeholder: "Поиск по названию запроса",
|
||||
search_contour: "Поиск по контуру",
|
||||
search_state: "Поиск по статусу",
|
||||
search_priority: "Поиск по приоритету",
|
||||
search_assignee: "Поиск по исполнителю",
|
||||
search_requester: "Поиск по отправителю",
|
||||
sorting: {
|
||||
updated_at_desc: "Сначала последние изменения",
|
||||
requested_at_desc: "Сначала новые отправки",
|
||||
target_date_asc: "Сначала ближайший срок",
|
||||
},
|
||||
},
|
||||
empty: {
|
||||
outgoing_title: "Нет исходящих запросов",
|
||||
outgoing_description: "Здесь будут видны запросы, которые этот контур отправил в другие проекты.",
|
||||
incoming_title: "Нет входящих запросов",
|
||||
incoming_description: "Здесь будут видны запросы, которые пришли в этот контур из других проектов.",
|
||||
},
|
||||
},
|
||||
list: {
|
||||
last_updated: "Последнее изменение",
|
||||
|
|
@ -635,6 +664,9 @@ export default {
|
|||
no_data_yet: "Нет данных",
|
||||
syncing: "Синхронизация",
|
||||
add_work_item: "Добавить рабочий элемент",
|
||||
app_header: {
|
||||
add_task: "Добавить задачу",
|
||||
},
|
||||
advanced_description_placeholder: "Нажмите '/' для команд",
|
||||
create_work_item: "Создать рабочий элемент",
|
||||
attachments: "Вложения",
|
||||
|
|
@ -1294,9 +1326,14 @@ export default {
|
|||
file_size_limit: "Максимальный размер файла - {size} МБ",
|
||||
drag_and_drop: "Перетащите файл для загрузки",
|
||||
delete: "Удалить вложение",
|
||||
delete_confirmation:
|
||||
"Вы уверены, что хотите удалить вложение {value}? Вложение будет удалено без возможности восстановления.",
|
||||
},
|
||||
label: {
|
||||
select: "Выбрать метку",
|
||||
delete_confirmation:
|
||||
'Вы уверены, что хотите удалить метку "{value}"? Она будет удалена из всех рабочих элементов и представлений, где используется.',
|
||||
delete_error: "Не удалось удалить метку. Попробуйте снова.",
|
||||
create: {
|
||||
success: "Метка создана",
|
||||
failed: "Ошибка создания метки",
|
||||
|
|
@ -1361,7 +1398,7 @@ export default {
|
|||
modals: {
|
||||
decline: {
|
||||
title: "Отклонить рабочий элемент",
|
||||
content: "Вы уверены, что хотите отклонить рабочий элемент {value}?",
|
||||
content: "Вы уверены, что хотите отклонить рабочий элемент {value}? Это действие нельзя отменить.",
|
||||
},
|
||||
delete: {
|
||||
title: "Удалить рабочий элемент",
|
||||
|
|
@ -1853,6 +1890,11 @@ export default {
|
|||
description: "Автоматизируйте уведомления во внешние сервисы при событиях проекта.",
|
||||
title: "Вебхуки",
|
||||
add_webhook: "Добавить вебхук",
|
||||
delete: {
|
||||
title: "Удалить вебхук",
|
||||
description:
|
||||
"Вы уверены, что хотите удалить этот вебхук? События больше не будут отправляться в этот вебхук. Это действие нельзя отменить.",
|
||||
},
|
||||
modal: {
|
||||
title: "Создать вебхук",
|
||||
details: "Детали вебхука",
|
||||
|
|
@ -2045,6 +2087,12 @@ export default {
|
|||
heading: "Статусы",
|
||||
description: "Определяйте и настраивайте статусы рабочего процесса для отслеживания прогресса рабочих элементов.",
|
||||
describe_this_state_for_your_members: "Опишите этот статус для участников",
|
||||
delete: {
|
||||
blocked: "В этом статусе есть рабочие элементы. Переместите их в другой статус, чтобы удалить текущий.",
|
||||
error: "Не удалось удалить статус. Попробуйте снова.",
|
||||
disabled_default: "Нельзя удалить статус по умолчанию.",
|
||||
disabled_last: "Нельзя оставить группу без статусов.",
|
||||
},
|
||||
empty_state: {
|
||||
title: "Нет статусов для группы {groupKey}",
|
||||
description: "Создайте новый статус",
|
||||
|
|
@ -2073,6 +2121,17 @@ export default {
|
|||
label: "Оценки",
|
||||
title: "Включить оценки для моего проекта",
|
||||
enable_description: "Они помогают вам в общении о сложности и рабочей нагрузке команды.",
|
||||
delete_modal: {
|
||||
title: "Удалить систему оценок",
|
||||
description:
|
||||
'Удаление системы оценок "{value}" безвозвратно уберет её из всех рабочих элементов проекта. Если вы включите оценки снова, рабочие элементы придется настраивать заново.',
|
||||
submit: "Удалить систему оценок",
|
||||
loading: "Удаление",
|
||||
success_title: "Система оценок удалена",
|
||||
success_message: "Система оценок удалена из проекта.",
|
||||
error_title: "Не удалось удалить систему оценок",
|
||||
error_message: "Мы не смогли удалить систему оценок. Попробуйте снова.",
|
||||
},
|
||||
list_heading: "Список оценок",
|
||||
archived_heading: "Архивные оценки",
|
||||
archived_description: "Это оценки из предыдущих версий проекта, которые сейчас не используются. Подробнее о них",
|
||||
|
|
@ -2461,6 +2520,14 @@ export default {
|
|||
},
|
||||
},
|
||||
project_page: {
|
||||
delete_modal: {
|
||||
title: "Удалить страницу",
|
||||
content: 'Вы уверены, что хотите удалить страницу "{value}"? Страница будет удалена без возможности восстановления.',
|
||||
success_title: "Страница удалена",
|
||||
success_message: "Страница успешно удалена.",
|
||||
error_title: "Не удалось удалить страницу",
|
||||
error_message: "Не удалось удалить страницу. Попробуйте снова.",
|
||||
},
|
||||
empty_state: {
|
||||
general: {
|
||||
title: "Создавайте заметки, документы или базу знаний. Используйте Galileo, ИИ-помощник NODE.DC.",
|
||||
|
|
|
|||
|
|
@ -41,9 +41,8 @@ export interface DialogTitleProps extends React.ComponentProps<typeof BaseDialog
|
|||
}
|
||||
|
||||
// Constants
|
||||
const OVERLAY_CLASSNAME = cn("fixed inset-0 z-90 bg-backdrop/70 backdrop-blur-sm");
|
||||
const BASE_CLASSNAME =
|
||||
"nodedc-glass-modal relative w-full rounded-[28px] border border-subtle/70 bg-surface-1/78 text-left shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-2xl z-100";
|
||||
const OVERLAY_CLASSNAME = cn("fixed inset-0 z-90 bg-black/60 backdrop-blur-md");
|
||||
const BASE_CLASSNAME = "nodedc-glass-modal relative z-100 w-full rounded-[28px] text-left";
|
||||
|
||||
// Utility functions
|
||||
const getPositionClassNames = (position: DialogPosition) =>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export function ModalPortal({
|
|||
const positionClass = fullScreen ? "" : PORTAL_POSITION_CLASSES[position];
|
||||
|
||||
return cn(
|
||||
"nodedc-glass-modal absolute top-0 h-full rounded-[28px] border border-subtle/70 bg-surface-1/78 shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-2xl transition-transform duration-300 ease-out",
|
||||
"nodedc-glass-modal absolute top-0 h-full rounded-[28px] transition-transform duration-300 ease-out",
|
||||
widthClass,
|
||||
positionClass,
|
||||
contentClassName
|
||||
|
|
@ -101,7 +101,7 @@ export function ModalPortal({
|
|||
>
|
||||
{showOverlay && (
|
||||
<div
|
||||
className={cn("absolute inset-0 bg-black/50 backdrop-blur-sm transition-colors duration-300", overlayClassName)}
|
||||
className={cn("absolute inset-0 bg-black/60 backdrop-blur-md transition-colors duration-300", overlayClassName)}
|
||||
onClick={handleOverlayClick}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -53,9 +53,28 @@ export type TExternalContourMirroredActivity = {
|
|||
actor_detail?: Pick<IUser, "id" | "display_name" | "avatar_url"> | null;
|
||||
};
|
||||
|
||||
export type TExternalContourBoardDirection = "outgoing" | "incoming";
|
||||
|
||||
export type TExternalContourBoardProject = Pick<IProjectLite, "id" | "identifier" | "name" | "logo_props">;
|
||||
|
||||
export type TExternalContourBoardRequestedBy = {
|
||||
id: string | null;
|
||||
display_name: string | null;
|
||||
};
|
||||
|
||||
export type TExternalContourBoardCapabilities = {
|
||||
can_open_detail: boolean;
|
||||
can_open_target_issue: boolean;
|
||||
can_edit_request: boolean;
|
||||
can_reply: boolean;
|
||||
can_source_decide: boolean;
|
||||
};
|
||||
|
||||
export type TExternalContourRequest = {
|
||||
capabilities?: TExternalContourBoardCapabilities;
|
||||
created_at: string;
|
||||
created_by: string | null;
|
||||
direction?: TExternalContourBoardDirection;
|
||||
has_unread_updates?: boolean;
|
||||
id: string;
|
||||
issue: TExternalContourIssue;
|
||||
|
|
@ -71,8 +90,11 @@ export type TExternalContourRequest = {
|
|||
target_project_name?: string | null;
|
||||
requested_by_id?: string | null;
|
||||
requested_by_name?: string | null;
|
||||
requested_by?: TExternalContourBoardRequestedBy | null;
|
||||
requested_at?: string | null;
|
||||
source_project?: TExternalContourBoardProject | null;
|
||||
status: "open" | "closed";
|
||||
target_project?: TExternalContourBoardProject | null;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
|
|
@ -80,6 +102,45 @@ export type TExternalContourRequestResponse = TPaginationInfo & {
|
|||
results: TExternalContourRequest[];
|
||||
};
|
||||
|
||||
export type TExternalContourBoardFilter = {
|
||||
direction?: TExternalContourBoardDirection[];
|
||||
status?: TExternalContourRequest["status"] | TExternalContourRequest["status"][];
|
||||
state_groups?: string[];
|
||||
state_ids?: string[];
|
||||
priority?: string[];
|
||||
assignee_ids?: string[];
|
||||
created_by_ids?: string[];
|
||||
requested_by_ids?: string[];
|
||||
counterparty_project_ids?: string[];
|
||||
source_project_ids?: string[];
|
||||
target_project_ids?: string[];
|
||||
label_ids?: string[];
|
||||
target_date_exact?: string;
|
||||
target_date_from?: string;
|
||||
target_date_to?: string;
|
||||
has_unread_updates?: boolean;
|
||||
search?: string;
|
||||
};
|
||||
|
||||
export type TExternalContourBoardSorting = {
|
||||
order_by?: "requested_at" | "updated_at" | "issue__sequence_id" | "target_date";
|
||||
sort_by?: "asc" | "desc";
|
||||
};
|
||||
|
||||
export type TExternalContourBoardColumn = {
|
||||
key: TExternalContourBoardDirection;
|
||||
title: string;
|
||||
total_count: number;
|
||||
next_cursor?: string;
|
||||
results: TExternalContourRequest[];
|
||||
};
|
||||
|
||||
export type TExternalContourBoardResponse = {
|
||||
filters: Partial<TExternalContourBoardFilter>;
|
||||
sorting: TExternalContourBoardSorting;
|
||||
columns: TExternalContourBoardColumn[];
|
||||
};
|
||||
|
||||
export type TExternalContourTargetProject = IProjectLite & {
|
||||
inbox_view: boolean;
|
||||
};
|
||||
|
|
@ -87,5 +148,6 @@ export type TExternalContourTargetProject = IProjectLite & {
|
|||
export type TExternalContourTargetOptions = {
|
||||
project: TExternalContourTargetProject;
|
||||
member_ids: string[];
|
||||
states: Pick<IStateLite, "id" | "name" | "color" | "group">[];
|
||||
labels: Pick<IIssueLabel, "id" | "name" | "color" | "parent" | "project_id" | "workspace_id" | "sort_order">[];
|
||||
};
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
"@headlessui/react": "^1.7.3",
|
||||
"@plane/constants": "workspace:*",
|
||||
"@plane/hooks": "workspace:*",
|
||||
"@plane/i18n": "workspace:*",
|
||||
"@plane/propel": "workspace:*",
|
||||
"@plane/types": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export function InputSearch(props: IInputSearch) {
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 rounded-sm border border-subtle bg-surface-2 px-2",
|
||||
"nodedc-dropdown-search",
|
||||
inputContainerClassName
|
||||
)}
|
||||
>
|
||||
|
|
@ -53,7 +53,7 @@ export function InputSearch(props: IInputSearch) {
|
|||
as="input"
|
||||
ref={inputRef}
|
||||
className={cn(
|
||||
"w-full bg-transparent py-1 text-11 text-secondary placeholder:text-placeholder focus:outline-none",
|
||||
"w-full bg-transparent py-0 text-12 text-secondary placeholder:text-placeholder focus:outline-none",
|
||||
inputClassName
|
||||
)}
|
||||
value={query}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function DropdownOptions(props: IMultiSelectDropdownOptions | ISingleSele
|
|||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
<div className={cn("max-h-48 space-y-1 overflow-y-scroll", !disableSearch && "mt-2")}>
|
||||
<div className={cn("max-h-56 space-y-1 overflow-y-auto", !disableSearch && "mt-2")}>
|
||||
<>
|
||||
{options ? (
|
||||
options.length > 0 ? (
|
||||
|
|
@ -57,9 +57,9 @@ export function DropdownOptions(props: IMultiSelectDropdownOptions | ISingleSele
|
|||
disabled={option.disabled}
|
||||
className={({ active, selected }) =>
|
||||
cn(
|
||||
"flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 select-none",
|
||||
"nodedc-dropdown-option",
|
||||
{
|
||||
"bg-layer-1": active,
|
||||
"bg-white/6": active,
|
||||
"text-primary": selected,
|
||||
"text-secondary": !selected,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { Combobox } from "@headlessui/react";
|
||||
import { sortBy } from "lodash-es";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePopper } from "react-popper";
|
||||
// plane imports
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
|
|
@ -139,35 +140,34 @@ export function MultiSelectDropdown(props: IMultiSelectDropdown) {
|
|||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className={cn(
|
||||
"my-1 w-48 rounded-sm border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 text-11 shadow-raised-200 focus:outline-none",
|
||||
optionsContainerClassName
|
||||
)}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<DropdownOptions
|
||||
isOpen={isOpen}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
inputIcon={inputIcon}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
inputClassName={inputClassName}
|
||||
inputContainerClassName={inputContainerClassName}
|
||||
disableSearch={disableSearch}
|
||||
keyExtractor={keyExtractor}
|
||||
options={sortedOptions}
|
||||
value={value}
|
||||
renderItem={renderItem}
|
||||
loader={loader}
|
||||
/>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
)}
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Combobox.Options data-prevent-outside-click className="fixed z-30" static>
|
||||
<div
|
||||
className={cn("nodedc-dropdown-surface my-1 w-56", optionsContainerClassName)}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<DropdownOptions
|
||||
isOpen={isOpen}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
inputIcon={inputIcon}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
inputClassName={inputClassName}
|
||||
inputContainerClassName={inputContainerClassName}
|
||||
disableSearch={disableSearch}
|
||||
keyExtractor={keyExtractor}
|
||||
options={sortedOptions}
|
||||
value={value}
|
||||
renderItem={renderItem}
|
||||
loader={loader}
|
||||
/>
|
||||
</div>
|
||||
</Combobox.Options>,
|
||||
document.body
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { Combobox } from "@headlessui/react";
|
||||
import { sortBy } from "lodash-es";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePopper } from "react-popper";
|
||||
// plane imports
|
||||
import { useOutsideClickDetector } from "@plane/hooks";
|
||||
|
|
@ -138,36 +139,35 @@ export function Dropdown(props: ISingleSelectDropdown) {
|
|||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<Combobox.Options className="fixed z-10" static>
|
||||
<div
|
||||
className={cn(
|
||||
"my-1 w-48 rounded-sm border-[0.5px] border-strong bg-surface-1 px-2 py-2 text-11 shadow-raised-200 focus:outline-none",
|
||||
optionsContainerClassName
|
||||
)}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<DropdownOptions
|
||||
isOpen={isOpen}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
inputIcon={inputIcon}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
inputClassName={inputClassName}
|
||||
inputContainerClassName={inputContainerClassName}
|
||||
disableSearch={disableSearch}
|
||||
keyExtractor={keyExtractor}
|
||||
options={sortedOptions}
|
||||
value={value}
|
||||
renderItem={renderItem}
|
||||
loader={loader}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
</div>
|
||||
</Combobox.Options>
|
||||
)}
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<Combobox.Options data-prevent-outside-click className="fixed z-30" static>
|
||||
<div
|
||||
className={cn("nodedc-dropdown-surface my-1 w-56", optionsContainerClassName)}
|
||||
ref={setPopperElement}
|
||||
style={styles.popper}
|
||||
{...attributes.popper}
|
||||
>
|
||||
<DropdownOptions
|
||||
isOpen={isOpen}
|
||||
query={query}
|
||||
setQuery={setQuery}
|
||||
inputIcon={inputIcon}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
inputClassName={inputClassName}
|
||||
inputContainerClassName={inputContainerClassName}
|
||||
disableSearch={disableSearch}
|
||||
keyExtractor={keyExtractor}
|
||||
options={sortedOptions}
|
||||
value={value}
|
||||
renderItem={renderItem}
|
||||
loader={loader}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
</div>
|
||||
</Combobox.Options>,
|
||||
document.body
|
||||
)}
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { LucideIcon } from "lucide-react";
|
|||
import { AlertTriangle, Info } from "lucide-react";
|
||||
import React from "react";
|
||||
// components
|
||||
import { useTranslation } from "@plane/i18n";
|
||||
import type { TButtonVariant } from "@plane/propel/button";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { cn } from "../utils";
|
||||
|
|
@ -43,16 +44,17 @@ const VARIANT_ICONS: Record<TModalVariant, LucideIcon> = {
|
|||
};
|
||||
|
||||
const BUTTON_VARIANTS: Record<TModalVariant, TButtonVariant> = {
|
||||
danger: "error-fill",
|
||||
danger: "primary",
|
||||
primary: "primary",
|
||||
};
|
||||
|
||||
const VARIANT_CLASSES: Record<TModalVariant, string> = {
|
||||
danger: "bg-danger-subtle text-danger-primary",
|
||||
primary: "bg-accent-primary/20 text-accent-primary",
|
||||
danger: "nodedc-modal-alert-icon nodedc-modal-alert-icon-danger",
|
||||
primary: "nodedc-modal-alert-icon nodedc-modal-alert-icon-primary",
|
||||
};
|
||||
|
||||
export function AlertModalCore(props: Props) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
content,
|
||||
handleClose,
|
||||
|
|
@ -62,10 +64,10 @@ export function AlertModalCore(props: Props) {
|
|||
isOpen,
|
||||
position = EModalPosition.CENTER,
|
||||
primaryButtonText = {
|
||||
loading: "Deleting",
|
||||
default: "Delete",
|
||||
loading: t("deleting"),
|
||||
default: t("delete"),
|
||||
},
|
||||
secondaryButtonText = "Cancel",
|
||||
secondaryButtonText = t("cancel"),
|
||||
title,
|
||||
variant = "danger",
|
||||
width = EModalWidth.XL,
|
||||
|
|
@ -94,11 +96,11 @@ export function AlertModalCore(props: Props) {
|
|||
</span>
|
||||
)}
|
||||
<div className="text-center sm:text-left">
|
||||
<h3 className="text-18 font-medium text-secondary">{title}</h3>
|
||||
<h3 className="text-18 font-medium text-primary">{title}</h3>
|
||||
<p className="mt-1 text-13 text-secondary">{content}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-3 border-t border-subtle/70 px-6 py-4 sm:flex-row sm:justify-end">
|
||||
<div className="flex flex-col-reverse gap-3 px-6 py-4 sm:flex-row sm:justify-end">
|
||||
<Button variant="secondary" onClick={handleClose} className="nodedc-modal-secondary-button min-w-[8.25rem]">
|
||||
{secondaryButtonText}
|
||||
</Button>
|
||||
|
|
@ -108,8 +110,8 @@ export function AlertModalCore(props: Props) {
|
|||
onClick={handleSubmit}
|
||||
loading={isSubmitting}
|
||||
className={cn("min-w-[8.25rem]", {
|
||||
"nodedc-modal-danger-button": variant === "danger",
|
||||
"nodedc-modal-primary-button": variant === "primary",
|
||||
"nodedc-modal-danger-button": variant === "danger",
|
||||
})}
|
||||
>
|
||||
{isSubmitting ? primaryButtonText.loading : primaryButtonText.default}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ export function ModalCore(props: Props) {
|
|||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-backdrop/70 backdrop-blur-sm transition-opacity" />
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-md transition-opacity" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-30 overflow-y-auto">
|
||||
|
|
@ -57,7 +57,7 @@ export function ModalCore(props: Props) {
|
|||
>
|
||||
<Dialog.Panel
|
||||
className={cn(
|
||||
"nodedc-glass-modal relative w-full transform rounded-[28px] border border-subtle/70 bg-surface-1/78 text-left shadow-[0_16px_48px_rgba(0,0,0,0.34)] backdrop-blur-2xl transition-all",
|
||||
"nodedc-glass-modal relative w-full transform rounded-[28px] text-left transition-all",
|
||||
width,
|
||||
className
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,11 @@ import { createFilterConfig, getMultiSelectConfig, createOperatorConfigEntry } f
|
|||
/**
|
||||
* Priority filter specific params
|
||||
*/
|
||||
export type TCreatePriorityFilterParams = TCreateFilterConfigParams & IFilterIconConfig<TIssuePriorities>;
|
||||
export type TCreatePriorityFilterParams = TCreateFilterConfigParams &
|
||||
IFilterIconConfig<TIssuePriorities> & {
|
||||
filterLabel?: string;
|
||||
getItemLabel?: (priorityKey: TIssuePriorities) => string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get the priority multi select config
|
||||
|
|
@ -33,7 +37,7 @@ export const getPriorityMultiSelectConfig = (
|
|||
{
|
||||
items: ISSUE_PRIORITIES,
|
||||
getId: (priority) => priority.key,
|
||||
getLabel: (priority) => priority.title,
|
||||
getLabel: (priority) => params.getItemLabel?.(priority.key) ?? priority.title,
|
||||
getValue: (priority) => priority.key,
|
||||
getIconData: (priority) => priority.key,
|
||||
},
|
||||
|
|
@ -57,7 +61,7 @@ export const getPriorityFilterConfig =
|
|||
(params: TCreatePriorityFilterParams) =>
|
||||
createFilterConfig<P>({
|
||||
id: key,
|
||||
label: "Priority",
|
||||
label: params.filterLabel ?? "Priority",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
supportedOperatorConfigsMap: new Map([
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export const getProjectFilterConfig =
|
|||
(params: TCreateProjectFilterParams) =>
|
||||
createFilterConfig<P>({
|
||||
id: key,
|
||||
label: "Projects",
|
||||
label: params.filterLabel ?? "Projects",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
supportedOperatorConfigsMap: new Map([
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ export const getSupportedDateOperators = (params: TCreateDateFilterParams): TOpe
|
|||
*/
|
||||
export type TCreateProjectFilterParams = TCreateFilterConfigParams &
|
||||
IFilterIconConfig<IProject> & {
|
||||
filterLabel?: string;
|
||||
projects: IProject[];
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,11 @@ import { createFilterConfig, getMultiSelectConfig, createOperatorConfigEntry } f
|
|||
/**
|
||||
* State group filter specific params
|
||||
*/
|
||||
export type TCreateStateGroupFilterParams = TCreateFilterConfigParams & IFilterIconConfig<TStateGroups>;
|
||||
export type TCreateStateGroupFilterParams = TCreateFilterConfigParams &
|
||||
IFilterIconConfig<TStateGroups> & {
|
||||
filterLabel?: string;
|
||||
getItemLabel?: (stateGroupKey: TStateGroups) => string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get the state group multi select config
|
||||
|
|
@ -32,7 +36,7 @@ export const getStateGroupMultiSelectConfig = (
|
|||
{
|
||||
items: Object.values(STATE_GROUPS),
|
||||
getId: (state) => state.key,
|
||||
getLabel: (state) => state.label,
|
||||
getLabel: (state) => params.getItemLabel?.(state.key) ?? state.label,
|
||||
getValue: (state) => state.key,
|
||||
getIconData: (state) => state.key,
|
||||
},
|
||||
|
|
@ -56,7 +60,7 @@ export const getStateGroupFilterConfig =
|
|||
(params: TCreateStateGroupFilterParams) =>
|
||||
createFilterConfig<P>({
|
||||
id: key,
|
||||
label: "State Group",
|
||||
label: params.filterLabel ?? "State Group",
|
||||
...params,
|
||||
icon: params.filterIcon,
|
||||
supportedOperatorConfigsMap: new Map([
|
||||
|
|
|
|||
|
|
@ -1317,6 +1317,9 @@ importers:
|
|||
'@plane/hooks':
|
||||
specifier: workspace:*
|
||||
version: link:../hooks
|
||||
'@plane/i18n':
|
||||
specifier: workspace:*
|
||||
version: link:../i18n
|
||||
'@plane/propel':
|
||||
specifier: workspace:*
|
||||
version: link:../propel
|
||||
|
|
|
|||
Loading…
Reference in New Issue