Compare commits

..

21 Commits

Author SHA1 Message Date
DCCONSTRUCTIONS 6337b6e4ac UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: локальная привязка окна профиля в верхнем toolbar 2026-04-21 19:35:45 +03:00
DCCONSTRUCTIONS f1f29185bf UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: центрирование панели режимов в нижнем dock 2026-04-21 14:32:31 +03:00
DCCONSTRUCTIONS 570e42b212 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: синхронизация исходящих задач после создания запроса во внешнем контуре 2026-04-21 13:35:28 +03:00
DCCONSTRUCTIONS dd964f5d99 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: выравнивание scrollbar входящей колонки внешнего контура 2026-04-21 13:09:54 +03:00
DCCONSTRUCTIONS 0a85ea3cb2 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: унификация фильтров внешнего контура с внутренним rich-filters слоем 2026-04-21 12:29:32 +03:00
DCCONSTRUCTIONS 2c54a8f274 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: русификация значений rich-filters во внутреннем контуре 2026-04-21 11:07:58 +03:00
DCCONSTRUCTIONS a5bf967862 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: исправление перекрытия панели фильтров kanban-оверлеем 2026-04-21 10:51:16 +03:00
DCCONSTRUCTIONS e4a59e7a54 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: унификация нижнего dock и CTA-кнопки 2026-04-21 10:22:22 +03:00
DCCONSTRUCTIONS bcd4d676db UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: выравнивание нижнего dock внешних контуров с внутренним контуром 2026-04-21 09:55:09 +03:00
DCCONSTRUCTIONS 91906e917e ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: отказ от open-closed табов и стабилизация фильтров внешних контуров 2026-04-21 08:32:20 +03:00
DCCONSTRUCTIONS c6645bb4fc UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: приведение модалки создания рабочего элемента к дизайн-канону 2026-04-21 08:14:35 +03:00
DCCONSTRUCTIONS ba34162eeb UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: исправление ширины карточек внешних контуров 2026-04-21 08:03:03 +03:00
DCCONSTRUCTIONS d3b4c0689c ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: интерактивные карточки и фильтры двусторонней доски внешних контуров 2026-04-20 22:57:18 +03:00
DCCONSTRUCTIONS 6a3adcd245 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: стабилизация read-only фильтров двусторонней доски внешних контуров 2026-04-20 21:51:02 +03:00
DCCONSTRUCTIONS c880c0a319 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: выделение action-set и упрощение mobile header внешнего контура 2026-04-20 21:12:42 +03:00
DCCONSTRUCTIONS ab2a5ffb9a UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: унификация shell карточки внешнего контура с внутренним peek 2026-04-20 21:07:49 +03:00
DCCONSTRUCTIONS 8bf6f2a510 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: frontend read-layer и первый экран двусторонней доски внешних контуров 2026-04-20 20:49:09 +03:00
DCCONSTRUCTIONS 0184ff9a32 ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: additive read-layer двусторонней доски внешних контуров 2026-04-20 20:31:02 +03:00
DCCONSTRUCTIONS f12a3b7338 АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: data contract двусторонней доски внешних контуров 2026-04-20 20:03:31 +03:00
DCCONSTRUCTIONS 969c218e99 АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: roadmap двусторонней доски внешних контуров 2026-04-20 19:51:02 +03:00
DCCONSTRUCTIONS 6cb0545957 UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: приведение delete-модалок к black-glass канону 2026-04-20 18:48:47 +03:00
86 changed files with 4854 additions and 570 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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 - определение границ 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)

View File

@ -27,6 +27,22 @@
Дальше по roadmap пока не идем, пока не приняты продуктовые решения по внутреннему жизненному циклу принятого запроса. Дальше по 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. Термины и навигация ## Этап 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 ### 1. Не ломать штатный intake

View File

@ -54,6 +54,7 @@ from .intake import (
IntakeIssueUpdateSerializer, IntakeIssueUpdateSerializer,
) )
from .external_contours import ( from .external_contours import (
ExternalContourBoardItemSerializer,
ExternalContourRequestCreateSerializer, ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer, ExternalContourRequestDecisionSerializer,
ExternalContourRequestReplySerializer, ExternalContourRequestReplySerializer,

View File

@ -44,6 +44,11 @@ class ExternalContourRequestReplySerializer(serializers.Serializer):
class ExternalContourRequestUpdateSerializer(serializers.Serializer): class ExternalContourRequestUpdateSerializer(serializers.Serializer):
name = serializers.CharField(max_length=255, required=False) name = serializers.CharField(max_length=255, required=False)
description_html = serializers.CharField(required=False, allow_blank=True, allow_null=True) 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): def validate(self, data):
if not data: if not data:
@ -69,6 +74,7 @@ class ExternalContourTargetProjectSerializer(BaseSerializer):
class ExternalContourTargetOptionsSerializer(serializers.Serializer): class ExternalContourTargetOptionsSerializer(serializers.Serializer):
project = ExternalContourTargetProjectSerializer(read_only=True) project = ExternalContourTargetProjectSerializer(read_only=True)
member_ids = serializers.ListField(child=serializers.UUIDField(), 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) labels = LabelSerializer(many=True, read_only=True)
@ -212,6 +218,10 @@ class ExternalContourRequestSerializer(BaseSerializer):
return obj.extra.get("source_project_id") return obj.extra.get("source_project_id")
def get_has_unread_updates(self, obj): 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") request = self.context.get("request")
user = getattr(request, "user", None) user = getattr(request, "user", None)
user_id = getattr(user, "id", 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"]: if issue and issue.state and issue.state.group in ["completed", "cancelled"]:
return "closed" return "closed"
return "open" 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

View File

@ -6,6 +6,8 @@ from django.urls import path
from plane.app.views import ( from plane.app.views import (
ExternalContourAttachmentDownloadEndpoint, ExternalContourAttachmentDownloadEndpoint,
ExternalContourBoardEndpoint,
ExternalContourBoardItemDetailEndpoint,
ExternalContourDetailEndpoint, ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint, ExternalContourDecisionEndpoint,
ExternalContourListCreateEndpoint, ExternalContourListCreateEndpoint,
@ -16,6 +18,16 @@ from plane.app.views import (
urlpatterns = [ 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( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/", "workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/",
ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]), ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]),

View File

@ -247,11 +247,15 @@ class ExternalContourTargetOptionsEndpoint(BaseAPIView):
) )
labels = Label.objects.filter(project=target_project).order_by("sort_order", "name") 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( serializer = ExternalContourTargetOptionsSerializer(
{ {
"project": target_project, "project": target_project,
"member_ids": member_ids, "member_ids": member_ids,
"states": states,
"labels": labels, "labels": labels,
} }
) )
@ -326,10 +330,17 @@ class ExternalContourDetailEndpoint(BaseAPIView):
serializer = ExternalContourRequestUpdateSerializer(data=request.data) serializer = ExternalContourRequestUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True) 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_serializer = IssueCreateSerializer(
issue, issue,
data=serializer.validated_data, data=issue_update_data,
partial=True, partial=True,
context={ context={
"project_id": str(issue.project_id), "project_id": str(issue.project_id),

View File

@ -6,6 +6,8 @@ from django.urls import path
from plane.app.views import ( from plane.app.views import (
ExternalContourAttachmentDownloadEndpoint, ExternalContourAttachmentDownloadEndpoint,
ExternalContourBoardEndpoint,
ExternalContourBoardItemDetailEndpoint,
ExternalContourDetailEndpoint, ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint, ExternalContourDecisionEndpoint,
ExternalContourListCreateEndpoint, ExternalContourListCreateEndpoint,
@ -16,6 +18,16 @@ from plane.app.views import (
urlpatterns = [ 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( path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/", "workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/",
ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]), ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]),

View File

@ -226,6 +226,8 @@ from .notification.base import (
from .exporter.base import ExportIssuesEndpoint from .exporter.base import ExportIssuesEndpoint
from .external_contours import ( from .external_contours import (
ExternalContourAttachmentDownloadEndpoint, ExternalContourAttachmentDownloadEndpoint,
ExternalContourBoardEndpoint,
ExternalContourBoardItemDetailEndpoint,
ExternalContourListCreateEndpoint, ExternalContourListCreateEndpoint,
ExternalContourDetailEndpoint, ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint, ExternalContourDecisionEndpoint,

View File

@ -3,6 +3,7 @@
# See the LICENSE file for details. # See the LICENSE file for details.
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.db.models import Q
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils import timezone from django.utils import timezone
from rest_framework.exceptions import ValidationError 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.utils.host import base_host
from plane.api.serializers import ( from plane.api.serializers import (
ExternalContourBoardItemSerializer,
ExternalContourRequestCreateSerializer, ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer, ExternalContourRequestDecisionSerializer,
ExternalContourRequestReplySerializer, ExternalContourRequestReplySerializer,
@ -247,17 +249,338 @@ class ExternalContourTargetOptionsEndpoint(BaseAPIView):
) )
labels = Label.objects.filter(project=target_project).order_by("sort_order", "name") 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( serializer = ExternalContourTargetOptionsSerializer(
{ {
"project": target_project, "project": target_project,
"member_ids": member_ids, "member_ids": member_ids,
"states": states,
"labels": labels, "labels": labels,
} }
) )
return Response(serializer.data, status=status.HTTP_200_OK) 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): class ExternalContourDetailEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission] permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourRequestSerializer serializer_class = ExternalContourRequestSerializer
@ -326,10 +649,17 @@ class ExternalContourDetailEndpoint(BaseAPIView):
serializer = ExternalContourRequestUpdateSerializer(data=request.data) serializer = ExternalContourRequestUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True) 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_serializer = IssueCreateSerializer(
issue, issue,
data=serializer.validated_data, data=issue_update_data,
partial=True, partial=True,
context={ context={
"project_id": str(issue.project_id), "project_id": str(issue.project_id),
@ -519,16 +849,13 @@ class ExternalContourReplyEndpoint(BaseAPIView):
return Response(response_serializer.data, status=status.HTTP_200_OK) return Response(response_serializer.data, status=status.HTTP_200_OK)
class ExternalContourAttachmentDownloadEndpoint(BaseAPIView): class ExternalContourAttachmentDownloadEndpoint(ExternalContourReadMixin, BaseAPIView):
permission_classes = [ProjectLitePermission] permission_classes = [ProjectLitePermission]
def get_queryset(self): def get_queryset(self):
return IntakeIssue.objects.filter( return self.get_board_item_queryset().filter(pk=self.kwargs.get("request_id")).select_related(
workspace__slug=self.kwargs.get("slug"), "issue", "issue__project", "workspace"
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")
def get(self, request, slug, project_id, request_id, attachment_id): def get(self, request, slug, project_id, request_id, attachment_id):
contour_request = get_object_or_404(self.get_queryset()) contour_request = get_object_or_404(self.get_queryset())

View File

@ -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

View File

@ -7,9 +7,7 @@
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Outlet } from "react-router"; import { Outlet } from "react-router";
import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider"; import { ProjectsAppPowerKProvider } from "@/components/power-k/projects-app-provider";
// plane web components import { ProjectShellTopToolbar } from "./project-shell-top-toolbar";
import { ProjectAppSidebar } from "./_sidebar";
import { ExtendedProjectSidebar } from "./extended-project-sidebar";
function WorkspaceLayout() { function WorkspaceLayout() {
return ( 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 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 id="full-screen-portal" className="absolute inset-0 w-full" />
<div className="relative flex size-full overflow-hidden"> <div className="relative flex size-full overflow-hidden">
<ProjectAppSidebar /> <main className="relative flex h-full w-full min-w-0 flex-col overflow-hidden bg-surface-1">
<ExtendedProjectSidebar /> <ProjectShellTopToolbar />
<main className="relative flex h-full w-full flex-col overflow-hidden bg-surface-1"> <div className="relative flex min-h-0 w-full flex-1 overflow-hidden">
<Outlet /> <Outlet />
</div>
</main> </main>
</div> </div>
</div> </div>

View File

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

View File

@ -5,17 +5,23 @@
*/ */
import { Outlet } from "react-router"; import { Outlet } from "react-router";
import { useParams } from "react-router";
import { AppHeader } from "@/components/core/app-header"; import { AppHeader } from "@/components/core/app-header";
import { ContentWrapper } from "@/components/core/content-wrapper"; import { ContentWrapper } from "@/components/core/content-wrapper";
import { ProjectExternalContoursHeader } from "@/plane-web/components/projects/external-contours/header"; 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() { export default function ProjectExternalContoursLayout() {
const { projectId, workspaceSlug } = useParams();
if (!projectId || !workspaceSlug) return <Outlet />;
return ( return (
<> <ProjectExternalContoursFiltersProvider projectId={projectId} workspaceSlug={workspaceSlug}>
<AppHeader header={<ProjectExternalContoursHeader />} /> <AppHeader header={<ProjectExternalContoursHeader />} />
<ContentWrapper> <ContentWrapper>
<Outlet /> <Outlet />
</ContentWrapper> </ContentWrapper>
</> </ProjectExternalContoursFiltersProvider>
); );
} }

View File

@ -17,7 +17,6 @@ import {
WORK_ITEM_TRACKER_ELEMENTS, WORK_ITEM_TRACKER_ELEMENTS,
} from "@plane/constants"; } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { NewTabIcon, WorkItemsIcon } from "@plane/propel/icons"; import { NewTabIcon, WorkItemsIcon } from "@plane/propel/icons";
import { Tooltip } from "@plane/propel/tooltip"; import { Tooltip } from "@plane/propel/tooltip";
import { EIssuesStoreType } from "@plane/types"; import { EIssuesStoreType } from "@plane/types";
@ -25,6 +24,7 @@ import { Breadcrumbs, Header } from "@plane/ui";
// components // components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { CountChip } from "@/components/common/count-chip"; import { CountChip } from "@/components/common/count-chip";
import { AppHeaderPrimaryActionButton } from "@/components/core/app-header/primary-action-button";
// constants // constants
import { HeaderFilters } from "@/components/issues/filters"; import { HeaderFilters } from "@/components/issues/filters";
// helpers // helpers
@ -117,18 +117,14 @@ export const IssuesHeader = observer(function IssuesHeader() {
/> />
</div> </div>
{canUserCreateIssue && ( {canUserCreateIssue && (
<Button <AppHeaderPrimaryActionButton
variant="primary"
size="lg"
className="nodedc-toolbar-primary nodedc-toolbar-primary-wide"
onClick={() => { onClick={() => {
toggleCreateIssueModal(true, EIssuesStoreType.PROJECT); toggleCreateIssueModal(true, EIssuesStoreType.PROJECT);
}} }}
data-ph-element={WORK_ITEM_TRACKER_ELEMENTS.HEADER_ADD_BUTTON.WORK_ITEMS} data-ph-element={WORK_ITEM_TRACKER_ELEMENTS.HEADER_ADD_BUTTON.WORK_ITEMS}
> >
<div className="block sm:hidden">{t("issue.label", { count: 1 })}</div> {t("app_header.add_task")}
<div className="hidden sm:block">{t("issue.add.label")}</div> </AppHeaderPrimaryActionButton>
</Button>
)} )}
</Header.RightItem> </Header.RightItem>
</Header> </Header>

View File

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

View File

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

View File

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

View File

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

View File

@ -9,27 +9,33 @@ import { observer } from "mobx-react";
import useSWR from "swr"; import useSWR from "swr";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import type { TNameDescriptionLoader } from "@plane/types"; import type { TNameDescriptionLoader } from "@plane/types";
import { ContentWrapper } from "@plane/ui";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours"; import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useUser, useUserPermissions } from "@/hooks/store/user"; import { useUser, useUserPermissions } from "@/hooks/store/user";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
import { ExternalContoursIssueActionsHeader } from "./issue-header"; import { ExternalContoursPeekShell } from "./peek-shell";
import { ExternalContoursIssueMainContent } from "./issue-root"; import { ExternalContoursIssueMainContent } from "./issue-root";
type Props = { type Props = {
workspaceSlug: string; workspaceSlug: string;
projectId: string; projectId: string;
inboxIssueId: string; inboxIssueId: string;
isMobileSidebar: boolean; embedIssue?: boolean;
setIsMobileSidebar: (value: boolean) => void; embedRemoveCurrentNotification?: () => void;
}; };
export const ExternalContoursContentRoot = observer(function ExternalContoursContentRoot(props: Props) { 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 router = useAppRouter();
const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved"); const [isSubmitting, setIsSubmitting] = useState<TNameDescriptionLoader>("saved");
const [isDetailResolved, setIsDetailResolved] = useState(false);
const { data: currentUser } = useUser(); const { data: currentUser } = useUser();
const { currentTab, fetchRequestById, getRequestById, getIsRequestAvailable } = useProjectExternalContours(); const { fetchRequestById, getRequestById } = useProjectExternalContours();
const contourRequest = getRequestById(inboxIssueId); const contourRequest = getRequestById(inboxIssueId);
const issue = contourRequest?.issue; const issue = contourRequest?.issue;
const targetProjectId = issue?.project_id || projectId; const targetProjectId = issue?.project_id || projectId;
@ -38,20 +44,24 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
targetProjectId && getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId) !== undefined targetProjectId && getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId) !== undefined
); );
const isIssueAvailable = getIsRequestAvailable(inboxIssueId?.toString() || "");
useEffect(() => { useEffect(() => {
if (!isIssueAvailable && inboxIssueId) { if (isDetailResolved && !contourRequest && inboxIssueId) {
router.replace(`/${workspaceSlug}/projects/${projectId}/external-contours?currentTab=${currentTab}`); router.replace(`/${workspaceSlug}/projects/${projectId}/external-contours`);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isIssueAvailable]); }, [contourRequest, inboxIssueId, isDetailResolved, projectId, router, workspaceSlug]);
useSWR( useSWR(
workspaceSlug && projectId && inboxIssueId workspaceSlug && projectId && inboxIssueId
? `PROJECT_EXTERNAL_CONTOUR_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}` ? `PROJECT_EXTERNAL_CONTOUR_DETAIL_${workspaceSlug}_${projectId}_${inboxIssueId}`
: null, : 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, revalidateOnFocus: !hasDirectTargetAccess,
revalidateIfStale: !hasDirectTargetAccess, revalidateIfStale: !hasDirectTargetAccess,
@ -78,19 +88,16 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
if (!contourRequest || !issue) return <></>; if (!contourRequest || !issue) return <></>;
return ( return (
<div className="relative flex h-full w-full flex-col overflow-hidden pt-6"> <ExternalContoursPeekShell
<div className="z-[11] min-h-[52px] flex-shrink-0">
<ExternalContoursIssueActionsHeader
setIsMobileSidebar={setIsMobileSidebar}
isMobileSidebar={isMobileSidebar}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
sourceProjectId={projectId} projectId={projectId}
contourRequest={contourRequest} contourRequest={contourRequest}
isSubmitting={isSubmitting}
hasDirectTargetAccess={hasDirectTargetAccess} hasDirectTargetAccess={hasDirectTargetAccess}
/> isSubmitting={isSubmitting}
</div> embedIssue={embedIssue}
<ContentWrapper className="space-y-4 px-4 pb-4 pt-4"> embedRemoveCurrentNotification={embedRemoveCurrentNotification}
onClose={() => router.replace(`/${workspaceSlug}/projects/${projectId}/external-contours`)}
>
<ExternalContoursIssueMainContent <ExternalContoursIssueMainContent
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
sourceProjectId={projectId} sourceProjectId={projectId}
@ -101,7 +108,6 @@ export const ExternalContoursContentRoot = observer(function ExternalContoursCon
isSubmitting={isSubmitting} isSubmitting={isSubmitting}
setIsSubmitting={setIsSubmitting} setIsSubmitting={setIsSubmitting}
/> />
</ContentWrapper> </ExternalContoursPeekShell>
</div>
); );
}); });

View File

@ -12,9 +12,9 @@ import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TIssue } from "@plane/types"; import type { TIssue } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types";
import { ToggleSwitch } from "@plane/ui"; import { ToggleSwitch } from "@plane/ui";
import { useProject } from "@/hooks/store/use-project"; 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 { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useWorkspace } from "@/hooks/store/use-workspace"; import { useWorkspace } from "@/hooks/store/use-workspace";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
@ -47,6 +47,7 @@ export const ExternalContoursCreateRoot = observer(function ExternalContoursCrea
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id; const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id;
const { currentProjectDetails } = useProject(); const { currentProjectDetails } = useProject();
const { createRequest, fetchTargetOptions, fetchTargetProjects } = useProjectExternalContours(); const { createRequest, fetchTargetOptions, fetchTargetProjects } = useProjectExternalContours();
const { fetchBoard } = useProjectExternalContoursBoard();
const descriptionEditorRef = useRef<EditorRefApi>(null); const descriptionEditorRef = useRef<EditorRefApi>(null);
const [createMore, setCreateMore] = useState(false); const [createMore, setCreateMore] = useState(false);
const [formSubmitting, setFormSubmitting] = useState(false); const [formSubmitting, setFormSubmitting] = useState(false);
@ -97,6 +98,7 @@ export const ExternalContoursCreateRoot = observer(function ExternalContoursCrea
try { try {
const createdRequest = await createRequest(workspaceSlug, projectId, formData); const createdRequest = await createRequest(workspaceSlug, projectId, formData);
await fetchBoard(workspaceSlug, projectId);
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: t("success"), title: t("success"),
@ -110,7 +112,7 @@ export const ExternalContoursCreateRoot = observer(function ExternalContoursCrea
} }
if (createdRequest?.id) { 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) { } catch (error: any) {
setToast({ setToast({

View File

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

View File

@ -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,
};
};

View File

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

View File

@ -10,14 +10,16 @@ import { useParams } from "next/navigation";
import { RefreshCcw } from "lucide-react"; import { RefreshCcw } from "lucide-react";
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { TransferIcon } from "@plane/propel/icons"; import { TransferIcon } from "@plane/propel/icons";
import { Breadcrumbs, Header } from "@plane/ui"; import { Breadcrumbs, Header } from "@plane/ui";
import { BreadcrumbLink } from "@/components/common/breadcrumb-link"; 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 { useProject } from "@/hooks/store/use-project";
import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours"; import { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useUserPermissions } from "@/hooks/store/user"; import { useUserPermissions } from "@/hooks/store/user";
import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common"; import { CommonProjectBreadcrumbs } from "@/plane-web/components/breadcrumbs/common";
import { useExternalContoursFilter } from "./filters/provider";
import { ExternalContourCreateModalRoot } from "./create-modal"; import { ExternalContourCreateModalRoot } from "./create-modal";
export const ProjectExternalContoursHeader = observer(function ProjectExternalContoursHeader() { export const ProjectExternalContoursHeader = observer(function ProjectExternalContoursHeader() {
@ -27,6 +29,7 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
const { allowPermissions } = useUserPermissions(); const { allowPermissions } = useUserPermissions();
const { loader: currentProjectDetailsLoader } = useProject(); const { loader: currentProjectDetailsLoader } = useProject();
const { loader } = useProjectExternalContours(); const { loader } = useProjectExternalContours();
const filter = useExternalContoursFilter();
const isAuthorized = allowPermissions( const isAuthorized = allowPermissions(
[EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST], [EUserPermissions.ADMIN, EUserPermissions.MEMBER, EUserPermissions.GUEST],
@ -34,9 +37,9 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
); );
return ( return (
<Header className="pt-4"> <Header>
<Header.LeftItem className="items-center pt-2"> <Header.LeftItem>
<div className="flex flex-grow items-center gap-4"> <div className="flex min-w-0 flex-grow items-center gap-4">
<Breadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}> <Breadcrumbs isLoading={currentProjectDetailsLoader === "init-loader"}>
<CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} /> <CommonProjectBreadcrumbs workspaceSlug={workspaceSlug?.toString()} projectId={projectId?.toString()} />
<Breadcrumbs.Item <Breadcrumbs.Item
@ -60,23 +63,21 @@ export const ProjectExternalContoursHeader = observer(function ProjectExternalCo
)} )}
</div> </div>
</Header.LeftItem> </Header.LeftItem>
<Header.RightItem className="pt-2"> <Header.RightItem>
{workspaceSlug && projectId && isAuthorized ? ( {workspaceSlug && projectId && isAuthorized ? (
<div className="flex items-center gap-2"> <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 <ExternalContourCreateModalRoot
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()} projectId={projectId.toString()}
modalState={createIssueModal} modalState={createIssueModal}
handleModalClose={() => setCreateIssueModal(false)} handleModalClose={() => setCreateIssueModal(false)}
/> />
<Button <AppHeaderPrimaryActionButton onClick={() => setCreateIssueModal(true)}>
variant="primary" {t("app_header.add_task")}
size="lg" </AppHeaderPrimaryActionButton>
onClick={() => setCreateIssueModal(true)}
className="nodedc-external-primary-button min-w-[14rem]"
>
{t("external_contours_page.header.add_request")}
</Button>
</div> </div>
) : null} ) : null}
</Header.RightItem> </Header.RightItem>

View File

@ -6,21 +6,57 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { observer } from "mobx-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 { useTranslation } from "@plane/i18n";
import { IconButton } from "@plane/propel/icon-button";
import { Button } from "@plane/propel/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 { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TExternalContourRequest, TNameDescriptionLoader } from "@plane/types"; import type { TExternalContourRequest, TNameDescriptionLoader } from "@plane/types";
import { EInboxIssueCurrentTab } from "@plane/types"; import { ControlLink, CustomSelect, Header, Row, Tooltip } from "@plane/ui";
import { ControlLink, Header, Row } from "@plane/ui";
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils"; import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status"; import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status";
import { useProject } from "@/hooks/store/use-project"; 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 { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { useAppRouter } from "@/hooks/use-app-router"; import { useAppRouter } from "@/hooks/use-app-router";
import { ExternalContourActionsMenu } from "./actions-menu";
import { ExternalContourStatePill } from "./state-pill"; import { ExternalContourStatePill } from "./state-pill";
import { ExternalContourDeclineModal } from "./decline-modal"; 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 = { type Props = {
workspaceSlug: string; workspaceSlug: string;
@ -28,8 +64,10 @@ type Props = {
contourRequest: TExternalContourRequest; contourRequest: TExternalContourRequest;
hasDirectTargetAccess: boolean; hasDirectTargetAccess: boolean;
isSubmitting: TNameDescriptionLoader; isSubmitting: TNameDescriptionLoader;
isMobileSidebar: boolean; removeRoutePeekId: () => void;
setIsMobileSidebar: (value: boolean) => void; peekMode: TExternalContourPeekMode;
setPeekMode: (value: TExternalContourPeekMode) => void;
embedIssue?: boolean;
}; };
export const ExternalContoursIssueActionsHeader = observer(function ExternalContoursIssueActionsHeader(props: Props) { export const ExternalContoursIssueActionsHeader = observer(function ExternalContoursIssueActionsHeader(props: Props) {
@ -39,33 +77,43 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
contourRequest, contourRequest,
hasDirectTargetAccess, hasDirectTargetAccess,
isSubmitting, isSubmitting,
isMobileSidebar, removeRoutePeekId,
setIsMobileSidebar, peekMode,
setPeekMode,
embedIssue = false,
} = props; } = props;
const { t } = useTranslation(); const { t } = useTranslation();
const router = useAppRouter(); const router = useAppRouter();
const [isDeclineModalOpen, setIsDeclineModalOpen] = useState(false); const [isDeclineModalOpen, setIsDeclineModalOpen] = useState(false);
const { currentTab, decideRequest, filteredRequestIds, handleCurrentTab, loader } = useProjectExternalContours(); const { decideRequest, filteredRequestIds, loader } = useProjectExternalContours();
const { columnIdsMap } = useProjectExternalContoursBoard();
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const issue = contourRequest.issue; const issue = contourRequest.issue;
const currentRequestId = contourRequest.id; 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 isSourceAccepted = contourRequest.source_decision === "accepted";
const redirectToRelativeIssue = useCallback( const redirectToRelativeIssue = useCallback(
(direction: "next" | "prev") => { (direction: "next" | "prev") => {
if (!filteredRequestIds || !currentRequestId) return; if (!relativeRequestIds || !currentRequestId || !hasRelativeNavigation || relativeRequestIds.length <= 1) return;
const currentIssueIndex = filteredRequestIds.findIndex((requestId) => requestId === currentRequestId); const currentIssueIndex = relativeRequestIds.findIndex((requestId) => requestId === currentRequestId);
if (currentIssueIndex === -1) return;
const nextIssueIndex = const nextIssueIndex =
direction === "next" direction === "next"
? (currentIssueIndex + 1) % filteredRequestIds.length ? (currentIssueIndex + 1) % relativeRequestIds.length
: (currentIssueIndex - 1 + filteredRequestIds.length) % filteredRequestIds.length; : (currentIssueIndex - 1 + relativeRequestIds.length) % relativeRequestIds.length;
const nextIssueId = filteredRequestIds[nextIssueIndex]; const nextIssueId = relativeRequestIds[nextIssueIndex];
if (!nextIssueId) return; 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(() => { useEffect(() => {
@ -79,6 +127,7 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
}, [redirectToRelativeIssue]); }, [redirectToRelativeIssue]);
const targetProjectIdentifier = issue.project_detail?.identifier || getProjectById(issue.project_id || "")?.identifier; const targetProjectIdentifier = issue.project_detail?.identifier || getProjectById(issue.project_id || "")?.identifier;
const requestLink = `/${workspaceSlug}/projects/${sourceProjectId}/external-contours?inboxIssueId=${contourRequest.id}`;
const workItemLink = generateWorkItemLink({ const workItemLink = generateWorkItemLink({
workspaceSlug: workspaceSlug?.toString(), workspaceSlug: workspaceSlug?.toString(),
projectId: issue.project_id, projectId: issue.project_id,
@ -86,9 +135,15 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
projectIdentifier: targetProjectIdentifier, projectIdentifier: targetProjectIdentifier,
sequenceId: issue.sequence_id, sequenceId: issue.sequence_id,
}); });
const subscriptionProjectId = issue.project_id || sourceProjectId;
const { isSubscribed, loading: isSubscriptionLoading, toggleSubscription } = useExternalContourSubscription({
workspaceSlug,
projectId: subscriptionProjectId,
issueId: issue.id,
});
const handleCopyLink = () => const handleCopyLink = () =>
copyUrlToClipboard(workItemLink).then(() => copyUrlToClipboard(requestLink).then(() =>
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: t("common.link_copied"), 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) => { const handleDecision = async (action: "accept" | "decline", comment?: string) => {
try { try {
await decideRequest(workspaceSlug, sourceProjectId, contourRequest.id, action, comment); await decideRequest(workspaceSlug, sourceProjectId, contourRequest.id, action, comment);
if (action === "decline") { if (action === "decline") {
setIsDeclineModalOpen(false); setIsDeclineModalOpen(false);
await handleCurrentTab(workspaceSlug, sourceProjectId, EInboxIssueCurrentTab.OPEN); router.push(`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?inboxIssueId=${contourRequest.id}`);
router.push(
`/${workspaceSlug}/projects/${sourceProjectId}/external-contours?currentTab=${EInboxIssueCurrentTab.OPEN}&inboxIssueId=${contourRequest.id}`
);
} }
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
@ -133,25 +207,71 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
onSubmit={(comment) => handleDecision("decline", comment)} 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"> <Row
<div className="flex items-center gap-4"> 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 && ( {issue?.project_id && issue.sequence_id && (
<h3 className="flex-shrink-0 text-14 font-medium text-tertiary"> <h3 className="flex-shrink-0 text-14 font-medium text-tertiary">
{targetProjectIdentifier}-{issue.sequence_id} {targetProjectIdentifier}-{issue.sequence_id}
</h3> </h3>
)} )}
<ExternalContourStatePill request={contourRequest} /> <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} /> <NameDescriptionUpdateStatus isSubmitting={isSubmitting} />
</div> </div>
</div> </div>
<div className="nodedc-external-detail-toolbar"> <div className="nodedc-external-detail-toolbar min-w-0">
<div className="nodedc-external-toolbar-cluster"> <div className="nodedc-external-toolbar-cluster">
<button <button
type="button" type="button"
aria-label="Previous request" aria-label="Previous request"
onClick={() => redirectToRelativeIssue("prev")} onClick={() => redirectToRelativeIssue("prev")}
disabled={!hasRelativeNavigation || relativeRequestIds.length <= 1}
className="nodedc-external-icon-button" className="nodedc-external-icon-button"
> >
<ChevronUpIcon className="size-3.5" /> <ChevronUpIcon className="size-3.5" />
@ -160,6 +280,7 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
type="button" type="button"
aria-label="Next request" aria-label="Next request"
onClick={() => redirectToRelativeIssue("next")} onClick={() => redirectToRelativeIssue("next")}
disabled={!hasRelativeNavigation || relativeRequestIds.length <= 1}
className="nodedc-external-icon-button" className="nodedc-external-icon-button"
> >
<ChevronDownIcon className="size-3.5" /> <ChevronDownIcon className="size-3.5" />
@ -186,56 +307,68 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
</div> </div>
)} )}
<Button {hasDirectTargetAccess && (
<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" variant="secondary"
size="lg" size="lg"
prependIcon={<LinkIcon className="h-2.5 w-2.5" />}
onClick={handleCopyLink} onClick={handleCopyLink}
className="nodedc-external-action-button" 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"
{t("external_contours_page.actions.copy")} />
</Button> </Tooltip>
{hasDirectTargetAccess && (
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self"> <ExternalContourActionsMenu
<Button variant="secondary" size="lg" prependIcon={<NewTabIcon className="h-2.5 w-2.5" />} className="nodedc-external-action-button"> canOpenTargetWorkItem={hasDirectTargetAccess}
{t("external_contours_page.actions.open")} canReviewClosedRequest={canReviewClosedRequest}
</Button> isSubscribed={isSubscribed}
</ControlLink> isSubscriptionLoading={isSubscriptionLoading}
)} onCopy={handleCopyLink}
onOpenTarget={handleOpenTarget}
onToggleSubscription={handleToggleSubscription}
/>
</div> </div>
</div> </div>
</Row> </Row>
<Header className="justify-start lg:hidden"> <Header className="justify-start px-4 py-3 lg:hidden">
<PanelLeft
onClick={() => setIsMobileSidebar(!isMobileSidebar)}
className={`my-auto mr-2 h-4 w-4 flex-shrink-0 ${isMobileSidebar ? "text-accent-primary" : "text-secondary"}`}
/>
<div className="flex w-full items-center gap-2"> <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} /> <ExternalContourStatePill request={contourRequest} />
<div className="ml-auto flex items-center gap-2"> <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 && ( {isSourceAccepted && (
<div className="nodedc-external-readonly-value min-h-10 w-auto px-4 text-13 font-medium"> <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")} {t("external_contours_page.traceability.source_decision_accepted")}
</div> </div>
)} )}
{hasDirectTargetAccess && ( <ExternalContourActionsMenu
<ControlLink href={workItemLink} onClick={() => router.push(workItemLink)} target="_self"> canOpenTargetWorkItem={hasDirectTargetAccess}
<Button variant="secondary" size="sm" className="nodedc-external-action-button"> canReviewClosedRequest={canReviewClosedRequest}
{t("external_contours_page.actions.open")} includeDecisionActions
</Button> isSubscribed={isSubscribed}
</ControlLink> isSubscriptionLoading={isSubscriptionLoading}
)} onAccept={() => handleDecision("accept")}
onCopy={handleCopyLink}
onDecline={() => setIsDeclineModalOpen(true)}
onOpenTarget={handleOpenTarget}
onToggleSubscription={handleToggleSubscription}
/>
</div> </div>
</div> </div>
</Header> </Header>

View File

@ -37,7 +37,7 @@ export const ExternalContoursIssueContentProperties = observer(function External
<div className="w-full overflow-visible"> <div className="w-full overflow-visible">
<h5 className="mb-2 text-body-sm-medium">{t("external_contours_page.properties.section_title")}</h5> <h5 className="mb-2 text-body-sm-medium">{t("external_contours_page.properties.section_title")}</h5>
<div className={`${!isEditable ? "opacity-60" : ""}`}> <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-row">
<div className="nodedc-external-property-label"> <div className="nodedc-external-property-label">
<PriorityPropertyIcon className="h-4 w-4 flex-shrink-0" /> <PriorityPropertyIcon className="h-4 w-4 flex-shrink-0" />

View File

@ -125,10 +125,7 @@ export const ExternalContoursIssueMainContent = observer(function ExternalContou
remove: async () => undefined, remove: async () => undefined,
update: async (_workspaceSlug: string, _projectId: string, requestId: string, data: Partial<TIssue>) => { update: async (_workspaceSlug: string, _projectId: string, requestId: string, data: Partial<TIssue>) => {
try { try {
await updateRequest(workspaceSlug, sourceProjectId, requestId, { await updateRequest(workspaceSlug, sourceProjectId, requestId, data);
name: data.name,
description_html: data.description_html,
});
} catch { } catch {
setToast({ title: t("error"), type: TOAST_TYPE.ERROR, message: t("issue_could_not_be_updated") }); 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="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="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-row">
<div className="nodedc-external-property-label"> <div className="nodedc-external-property-label">
<PriorityPropertyIcon className="h-4 w-4 flex-shrink-0" /> <PriorityPropertyIcon className="h-4 w-4 flex-shrink-0" />

View File

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

View File

@ -4,18 +4,17 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { PanelLeft } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { TransferIcon } from "@plane/propel/icons"; import { TransferIcon } from "@plane/propel/icons";
import type { TInboxIssueCurrentTab } from "@plane/types"; import type { TInboxIssueCurrentTab } from "@plane/types";
import { EInboxIssueCurrentTab } 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 { useProjectExternalContours } from "@/hooks/store/use-project-external-contours";
import { ExternalContoursBoardRoot } from "./board-root";
import { ExternalContoursContentRoot } from "./content-root"; import { ExternalContoursContentRoot } from "./content-root";
import { ExternalContoursEmptyState } from "./empty-state"; import { useExternalContoursFilter } from "./filters/provider";
import { ExternalContoursSidebar } from "./sidebar";
type TExternalContoursRoot = { type TExternalContoursRoot = {
workspaceSlug: string; workspaceSlug: string;
@ -26,10 +25,15 @@ type TExternalContoursRoot = {
export const ExternalContoursRoot = observer(function ExternalContoursRoot(props: TExternalContoursRoot) { export const ExternalContoursRoot = observer(function ExternalContoursRoot(props: TExternalContoursRoot) {
const { workspaceSlug, projectId, inboxIssueId, navigationTab } = props; const { workspaceSlug, projectId, inboxIssueId, navigationTab } = props;
const [isMobileSidebar, setIsMobileSidebar] = useState(true);
const { t } = useTranslation();
const { loader, error, currentTab, currentProjectId, requestIds, handleCurrentTab, fetchRequests } = const { loader, error, currentTab, currentProjectId, requestIds, handleCurrentTab, fetchRequests } =
useProjectExternalContours(); useProjectExternalContours();
const {
error: boardError,
currentProjectId: boardProjectId,
fetchBoard,
loader: boardLoader,
} = useProjectExternalContoursBoard();
const filter = useExternalContoursFilter();
useEffect(() => { useEffect(() => {
if (!workspaceSlug || !projectId) return; if (!workspaceSlug || !projectId) return;
@ -56,7 +60,15 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspaceSlug, projectId, navigationTab]); }, [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 ( return (
<div className="relative flex h-full w-full flex-col items-center justify-center gap-3"> <div className="relative flex h-full w-full flex-col items-center justify-center gap-3">
<TransferIcon className="size-[60px]" strokeWidth={1.5} /> <TransferIcon className="size-[60px]" strokeWidth={1.5} />
@ -65,52 +77,29 @@ export const ExternalContoursRoot = observer(function ExternalContoursRoot(props
); );
} }
if (boardError && boardError?.status === "init-error" && !inboxIssueId) {
return ( return (
<> <div className="relative flex h-full w-full flex-col items-center justify-center gap-3">
{!inboxIssueId && ( <TransferIcon className="size-[60px]" strokeWidth={1.5} />
<div className="flex h-12 w-full items-center border-b border-subtle px-4 lg:hidden"> <div className="text-secondary">{boardError?.message}</div>
<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> </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 <ExternalContoursContentRoot
setIsMobileSidebar={setIsMobileSidebar}
isMobileSidebar={isMobileSidebar}
workspaceSlug={workspaceSlug.toString()} workspaceSlug={workspaceSlug.toString()}
projectId={projectId.toString()} projectId={projectId.toString()}
inboxIssueId={inboxIssueId.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>
</> </div>
); );
}); });

View File

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

View File

@ -7,6 +7,7 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { AtSign, Briefcase } from "lucide-react"; import { AtSign, Briefcase } from "lucide-react";
// plane imports // plane imports
import { useTranslation } from "@plane/i18n";
import { Logo } from "@plane/propel/emoji-icon-picker"; import { Logo } from "@plane/propel/emoji-icon-picker";
import { import {
CalendarLayoutIcon, CalendarLayoutIcon,
@ -91,6 +92,7 @@ export type TWorkItemFiltersConfig = {
export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps): TWorkItemFiltersConfig => { export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps): TWorkItemFiltersConfig => {
const { allowedFilters, cycleIds, labelIds, memberIds, moduleIds, projectId, projectIds, stateIds, workspaceSlug } = const { allowedFilters, cycleIds, labelIds, memberIds, moduleIds, projectId, projectIds, stateIds, workspaceSlug } =
props; props;
const { t } = useTranslation();
// store hooks // store hooks
const { loader: projectLoader, getProjectById } = useProject(); const { loader: projectLoader, getProjectById } = useProject();
const { getCycleById } = useCycle(); const { getCycleById } = useCycle();
@ -152,11 +154,13 @@ export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps):
() => () =>
getStateGroupFilterConfig<TWorkItemFilterProperty>("state_group")({ getStateGroupFilterConfig<TWorkItemFilterProperty>("state_group")({
isEnabled: isFilterEnabled("state_group"), isEnabled: isFilterEnabled("state_group"),
filterLabel: t("common.state_group"),
filterIcon: StatePropertyIcon, filterIcon: StatePropertyIcon,
getItemLabel: (stateGroupKey) => t(`workspace_projects.state.${stateGroupKey}`),
getOptionIcon: (stateGroupKey) => <StateGroupIcon stateGroup={stateGroupKey} />, getOptionIcon: (stateGroupKey) => <StateGroupIcon stateGroup={stateGroupKey} />,
...operatorConfigs, ...operatorConfigs,
}), }),
[isFilterEnabled, operatorConfigs] [isFilterEnabled, operatorConfigs, t]
); );
// state filter config // state filter config
@ -298,11 +302,13 @@ export const useWorkItemFiltersConfig = (props: TUseWorkItemFiltersConfigProps):
() => () =>
getPriorityFilterConfig<TWorkItemFilterProperty>("priority")({ getPriorityFilterConfig<TWorkItemFilterProperty>("priority")({
isEnabled: isFilterEnabled("priority"), isEnabled: isFilterEnabled("priority"),
filterLabel: t("common.priority"),
filterIcon: PriorityPropertyIcon, filterIcon: PriorityPropertyIcon,
getItemLabel: (priorityKey) => (priorityKey === "none" ? t("common.none") : t(priorityKey)),
getOptionIcon: (priority) => <PriorityIcon priority={priority} />, getOptionIcon: (priority) => <PriorityIcon priority={priority} />,
...operatorConfigs, ...operatorConfigs,
}), }),
[isFilterEnabled, operatorConfigs] [isFilterEnabled, operatorConfigs, t]
); );
// start date filter config // start date filter config

View File

@ -4,7 +4,8 @@
* See the LICENSE file for details. * 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"; import { observer } from "mobx-react";
// plane imports // plane imports
import { Row } from "@plane/ui"; import { Row } from "@plane/ui";
@ -21,10 +22,40 @@ export interface AppHeaderProps {
export const AppHeader = observer(function AppHeader(props: AppHeaderProps) { export const AppHeader = observer(function AppHeader(props: AppHeaderProps) {
const { header, mobileHeader, className, rowClassName } = props; 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 ( return (
<div className={cn("z-[18]", className)}> <div ref={containerRef} className={cn("fixed right-0 bottom-0 z-[18]", className)} style={dockStyle}>
<Row className={cn("flex h-11 w-full items-center gap-2 border-b border-subtle bg-surface-1", rowClassName)}> <Row className={cn("nodedc-bottom-dock flex h-11 w-full items-center gap-2", rowClassName)}>
<ExtendedAppHeader header={header} /> <ExtendedAppHeader header={header} />
</Row> </Row>
{mobileHeader && mobileHeader} {mobileHeader && mobileHeader}

View File

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

View File

@ -49,8 +49,8 @@ export const CycleDeleteModal = observer(function CycleDeleteModal(props: ICycle
if (cycleId || peekCycle) router.push(`/${workspaceSlug}/projects/${projectId}/cycles`); if (cycleId || peekCycle) router.push(`/${workspaceSlug}/projects/${projectId}/cycles`);
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success!", title: t("common.success"),
message: "Cycle deleted successfully.", message: t("entity.delete.success", { entity: t("common.cycle").toLowerCase() }),
}); });
}) })
.catch((errors) => { .catch((errors) => {
@ -68,8 +68,8 @@ export const CycleDeleteModal = observer(function CycleDeleteModal(props: ICycle
} catch { } catch {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Warning!", title: t("common.warning"),
message: "Something went wrong please try again later.", message: t("common.something_went_wrong"),
}); });
} }
@ -82,14 +82,11 @@ export const CycleDeleteModal = observer(function CycleDeleteModal(props: ICycle
handleSubmit={formSubmit} handleSubmit={formSubmit}
isSubmitting={loader} isSubmitting={loader}
isOpen={isOpen} isOpen={isOpen}
title="Delete cycle" title={t("entity.delete.label", { entity: t("common.cycle") })}
content={ content={t("entity.delete.confirmation", {
<> entity: t("common.cycle").toLowerCase(),
Are you sure you want to delete cycle{' "'} identifier: cycle?.name ? `"${cycle.name}"` : "",
<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.
</>
}
/> />
); );
}); });

View File

@ -7,6 +7,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
// plane imports // plane imports
@ -211,8 +212,9 @@ export const WorkItemStateDropdownBase = observer(function WorkItemStateDropdown
button={comboButton} button={comboButton}
renderByDefault={renderByDefault} renderByDefault={renderByDefault}
> >
{isOpen && ( {isOpen &&
<Combobox.Options className="fixed z-10" static> createPortal(
<Combobox.Options data-prevent-outside-click className="fixed z-30" static>
<div <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" 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} ref={setPopperElement}
@ -263,7 +265,8 @@ export const WorkItemStateDropdownBase = observer(function WorkItemStateDropdown
)} )}
</div> </div>
</div> </div>
</Combobox.Options> </Combobox.Options>,
document.body
)} )}
</ComboDropDown> </ComboDropDown>
); );

View File

@ -7,9 +7,9 @@
import { useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// ui // ui
import { Button } from "@plane/propel/button"; import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { EModalPosition, EModalWidth, ModalCore } from "@plane/ui"; import { AlertModalCore } from "@plane/ui";
// hooks // hooks
import { useProjectEstimates } from "@/hooks/store/estimates"; import { useProjectEstimates } from "@/hooks/store/estimates";
import { useEstimate } from "@/hooks/store/estimates/use-estimate"; import { useEstimate } from "@/hooks/store/estimates/use-estimate";
@ -26,6 +26,7 @@ type TDeleteEstimateModal = {
export const DeleteEstimateModal = observer(function DeleteEstimateModal(props: TDeleteEstimateModal) { export const DeleteEstimateModal = observer(function DeleteEstimateModal(props: TDeleteEstimateModal) {
// props // props
const { workspaceSlug, projectId, estimateId, isOpen, handleClose } = props; const { workspaceSlug, projectId, estimateId, isOpen, handleClose } = props;
const { t } = useTranslation();
// hooks // hooks
const { areEstimateEnabledByProjectId, deleteEstimate } = useProjectEstimates(); const { areEstimateEnabledByProjectId, deleteEstimate } = useProjectEstimates();
const { asJson: estimate } = useEstimate(estimateId); const { asJson: estimate } = useEstimate(estimateId);
@ -44,46 +45,32 @@ export const DeleteEstimateModal = observer(function DeleteEstimateModal(props:
setButtonLoader(false); setButtonLoader(false);
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Estimate deleted", title: t("project_settings.estimates.delete_modal.success_title"),
message: "Estimate has been removed from your project.", message: t("project_settings.estimates.delete_modal.success_message"),
}); });
handleClose(); handleClose();
} catch (_error) { } catch (_error) {
setButtonLoader(false); setButtonLoader(false);
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Estimate creation failed", title: t("project_settings.estimates.delete_modal.error_title"),
message: "We were unable to delete the estimate, please try again.", message: t("project_settings.estimates.delete_modal.error_message"),
}); });
} }
}; };
return ( return (
<ModalCore isOpen={isOpen} position={EModalPosition.TOP} width={EModalWidth.XXL}> <AlertModalCore
<div className="relative space-y-6 py-5"> handleClose={handleClose}
{/* heading */} handleSubmit={handleDeleteEstimate}
<div className="relative flex items-center justify-between gap-2 px-5"> isSubmitting={buttonLoader}
<div className="text-18 font-medium text-primary">Delete Estimate System</div> isOpen={isOpen}
</div> title={t("project_settings.estimates.delete_modal.title")}
content={t("project_settings.estimates.delete_modal.description", { value: estimate?.name ?? "" })}
{/* estimate steps */} primaryButtonText={{
<div className="px-5"> loading: t("project_settings.estimates.delete_modal.loading"),
<div className="text-14 text-secondary"> default: t("project_settings.estimates.delete_modal.submit"),
Deleting the estimate <span className="font-bold text-primary">{estimate?.name}</span> }}
&nbsp;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>
); );
}); });

View File

@ -47,16 +47,9 @@ export function DeclineIssueModal(props: Props) {
isSubmitting={isDeclining} isSubmitting={isDeclining}
isOpen={isOpen} isOpen={isOpen}
title={t("inbox_issue.modals.decline.title")} title={t("inbox_issue.modals.decline.title")}
// TODO: Need to translate the confirmation message content={t("inbox_issue.modals.decline.content", {
content={ value: `${projectDetails?.identifier}-${data?.sequence_id}`,
<> })}
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.
</>
}
primaryButtonText={{ primaryButtonText={{
loading: t("declining"), loading: t("declining"),
default: t("decline"), default: t("decline"),

View File

@ -63,14 +63,7 @@ export const IssueAttachmentDeleteModal = observer(function IssueAttachmentDelet
isSubmitting={loader} isSubmitting={loader}
isOpen={isOpen} isOpen={isOpen}
title={t("attachment.delete")} title={t("attachment.delete")}
content={ content={t("attachment.delete_confirmation", { value: getFileName(attachment.attributes.name) })}
<>
{/* 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.
</>
}
/> />
); );
}); });

View File

@ -94,21 +94,23 @@ export const HeaderFilters = observer(function HeaderFilters(props: Props) {
projectDetails={currentProjectDetails ?? undefined} projectDetails={currentProjectDetails ?? undefined}
isEpic={storeType === EIssuesStoreType.EPIC} isEpic={storeType === EIssuesStoreType.EPIC}
/> />
<div className="nodedc-top-toolbar-cluster flex items-center gap-2"> <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="hidden @4xl:flex"> <div className="pointer-events-auto hidden @4xl:flex">
<LayoutSelection <LayoutSelection
layouts={LAYOUTS} layouts={LAYOUTS}
onChange={(layout) => handleLayoutChange(layout)} onChange={(layout) => handleLayoutChange(layout)}
selectedLayout={activeLayout} selectedLayout={activeLayout}
/> />
</div> </div>
<div className="flex @4xl:hidden"> <div className="pointer-events-auto flex @4xl:hidden">
<MobileLayoutSelection <MobileLayoutSelection
layouts={LAYOUTS} layouts={LAYOUTS}
onChange={(layout) => handleLayoutChange(layout)} onChange={(layout) => handleLayoutChange(layout)}
activeLayout={activeLayout} activeLayout={activeLayout}
/> />
</div> </div>
</div>
<div className="nodedc-top-toolbar-cluster flex items-center gap-2">
<WorkItemFiltersToggle entityType={storeType} entityId={projectId} /> <WorkItemFiltersToggle entityType={storeType} entityId={projectId} />
<FiltersDropdown <FiltersDropdown
miniIcon={<SlidersHorizontal className="size-3.5" />} miniIcon={<SlidersHorizontal className="size-3.5" />}

View File

@ -27,10 +27,11 @@ export type TIssueSubscription = {
issueId: string; issueId: string;
serviceType?: EIssueServiceType; serviceType?: EIssueServiceType;
buttonClassName?: string; buttonClassName?: string;
iconOnly?: boolean;
}; };
export const IssueSubscription = observer(function IssueSubscription(props: TIssueSubscription) { 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(); const { t } = useTranslation();
// hooks // hooks
const { const {
@ -77,7 +78,7 @@ export const IssueSubscription = observer(function IssueSubscription(props: TIss
if (isNil(isSubscribed)) if (isNil(isSubscribed))
return ( return (
<Loader> <Loader>
<Loader.Item width="106px" height="28px" /> <Loader.Item width={iconOnly ? "40px" : "106px"} height={iconOnly ? "40px" : "28px"} />
</Loader> </Loader>
); );
@ -91,7 +92,8 @@ export const IssueSubscription = observer(function IssueSubscription(props: TIss
disabled={!isEditable || loading} disabled={!isEditable || loading}
size="lg" size="lg"
> >
{loading ? ( {!iconOnly &&
(loading ? (
<span> <span>
<span className="hidden sm:block">{t("common.loading")}</span> <span className="hidden sm:block">{t("common.loading")}</span>
</span> </span>
@ -99,7 +101,7 @@ export const IssueSubscription = observer(function IssueSubscription(props: TIss
<div className="hidden sm:block">{t("common.actions.unsubscribe")}</div> <div className="hidden sm:block">{t("common.actions.unsubscribe")}</div>
) : ( ) : (
<div className="hidden sm:block">{t("common.actions.subscribe")}</div> <div className="hidden sm:block">{t("common.actions.subscribe")}</div>
)} ))}
</Button> </Button>
</div> </div>
); );

View File

@ -249,9 +249,9 @@ export const BaseKanBanRoot = observer(function BaseKanBanRoot(props: IBaseKanBa
/> />
{/* drag and delete component */} {/* drag and delete component */}
<div <div
className={`fixed left-1/2 -translate-x-1/2 ${ 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 ? "z-40" : "" isDragging ? "pointer-events-auto z-40" : "pointer-events-none"
} top-3 mx-3 flex w-[min(40rem,calc(100vw-2rem))] items-center justify-center`} }`}
ref={deleteAreaRef} ref={deleteAreaRef}
> >
<div <div

View File

@ -84,9 +84,10 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
const maxDate = getDate(targetDate); const maxDate = getDate(targetDate);
maxDate?.setDate(maxDate.getDate()); maxDate?.setDate(maxDate.getDate());
const propertyButtonClassName = "nodedc-work-item-property-button";
return ( return (
<div className="flex flex-wrap items-center gap-2"> <div className="nodedc-work-item-properties-row">
<Controller <Controller
control={control} control={control}
name="state_id" name="state_id"
@ -100,6 +101,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
}} }}
projectId={projectId ?? undefined} projectId={projectId ?? undefined}
buttonVariant="border-with-text" buttonVariant="border-with-text"
buttonClassName={propertyButtonClassName}
tabIndex={getIndex("state_id")} tabIndex={getIndex("state_id")}
isForWorkItemCreation={!id} isForWorkItemCreation={!id}
/> />
@ -118,6 +120,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
handleFormChange(); handleFormChange();
}} }}
buttonVariant="border-with-text" buttonVariant="border-with-text"
buttonClassName={propertyButtonClassName}
tabIndex={getIndex("priority")} tabIndex={getIndex("priority")}
/> />
</div> </div>
@ -136,7 +139,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
handleFormChange(); handleFormChange();
}} }}
buttonVariant={value?.length > 0 ? "transparent-without-text" : "border-with-text"} buttonVariant={value?.length > 0 ? "transparent-without-text" : "border-with-text"}
buttonClassName={value?.length > 0 ? "hover:bg-transparent" : ""} buttonClassName={propertyButtonClassName}
placeholder={t("assignees")} placeholder={t("assignees")}
multiple multiple
tabIndex={getIndex("assignee_ids")} tabIndex={getIndex("assignee_ids")}
@ -156,6 +159,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
handleFormChange(); handleFormChange();
}} }}
projectId={projectId ?? undefined} projectId={projectId ?? undefined}
buttonClassName={propertyButtonClassName}
tabIndex={getIndex("label_ids")} tabIndex={getIndex("label_ids")}
createLabelEnabled={!!canCreateLabel} createLabelEnabled={!!canCreateLabel}
/> />
@ -174,6 +178,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
handleFormChange(); handleFormChange();
}} }}
buttonVariant="border-with-text" buttonVariant="border-with-text"
buttonClassName={propertyButtonClassName}
maxDate={maxDate ?? undefined} maxDate={maxDate ?? undefined}
placeholder={t("start_date")} placeholder={t("start_date")}
tabIndex={getIndex("start_date")} tabIndex={getIndex("start_date")}
@ -193,6 +198,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
handleFormChange(); handleFormChange();
}} }}
buttonVariant="border-with-text" buttonVariant="border-with-text"
buttonClassName={propertyButtonClassName}
minDate={minDate ?? undefined} minDate={minDate ?? undefined}
placeholder={t("due_date")} placeholder={t("due_date")}
tabIndex={getIndex("target_date")} tabIndex={getIndex("target_date")}
@ -215,6 +221,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
placeholder={t("cycle.label", { count: 1 })} placeholder={t("cycle.label", { count: 1 })}
value={value} value={value}
buttonVariant="border-with-text" buttonVariant="border-with-text"
buttonClassName={propertyButtonClassName}
tabIndex={getIndex("cycle_id")} tabIndex={getIndex("cycle_id")}
/> />
</div> </div>
@ -236,6 +243,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
}} }}
placeholder={t("modules")} placeholder={t("modules")}
buttonVariant="border-with-text" buttonVariant="border-with-text"
buttonClassName={propertyButtonClassName}
tabIndex={getIndex("module_ids")} tabIndex={getIndex("module_ids")}
multiple multiple
showCount showCount
@ -258,6 +266,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
}} }}
projectId={projectId} projectId={projectId}
buttonVariant="border-with-text" buttonVariant="border-with-text"
buttonClassName={propertyButtonClassName}
tabIndex={getIndex("estimate_point")} tabIndex={getIndex("estimate_point")}
placeholder={t("estimate")} placeholder={t("estimate")}
/> />
@ -271,7 +280,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
customButton={ customButton={
<button <button
type="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 && ( {selectedParentIssue?.project_id && (
<IssueIdentifier <IssueIdentifier
@ -313,7 +322,7 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
) : ( ) : (
<button <button
type="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)} onClick={() => setParentIssueListModalOpen(true)}
> >
<ParentPropertyIcon className="h-3 w-3 flex-shrink-0" /> <ParentPropertyIcon className="h-3 w-3 flex-shrink-0" />

View File

@ -486,7 +486,7 @@ export const IssueFormRoot = observer(function IssueFormRoot(props: IssueFormPro
workspaceSlug={workspaceSlug?.toString()} workspaceSlug={workspaceSlug?.toString()}
/> />
</div> </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"> <div className="pb-3">
<IssueDefaultProperties <IssueDefaultProperties
control={control} control={control}
@ -504,12 +504,12 @@ export const IssueFormRoot = observer(function IssueFormRoot(props: IssueFormPro
</div> </div>
{showActionButtons && ( {showActionButtons && (
<div <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")} tabIndex={getIndex("create_more")}
> >
{!data?.id && ( {!data?.id && (
<div <div
className="inline-flex cursor-pointer items-center gap-1.5" className="nodedc-work-item-create-more"
onClick={() => onCreateMoreToggleChange(!isCreateMoreToggleEnabled)} onClick={() => onCreateMoreToggleChange(!isCreateMoreToggleEnabled)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled); if (e.key === "Enter") onCreateMoreToggleChange(!isCreateMoreToggleEnabled);
@ -517,7 +517,7 @@ export const IssueFormRoot = observer(function IssueFormRoot(props: IssueFormPro
role="button" role="button"
> >
<ToggleSwitch value={isCreateMoreToggleEnabled} onChange={() => {}} size="sm" /> <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>
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@ -207,7 +207,8 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
projectId={projectId} projectId={projectId}
issueId={issueId} 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}> <Tooltip tooltipContent={t("common.actions.copy_link")} isMobile={isMobile}>

View File

@ -8,6 +8,7 @@ import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
// types // types
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IIssueLabel } from "@plane/types"; import type { IIssueLabel } from "@plane/types";
// ui // ui
@ -23,6 +24,7 @@ type Props = {
export const DeleteLabelModal = observer(function DeleteLabelModal(props: Props) { export const DeleteLabelModal = observer(function DeleteLabelModal(props: Props) {
const { isOpen, onClose, data } = props; const { isOpen, onClose, data } = props;
const { t } = useTranslation();
// router // router
const { workspaceSlug, projectId } = useParams(); const { workspaceSlug, projectId } = useParams();
// store hooks // store hooks
@ -46,10 +48,10 @@ export const DeleteLabelModal = observer(function DeleteLabelModal(props: Props)
}) })
.catch((err) => { .catch((err) => {
setIsDeleteLoading(false); setIsDeleteLoading(false);
const error = err?.error || "Label could not be deleted. Please try again."; const error = err?.error || t("label.delete_error");
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("common.error.label"),
message: error, message: error,
}); });
}); });
@ -61,13 +63,8 @@ export const DeleteLabelModal = observer(function DeleteLabelModal(props: Props)
handleSubmit={handleDeletion} handleSubmit={handleDeletion}
isSubmitting={isDeleteLoading} isSubmitting={isDeleteLoading}
isOpen={isOpen} isOpen={isOpen}
title="Delete Label" title={t("entity.delete.label", { entity: t("common.label") })}
content={ content={t("label.delete_confirmation", { value: data?.name ?? "" })}
<>
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.
</>
}
/> />
); );
}); });

View File

@ -52,8 +52,8 @@ export const DeleteModuleModal = observer(function DeleteModuleModal(props: Prop
handleClose(); handleClose();
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success!", title: t("common.success"),
message: "Module deleted successfully.", message: t("entity.delete.success", { entity: t("common.module").toLowerCase() }),
}); });
}) })
.catch((errors) => { .catch((errors) => {
@ -76,14 +76,11 @@ export const DeleteModuleModal = observer(function DeleteModuleModal(props: Prop
handleSubmit={handleDeletion} handleSubmit={handleDeletion}
isSubmitting={isDeleteLoading} isSubmitting={isDeleteLoading}
isOpen={isOpen} isOpen={isOpen}
title="Delete module" title={t("entity.delete.label", { entity: t("common.module") })}
content={ content={t("entity.delete.confirmation", {
<> entity: t("common.module").toLowerCase(),
Are you sure you want to delete module-{" "} identifier: data?.name ? `"${data.name}"` : "",
<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.
</>
}
/> />
); );
}); });

View File

@ -132,7 +132,7 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
const width = 320; const width = 320;
const viewportPadding = 16; const viewportPadding = 16;
const left = Math.min(rect.left, window.innerWidth - width - viewportPadding); const left = Math.min(rect.left, window.innerWidth - width - viewportPadding);
const top = rect.top + rect.height / 2; const top = rect.bottom + 10;
setSidebarSearchPosition({ setSidebarSearchPosition({
left, left,
@ -331,7 +331,7 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
ref={sidebarSearchButtonRef} ref={sidebarSearchButtonRef}
type="button" type="button"
className={cn( 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={() => { onClick={() => {
if (isOpen) { if (isOpen) {
@ -374,7 +374,6 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
left: `${sidebarSearchPosition.left}px`, left: `${sidebarSearchPosition.left}px`,
top: `${sidebarSearchPosition.top}px`, top: `${sidebarSearchPosition.top}px`,
width: `${sidebarSearchPosition.width}px`, width: `${sidebarSearchPosition.width}px`,
transform: "translateY(-50%)",
}} }}
> >
<div className="relative"> <div className="relative">
@ -400,7 +399,7 @@ export const TopNavPowerK = observer((props: TTopNavPowerKProps) => {
</button> </button>
)} )}
</div> </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="px-4 pb-2">
<div className="text-[13px] font-medium text-secondary"> <div className="text-[13px] font-medium text-secondary">
{t("power_k.search_menu.quick_access_title")} {t("power_k.search_menu.quick_access_title")}

View File

@ -8,6 +8,7 @@ import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// ui // ui
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { AlertModalCore } from "@plane/ui"; import { AlertModalCore } from "@plane/ui";
import { getPageName } from "@plane/utils"; import { getPageName } from "@plane/utils";
@ -28,6 +29,7 @@ type TConfirmPageDeletionProps = {
export const DeletePageModal = observer(function DeletePageModal(props: TConfirmPageDeletionProps) { export const DeletePageModal = observer(function DeletePageModal(props: TConfirmPageDeletionProps) {
const { isOpen, onClose, page, storeType } = props; const { isOpen, onClose, page, storeType } = props;
const { t } = useTranslation();
// states // states
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
// store hooks // store hooks
@ -52,8 +54,8 @@ export const DeletePageModal = observer(function DeletePageModal(props: TConfirm
handleClose(); handleClose();
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success!", title: t("project_page.delete_modal.success_title"),
message: "Page deleted successfully.", message: t("project_page.delete_modal.success_message"),
}); });
if (routePageId) { if (routePageId) {
@ -63,8 +65,8 @@ export const DeletePageModal = observer(function DeletePageModal(props: TConfirm
.catch(() => { .catch(() => {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("project_page.delete_modal.error_title"),
message: "Page could not be deleted. Please try again.", message: t("project_page.delete_modal.error_message"),
}); });
}); });
@ -79,14 +81,8 @@ export const DeletePageModal = observer(function DeletePageModal(props: TConfirm
handleSubmit={handleDelete} handleSubmit={handleDelete}
isSubmitting={isDeleting} isSubmitting={isDeleting}
isOpen={isOpen} isOpen={isOpen}
title="Delete page" title={t("project_page.delete_modal.title")}
content={ content={t("project_page.delete_modal.content", { value: getPageName(name) })}
<>
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.
</>
}
/> />
); );
}); });

View File

@ -7,6 +7,7 @@
import { useState } from "react"; import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Loader } from "lucide-react"; import { Loader } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { CloseIcon } from "@plane/propel/icons"; import { CloseIcon } from "@plane/propel/icons";
// plane imports // plane imports
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
@ -26,6 +27,7 @@ type TStateDelete = {
export const StateDelete = observer(function StateDelete(props: TStateDelete) { export const StateDelete = observer(function StateDelete(props: TStateDelete) {
const { totalStates, state, deleteStateCallback } = props; const { totalStates, state, deleteStateCallback } = props;
const { t } = useTranslation();
// hooks // hooks
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
// states // states
@ -47,15 +49,14 @@ export const StateDelete = observer(function StateDelete(props: TStateDelete) {
if (errorStatus.status === 400) { if (errorStatus.status === 400) {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("common.error.label"),
message: message: t("project_settings.states.delete.blocked"),
"This state contains some work items within it, please move them to some other state to delete this state.",
}); });
} else { } else {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("common.error.label"),
message: "State could not be deleted. Please try again.", message: t("project_settings.states.delete.error"),
}); });
} }
setIsDelete(false); setIsDelete(false);
@ -69,13 +70,11 @@ export const StateDelete = observer(function StateDelete(props: TStateDelete) {
handleSubmit={handleDeleteState} handleSubmit={handleDeleteState}
isSubmitting={isDelete} isSubmitting={isDelete}
isOpen={isDeleteModal} isOpen={isDeleteModal}
title="Delete State" title={t("entity.delete.label", { entity: t("common.state") })}
content={ content={t("entity.delete.confirmation", {
<> entity: t("common.state").toLowerCase(),
Are you sure you want to delete state- <span className="font-medium text-primary">{state?.name}</span>? All identifier: state?.name ? `"${state.name}"` : "",
of the data related to the state will be permanently removed. This action cannot be undone. })}
</>
}
/> />
<button <button
@ -89,7 +88,11 @@ export const StateDelete = observer(function StateDelete(props: TStateDelete) {
> >
<Tooltip <Tooltip
tooltipContent={ 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} isMobile={isMobile}
disabled={!isDeleteDisabled} disabled={!isDeleteDisabled}

View File

@ -8,6 +8,7 @@ import { useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
// Plane imports // Plane imports
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { IState } from "@plane/types"; import type { IState } from "@plane/types";
// ui // ui
@ -23,6 +24,7 @@ type TStateDeleteModal = {
export const StateDeleteModal = observer(function StateDeleteModal(props: TStateDeleteModal) { export const StateDeleteModal = observer(function StateDeleteModal(props: TStateDeleteModal) {
const { isOpen, onClose, data } = props; const { isOpen, onClose, data } = props;
const { t } = useTranslation();
// states // states
const [isDeleteLoading, setIsDeleteLoading] = useState(false); const [isDeleteLoading, setIsDeleteLoading] = useState(false);
// router // router
@ -47,15 +49,14 @@ export const StateDeleteModal = observer(function StateDeleteModal(props: TState
if (err.status === 400) if (err.status === 400)
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("common.error.label"),
message: message: t("project_settings.states.delete.blocked"),
"This state contains some work items within it, please move them to some other state to delete this state.",
}); });
else else
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("common.error.label"),
message: "State could not be deleted. Please try again.", message: t("project_settings.states.delete.error"),
}); });
}) })
.finally(() => { .finally(() => {
@ -69,13 +70,11 @@ export const StateDeleteModal = observer(function StateDeleteModal(props: TState
handleSubmit={handleDeletion} handleSubmit={handleDeletion}
isSubmitting={isDeleteLoading} isSubmitting={isDeleteLoading}
isOpen={isOpen} isOpen={isOpen}
title="Delete State" title={t("entity.delete.label", { entity: t("common.state") })}
content={ content={t("entity.delete.confirmation", {
<> entity: t("common.state").toLowerCase(),
Are you sure you want to delete state- <span className="font-medium text-primary">{data?.name}</span>? All of identifier: data?.name ? `"${data.name}"` : "",
the data related to the state will be permanently removed. This action cannot be undone. })}
</>
}
/> />
); );
}); });

View File

@ -48,6 +48,7 @@ export const MultiSelectFilterValueInput = observer(function MultiSelectFilterVa
return ( return (
<CustomSearchSelect <CustomSearchSelect
{...getCommonCustomSearchSelectProps(isDisabled)} {...getCommonCustomSearchSelectProps(isDisabled)}
className="min-w-[4.5rem]"
value={toFilterArray(condition.value)} value={toFilterArray(condition.value)}
onChange={handleSelectChange} onChange={handleSelectChange}
options={formattedOptions} options={formattedOptions}

View File

@ -50,6 +50,7 @@ export const SingleSelectFilterValueInput = observer(function SingleSelectFilter
return ( return (
<CustomSearchSelect <CustomSearchSelect
{...getCommonCustomSearchSelectProps(isDisabled)} {...getCommonCustomSearchSelectProps(isDisabled)}
className="min-w-[4.5rem]"
value={condition.value} value={condition.value}
onChange={handleSelectChange} onChange={handleSelectChange}
options={formattedOptions} options={formattedOptions}

View File

@ -16,6 +16,7 @@ import { AddFilterButton } from "@/components/rich-filters/add-filters/button";
type TFiltersToggleProps<P extends TFilterProperty, E extends TExternalFilter> = { type TFiltersToggleProps<P extends TFilterProperty, E extends TExternalFilter> = {
filter: IFilterInstance<P, E> | undefined; filter: IFilterInstance<P, E> | undefined;
showAddFilterButtonWhenEmpty?: boolean;
}; };
const COMMON_CLASSNAME = const COMMON_CLASSNAME =
@ -24,13 +25,13 @@ const COMMON_CLASSNAME =
export const FiltersToggle = observer(function FiltersToggle<P extends TFilterProperty, E extends TExternalFilter>( export const FiltersToggle = observer(function FiltersToggle<P extends TFilterProperty, E extends TExternalFilter>(
props: TFiltersToggleProps<P, E> props: TFiltersToggleProps<P, E>
) { ) {
const { filter } = props; const { filter, showAddFilterButtonWhenEmpty = true } = props;
// derived values // derived values
const hasAnyConditions = (filter?.allConditionsForDisplay.length ?? 0) > 0; const hasAnyConditions = (filter?.allConditionsForDisplay.length ?? 0) > 0;
const isFilterRowVisible = filter?.isVisible ?? false; const isFilterRowVisible = filter?.isVisible ?? false;
const hasUpdates = filter?.canUpdateView === true && filter?.hasChanges === true; const hasUpdates = filter?.canUpdateView === true && filter?.hasChanges === true;
const showFilterRowChangesPill = hasUpdates || hasAnyConditions === true; const showFilterRowChangesPill = hasUpdates || hasAnyConditions === true;
const showAddFilterButton = !hasAnyConditions && !isFilterRowVisible && !hasUpdates; const showAddFilterButton = showAddFilterButtonWhenEmpty && !hasAnyConditions && !isFilterRowVisible && !hasUpdates;
const handleToggleFilter = () => { const handleToggleFilter = () => {
if (!filter) { if (!filter) {

View File

@ -9,6 +9,8 @@ const FILTER_LABEL_MAP: Record<string, string> = {
Label: "Метка", Label: "Метка",
Labels: "Метки", Labels: "Метки",
Project: "Проект", Project: "Проект",
Projects: "Проекты",
Contour: "Контур",
"Created by": "Автор", "Created by": "Автор",
"Created at": "Дата создания", "Created at": "Дата создания",
"Updated at": "Дата обновления", "Updated at": "Дата обновления",

View File

@ -7,6 +7,7 @@
import { useState } from "react"; import { useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
// ui // ui
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast"; import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { AlertModalCore } from "@plane/ui"; import { AlertModalCore } from "@plane/ui";
// hooks // hooks
@ -20,6 +21,7 @@ interface IDeleteWebhook {
export function DeleteWebhookModal(props: IDeleteWebhook) { export function DeleteWebhookModal(props: IDeleteWebhook) {
const { isOpen, onClose } = props; const { isOpen, onClose } = props;
const { t } = useTranslation();
// states // states
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
// router // router
@ -41,14 +43,14 @@ export function DeleteWebhookModal(props: IDeleteWebhook) {
router.replace(`/${workspaceSlug}/settings/webhooks/`); router.replace(`/${workspaceSlug}/settings/webhooks/`);
setToast({ setToast({
type: TOAST_TYPE.SUCCESS, type: TOAST_TYPE.SUCCESS,
title: "Success!", title: t("workspace_settings.settings.webhooks.toasts.removed.title"),
message: "Webhook deleted successfully.", message: t("workspace_settings.settings.webhooks.toasts.removed.message"),
}); });
} catch (_error) { } catch (_error) {
setToast({ setToast({
type: TOAST_TYPE.ERROR, type: TOAST_TYPE.ERROR,
title: "Error!", title: t("workspace_settings.settings.webhooks.toasts.not_removed.title"),
message: "Webhook could not be deleted. Please try again.", message: t("workspace_settings.settings.webhooks.toasts.not_removed.message"),
}); });
} }
setIsDeleting(false); setIsDeleting(false);
@ -60,13 +62,8 @@ export function DeleteWebhookModal(props: IDeleteWebhook) {
handleSubmit={handleDelete} handleSubmit={handleDelete}
isSubmitting={isDeleting} isSubmitting={isDeleting}
isOpen={isOpen} isOpen={isOpen}
title="Delete webhook" title={t("workspace_settings.settings.webhooks.delete.title")}
content={ content={t("workspace_settings.settings.webhooks.delete.description")}
<>
Are you sure you want to delete this webhook? Future events will not be delivered to this webhook. This action
cannot be undone.
</>
}
/> />
); );
} }

View File

@ -6,6 +6,7 @@
import { Disclosure, Transition } from "@headlessui/react"; import { Disclosure, Transition } from "@headlessui/react";
import { WORKSPACE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants"; import { WORKSPACE_SETTINGS_TRACKER_ELEMENTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
import { ChevronDownIcon, ChevronUpIcon } from "@plane/propel/icons"; import { ChevronDownIcon, ChevronUpIcon } from "@plane/propel/icons";
@ -15,13 +16,14 @@ type Props = {
export function WebhookDeleteSection(props: Props) { export function WebhookDeleteSection(props: Props) {
const { openDeleteModal } = props; const { openDeleteModal } = props;
const { t } = useTranslation();
return ( return (
<Disclosure as="div" className="border-t border-subtle"> <Disclosure as="div" className="border-t border-subtle">
{({ open }) => ( {({ open }) => (
<div className="w-full"> <div className="w-full">
<Disclosure.Button as="button" type="button" className="flex w-full items-center justify-between py-4"> <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" />} {open ? <ChevronUpIcon className="h-5 w-5" /> : <ChevronDownIcon className="h-5 w-5" />}
</Disclosure.Button> </Disclosure.Button>
@ -36,18 +38,16 @@ export function WebhookDeleteSection(props: Props) {
> >
<Disclosure.Panel> <Disclosure.Panel>
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<span className="text-13 tracking-tight"> <span className="text-13 tracking-tight">{t("workspace_settings.settings.webhooks.delete.description")}</span>
Once a webhook is deleted, it cannot be restored. Future events will no longer be delivered to this
webhook.
</span>
<div> <div>
<Button <Button
variant="error-fill" variant="error-fill"
size="lg" size="lg"
onClick={openDeleteModal} onClick={openDeleteModal}
className="nodedc-modal-danger-button"
data-ph-element={WORKSPACE_SETTINGS_TRACKER_ELEMENTS.WEBHOOK_DELETE_BUTTON} data-ph-element={WORKSPACE_SETTINGS_TRACKER_ELEMENTS.WEBHOOK_DELETE_BUTTON}
> >
Delete webhook {t("workspace_settings.settings.webhooks.delete.title")}
</Button> </Button>
</div> </div>
</div> </div>

View File

@ -24,5 +24,5 @@ export const WorkItemFiltersToggle = observer(function WorkItemFiltersToggle(pro
// derived values // derived values
const filter = getFilter(entityType, entityId); const filter = getFilter(entityType, entityId);
return <FiltersToggle filter={filter} />; return <FiltersToggle filter={filter} showAddFilterButtonWhenEmpty={false} />;
}); });

View File

@ -102,11 +102,11 @@ export const NotificationsRoot = observer(function NotificationsRoot({ workspace
</div> </div>
) : ( ) : (
<ExternalContoursContentRoot <ExternalContoursContentRoot
setIsMobileSidebar={() => {}}
isMobileSidebar={false}
workspaceSlug={workspace_slug} workspaceSlug={workspace_slug}
projectId={project_id} projectId={project_id}
inboxIssueId={issue_id} inboxIssueId={issue_id}
embedIssue
embedRemoveCurrentNotification={embedRemoveCurrentNotification}
/> />
)} )}
</> </>

View File

@ -5,6 +5,7 @@
*/ */
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Menu } from "@headlessui/react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { LogOut, Settings, Settings2 } from "lucide-react"; 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"; import { useUser } from "@/hooks/store/user";
type TUserMenuRootProps = { type TUserMenuRootProps = {
variant?: "default" | "sidebar-utility"; variant?: "default" | "sidebar-utility" | "toolbar";
}; };
export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootProps) { export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootProps) {
@ -42,6 +43,9 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
// translation // translation
const { t } = useTranslation(); const { t } = useTranslation();
const isSidebarUtilityVariant = variant === "sidebar-utility";
const isToolbarVariant = variant === "toolbar";
const handleSignOut = () => { const handleSignOut = () => {
signOut().catch(() => signOut().catch(() =>
setToast({ setToast({
@ -58,46 +62,8 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
else toggleAnySidebarDropdown(false); else toggleAnySidebarDropdown(false);
}, [isUserMenuOpen, toggleAnySidebarDropdown]); }, [isUserMenuOpen, toggleAnySidebarDropdown]);
return ( const menuContent = (
<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
>
<div className="relative h-29 w-full rounded-lg"> <div className="relative h-29 w-full rounded-lg">
<CoverImage <CoverImage
src={currentUser?.cover_image_url ?? undefined} src={currentUser?.cover_image_url ?? undefined}
@ -164,6 +130,77 @@ export const UserMenuRoot = observer(function UserMenuRoot(props: TUserMenuRootP
{t("enter_god_mode")} {t("enter_god_mode")}
</CustomMenu.MenuItem> </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> </CustomMenu>
); );
}); });

View File

@ -31,7 +31,7 @@ import { WorkspaceLogo } from "../logo";
import SidebarDropdownItem from "./dropdown-item"; import SidebarDropdownItem from "./dropdown-item";
type WorkspaceMenuRootProps = { type WorkspaceMenuRootProps = {
variant: "sidebar" | "top-navigation" | "sidebar-panel"; variant: "sidebar" | "top-navigation" | "sidebar-panel" | "toolbar";
}; };
type WorkspaceMenuStateSyncProps = { type WorkspaceMenuStateSyncProps = {
@ -46,7 +46,7 @@ function WorkspaceMenuStateSync(props: WorkspaceMenuStateSyncProps) {
const { open, variant, sidebarPanelButtonRef, onSidebarDropdownToggle, onSidebarPanelPositionChange } = props; const { open, variant, sidebarPanelButtonRef, onSidebarDropdownToggle, onSidebarPanelPositionChange } = props;
const updateSidebarPanelMenuPosition = useCallback(() => { 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 rect = sidebarPanelButtonRef.current.getBoundingClientRect();
const width = 480; const width = 480;
@ -64,7 +64,7 @@ function WorkspaceMenuStateSync(props: WorkspaceMenuStateSyncProps) {
}, [onSidebarDropdownToggle, open]); }, [onSidebarDropdownToggle, open]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!open || variant !== "sidebar-panel") { if (!open || !["sidebar-panel", "toolbar"].includes(variant)) {
onSidebarPanelPositionChange(null); onSidebarPanelPositionChange(null);
return; return;
} }
@ -133,6 +133,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
"w-full justify-center text-center": variant === "sidebar", "w-full justify-center text-center": variant === "sidebar",
"flex-grow justify-stretch text-left": variant === "top-navigation", "flex-grow justify-stretch text-left": variant === "top-navigation",
"w-full max-w-none justify-stretch text-left": variant === "sidebar-panel", "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 }) => { {({ open, close }: { open: boolean; close: () => void }) => {
@ -220,6 +221,24 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
/> />
</Menu.Button> </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 = ( const menuItems = (
<Menu.Items <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", "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": "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-11 left-14": variant === "sidebar",
"top-10 left-4": variant === "top-navigation", "top-10 left-4": variant === "top-navigation",
"nodedc-glass-modal nodedc-glass-popup-surface rounded-[1.5rem] divide-white/10": "nodedc-glass-modal nodedc-glass-popup-surface rounded-[1.5rem] divide-white/10":
variant === "sidebar-panel", ["sidebar-panel", "toolbar"].includes(variant),
} }
)} )}
style={ style={
variant === "sidebar-panel" && sidebarPanelMenuPosition ["sidebar-panel", "toolbar"].includes(variant) && sidebarPanelMenuPosition
? { ? {
position: "fixed", position: "fixed",
left: `${sidebarPanelMenuPosition.left}px`, left: `${sidebarPanelMenuPosition.left}px`,
@ -251,8 +270,8 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
className={cn( 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", "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", "rounded-md bg-surface-1": !["sidebar-panel", "toolbar"].includes(variant),
"bg-transparent": variant === "sidebar-panel", "bg-transparent": ["sidebar-panel", "toolbar"].includes(variant),
} }
)} )}
> >
@ -324,7 +343,7 @@ export const WorkspaceMenuRoot = observer(function WorkspaceMenuRoot(props: Work
</Menu.Items> </Menu.Items>
); );
if (variant === "sidebar-panel") { if (["sidebar-panel", "toolbar"].includes(variant)) {
if (!open || !sidebarPanelMenuPosition || typeof document === "undefined") return null; if (!open || !sidebarPanelMenuPosition || typeof document === "undefined") return null;
return createPortal(menuItems, document.body); return createPortal(menuItems, document.body);
} }

View File

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

View File

@ -6,6 +6,9 @@
import { API_BASE_URL } from "@plane/constants"; import { API_BASE_URL } from "@plane/constants";
import type { import type {
TExternalContourBoardFilter,
TExternalContourBoardResponse,
TExternalContourBoardSorting,
TExternalContourRequest, TExternalContourRequest,
TExternalContourRequestResponse, TExternalContourRequestResponse,
TExternalContourTargetOptions, 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> { async retrieve(workspaceSlug: string, projectId: string, requestId: string): Promise<TExternalContourRequest> {
return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/`) return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/`)
.then((response) => response?.data) .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( async updateRequest(
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
requestId: 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> { ): Promise<TExternalContourRequest> {
return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/`, data) return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/external-contours/${requestId}/`, data)
.then((response) => response?.data) .then((response) => response?.data)

View File

@ -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",
};
});
}
};
}

View File

@ -38,7 +38,7 @@ export interface IProjectExternalContoursStore {
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
requestId: 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>; ) => Promise<TExternalContourRequest | undefined>;
decideRequest: ( decideRequest: (
workspaceSlug: string, workspaceSlug: string,
@ -215,7 +215,7 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
fetchRequestById = async (workspaceSlug: string, projectId: string, requestId: string) => { fetchRequestById = async (workspaceSlug: string, projectId: string, requestId: string) => {
this.loader = "issue-loading"; this.loader = "issue-loading";
try { try {
const request = await this.externalContourService.retrieve(workspaceSlug, projectId, requestId); const request = await this.externalContourService.retrieveBoardItem(workspaceSlug, projectId, requestId);
runInAction(() => { runInAction(() => {
this.upsertRequests([request]); this.upsertRequests([request]);
this.loader = undefined; this.loader = undefined;
@ -251,7 +251,7 @@ export class ProjectExternalContoursStore implements IProjectExternalContoursSto
workspaceSlug: string, workspaceSlug: string,
projectId: string, projectId: string,
requestId: 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"; this.loader = "mutation-loading";
try { try {

View File

@ -32,6 +32,8 @@ import { EditorAssetStore } from "./editor/asset.store";
import type { IProjectEstimateStore } from "./estimates/project-estimate.store"; import type { IProjectEstimateStore } from "./estimates/project-estimate.store";
import { ProjectEstimateStore } from "./estimates/project-estimate.store"; import { ProjectEstimateStore } from "./estimates/project-estimate.store";
import type { IProjectExternalContoursStore } from "./external-contours/project-external-contours.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 { ProjectExternalContoursStore } from "./external-contours/project-external-contours.store";
import type { IFavoriteStore } from "./favorite.store"; import type { IFavoriteStore } from "./favorite.store";
import { FavoriteStore } from "./favorite.store"; import { FavoriteStore } from "./favorite.store";
@ -95,6 +97,7 @@ export class CoreRootStore {
instance: IInstanceStore; instance: IInstanceStore;
user: IUserStore; user: IUserStore;
projectInbox: IProjectInboxStore; projectInbox: IProjectInboxStore;
projectExternalContoursBoard: IProjectExternalContoursBoardStore;
projectExternalContours: IProjectExternalContoursStore; projectExternalContours: IProjectExternalContoursStore;
projectEstimate: IProjectEstimateStore; projectEstimate: IProjectEstimateStore;
multipleSelect: IMultipleSelectStore; multipleSelect: IMultipleSelectStore;
@ -127,6 +130,7 @@ export class CoreRootStore {
this.multipleSelect = new MultipleSelectStore(); this.multipleSelect = new MultipleSelectStore();
this.projectInbox = new ProjectInboxStore(this); this.projectInbox = new ProjectInboxStore(this);
this.projectExternalContours = new ProjectExternalContoursStore(this); this.projectExternalContours = new ProjectExternalContoursStore(this);
this.projectExternalContoursBoard = new ProjectExternalContoursBoardStore(this);
this.projectPages = new ProjectPageStore(this as unknown as RootStore); this.projectPages = new ProjectPageStore(this as unknown as RootStore);
this.projectEstimate = new ProjectEstimateStore(this); this.projectEstimate = new ProjectEstimateStore(this);
this.workspaceNotification = new WorkspaceNotificationStore(this); this.workspaceNotification = new WorkspaceNotificationStore(this);
@ -161,6 +165,7 @@ export class CoreRootStore {
this.dashboard = new DashboardStore(this); this.dashboard = new DashboardStore(this);
this.projectInbox = new ProjectInboxStore(this); this.projectInbox = new ProjectInboxStore(this);
this.projectExternalContours = new ProjectExternalContoursStore(this); this.projectExternalContours = new ProjectExternalContoursStore(this);
this.projectExternalContoursBoard = new ProjectExternalContoursBoardStore(this);
this.projectPages = new ProjectPageStore(this as unknown as RootStore); this.projectPages = new ProjectPageStore(this as unknown as RootStore);
this.multipleSelect = new MultipleSelectStore(); this.multipleSelect = new MultipleSelectStore();
this.projectEstimate = new ProjectEstimateStore(this); this.projectEstimate = new ProjectEstimateStore(this);

View File

@ -218,9 +218,16 @@
} }
.nodedc-glass-modal { .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: box-shadow:
0 20px 56px rgba(0, 0, 0, 0.34), 0 24px 64px rgba(0, 0, 0, 0.42),
0 4px 16px rgba(0, 0, 0, 0.18); 0 8px 22px rgba(0, 0, 0, 0.24);
} }
.nodedc-glass-surface { .nodedc-glass-surface {
@ -248,6 +255,30 @@
0 6px 18px rgba(0, 0, 0, 0.2); 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="button"],
.nodedc-glass-modal [data-slot="icon-button"] { .nodedc-glass-modal [data-slot="icon-button"] {
border: none !important; border: none !important;
@ -268,11 +299,24 @@
} }
.nodedc-glass-modal button:focus-visible, .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; outline: none !important;
box-shadow: 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 { .nodedc-modal-field {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), 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); 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 { .nodedc-modal-input {
background: background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%), linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(255, 255, 255, 0.012) 100%),
@ -485,17 +579,19 @@
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
border-radius: 1.25rem !important; border-radius: 1.25rem !important;
background: rgb(var(--nodedc-card-active-rgb)) !important; background: rgb(var(--nodedc-accent-rgb)) !important;
color: #0b1117 !important; color: #0b1117 !important;
padding-inline: 1.25rem !important; padding-inline: 1.25rem !important;
} }
.nodedc-modal-primary-button:hover { .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-primary-button *, .nodedc-modal-primary-button *,
.nodedc-modal-danger-button,
.nodedc-modal-danger-button *,
.nodedc-settings-primary-button, .nodedc-settings-primary-button,
.nodedc-settings-primary-button *, .nodedc-settings-primary-button *,
.nodedc-settings-save-button, .nodedc-settings-save-button,
@ -509,9 +605,34 @@
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
border-radius: 1.25rem !important; border-radius: 1.25rem !important;
background: rgb(var(--nodedc-accent-rgb)) !important;
color: #0b1117 !important;
padding-inline: 1.25rem !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 { .nodedc-modal-chip {
min-height: 2.5rem; min-height: 2.5rem;
border: 0 !important; border: 0 !important;

View File

@ -295,6 +295,35 @@ export default {
tabs: { tabs: {
open: "Open", open: "Open",
closed: "Closed", 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: { list: {
last_updated: "Last updated", last_updated: "Last updated",
@ -479,6 +508,9 @@ export default {
no_data_yet: "No Data yet", no_data_yet: "No Data yet",
syncing: "Syncing", syncing: "Syncing",
add_work_item: "Add work item", add_work_item: "Add work item",
app_header: {
add_task: "Add task",
},
advanced_description_placeholder: "Press '/' for commands", advanced_description_placeholder: "Press '/' for commands",
create_work_item: "Create work item", create_work_item: "Create work item",
attachments: "Attachments", attachments: "Attachments",
@ -1138,9 +1170,14 @@ export default {
file_size_limit: "File must be of {size}MB or less in size.", file_size_limit: "File must be of {size}MB or less in size.",
drag_and_drop: "Drag and drop anywhere to upload", drag_and_drop: "Drag and drop anywhere to upload",
delete: "Delete attachment", 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: { label: {
select: "Add labels", 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: { create: {
success: "Label created successfully", success: "Label created successfully",
failed: "Label creation failed", failed: "Label creation failed",
@ -1205,7 +1242,7 @@ export default {
modals: { modals: {
decline: { decline: {
title: "Decline work item", 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: { delete: {
title: "Delete work item", title: "Delete work item",
@ -1691,6 +1728,11 @@ export default {
description: "Automate notifications to external services when project events occur.", description: "Automate notifications to external services when project events occur.",
title: "Webhooks", title: "Webhooks",
add_webhook: "Add webhook", 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: { modal: {
title: "Create webhook", title: "Create webhook",
details: "Webhook details", details: "Webhook details",
@ -1886,6 +1928,12 @@ export default {
heading: "States", heading: "States",
description: "Define and customize workflow states to track the progress of your work items.", 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.", 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: { empty_state: {
title: "No states available for the {groupKey} group", title: "No states available for the {groupKey} group",
description: "Please create a new state", description: "Please create a new state",
@ -1914,6 +1962,17 @@ export default {
label: "Estimates", label: "Estimates",
title: "Enable estimates for my project", title: "Enable estimates for my project",
enable_description: "They help you in communicating complexity and workload of the team.", 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", list_heading: "Estimate list",
archived_heading: "Archived estimates", archived_heading: "Archived estimates",
archived_description: "These are estimates from earlier project versions that are not currently in use. Read more", 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: { 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: { empty_state: {
general: { general: {
title: title:

View File

@ -452,6 +452,35 @@ export default {
tabs: { tabs: {
open: "Открытые", open: "Открытые",
closed: "Закрытые", 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: { list: {
last_updated: "Последнее изменение", last_updated: "Последнее изменение",
@ -635,6 +664,9 @@ export default {
no_data_yet: "Нет данных", no_data_yet: "Нет данных",
syncing: "Синхронизация", syncing: "Синхронизация",
add_work_item: "Добавить рабочий элемент", add_work_item: "Добавить рабочий элемент",
app_header: {
add_task: "Добавить задачу",
},
advanced_description_placeholder: "Нажмите '/' для команд", advanced_description_placeholder: "Нажмите '/' для команд",
create_work_item: "Создать рабочий элемент", create_work_item: "Создать рабочий элемент",
attachments: "Вложения", attachments: "Вложения",
@ -1294,9 +1326,14 @@ export default {
file_size_limit: "Максимальный размер файла - {size} МБ", file_size_limit: "Максимальный размер файла - {size} МБ",
drag_and_drop: "Перетащите файл для загрузки", drag_and_drop: "Перетащите файл для загрузки",
delete: "Удалить вложение", delete: "Удалить вложение",
delete_confirmation:
"Вы уверены, что хотите удалить вложение {value}? Вложение будет удалено без возможности восстановления.",
}, },
label: { label: {
select: "Выбрать метку", select: "Выбрать метку",
delete_confirmation:
'Вы уверены, что хотите удалить метку "{value}"? Она будет удалена из всех рабочих элементов и представлений, где используется.',
delete_error: "Не удалось удалить метку. Попробуйте снова.",
create: { create: {
success: "Метка создана", success: "Метка создана",
failed: "Ошибка создания метки", failed: "Ошибка создания метки",
@ -1361,7 +1398,7 @@ export default {
modals: { modals: {
decline: { decline: {
title: "Отклонить рабочий элемент", title: "Отклонить рабочий элемент",
content: "Вы уверены, что хотите отклонить рабочий элемент {value}?", content: "Вы уверены, что хотите отклонить рабочий элемент {value}? Это действие нельзя отменить.",
}, },
delete: { delete: {
title: "Удалить рабочий элемент", title: "Удалить рабочий элемент",
@ -1853,6 +1890,11 @@ export default {
description: "Автоматизируйте уведомления во внешние сервисы при событиях проекта.", description: "Автоматизируйте уведомления во внешние сервисы при событиях проекта.",
title: "Вебхуки", title: "Вебхуки",
add_webhook: "Добавить вебхук", add_webhook: "Добавить вебхук",
delete: {
title: "Удалить вебхук",
description:
"Вы уверены, что хотите удалить этот вебхук? События больше не будут отправляться в этот вебхук. Это действие нельзя отменить.",
},
modal: { modal: {
title: "Создать вебхук", title: "Создать вебхук",
details: "Детали вебхука", details: "Детали вебхука",
@ -2045,6 +2087,12 @@ export default {
heading: "Статусы", heading: "Статусы",
description: "Определяйте и настраивайте статусы рабочего процесса для отслеживания прогресса рабочих элементов.", description: "Определяйте и настраивайте статусы рабочего процесса для отслеживания прогресса рабочих элементов.",
describe_this_state_for_your_members: "Опишите этот статус для участников", describe_this_state_for_your_members: "Опишите этот статус для участников",
delete: {
blocked: "В этом статусе есть рабочие элементы. Переместите их в другой статус, чтобы удалить текущий.",
error: "Не удалось удалить статус. Попробуйте снова.",
disabled_default: "Нельзя удалить статус по умолчанию.",
disabled_last: "Нельзя оставить группу без статусов.",
},
empty_state: { empty_state: {
title: "Нет статусов для группы {groupKey}", title: "Нет статусов для группы {groupKey}",
description: "Создайте новый статус", description: "Создайте новый статус",
@ -2073,6 +2121,17 @@ export default {
label: "Оценки", label: "Оценки",
title: "Включить оценки для моего проекта", title: "Включить оценки для моего проекта",
enable_description: "Они помогают вам в общении о сложности и рабочей нагрузке команды.", enable_description: "Они помогают вам в общении о сложности и рабочей нагрузке команды.",
delete_modal: {
title: "Удалить систему оценок",
description:
'Удаление системы оценок "{value}" безвозвратно уберет её из всех рабочих элементов проекта. Если вы включите оценки снова, рабочие элементы придется настраивать заново.',
submit: "Удалить систему оценок",
loading: "Удаление",
success_title: "Система оценок удалена",
success_message: "Система оценок удалена из проекта.",
error_title: "Не удалось удалить систему оценок",
error_message: "Мы не смогли удалить систему оценок. Попробуйте снова.",
},
list_heading: "Список оценок", list_heading: "Список оценок",
archived_heading: "Архивные оценки", archived_heading: "Архивные оценки",
archived_description: "Это оценки из предыдущих версий проекта, которые сейчас не используются. Подробнее о них", archived_description: "Это оценки из предыдущих версий проекта, которые сейчас не используются. Подробнее о них",
@ -2461,6 +2520,14 @@ export default {
}, },
}, },
project_page: { project_page: {
delete_modal: {
title: "Удалить страницу",
content: 'Вы уверены, что хотите удалить страницу "{value}"? Страница будет удалена без возможности восстановления.',
success_title: "Страница удалена",
success_message: "Страница успешно удалена.",
error_title: "Не удалось удалить страницу",
error_message: "Не удалось удалить страницу. Попробуйте снова.",
},
empty_state: { empty_state: {
general: { general: {
title: "Создавайте заметки, документы или базу знаний. Используйте Galileo, ИИ-помощник NODE.DC.", title: "Создавайте заметки, документы или базу знаний. Используйте Galileo, ИИ-помощник NODE.DC.",

View File

@ -41,9 +41,8 @@ export interface DialogTitleProps extends React.ComponentProps<typeof BaseDialog
} }
// Constants // Constants
const OVERLAY_CLASSNAME = cn("fixed inset-0 z-90 bg-backdrop/70 backdrop-blur-sm"); const OVERLAY_CLASSNAME = cn("fixed inset-0 z-90 bg-black/60 backdrop-blur-md");
const BASE_CLASSNAME = const BASE_CLASSNAME = "nodedc-glass-modal relative z-100 w-full rounded-[28px] text-left";
"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";
// Utility functions // Utility functions
const getPositionClassNames = (position: DialogPosition) => const getPositionClassNames = (position: DialogPosition) =>

View File

@ -84,7 +84,7 @@ export function ModalPortal({
const positionClass = fullScreen ? "" : PORTAL_POSITION_CLASSES[position]; const positionClass = fullScreen ? "" : PORTAL_POSITION_CLASSES[position];
return cn( 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, widthClass,
positionClass, positionClass,
contentClassName contentClassName
@ -101,7 +101,7 @@ export function ModalPortal({
> >
{showOverlay && ( {showOverlay && (
<div <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} onClick={handleOverlayClick}
aria-hidden="true" aria-hidden="true"
/> />

View File

@ -53,9 +53,28 @@ export type TExternalContourMirroredActivity = {
actor_detail?: Pick<IUser, "id" | "display_name" | "avatar_url"> | null; 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 = { export type TExternalContourRequest = {
capabilities?: TExternalContourBoardCapabilities;
created_at: string; created_at: string;
created_by: string | null; created_by: string | null;
direction?: TExternalContourBoardDirection;
has_unread_updates?: boolean; has_unread_updates?: boolean;
id: string; id: string;
issue: TExternalContourIssue; issue: TExternalContourIssue;
@ -71,8 +90,11 @@ export type TExternalContourRequest = {
target_project_name?: string | null; target_project_name?: string | null;
requested_by_id?: string | null; requested_by_id?: string | null;
requested_by_name?: string | null; requested_by_name?: string | null;
requested_by?: TExternalContourBoardRequestedBy | null;
requested_at?: string | null; requested_at?: string | null;
source_project?: TExternalContourBoardProject | null;
status: "open" | "closed"; status: "open" | "closed";
target_project?: TExternalContourBoardProject | null;
updated_at: string; updated_at: string;
}; };
@ -80,6 +102,45 @@ export type TExternalContourRequestResponse = TPaginationInfo & {
results: TExternalContourRequest[]; 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 & { export type TExternalContourTargetProject = IProjectLite & {
inbox_view: boolean; inbox_view: boolean;
}; };
@ -87,5 +148,6 @@ export type TExternalContourTargetProject = IProjectLite & {
export type TExternalContourTargetOptions = { export type TExternalContourTargetOptions = {
project: TExternalContourTargetProject; project: TExternalContourTargetProject;
member_ids: string[]; member_ids: string[];
states: Pick<IStateLite, "id" | "name" | "color" | "group">[];
labels: Pick<IIssueLabel, "id" | "name" | "color" | "parent" | "project_id" | "workspace_id" | "sort_order">[]; labels: Pick<IIssueLabel, "id" | "name" | "color" | "parent" | "project_id" | "workspace_id" | "sort_order">[];
}; };

View File

@ -34,6 +34,7 @@
"@headlessui/react": "^1.7.3", "@headlessui/react": "^1.7.3",
"@plane/constants": "workspace:*", "@plane/constants": "workspace:*",
"@plane/hooks": "workspace:*", "@plane/hooks": "workspace:*",
"@plane/i18n": "workspace:*",
"@plane/propel": "workspace:*", "@plane/propel": "workspace:*",
"@plane/types": "workspace:*", "@plane/types": "workspace:*",
"@plane/utils": "workspace:*", "@plane/utils": "workspace:*",

View File

@ -44,7 +44,7 @@ export function InputSearch(props: IInputSearch) {
return ( return (
<div <div
className={cn( className={cn(
"flex items-center gap-1.5 rounded-sm border border-subtle bg-surface-2 px-2", "nodedc-dropdown-search",
inputContainerClassName inputContainerClassName
)} )}
> >
@ -53,7 +53,7 @@ export function InputSearch(props: IInputSearch) {
as="input" as="input"
ref={inputRef} ref={inputRef}
className={cn( 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 inputClassName
)} )}
value={query} value={query}

View File

@ -46,7 +46,7 @@ export function DropdownOptions(props: IMultiSelectDropdownOptions | ISingleSele
isMobile={isMobile} 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 ? (
options.length > 0 ? ( options.length > 0 ? (
@ -57,9 +57,9 @@ export function DropdownOptions(props: IMultiSelectDropdownOptions | ISingleSele
disabled={option.disabled} disabled={option.disabled}
className={({ active, selected }) => className={({ active, selected }) =>
cn( 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-primary": selected,
"text-secondary": !selected, "text-secondary": !selected,
}, },

View File

@ -7,6 +7,7 @@
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { sortBy } from "lodash-es"; import { sortBy } from "lodash-es";
import React, { useMemo, useRef, useState } from "react"; import React, { useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
// plane imports // plane imports
import { useOutsideClickDetector } from "@plane/hooks"; import { useOutsideClickDetector } from "@plane/hooks";
@ -139,13 +140,11 @@ export function MultiSelectDropdown(props: IMultiSelectDropdown) {
disabled={disabled} disabled={disabled}
/> />
{isOpen && ( {isOpen &&
<Combobox.Options className="fixed z-10" static> createPortal(
<Combobox.Options data-prevent-outside-click className="fixed z-30" static>
<div <div
className={cn( className={cn("nodedc-dropdown-surface my-1 w-56", optionsContainerClassName)}
"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} ref={setPopperElement}
style={styles.popper} style={styles.popper}
{...attributes.popper} {...attributes.popper}
@ -166,7 +165,8 @@ export function MultiSelectDropdown(props: IMultiSelectDropdown) {
loader={loader} loader={loader}
/> />
</div> </div>
</Combobox.Options> </Combobox.Options>,
document.body
)} )}
</Combobox> </Combobox>
); );

View File

@ -7,6 +7,7 @@
import { Combobox } from "@headlessui/react"; import { Combobox } from "@headlessui/react";
import { sortBy } from "lodash-es"; import { sortBy } from "lodash-es";
import React, { useMemo, useRef, useState } from "react"; import React, { useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper"; import { usePopper } from "react-popper";
// plane imports // plane imports
import { useOutsideClickDetector } from "@plane/hooks"; import { useOutsideClickDetector } from "@plane/hooks";
@ -138,13 +139,11 @@ export function Dropdown(props: ISingleSelectDropdown) {
disabled={disabled} disabled={disabled}
/> />
{isOpen && ( {isOpen &&
<Combobox.Options className="fixed z-10" static> createPortal(
<Combobox.Options data-prevent-outside-click className="fixed z-30" static>
<div <div
className={cn( className={cn("nodedc-dropdown-surface my-1 w-56", optionsContainerClassName)}
"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} ref={setPopperElement}
style={styles.popper} style={styles.popper}
{...attributes.popper} {...attributes.popper}
@ -166,7 +165,8 @@ export function Dropdown(props: ISingleSelectDropdown) {
handleClose={handleClose} handleClose={handleClose}
/> />
</div> </div>
</Combobox.Options> </Combobox.Options>,
document.body
)} )}
</Combobox> </Combobox>
); );

View File

@ -8,6 +8,7 @@ import type { LucideIcon } from "lucide-react";
import { AlertTriangle, Info } from "lucide-react"; import { AlertTriangle, Info } from "lucide-react";
import React from "react"; import React from "react";
// components // components
import { useTranslation } from "@plane/i18n";
import type { TButtonVariant } from "@plane/propel/button"; import type { TButtonVariant } from "@plane/propel/button";
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
import { cn } from "../utils"; import { cn } from "../utils";
@ -43,16 +44,17 @@ const VARIANT_ICONS: Record<TModalVariant, LucideIcon> = {
}; };
const BUTTON_VARIANTS: Record<TModalVariant, TButtonVariant> = { const BUTTON_VARIANTS: Record<TModalVariant, TButtonVariant> = {
danger: "error-fill", danger: "primary",
primary: "primary", primary: "primary",
}; };
const VARIANT_CLASSES: Record<TModalVariant, string> = { const VARIANT_CLASSES: Record<TModalVariant, string> = {
danger: "bg-danger-subtle text-danger-primary", danger: "nodedc-modal-alert-icon nodedc-modal-alert-icon-danger",
primary: "bg-accent-primary/20 text-accent-primary", primary: "nodedc-modal-alert-icon nodedc-modal-alert-icon-primary",
}; };
export function AlertModalCore(props: Props) { export function AlertModalCore(props: Props) {
const { t } = useTranslation();
const { const {
content, content,
handleClose, handleClose,
@ -62,10 +64,10 @@ export function AlertModalCore(props: Props) {
isOpen, isOpen,
position = EModalPosition.CENTER, position = EModalPosition.CENTER,
primaryButtonText = { primaryButtonText = {
loading: "Deleting", loading: t("deleting"),
default: "Delete", default: t("delete"),
}, },
secondaryButtonText = "Cancel", secondaryButtonText = t("cancel"),
title, title,
variant = "danger", variant = "danger",
width = EModalWidth.XL, width = EModalWidth.XL,
@ -94,11 +96,11 @@ export function AlertModalCore(props: Props) {
</span> </span>
)} )}
<div className="text-center sm:text-left"> <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> <p className="mt-1 text-13 text-secondary">{content}</p>
</div> </div>
</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]"> <Button variant="secondary" onClick={handleClose} className="nodedc-modal-secondary-button min-w-[8.25rem]">
{secondaryButtonText} {secondaryButtonText}
</Button> </Button>
@ -108,8 +110,8 @@ export function AlertModalCore(props: Props) {
onClick={handleSubmit} onClick={handleSubmit}
loading={isSubmitting} loading={isSubmitting}
className={cn("min-w-[8.25rem]", { className={cn("min-w-[8.25rem]", {
"nodedc-modal-danger-button": variant === "danger",
"nodedc-modal-primary-button": variant === "primary", "nodedc-modal-primary-button": variant === "primary",
"nodedc-modal-danger-button": variant === "danger",
})} })}
> >
{isSubmitting ? primaryButtonText.loading : primaryButtonText.default} {isSubmitting ? primaryButtonText.loading : primaryButtonText.default}

View File

@ -41,7 +41,7 @@ export function ModalCore(props: Props) {
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" 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> </Transition.Child>
<div className="fixed inset-0 z-30 overflow-y-auto"> <div className="fixed inset-0 z-30 overflow-y-auto">
@ -57,7 +57,7 @@ export function ModalCore(props: Props) {
> >
<Dialog.Panel <Dialog.Panel
className={cn( 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, width,
className className
)} )}

View File

@ -18,7 +18,11 @@ import { createFilterConfig, getMultiSelectConfig, createOperatorConfigEntry } f
/** /**
* Priority filter specific params * 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 * Helper to get the priority multi select config
@ -33,7 +37,7 @@ export const getPriorityMultiSelectConfig = (
{ {
items: ISSUE_PRIORITIES, items: ISSUE_PRIORITIES,
getId: (priority) => priority.key, getId: (priority) => priority.key,
getLabel: (priority) => priority.title, getLabel: (priority) => params.getItemLabel?.(priority.key) ?? priority.title,
getValue: (priority) => priority.key, getValue: (priority) => priority.key,
getIconData: (priority) => priority.key, getIconData: (priority) => priority.key,
}, },
@ -57,7 +61,7 @@ export const getPriorityFilterConfig =
(params: TCreatePriorityFilterParams) => (params: TCreatePriorityFilterParams) =>
createFilterConfig<P>({ createFilterConfig<P>({
id: key, id: key,
label: "Priority", label: params.filterLabel ?? "Priority",
...params, ...params,
icon: params.filterIcon, icon: params.filterIcon,
supportedOperatorConfigsMap: new Map([ supportedOperatorConfigsMap: new Map([

View File

@ -24,7 +24,7 @@ export const getProjectFilterConfig =
(params: TCreateProjectFilterParams) => (params: TCreateProjectFilterParams) =>
createFilterConfig<P>({ createFilterConfig<P>({
id: key, id: key,
label: "Projects", label: params.filterLabel ?? "Projects",
...params, ...params,
icon: params.filterIcon, icon: params.filterIcon,
supportedOperatorConfigsMap: new Map([ supportedOperatorConfigsMap: new Map([

View File

@ -33,6 +33,7 @@ export const getSupportedDateOperators = (params: TCreateDateFilterParams): TOpe
*/ */
export type TCreateProjectFilterParams = TCreateFilterConfigParams & export type TCreateProjectFilterParams = TCreateFilterConfigParams &
IFilterIconConfig<IProject> & { IFilterIconConfig<IProject> & {
filterLabel?: string;
projects: IProject[]; projects: IProject[];
}; };

View File

@ -17,7 +17,11 @@ import { createFilterConfig, getMultiSelectConfig, createOperatorConfigEntry } f
/** /**
* State group filter specific params * 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 * Helper to get the state group multi select config
@ -32,7 +36,7 @@ export const getStateGroupMultiSelectConfig = (
{ {
items: Object.values(STATE_GROUPS), items: Object.values(STATE_GROUPS),
getId: (state) => state.key, getId: (state) => state.key,
getLabel: (state) => state.label, getLabel: (state) => params.getItemLabel?.(state.key) ?? state.label,
getValue: (state) => state.key, getValue: (state) => state.key,
getIconData: (state) => state.key, getIconData: (state) => state.key,
}, },
@ -56,7 +60,7 @@ export const getStateGroupFilterConfig =
(params: TCreateStateGroupFilterParams) => (params: TCreateStateGroupFilterParams) =>
createFilterConfig<P>({ createFilterConfig<P>({
id: key, id: key,
label: "State Group", label: params.filterLabel ?? "State Group",
...params, ...params,
icon: params.filterIcon, icon: params.filterIcon,
supportedOperatorConfigsMap: new Map([ supportedOperatorConfigsMap: new Map([

View File

@ -1317,6 +1317,9 @@ importers:
'@plane/hooks': '@plane/hooks':
specifier: workspace:* specifier: workspace:*
version: link:../hooks version: link:../hooks
'@plane/i18n':
specifier: workspace:*
version: link:../i18n
'@plane/propel': '@plane/propel':
specifier: workspace:* specifier: workspace:*
version: link:../propel version: link:../propel